Образ собрать несложно — но плохой образ тянет в продакшен лишние гигабайты, открытые уязвимости и, в худшем случае, секреты, которые туда не должны были попасть. В этой статье разберём практики, которые отличают образ «работает» от образа «готов к эксплуатации».
Ниже — четыре базовых образа такт за тактом: что каждый тащит внутрь, сколько весит и чем приходится платить за экономию.
Начинать стоит с 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.
Один и тот же 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.
Четыре слоя одного образа: смотрите на третий, где лежит секрет, и на четвёртый, который его не удаляет.
Правильный подход: передавать секреты через переменные окружения при запуске контейнера или через секретные хранилища (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 МБ, из них код 8 | Spring 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-jre280, alpine 175, distroless 190; сборка в одном образе 700–900 против 300–350 после разделения; главная экономия в слое кода 8 МБ, а не в мегабайтах базы. - В образ кладут и указания о запуске:
HEALTHCHECKс--start-period, при необходимостиSTOPSIGNAL, метки OCI с ссылкой на коммит — по ним через полгода находят, из чего собран образ. - Что внутри, показывают
docker history(где вес и какиеARGвидны всем),docker image inspect(пользователь, архитектура, точка входа) иdive; удалённый в другом слое файл продолжает весить. - SBOM (
--sbom) отвечает на вопрос «нас касается новая уязвимость», подпись (cosignпо дайджесту) — на вопрос «этот образ собрали мы».
Что почитать дальше
- Многоступенчатая сборка и слои образа — как Docker кеширует слои и почему порядок инструкций в Dockerfile важен.
- JVM внутри контейнера — почему JVM не видит правильный объём памяти и как это исправить флагами.
- Реестры и CI/CD для образов — как публиковать образы и встроить сборку в конвейер автоматически.