← назад к разделу

Образ собрать несложно — но плохой образ тянет в продакшен лишние гигабайты, открытые уязвимости и, в худшем случае, секреты, которые туда не должны были попасть. В этой статье разберём практики, которые отличают образ «работает» от образа «готов к эксплуатации».

Зачем заботиться о размере и безопасности

Когда разработчик впервые собирает образ, он обычно берёт самый очевидный базовый образ — например, 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 для образов — как публиковать образы и встроить сборку в конвейер автоматически.