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

Собрать образ для Spring Boot просто: FROM с Java, COPY jar, ENTRYPOINT — и docker build проходит с первого раза. Грабли начинаются потом, и все они выглядят как чужие проблемы: каждый коммит гонит в реестр 120 МБ, хотя поменялась одна строка; docker stop десять секунд молчит и рвёт живые запросы; в кластере под не стартует из-за политики «не от root»; пароль от базы находится в docker history; а образ, собранный на ноутбуке, падает на сервере с exec format error.

Всё это решается в самом Dockerfile, и ниже разобрано по одному. Первое и самое дорогое по мегабайтам решение — как содержимое jar разложить по слоям образа.

первая сборка — кэша ещё нет поменяли одну строку в контроллере подняли версию Spring Boot — зависимости изменились вариант 1 · FROM eclipse-temurin:21-jre, весь jar одним слоем COPY build/libs/app.jar app.jar 120 МБ · зависимости 111 + загрузчик 1 + ваш код 8 собран впервые пересобран целиком ушло в реестр 120 МБ вариант 2 · та же база, layered jar разложен на четыре слоя dependencies/ 111 МБ spring-boot-loader/ 1 МБ snapshot-dependencies/ 0 МБ application/ · ваш код 8 МБ собран впервыесобран впервыесобран впервыесобран впервые из кэшаиз кэшаиз кэша пересобранпересобранпересобран пересобран ушло в реестр 120 МБ 8 МБ 120 МБ кэша нет — первая сборка стоит 120 МБ в обоих вариантах код меняется каждый коммит — слои экономят 112 МБ из 120 верхний слой изменился — всё ниже пересобрано, снова 120 МБ

Кэш слоёв работает сверху вниз: пока правится только 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
app.jar → extract --layers --launcher → четыре COPY сверху вниз внутри app.jar папка после extract меняется BOOT-INF/lib/*.jar все зависимости · 111 МБ org/springframework/boot/loader/ загрузчик JarLauncher · 1 МБ BOOT-INF/lib/*-SNAPSHOT.jar зависимости-снапшоты · 0 МБ BOOT-INF/classes/ + META-INF/ ваш код и ресурсы · 8 МБ dependencies/ COPY первый spring-boot-loader/ COPY второй snapshot-dependencies/ COPY третий application/ COPY последний раз в недели с версией Boot каждый снапшот каждый коммит BOOT-INF/layers.idx карта внутри jar: какой путь в какую папку — по ней extract и режет архив

Четыре папки — это четыре 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 --launcherorg.springframework.boot.loader.launch.JarLauncher
3.2-Djarmode=layertools -jar app.jar extractorg.springframework.boot.loader.launch.JarLauncher
3.0–3.1-Djarmode=layertools -jar app.jar extractorg.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 обработчик ставит всегда — но до неё сигнал не дошёл.

sh -c "java $JAVA_OPTS -jar …" sh -c "exec java $JAVA_OPTS -jar …" docker stop → SIGTERM PID 1 · sh PID 7 · java sh без обработчика: PID 1 сигнал теряет java его так и не увидела 10 с → SIGKILL → Exited (137) docker stop → SIGTERM PID 1 · java sh заместила себя: exec JVM ставит обработчик SIGTERM всегда shutdown hooks отработали 0,2 с → Exited (143)

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 с их калькулятором памяти.

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