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

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

Ниже — четыре базовых образа такт за тактом: что каждый тащит внутрь, сколько весит и чем приходится платить за экономию.

одна строка FROM: что внутри образа, сколько весит и чем платим что лежит внутри образа чем это оборачивается JDK и компилятор javac пакетный менеджер apt/apk оболочка sh, ls, curl JRE — среда выполнения app.jar — приложение размер базового образа FROM openjdk:latestпервая попыткаестьестьестьестьестьтег latest: при пересборке другая Javaкомпилятор и apt уезжают в продbash и curl — инструментарий внутрирепозиторий заброшен — обновлений нетвнутри всё, что нужно атакующему≈500 МБJDK, apt, bash, curl — всё едет в прод FROM eclipse-temurin:21.0.5_11-jre-jammyвыбор по умолчаниюнетестьестьестьестьjar исполняется на JRE — JDK не нужентег с версией — сборка повторяемавнутри sh — зайти и посмотреть можнов отчёте сканера — только база Ubuntuстарт для нового проекта≈280 МБ280 МБ — точка отсчёта для остальных FROM eclipse-temurin:21-jre-alpineминус ~100 МБнетестьестьестьестьминус ~100 МБ за счёт AlpineJRE собран на musl, не на glibcnetty-tcnative и snappy могут упастьпроверять на тестах до переходадешевле, но не бесплатно≈175 МБ175 МБ — примерно на 100 меньше temurin FROM gcr.io/distroless/java21-debian12максимум безопасностинетнетнетестьестьни sh, ни ls, ни пакетного менеджераdocker exec внутрь уже не зайдётдиагностика — docker cp или sidecarпо размеру — как обычный JRE-образповерхность атаки минимальна≈190 МБ≈190 МБ — не меньше alpine, зато без утилит

Начинать стоит с eclipse-temurin:21-jre: около 280 МБ и живой поток обновлений. Alpine снимает ещё сотню мегабайт, но меняет glibc на musl; distroless по размеру почти как обычный JRE-образ — его берут не за размер, а за то, что внутрь нечем зайти.

Обязательно

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

Когда разработчик впервые собирает образ, он обычно берёт самый очевидный базовый образ — например, openjdk — и добавляет в него приложение. Это работает, и весит такой образ не так уж страшно: порядка 500 МБ, не «несколько гигабайт», как иногда пишут. Но внутри лежат компилятор, отладчики, пакетный менеджер и ещё сотни пакетов, которые приложению в продакшене никогда не понадобятся.

С openjdk есть и отдельная беда, куда важнее размера: этот репозиторий на Docker Hub заброшен. Новые версии Java туда не выкладывают, обновления безопасности к старым — тоже. Тега latest там уже просто нет, а то, что осталось, стареет с каждым днём. Именно поэтому его не берут, а не из-за лишних мегабайт.

Проблемы не только в размере:

  • Каждый лишний пакет — потенциальная уязвимость. Если в образе есть curl или bash, злоумышленник, получивший доступ к контейнеру, сразу получает удобный инструментарий.
  • Большие образы дольше загружаются при развёртывании — особенно заметно при горизонтальном масштабировании, когда новые узлы поднимаются за секунды.
  • Чем меньше образ, тем быстрее его проверяет сканер уязвимостей и тем меньше ложных срабатываний.

Короткая формула: меньше в образе → меньше поверхность атаки.

Выбор базового образа

Для Java-приложений на основе Spring Boot исполняемый jar не требует JDK — ему нужна только JRE. Это первый шаг к уменьшению образа.

Как выбирать: для нового проекта — начните с eclipse-temurin:21-jre, переходите на distroless, когда ужесточаете требования по безопасности. Ниже — что стоит за каждым из трёх вариантов, от универсального к самому закрытому.

eclipse-temurin с JRE

eclipse-temurin:21-jre — рекомендованный выбор для большинства случаев. Это официальный образ OpenJDK от Eclipse Adoptium, построенный на Ubuntu (в имени тега это видно по суффиксу: -jammy — Ubuntu 22.04, -noble — 24.04). Содержит только JRE, весит около 280 МБ. Хорошо поддерживается, совместим с распространёнными инструментами диагностики.

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Alpine-варианты

