Собрать образ для Spring Boot просто: FROM с Java, COPY jar, ENTRYPOINT — и docker build проходит с первого раза. Грабли начинаются потом, и все они выглядят как чужие проблемы: каждый коммит гонит в реестр 120 МБ, хотя поменялась одна строка; docker stop десять секунд молчит и рвёт живые запросы; в кластере под не стартует из-за политики «не от root»; пароль от базы находится в docker history; а образ, собранный на ноутбуке, падает на сервере с exec format error.
Всё это решается в самом Dockerfile, и ниже разобрано по одному. Первое и самое дорогое по мегабайтам решение — как содержимое jar разложить по слоям образа.
Кэш слоёв работает сверху вниз: пока правится только application/, в реестр уходит 8 МБ вместо 120 — но стоит измениться dependencies/, и все слои ниже пересобираются заново.
Самый простой Dockerfile и что в нём дорого
FROM eclipse-temurin:21-jre
COPY build/libs/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Он рабочий, и два решения в нём правильные. Первое — JRE вместо JDK: eclipse-temurin:21-jdk весит около 450 МБ, 21-jre — около 280, а компилятор и отладчик в проде не нужны никому, кроме того, кто вломился в контейнер; лишние 170 МБ уезжают в каждый узел кластера при каждой выкладке. Второе — тег с версией вместо latest, чтобы через полгода сборка не переехала на следующую мажорную Java без вашего ведома.
Дорогое здесь — COPY целого jar одним слоем. Docker кэширует слой по содержимому файлов и внутрь архива не смотрит: изменилась одна строка кода — весь jar пересобирается и уезжает в реестр целиком, как в варианте 1 на схеме. Ваш код в нём — 8 МБ из 120, остальное — зависимости, которые не менялись неделями.
Layered jar: как Spring Boot режет jar на слои
Чтобы Docker кэшировал зависимости отдельно от кода, они должны попасть в разные инструкции COPY, то есть лежать в разных папках. Обычный jar — один файл, и распаковать его надо так, чтобы загрузчик Spring Boot потом нашёл классы на прежних местах.
Spring Boot с версии 2.3 решает это сам: bootJar кладёт внутрь архива файл BOOT-INF/layers.idx — список, какой путь к какому слою относится. Настраивать ничего не нужно, слои включены по умолчанию; блок layered { } в задаче bootJar остался для тех, кто хочет описать состав слоёв по-своему или выключить их (layered { enabled = false }). По этому индексу jar распаковывается на четыре папки одной командой:
java -Djarmode=tools -jar build/libs/app.jar extract --layers --launcher --destination build/extracted
Четыре папки — это четыре COPY, и их порядок сверху вниз повторяет частоту изменений: пока правится только код, три верхних слоя берутся из кэша, а в реестр уходит одна application/.
В dependencies/ попадает вся папка BOOT-INF/lib/ — в обычном сервисе полторы сотни jar-файлов и 110–150 МБ. Библиотеки с -SNAPSHOT в версии вынесены отдельно, потому что снапшот по определению меняется чаще выпущенной версии; в проекте без снапшотов папка пустая, и COPY пустой папки ничего не ломает. Все четыре папки копируются в один каталог, а не в подпапки: JarLauncher ищет BOOT-INF/ ровно там, где его разложил Spring Boot.
Команда распаковки и имя запускающего класса менялись от версии к версии. Чужой пример из интернета падает с Unsupported jarmode или ClassNotFoundException, и по сообщению не понять, что дело в версии:
| Версия Spring Boot | Команда распаковки | Запускающий класс |
|---|---|---|
| 3.3 и новее | -Djarmode=tools ... extract --layers --launcher | org.springframework.boot.loader.launch.JarLauncher |
| 3.2 | -Djarmode=layertools -jar app.jar extract | org.springframework.boot.loader.launch.JarLauncher |
| 3.0–3.1 | -Djarmode=layertools -jar app.jar extract | org.springframework.boot.loader.JarLauncher |
Есть два Dockerfile, которые делают одно и то же и выглядят почти одинаково, и разница между ними не в слоях, а в том, где распаковывать. Первый требует, чтобы extract запустили до docker build — на машине разработчика или в конвейере, — а Dockerfile копирует уже готовые build/extracted/*/ четырьмя обычными COPY. Второй распаковывает jar внутри сборки, на отдельном этапе:
FROM eclipse-temurin:21-jre AS builder
WORKDIR /app
COPY build/libs/app.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /app/extracted/dependencies/ ./
COPY --from=builder /app/extracted/spring-boot-loader/ ./
COPY --from=builder /app/extracted/snapshot-dependencies/ ./
COPY --from=builder /app/extracted/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Layered jar отвечает за то, как резать; многоэтапная сборка (FROM ... AS builder) — где. Здесь она нужна не ради размера, как в следующей статье, а ради того, чтобы конвейеру хватало одного jar: этап builder распаковывает, финальный образ забирает четыре папки, а сам jar и промежуточные файлы в него не попадают. Четыре инструкции вместо одной — это четыре слоя, и кэш работает на каждом.
Кто получает SIGTERM: exec, оболочка и PID 1
Симптом такой: docker stop десять секунд молчит, потом контейнер останавливается с Exited (137), а в логе приложения нет ни слова о завершении — хотя локально Ctrl+C останавливает его мягко. Соединения с базой оборваны, запросы в полёте потеряны.
Так бывает, когда запуск параметризовали через оболочку, чтобы подставлять флаги JVM из переменной:
# так не надо
ENV JAVA_OPTS=""
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
Первым процессом контейнера — PID 1 — становится sh, а Java — его потомок. docker stop посылает SIGTERM только PID 1. А у PID 1 особые правила: ядро не применяет к нему действия по умолчанию, и сигнал, для которого процесс не поставил обработчик, просто пропадает. Оболочка обработчик SIGTERM не ставит — она продолжает ждать потомка как ни в чём не бывало; Docker выжидает свои 10 секунд и посылает SIGKILL, который перехватить уже нельзя. JVM обработчик ставит всегда — но до неё сигнал не дошёл.
docker stop шлёт SIGTERM только PID 1. Оболочка обработчика не ставит, а для первого процесса контейнера такой сигнал просто пропадает; exec отдаёт PID 1 самой JVM, у которой обработчик есть всегда.
Лечит одно слово: exec заменяет процесс оболочки процессом Java, PID 1 достаётся JVM, и сигнал приходит по адресу.
# так надо
ENV JAVA_OPTS=""
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]
Сигнал дошёл — это ещё не мягкий останов. По умолчанию Spring Boot на SIGTERM гасит Tomcat сразу, и запросы в полёте обрываются; дорабатывать их он начнёт только с server.shutdown=graceful. И тут появляется второй счётчик: на фазу останова Spring отводит 30 секунд (spring.lifecycle.timeout-per-shutdown-phase), а Docker убивает через 10 — если запросы длинные, SIGKILL придёт раньше, чем Spring закончит. Либо docker stop -t 30, либо укоротить фазу до бюджета того, кто останавливает контейнер.
За оболочку в ENTRYPOINT платят дважды. Аргументы после имени образа в docker run до приложения не доедут — вся командная строка зашита в sh -c. И в образе обязана быть сама оболочка, а в distroless и tiny-базах её нет — такой контейнер не стартует. Без оболочки флаги задают переменной JDK_JAVA_OPTIONS: java читает её сама, и ENTRYPOINT ["java", "-jar", "app.jar"] остаётся exec-формой.
От кого работает процесс: USER
В образах eclipse-temurin пользователь не задан — процесс идёт от root, и это тот же uid 0, что и на хосте. Изоляция контейнера — это namespaces и cgroups, а не стена: уязвимость в приложении или в самой JVM даёт root внутри, а от root внутри до хоста короче, чем кажется. Права суперпользователя приложению не нужны: оно читает jar и слушает порт 8080, а привилегированные порты заканчиваются на 1024.
Кластер напомнит об этом первым: политика runAsNonRoot: true в Kubernetes не даст поду стартовать. Пользователя заводят в образе и переключаются на него до ENTRYPOINT:
FROM eclipse-temurin:21-jre
RUN useradd --system --uid 10001 --create-home appuser
WORKDIR /app
COPY --chown=appuser:appuser build/libs/app.jar app.jar
USER 10001
ENTRYPOINT ["java", "-jar", "app.jar"]
Две детали, на которых спотыкаются после USER. Первая — права на файлы: всё, что COPY положил до переключения, принадлежит root, и попытка писать рядом с jar — логи, временные файлы, кэш — заканчивается Permission denied на старте. --chown отдаёт файлы пользователю, а писать приложению лучше в /tmp. Вторая — число вместо имени в USER: Kubernetes проверяет runAsNonRoot по uid из образа, и при USER appuser отказывает с «cannot verify user is non-root» — uid по имени он не узнаёт. Полный разбор минимальных и безопасных образов — в отдельной статье.
Что в образ, а что снаружи: профиль, конфигурация, секреты
Один и тот же образ должен запускаться на стенде и в проде — иначе проверяли одно, а выкатили другое. Значит, всё, что различается между окружениями, в образ не запекают, а приносят при запуске. Деление простое: в образе — структура и умолчания, то есть application.yml с профилями, порт, таймауты; снаружи — адреса, лимиты и всё, что знает только это окружение; секреты — отдельным путём, потому что у них своя утечка.
Первый канал — переменные среды. Spring Boot читает их с ослабленным сопоставлением имён: SPRING_DATASOURCE_URL становится spring.datasource.url, а SPRING_PROFILES_ACTIVE=prod включает профиль:
docker run -e SPRING_PROFILES_ACTIVE=prod -e SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/app myapp:1.4.2
Второй — файл конфигурации рядом с jar. Spring Boot и без флагов ищет application.yml в ./config/ относительно рабочего каталога, а WORKDIR /app делает этот каталог равным /app/config/. Достаточно подмонтировать файл туда, и его значения перекроют зашитые в jar:
docker run -v ./prod.yml:/app/config/application.yml myapp:1.4.2
Каталог в другом месте подключают переменной SPRING_CONFIG_ADDITIONAL_LOCATION=file:/etc/app/ — она добавляет место к стандартным, не заменяя их.
Секреты через -e — уже компромисс: переменные среды видны в docker inspect каждому, у кого есть доступ к Docker-сокету, и попадают в дампы окружения при ошибках. Надёжнее файл. Compose кладёт секрет в /run/secrets/<имя>, а Spring Boot умеет читать такой каталог как набор свойств — имя файла становится именем свойства:
services:
app:
image: myapp:1.4.2
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_CONFIG_IMPORT: optional:configtree:/run/secrets/
secrets: [db_password]
secrets:
db_password:
file: ./secrets/db_password
Файл /run/secrets/db_password превращается в свойство db_password, и в application.yml пароль подставляется как ${db_password} — сам пароль не появляется ни в образе, ни в compose-файле, ни в docker inspect. В Kubernetes тот же приём работает с Secret, смонтированным как том. Секрет, который нужен во время сборки — токен к закрытому репозиторию зависимостей, — передают только через --mount=type=secret: через ARG или ENV он навсегда останется в истории слоёв; как это устроено, разобрано в статье про безопасные образы.
Как снаружи узнать, что приложение поднялось: HEALTHCHECK
Для Docker контейнер «работает», пока жив PID 1. JVM стартует за секунду, Spring Boot поднимается ещё 5–15 секунд, а зависшее после старта приложение с точки зрения Docker ничем не отличается от здорового. Инструкция HEALTHCHECK заставляет Docker периодически спрашивать само приложение:
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health/readiness || exit 1
Три вещи здесь не очевидны. Проверка выполняется внутри контейнера, значит, в образе нужен клиент: в eclipse-temurin на Ubuntu curl есть, в distroless нет ни его, ни оболочки — туда HEALTHCHECK не вставить, проверяет оркестратор снаружи. --start-period=40s — бюджет на старт: провалы внутри него не считаются, иначе медленно стартующая JVM получит unhealthy раньше, чем откроет порт. И адрес: в Spring Boot 3 группы liveness и readiness включаются сами только внутри Kubernetes — в Docker их включают свойством management.endpoint.health.probes.enabled=true, иначе проверка получает 404, и контейнер навсегда остаётся unhealthy, хотя /actuator/health отвечает UP.
Из коробки обе группы смотрят только на состояние самого приложения. Проверку базы добавляют в readiness через management.endpoint.health.group.readiness.include=readinessState,db, а в liveness — никогда: иначе сбой базы перезапустит все экземпляры разом. Что Docker делает с результатом проверки и почему сам он ничего не перезапускает, разобрано в статье про запуск контейнеров; Kubernetes инструкцию HEALTHCHECK игнорирует вовсе — у него свои пробы в манифесте пода.
Архитектура образа: arm64 на ноутбуке, amd64 на сервере
Образ, собранный на ноутбуке с Apple Silicon, на сервере не стартует: exec format error в первой же строке лога. Внутри образа лежат не только ваши классы, но и JRE — машинный код под архитектуру процессора. Ноутбук собрал linux/arm64, сервер ждёт linux/amd64, и переносимость байткода здесь не спасает: исполняет его платформенная JVM.
Архитектуру задают явно: docker build --platform linux/amd64 -t myapp:1.4.2 .. На arm64-машине этап builder при этом выполняется под эмуляцией — java ... extract идёт в разы медленнее, но работает, а финальный образ получается серверный. Надёжнее собирать в конвейере на amd64-раннере или сразу под обе платформы через buildx — это разобрано в статье про реестры и CI.
Buildpacks и Jib: когда не писать Dockerfile
Оба инструмента собирают образ Spring Boot без Dockerfile и делают за вас всё, что выше: JRE вместо JDK, слои по layers.idx, запуск не от root. Разница — в цене, и у каждого она своя.
Cloud Native Buildpacks встроены в плагин Spring Boot: ./gradlew bootBuildImage. Демон Docker при этом нужен — плагин собирает через него и на первой сборке скачивает сборщик Paketo на сотни мегабайт. Сборщик по умолчанию (с Spring Boot 3.4 — builder-jammy-java-tiny) даёт образ без оболочки: docker exec ... sh внутрь не зайдёт. Главная особенность — калькулятор памяти: при старте он считает кучу из лимита контейнера, вычитая фиксированные области — 250 потоков по 1 МБ стека, 240 МБ под кэш скомпилированного кода, 10 МБ прямой памяти и metaspace, — а это уже около полугигабайта. При лимите 512 МБ контейнер не стартует вовсе, а в логе стоит fixed memory regions require ... which is greater than ... available for allocation; лечится переменной BPL_JVM_THREAD_COUNT или лимитом побольше. Системный пакет через apt поставить негде — только подменой run-образа на свой.
Jib от Google собирает образ прямо из скомпилированных классов и отправляет в реестр без Docker на машине — ./gradlew jib; демон нужен только задаче jibDockerBuild, которая кладёт образ локально. Базой Jib берёт eclipse-temurin с JRE под вашу версию Java, слои режет по-своему — зависимости, снапшоты, ресурсы, классы — и запускает главный класс напрямую, без JarLauncher. Инструкции RUN у него нет, значит, и пакетов не поставить. Ещё одна неожиданность: ради воспроизводимости Jib ставит всем файлам время 1 января 1970 года, и docker images показывает образ, созданный больше полувека назад.
Выбор сводится к двум вопросам. Есть ли на сборочной машине Docker: нет — Jib, только он обходится без демона. Нужно ли в образе что-то кроме Java — системный пакет, шрифты, своя база, свой HEALTHCHECK: да — ручной Dockerfile, потому что ни Buildpacks, ни Jib инструкции RUN не дают. В остальных случаях Buildpacks экономят Dockerfile ценой чужого калькулятора памяти и образа без оболочки.
Коротко
- Слой Docker кэшируется целиком: jar одним
COPYпересобирается при каждой правке кода, а четыреCOPYизextract --layers --launcherоставляют в реестре однуapplication/. - Layered jar отвечает за то, как резать; multi-stage — где: этап
builderраспаковывает, финальный образ забирает четыре папки без самого jar. sh -c "java ..."безexecделает PID 1 оболочкой: SIGTERM пропадает, через 10 секунд SIGKILL иExited (137). Сexecсигнал получает JVM, код 143. Мягкий останов — только сserver.shutdown=graceful, и его 30 секунд должны укладываться в таймаутdocker stop.USERс числовым uid и--chownна файлы приложения:runAsNonRootпо имени пользователя не проверяется, а писать от root-файлов нельзя.- В образе — умолчания и структура; адреса и профиль — переменными среды или файлом в
/app/config/; секреты — файлами черезconfigtree:/run/secrets/, не через-e. HEALTHCHECKс--start-period;/actuator/health/readinessвне Kubernetes требуетmanagement.endpoint.health.probes.enabled=true; Kubernetes саму инструкцию игнорирует.- Образ с ноутбука на arm64 не стартует на amd64:
--platform linux/amd64или сборка в конвейере. - Без Docker на сборке — Jib; нужен
RUN— только Dockerfile; иначе Buildpacks с их калькулятором памяти.
Что почитать дальше
- Multi-stage сборка и кэш слоёв Docker — когда jar собирается внутри Docker, а не в конвейере: порядок
COPY pom.xmlиsrc/,.dockerignore. - Запуск контейнеров: docker run и жизненный цикл — SIGTERM, коды выхода 137 и 143, «жив» против «готов» и почему Docker сам не перезапускает
unhealthy. - Маленькие и безопасные образы Docker — alpine и distroless,
--mount=type=secretдля сборки, сканирование образа на уязвимости. - JVM в контейнере: память и ядра — как JVM видит лимит контейнера и что делает
MaxRAMPercentage; без этого калькулятор Buildpacks не понять.