Образ собрать несложно — но плохой образ тянет в продакшен лишние гигабайты, открытые уязвимости и, в худшем случае, секреты, которые туда не должны были попасть. В этой статье разберём практики, которые отличают образ «работает» от образа «готов к эксплуатации».
Зачем заботиться о размере и безопасности
Когда разработчик впервые собирает образ, он обычно берёт самый очевидный базовый образ — например, openjdk:latest — и добавляет в него приложение. Это работает. Но такой образ весит несколько гигабайт, содержит компилятор, отладчики, пакетный менеджер и ещё сотни пакетов, которые приложению в продакшене никогда не понадобятся.
Проблемы не только в размере:
- Каждый лишний пакет — потенциальная уязвимость. Если в образе есть
curlилиbash, злоумышленник, получивший доступ к контейнеру, сразу получает удобный инструментарий. - Большие образы дольше загружаются при развёртывании — особенно заметно при горизонтальном масштабировании, когда новые узлы поднимаются за секунды.
- Чем меньше образ, тем быстрее его проверяет сканер уязвимостей и тем меньше ложных срабатываний.
Короткая формула: меньше в образе → меньше поверхность атаки.
Выбор базового образа
Для Java-приложений на основе Spring Boot исполняемый jar не требует JDK — ему нужна только JRE. Это первый шаг к уменьшению образа.
Три варианта базового образа в порядке убывания размера:
eclipse-temurin с JRE
eclipse-temurin:21-jre — рекомендованный выбор для большинства случаев. Это официальный образ OpenJDK от Eclipse Adoptium, построенный на Debian slim. Содержит только JRE, весит ~200 МБ. Хорошо поддерживается, совместим с распространёнными инструментами диагностики.
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Alpine-варианты
eclipse-temurin:21-jre-alpine уменьшает образ ещё на 50–80 МБ за счёт минималистичного дистрибутива Alpine Linux. Минус — Alpine использует musl вместо glibc. Для большинства Spring Boot приложений это не проблема, но некоторые нативные библиотеки (например, BouncyCastle с нативными расширениями) могут вести себя неожиданно. Проверяйте на тестах перед переходом.
Distroless
Образы gcr.io/distroless/java21-debian12 от Google вообще не содержат оболочки, пакетного менеджера и утилит — только Java-рантайм и минимальные системные библиотеки. Поверхность атаки минимальна.
FROM gcr.io/distroless/java21-debian12
WORKDIR /app
COPY app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Плата за безопасность — отладка внутри контейнера невозможна: нет sh, нет ls, нет ничего. Для диагностики используют docker cp, внешние профилировщики или временный sidecar-контейнер с нужными инструментами.
Как выбирать: для нового проекта — начните с eclipse-temurin:21-jre, переходите на distroless, когда ужесточаете требования по безопасности.
Конкретные теги вместо latest
FROM eclipse-temurin:latest — это скрытая бомба замедленного действия. При следующей сборке образ может незаметно измениться: новая версия Java, другая ОС, другие зависимости. Образ, который работал в разработке, сломается в продакшене.
Фиксируйте версию явно:
# плохо — что именно загрузится, непредсказуемо
FROM eclipse-temurin:latest
# хорошо — версия зафиксирована
FROM eclipse-temurin:21.0.5_11-jre-jammy
Ещё надёжнее — фиксировать по дайджесту (хэшу):
FROM eclipse-temurin:21-jre@sha256:abc123...
Это гарантирует побайтовую идентичность образа при каждой сборке. Обновлять теги стоит сознательно, а не случайно.
Многоступенчатая сборка
Типичная ошибка — собирать jar прямо внутри образа с JDK, а потом оставлять JDK в финальном образе. В результате в продакшен уходит компилятор, который там совершенно не нужен.
Многоступенчатая сборка разделяет процесс на этапы: один образ для сборки, другой — для запуска. Финальный образ содержит только то, что нужно приложению.
# --- этап сборки ---
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /build
# сначала копируем только файлы зависимостей — слой кешируется,
# пока pom.xml/build.gradle не изменился
COPY build.gradle settings.gradle gradlew ./
COPY gradle/ gradle/
RUN ./gradlew dependencies --no-daemon
# теперь копируем исходники и собираем
COPY src/ src/
RUN ./gradlew bootJar --no-daemon
# --- финальный образ ---
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
# копируем только jar из этапа builder
COPY --from=builder /build/build/libs/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
JDK, Gradle, кеш зависимостей — всё это остаётся только в промежуточном слое и не попадает в финальный образ. Подробнее о слоях и многоступенчатых сборках — в статье Многоступенчатая сборка и слои образа.
.dockerignore — что не класть в контекст сборки
Когда вы запускаете docker build, Docker отправляет в демон весь контекст сборки — содержимое текущей директории. Если в ней есть node_modules/ на 500 МБ, target/ с предыдущей сборкой или файл .env с паролями — всё это улетает в демон и может осесть в слоях образа.
Файл .dockerignore работает так же, как .gitignore, — перечисляет то, что надо исключить:
# результаты предыдущих сборок
build/
target/
.gradle/
# файлы окружения и секреты
.env
*.env
*.key
*.pem
# IDE и ОС
.idea/
.DS_Store
*.iml
# исходники тестов (если не нужны в образе)
src/test/
Правило простое: всё, что не нужно для запуска приложения, не должно попадать в контекст.
Запуск от непривилегированного пользователя
По умолчанию процессы внутри контейнера запускаются от root. Это удобно при разработке, но в продакшене создаёт риск: если злоумышленник вырвется из контейнера, он получит доступ к хосту с правами суперпользователя.
Инструкция USER позволяет переключиться на обычного пользователя:
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
# создаём системного пользователя без домашней директории и оболочки
RUN addgroup --system appgroup && \
adduser --system --ingroup appgroup --no-create-home appuser
COPY --chown=appuser:appgroup app.jar app.jar
# переключаемся до запуска
USER appuser
ENTRYPOINT ["java", "-jar", "app.jar"]
--chown гарантирует, что файл приложения принадлежит этому пользователю. Приложению не нужны права root для чтения jar и запуска JVM.
Секреты не хранятся в образе
Это критически важное правило, которое нарушают чаще, чем кажется.
Что нельзя класть в Dockerfile и образ:
# НЕЛЬЗЯ — токен навсегда останется в истории слоёв
ENV DATABASE_PASSWORD=supersecret
# НЕЛЬЗЯ — даже если потом удалить, слой с секретом сохранится
COPY .env /app/.env
RUN rm /app/.env # удаление не помогает!
Docker строит образ послойно, и каждый RUN/COPY — это отдельный слой. Даже если секрет удалён в следующей инструкции, он навсегда остаётся в предыдущем слое и доступен через docker history или docker save.
Правильный подход: передавать секреты через переменные окружения при запуске контейнера, через секретные хранилища (Docker Secrets, Vault, AWS Secrets Manager) или через механизм --mount=type=secret при сборке:
# правильно — передать секрет при запуске, не при сборке
docker run -e DATABASE_PASSWORD="$DB_PASS" myapp:1.0
В Spring Boot значение переменной окружения автоматически подставляется в application.properties через ${DATABASE_PASSWORD}.
Сканирование образов на уязвимости
Даже правильно построенный образ может содержать уязвимости в базовых системных библиотеках. Сканеры проверяют образ по базам CVE и выдают отчёт: какой пакет, какая уязвимость, есть ли исправленная версия.
Docker Scout встроен в Docker Desktop и Docker Hub:
# проверить локальный образ
docker scout cves myapp:1.0
# краткая сводка
docker scout quickview myapp:1.0
Trivy — популярный независимый сканер с широкими возможностями:
# установка через brew (macOS)
brew install trivy
# сканирование образа
trivy image myapp:1.0
# только критические и высокие уязвимости
trivy image --severity HIGH,CRITICAL myapp:1.0
Оба инструмента хорошо интегрируются в CI/CD — можно прерывать конвейер сборки при обнаружении критических уязвимостей. Это превращает проверку безопасности из ручного действия в автоматический контроль качества.
Подробнее о публикации образов и встраивании сканирования в конвейер — в статье Реестры и CI/CD для образов.
Чек-лист production-образа
Перед тем как образ уйдёт в продакшен, пройдитесь по списку:
- Базовый образ содержит только JRE, не JDK
- Тег базового образа зафиксирован (не
latest) - Используется многоступенчатая сборка — JDK и инструменты сборки не попадают в финальный образ
.dockerignoreисключает тестовые файлы, кеши, файлы окружения- Приложение запускается от непривилегированного пользователя (
USER) - В Dockerfile нет паролей, токенов, ключей — они передаются извне при запуске
- Образ проверен сканером (
docker scoutилиtrivy)
Коротко
- Выбирайте минимальный базовый образ:
eclipse-temurin:21-jreдля большинства случаев, distroless для максимальной безопасности. - Фиксируйте версию тегом или дайджестом —
latestскрывает неожиданные обновления. - Многоступенчатая сборка держит JDK и инструменты сборки за пределами финального образа.
.dockerignoreне даёт кешам, IDE-файлам и файлам окружения попасть в контекст.- Запускайте процесс от непривилегированного пользователя — минимизируйте ущерб от взлома.
- Секреты никогда не кладутся в Dockerfile или слои образа — только передаются при запуске.
- Регулярное сканирование (
docker scout,trivy) выявляет уязвимости в системных библиотеках.
Что почитать дальше
- Многоступенчатая сборка и слои образа — как Docker кеширует слои и почему порядок инструкций в Dockerfile важен.
- JVM внутри контейнера — почему JVM не видит правильный объём памяти и как это исправить флагами.
- Реестры и CI/CD для образов — как публиковать образы и встроить сборку в конвейер автоматически.