eclipse-temurin:21-jre-alpine уменьшает образ примерно на сотню мегабайт — до ~175 МБ — за счёт минималистичного дистрибутива Alpine Linux. Минус — Alpine использует musl вместо glibc. Для большинства Spring Boot приложений это не проблема, но библиотеки с нативными вставками — например, ускоритель шифрования netty-tcnative или компрессор snappy — рассчитаны на glibc и на Alpine могут не загрузиться. Проверяйте на тестах перед переходом.

«Проверяйте на тестах» — совет без критерия, поэтому вот что именно проверять. Замена glibc на musl меняет три вещи, и все три бьют не в компиляции, а в работе под нагрузкой.

Разрешение имён работает иначе. У musl своя реализация: она опрашивает серверы имён параллельно и берёт первый ответ, не поддерживает часть возможностей /etc/resolv.conf (в том числе search с несколькими доменами ведёт себя по-другому) и по-своему обращается с записями IPv6. Практические симптомы: «иногда не находит соседний сервис» в Kubernetes, где короткие имена дополняются доменом поиска, и странные задержки на первом обращении. Проверять надо обращение по короткому имени сервиса из контейнера в кластере, а не ping по полному имени с ноутбука.

Профиль выделения памяти другой. Аллокатор musl экономнее по памяти и заметно медленнее glibc на нагрузке с большим числом потоков, которые часто выделяют и освобождают память, — а это ровно профиль приложения Spring под нагрузкой. Проверять надо задержку под нагрузкой, а не факт запуска: замер p99 на десяти минутах обстрела на обоих образах даёт ответ за час работы.

Нативные библиотеки должны быть собраны под musl. Часть проектов поставляет отдельные сборки, часть — нет; в последнем случае библиотека либо не загрузится, либо молча уйдёт в резервную реализацию на чистой Java (так делают и netty-tcnative, и snappy) — то есть шифрование или сжатие продолжит работать, только медленнее. Проверять надо журнал запуска: строка о том, что нативная реализация недоступна и включён резервный путь, обычно там есть.

Отсюда практический вывод: на Alpine переходят, когда сотня мегабайт действительно важна (тысячи узлов, платный трафик реестра), и после замера задержки под нагрузкой. Для обычного сервиса eclipse-temurin:21-jre на glibc проще и предсказуемее.

Distroless

Образы gcr.io/distroless/java21-debian12 от Google вообще не содержат оболочки, пакетного менеджера и утилит — только Java-рантайм и минимальные системные библиотеки. Поверхность атаки минимальна. При этом по размеру distroless — примерно 190 МБ, то есть не меньше Alpine и почти как обычный JRE-образ: его берут не за размер, а за то, что внутрь нечем зайти.

FROM gcr.io/distroless/java21-debian12
WORKDIR /app
COPY app.jar app.jar
USER nonroot
ENTRYPOINT ["java", "-jar", "app.jar"]

Обратите внимание, чего в этом примере нет: ни одной инструкции RUN. Она там и не сработает — RUN выполняется через оболочку, а оболочки в distroless нет. Значит, и пользователя себе не создать. Его и не надо: в образ уже встроен пользователь nonroot, достаточно на него переключиться строкой USER nonroot (или взять образ с тегом :nonroot, где он выбран заранее). Всё, что требует установки пакетов или подготовки файлов, делается на сборочном этапе multi-stage — в финальный distroless-образ приезжают только готовые файлы.

Плата за безопасность — отладка внутри контейнера невозможна: нет sh, нет ls, нет ничего. Для диагностики используют docker cp, внешние профилировщики или временный sidecar-контейнер с нужными инструментами.

Конкретные теги вместо 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 --mount=type=cache,target=/root/.gradle ./gradlew dependencies --no-daemon

# теперь копируем исходники и собираем
COPY src/ src/
RUN --mount=type=cache,target=/root/.gradle ./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, кеш зависимостей — всё это остаётся только в промежуточном слое и не попадает в финальный образ. Подробнее о слоях и многоступенчатых сборках — в статье Многоступенчатая сборка и слои образа.

Строка RUN --mount=type=cache,... в этом примере — отдельный приём, который стоит пояснить. Обычный RUN каждый раз начинает с чистого листа: скачанные Gradle зависимости ложатся в слой, а при сбросе кэша качаются заново. Эта запись заводит для каталога /root/.gradle постоянное хранилище на сборочной машине: оно переживает пересборки, но в слои образа не попадает — скачанные библиотеки не утяжелят результат.

Работает приём на BuildKit — это сборщик, включённый по умолчанию начиная с Docker 23. На машине постарше нужно попросить его явно: поставить DOCKER_BUILDKIT=1 перед командой и первой строкой Dockerfile написать # syntax=docker/dockerfile:1.

.dockerignore — что не класть в контекст сборки

Когда вы запускаете docker build, содержимое текущей директории становится контекстом сборки — набором файлов, откуда инструкции COPY могут что-то взять. Если в ней лежат node_modules/ на 500 МБ, target/ с предыдущей сборкой или файл .env с паролями — всё это сборщику придётся перебрать, а неаккуратный COPY затащит их в слои образа.

Файл .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.

без USER uid 0 в контейнере uid 0 на хосте root на машине USER appuser uid 101 в контейнере uid 101 на хосте только свой каталог

Один и тот же uid внутри контейнера и на хосте: сравните, что достанется взломщику при побеге в каждой строке.

И сразу про то, что ломается первым после добавления USER: приложению нужно куда-то писать. Раньше процесс был root и мог писать всюду, теперь не может — а писать он всё-таки пытается:

  • Tomcat внутри Spring Boot распаковывает временные файлы в каталог, который берёт из java.io.tmpdir, то есть в /tmp;
  • туда же идут снимки кучи, выгрузки, временные файлы загрузок и библиотек, которые распаковывают из себя нативный код (сжатие, криптография);
  • логи в файл, если приложение пишет не в поток вывода (а в контейнере оно должно писать в поток).

Проявляется это либо падением на старте (java.io.IOException: Permission denied, иногда без внятного стека), либо, что хуже, отказом уже под нагрузкой — при первой загрузке файла. Лечится одной строкой в Dockerfile: каталог создаётся и отдаётся нужному пользователю до переключения.

RUN mkdir /tmp/app && chown appuser:appgroup /tmp/app
USER appuser
ENTRYPOINT ["java", "-Djava.io.tmpdir=/tmp/app", "-jar", "app.jar"]

В базовых образах /tmp обычно уже доступен для записи всем, поэтому чаще всего достаточно ничего не делать — но проверить это надо запуском, а не предположением. Тем более что следующий шаг, корневая файловая система только для чтения из раздела ниже, отнимает и /tmp, и тогда его подключают отдельно.

Что ещё описывают в образе: HEALTHCHECK, STOPSIGNAL и метки

Кроме содержимого, образ несёт в себе несколько указаний о том, как его запускать. Они ничего не весят и избавляют от повторения одних и тех же флагов в каждом месте запуска.

HEALTHCHECK — проверка работоспособности, встроенная в образ. Тогда любой, кто запустит образ, получит её автоматически, без чтения вашей документации:

HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=40s \
  CMD wget -qO- http://localhost:8080/actuator/health/readiness || exit 1

Оговорка для минимальных образов: в distroless нет ни wget, ни curl, ни оболочки — там проверку либо оставляют оркестратору (в Kubernetes она всё равно описывается в манифесте пода и проверку из образа игнорирует), либо вызывают маленький класс через CMD ["java", ...]. Разбор параметров — в статье про запуск контейнеров.

STOPSIGNAL — какой сигнал посылать при остановке. Для JVM-приложения подходит стандартный SIGTERM, и строка нужна только если приложение завершается по другому сигналу. Полезнее знать обратное: если остановка контейнера всегда занимает ровно десять секунд, дело не в STOPSIGNAL, а в том, что первым процессом стал shell из-за строковой формы ENTRYPOINT.

Метки (LABEL) — сведения о происхождении образа, которые видны в реестре и в docker inspect. Есть договорённость об именах (OCI), и ей стоит следовать, потому что реестры и инструменты её понимают:

LABEL org.opencontainers.image.source="https://github.com/acme/orders" \
      org.opencontainers.image.revision="$GIT_SHA" \
      org.opencontainers.image.version="1.4.2"

Зачем это нужно на практике: через полгода по образу из реестра надо понять, из какого коммита он собран. Без меток это гадание по тегу, с метками — одна команда. Значения подставляют в конвейере через --build-arg, о чём статья про реестры.

EXPOSE — не настройка, а документация: порт всё равно надо публиковать флагом при запуске. Строка полезна тем, что читающий Dockerfile сразу видит, чего ожидать.

Секреты не хранятся в образе

Это критически важное правило, которое нарушают чаще, чем кажется.

Что нельзя класть в Dockerfile и образ:

# НЕЛЬЗЯ — токен навсегда останется в истории слоёв
ENV DATABASE_PASSWORD=supersecret

# НЕЛЬЗЯ — даже если потом удалить, слой с секретом сохранится
COPY .env /app/.env
RUN rm /app/.env  # удаление не помогает!

Docker строит образ послойно, и каждый RUN/COPY — это отдельный слой. Даже если секрет удалён в следующей инструкции, он навсегда остаётся в предыдущем слое и доступен через docker history или docker save.

FROM temurin слой 1, база COPY app.jar слой 2, приложение COPY .env слой 3, секрет внутри RUN rm .env слой 4, файла нет

Четыре слоя одного образа: смотрите на третий, где лежит секрет, и на четвёртый, который его не удаляет.

Правильный подход: передавать секреты через переменные окружения при запуске контейнера или через секретные хранилища (Docker Secrets, Vault, AWS Secrets Manager):

# правильно — передать секрет при запуске, не при сборке
docker run -e DATABASE_PASSWORD="$DB_PASS" myapp:1.0

Отдельный случай — когда токен нужен во время сборки: скачать зависимость из закрытого репозитория, дёрнуть внутренний сервис. Через ARG его передавать нельзя, значение осядет в истории образа. Для этого есть --mount=type=secret: файл с секретом подкладывается только на время одной инструкции RUN и в слой не записывается.

# syntax=docker/dockerfile:1
FROM eclipse-temurin:21-jdk-jammy
RUN --mount=type=secret,id=gradle_token \
    GRADLE_TOKEN="$(cat /run/secrets/gradle_token)" ./gradlew build --no-daemon
docker build --secret id=gradle_token,env=GRADLE_TOKEN -t myapp:1.0 .

После сборки docker history покажет саму строку RUN, но не значение токена — внутри образа его нет.

В Spring Boot значение переменной окружения автоматически подставляется в application.properties через ${DATABASE_PASSWORD}.

Как разглядеть, из чего состоит образ

Прежде чем оптимизировать, полезно увидеть, что внутри. Три инструмента, от самого доступного к самому удобному.

docker history показывает слои с размерами и командой, которая каждый создала:

docker history myapp:1.4.2 --no-trunc --format '{{.Size}}\t{{.CreatedBy}}'

По выводу сразу видно, какой слой большой и почему. Заодно здесь всплывает то, о чём предупреждает раздел про секреты: значения ARG и ENV видны в истории любому, кто скачал образ. Это и есть та самая проверка, которую стоит прогнать перед первой публикацией.

docker image inspect отдаёт всё, что записано в образе: точка входа, переменные, объявленные тома и порты, пользователь, метки, архитектура.

docker image inspect myapp:1.4.2 --format '{{.Config.User}} {{.Architecture}} {{json .Config.Entrypoint}}'

Это первое, куда смотрят, когда образ ведёт себя не так, как ожидали: запускается от root (пустой User), собран под другую архитектуру, тянет за собой переменную из базового образа.

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

dive myapp:1.4.2

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

Практический порядок: history — увидеть, где вес; dive — увидеть, что именно лежит; inspect — проверить, как запустится. Пятнадцать минут на новом образе экономят потом часы.

Сканирование образов на уязвимости

Даже правильно построенный образ может содержать уязвимости в базовых системных библиотеках. Сканеры проверяют образ по базам 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 — можно прерывать конвейер сборки при обнаружении критических уязвимостей. Это превращает проверку безопасности из ручного действия в автоматический контроль качества.

Сканер что-то нашёл: что делать

Отчёт сканера пугает длиной, и первая реакция — искать, что поправить в своём коде. Почти всегда чинить надо не его.

Девять из десяти находок — в базовом образе. Это системные библиотеки: openssl, zlib, libc, утилиты. Ваш код к ним отношения не имеет, и правильное действие одно: обновить тег базового образа и пересобрать. Свежий eclipse-temurin:21-jre-jammy закрывает разом десятки находок, потому что в него уже приехали исправленные пакеты. Отсюда и практика: базовый образ обновляют по расписанию (раз в неделю-две), а не когда сканер закричит. Автоматическое обновление зависимостей (Renovate, Dependabot) умеет делать это само, создавая запрос на изменение с новым тегом.

Часть находок — в зависимостях приложения. Их видно тому же сканеру, и лечатся они обновлением версии библиотеки в сборочном файле. Полезно помнить, что уязвимость в библиотеке, которую ваш код не вызывает, реально не эксплуатируется — но доказывать это дороже, чем обновиться.

Часть находок не исправить вовсе. Уязвимость есть, исправления от поставщика нет («won't fix», «affected, no fix available»), или она в пакете, который нельзя убрать. Если конвейер падает на любой находке уровня «высокая», он однажды встанет намертво и его отключат — это худший исход. Поэтому нужны две вещи:

  • Порог. Падать на CRITICAL и на HIGH с доступным исправлением (--ignore-unfixed у Trivy), остальное — в отчёт, а не в отказ сборки.
  • Список исключений с датой и причиной. У Trivy это файл .trivyignore, у Docker Scout — политика; в каждой строке идентификатор уязвимости, дата и одна строка «почему сейчас не чиним». Без даты список превращается в вечное разрешение, поэтому его пересматривают на том же расписании, что обновление образов.

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

SBOM и подпись образа

Два шага, которые обычно добавляют, когда образы начинают уезжать за пределы своей команды.

SBOM — список всего, из чего собран образ: пакеты системы, библиотеки Java, версии. Сборщик умеет прикладывать его к образу сам:

docker buildx build --sbom=true --provenance=true -t ghcr.io/acme/orders:1.4.2 --push .

Зачем это нужно на практике: когда выходит следующий громкий отчёт об уязвимости в популярной библиотеке, вопрос «нас это касается?» решается запросом к списку, а не пересборкой всех образов. Тот же --provenance записывает, чем и из какого исходного кода образ собран.

Подпись отвечает на другой вопрос — «этот образ действительно собрали мы». Инструмент cosign подписывает образ в реестре, а в кластере политика допускает к запуску только подписанные:

cosign sign ghcr.io/acme/orders@sha256:<дайджест>
cosign verify ghcr.io/acme/orders@sha256:<дайджест> --certificate-identity-regexp '.*' --certificate-oidc-issuer-regexp '.*'

Подписывают по дайджесту, а не по тегу: тег можно переставить на другой образ, дайджест — нет. Это та же логика, по которой развёртывание ссылается на дайджест, о чём статья про реестры.

Подробнее о публикации образов и встраивании сканирования в конвейер — в статье Реестры и CI/CD для образов.

Чек-лист production-образа

Перед тем как образ уйдёт в продакшен, пройдитесь по списку. Первые три пункта важнее остальных — с них и начинайте, если времени мало:

  • В Dockerfile нет паролей, токенов, ключей — они передаются извне при запуске
  • Тег базового образа зафиксирован (не latest)
  • Базовый образ содержит только JRE, не JDK
  • Используется многоступенчатая сборка — JDK и инструменты сборки не попадают в финальный образ
  • .dockerignore исключает тестовые файлы, кеши, файлы окружения
  • Приложение запускается от непривилегированного пользователя (USER)
  • Образ проверен сканером (docker scout или trivy)
Дополнительно: при первом чтении можно пропустить

Глубже: безопасность запуска, а не образарасширенное

Чек-лист выше про содержимое образа. Тот же образ можно запустить так, что взлом приложения даёт взломщику всю машину, и так, что даёт ему только этот контейнер. Разница в пяти флагах запуска.

Корневая файловая система только для чтения. --read-only запрещает писать в слой контейнера: подмена библиотеки или записанный веб-шелл невозможны. Приложению всё же нужно место для временных файлов, и его дают tmpfs: --tmpfs /tmp, а для Spring Boot ещё и каталог Tomcat, который живёт в /tmp (java.io.tmpdir). Всё остальное, что приложение пишет, это тома.

Без лишних возможностей. Процесс с правами root в контейнере имеет набор возможностей ядра (capabilities), и веб-сервису из них не нужна ни одна. --cap-drop=ALL снимает все, --cap-add=NET_BIND_SERVICE возвращает единственную, если нужно слушать порт ниже 1024, а с USER из чек-листа и портом 8080 не нужна и она. --security-opt=no-new-privileges:true запрещает процессу получить больше прав через setuid-файлы, и после этого побег из контейнера через уязвимость в утилите образа перестаёт быть возможным.

Никогда --privileged и никогда docker.sock внутри контейнера. Монтирование /var/run/docker.sock выглядит безобидно (нужно «посмотреть контейнеры» из приложения), а даёт права root на хосте: через сокет можно запустить любой контейнер с --privileged и корнем хоста в томе. Если приложению нужно управлять контейнерами, это делает отдельный сервис с ограниченным API, а не сокет в поде.

В Compose это выглядит так:

services:
  app:
    image: myapp:1.4.2
    read_only: true
    tmpfs: [/tmp]
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    user: "1000:1000"

В Kubernetes те же пять пунктов называются securityContext с readOnlyRootFilesystem, capabilities.drop, allowPrivilegeEscalation: false и runAsNonRoot, и кластерная политика может требовать их у каждого пода. Стандартный профиль seccomp, который Docker включает сам, режет опасные системные вызовы и остаётся включённым, если его не отключить руками. Проверяют всё это одним прогоном: контейнер стартует и работает с флагами выше; если нет, значит приложение пишет туда, куда не должно, или просит права, которые ему не положены, и это стоит выяснить до прода.

Глубже: опорные числа разделарасширенное

В статьях раздела размеры образов мелькают в разных местах, и чтобы на них можно было опереться, вот они в одном месте для приложения Spring Boot с jar около 120 МБ. Числа для linux/amd64, сжатый размер в реестре меньше примерно вдвое.

ЧтоРазмерГде разобрано
openjdk:latest с JDK, apt и bash, устаревшийоколо 500 МБвыбор базового образа выше
eclipse-temurin:21-jre, рекомендованная точка отсчётаоколо 280 МБтам же
eclipse-temurin:21-jre-alpineоколо 175 МБтам же, с оговоркой про musl
gcr.io/distroless/java21-debian12около 190 МБтам же, без оболочки
Сборка и запуск в одном образе: JDK, Maven, исходники, зависимости700–900 МБмногоступенчатая сборка
То же после разделения на этапы, temurin JRE плюс приложение300–350 МБтам же
Приложение поверх базового: зависимости плюс кодоколо 120 МБ, из них код 8Spring Boot в контейнере, layered jar

Что из этого следует. Разница между «наивным» образом и правильным это два-три раза, а не десять, и главная экономия не в мегабайтах, а в том, что при правке кода перекачивается 8 МБ слоя кода, а не 120. Alpine и distroless экономят ещё сотню мегабайт ценой оговорок, и для большинства сервисов 21-jre достаточно. Числа плывут от версии к версии на десятки мегабайт, поэтому свои измеряют через docker images и docker history, о чём статья про слои.

Коротко

  • Выбирайте минимальный базовый образ: eclipse-temurin:21-jre для большинства случаев, distroless для максимальной безопасности.
  • Фиксируйте версию тегом или дайджестом — latest скрывает неожиданные обновления. Многоступенчатая сборка держит JDK и инструменты сборки за пределами финального образа.
  • .dockerignore не даёт кешам, IDE-файлам и файлам окружения попасть в контекст. Секреты никогда не кладутся в Dockerfile или слои образа — только передаются при запуске.
  • Запускайте процесс от непривилегированного пользователя — минимизируйте ущерб от взлома. После USER приложению нужен доступ на запись во временный каталог: Tomcat и библиотеки пишут в /tmp, иначе падение на старте или при первой загрузке файла.
  • Регулярное сканирование (docker scout, trivy) выявляет уязвимости в системных библиотеках. Находку сканера чинят обновлением тега базового образа, а не своим кодом; конвейеру нужен порог (CRITICAL и исправимые HIGH) и список исключений с датой, иначе его отключат.
  • Безопасность запуска: --read-only с tmpfs для /tmp, --cap-drop=ALL, no-new-privileges, свой пользователь, никогда --privileged и docker.sock в контейнере; в Kubernetes это securityContext.
  • Опорные числа: наивный JDK-образ около 500 МБ, 21-jre 280, alpine 175, distroless 190; сборка в одном образе 700–900 против 300–350 после разделения; главная экономия в слое кода 8 МБ, а не в мегабайтах базы.
  • В образ кладут и указания о запуске: HEALTHCHECK с --start-period, при необходимости STOPSIGNAL, метки OCI с ссылкой на коммит — по ним через полгода находят, из чего собран образ.
  • Что внутри, показывают docker history (где вес и какие ARG видны всем), docker image inspect (пользователь, архитектура, точка входа) и dive; удалённый в другом слое файл продолжает весить.
  • SBOM (--sbom) отвечает на вопрос «нас касается новая уязвимость», подпись (cosign по дайджесту) — на вопрос «этот образ собрали мы».

Что почитать дальше