При сборке Java-приложения в Docker первый инстинкт — положить в контейнер всё: JDK, Maven, исходники, скомпилированные классы. Образ получается гигантским и несёт инструменты, которые в рабочем контейнере не нужны. Multi-stage сборка решает эту проблему: строим в одном образе, запускаем в другом, меньшем.
Дальше — та же история по тактам: что уезжает в прод, а что остаётся на сборочной машине, и что пересобирается после правки одного Java-класса.
Multi-stage решает размер: в прод уезжает app.jar на JRE — 320 МБ вместо 770. Порядок слоёв решает время: если COPY src/ стоит выше pom.xml, правка одного класса качает 260 МБ зависимостей заново.
Проблема: образ с «кухней» внутри
Представьте, что вы готовите блюдо и отвозите гостю не тарелку с едой, а всю кухню целиком — со столом, плитой и ножами. Именно это происходит, когда в финальный Docker-образ попадают Maven, JDK и исходники.
Типичный «наивный» Dockerfile выглядит так:
FROM eclipse-temurin:21-jdk
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests && mv target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Небольшая деталь, на которой спотыкаются в первый же раз: Maven кладёт результат не в target/app.jar, а в target/<имя-артефакта>-<версия>.jar. Поэтому в примере стоит mv target/*.jar app.jar — переименовать один раз и дальше не зависеть от версии. Второй путь — прописать в pom.xml строку <finalName>app</finalName>, тогда файл сразу будет называться app.jar.
Проблемы:
- Размер. Eclipse Temurin JDK 21 весит около 450 МБ. Maven скачивает ещё сотни МБ зависимостей. В итоге образ легко переваливает за 700 МБ.
- Безопасность. JDK, Maven и исходный код попадают в продуктовый контейнер. Если в образе найдётся уязвимость в инструменте сборки, она окажется на рабочем сервере.
- Медленная передача. Большие образы дольше скачиваются на каждом сервере.
Multi-stage: собираем отдельно, запускаем отдельно
Multi-stage сборка — это когда один Dockerfile содержит несколько инструкций FROM. Каждая FROM начинает новый этап (stage). Из предыдущего этапа можно скопировать только то, что нужно, — всё остальное Docker отбросит.
Короткая формула: собрать артефакт там, где есть инструменты; запустить артефакт там, где нет ничего лишнего.
Развилка сборки: слева всё, что остаётся на сборочной машине, справа единственное, что переносит мостик COPY --from, внизу из чего складывается финальный образ.
# ── Этап 1: сборка ──────────────────────────────────────────
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
# сначала зависимости (кэшируются отдельно — подробнее ниже)
COPY pom.xml .
COPY .mvn/ .mvn/
COPY mvnw .
RUN ./mvnw dependency:go-offline -q
# теперь исходники
COPY src/ src/
RUN ./mvnw package -DskipTests -q
# ── Этап 2: финальный образ ──────────────────────────────────
FROM eclipse-temurin:21-jre
WORKDIR /app
# копируем только jar из этапа build (маска — чтобы не зависеть от версии в имени)
COPY --from=build /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Что здесь происходит:
AS build— имя первого этапа. Имя произвольное, используется вCOPY --from=.COPY --from=build /app/target/*.jar app.jar— берём собранный jar из этапаbuildи кладём в текущий образ под коротким именем.- Финальный образ собирается на базе
eclipse-temurin:21-jre— это только среда выполнения, без компилятора и инструментов сборки. Сама база весит около 280 МБ, с вашим jar получается примерно 320 МБ вместо 770.
В финальный образ не попадают: Maven, JDK, исходники, .git, тесты, кэш зависимостей.
То же на Gradle. Пример выше на Maven, потому что dependency:go-offline наглядно показывает разделение шагов. На Gradle идея та же, а команды другие:
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
# сначала то, что описывает зависимости — этот слой переживёт правку кода
COPY gradlew .
COPY gradle/ gradle/
COPY settings.gradle.kts build.gradle.kts ./
RUN ./gradlew dependencies --no-daemon -q || true
COPY src/ src/
RUN ./gradlew bootJar --no-daemon -x test -q
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Три отличия, о которых стоит знать. --no-daemon обязателен: демон Gradle в контейнере сборки только съедает память и умирает вместе со слоем. Готового аналога go-offline у Gradle нет — задача dependencies скачивает большую часть, но не всё, поэтому || true не даёт сборке упасть на несовпадении конфигураций; надёжнее решать это кэшем из следующего раздела. И bootJar вместо build, чтобы не гонять проверки дважды — тесты в образе не запускают, для них есть отдельный этап, о котором ниже.
Как работают слои и почему порядок инструкций важен
Каждая инструкция RUN, COPY, ADD в Dockerfile создаёт слой — неизменяемый снимок файловой системы. Docker кэширует слои: если инструкция и всё, что она использует, не изменились — слой берётся из кэша, заново не выполняется.
Вот почему порядок инструкций в Dockerfile критичен: как только один слой изменился, все последующие пересобираются.
Рассмотрим плохой пример:
# Плохо: исходники копируются до зависимостей
COPY src/ src/
COPY pom.xml .
RUN ./mvnw dependency:go-offline -q
RUN ./mvnw package -DskipTests
Здесь изменение любого файла в src/ инвалидирует кэш COPY src/, и Maven заново скачивает все зависимости — хотя pom.xml не менялся.
Правильный порядок:
# Хорошо: зависимости кэшируются отдельно от кода
COPY pom.xml .
RUN ./mvnw dependency:go-offline -q # этот слой меняется только при изменении pom.xml
COPY src/ src/
RUN ./mvnw package -DskipTests # этот слой меняется при изменении кода
Теперь если вы изменили только Java-класс, Maven не скачивает зависимости повторно — они уже в кэше. Сборка ускоряется в несколько раз в конвейере.
Одну оговорку про dependency:go-offline стоит держать в голове: полной гарантии он не даёт. Часть плагинов эта задача не подтягивает, и следующий package всё равно сходит в сеть — просто за гораздо меньшим объёмом. Если хочется надёжности, кэш локального репозитория монтируют в сборку отдельно, но это уже приём для конвейера, а не для Dockerfile из учебника.
.dockerignore: что не должно попасть в контекст сборки
Когда вы запускаете docker build, содержимое директории с проектом становится контекстом сборки — набором файлов, из которого инструкции COPY могут что-то взять. Раньше Docker честно отправлял всю эту гору сборщику целиком; сейчас (BuildKit включён по умолчанию с Docker 23) он передаёт файлы по мере надобности. Но перебрать и сверить их всё равно приходится, и если не настроить исключения, в контекст попадут target/, .git/, IDE-файлы и всё остальное.
Файл .dockerignore работает по тем же правилам, что .gitignore, и решает три задачи:
- уменьшает контекст → сборка начинается быстрее;
- предотвращает инвалидацию кэша из-за несвязанных файлов;
- не допускает чувствительные данные в образ.
Минимальный .dockerignore для проекта на Maven:
target/
.git/
.idea/
*.iml
.DS_Store
Для Gradle аналогично добавьте build/ и .gradle/.
Контекст сборки с файлом исключений и без него: смотрите на третью колонку, лишние каталоги не только раздувают контекст, но и сбрасывают кэш слоя COPY.
Кэш зависимостей, который живёт дольше слоя
Приём «сначала файл зависимостей, потом исходники» работает, пока сборка идёт на вашей машине. У него два предела, и оба всплывают в конвейере.
Первый: любое изменение в pom.xml или build.gradle.kts — добавили одну библиотеку — сбрасывает слой с зависимостями целиком, и все они качаются заново. Второй, более болезненный: кэш слоёв лежит на машине сборки, а исполнитель конвейера обычно чистый, и кэша там нет вовсе. То есть ускорение, обещанное правильным порядком инструкций, в конвейере не работает.
Оба лечит один механизм современного сборщика (BuildKit) — кэш-монтирование:
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN --mount=type=cache,target=/root/.gradle \
./gradlew bootJar --no-daemon -x test -q
Что здесь происходит. Каталог /root/.gradle (для Maven — /root/.m2) на время выполнения команды подключается из отдельного хранилища кэша, которое не является слоем образа: в образ он не попадает, размер не увеличивает, но между сборками сохраняется. Добавили одну библиотеку — скачается только она, остальные лежат в кэше. Порядок COPY при этом становится менее критичным, и Dockerfile упрощается.
Оговорка про конвейер: само кэш-монтирование по умолчанию тоже локально. Чтобы оно работало на чистом исполнителе, кэш подключают явно:
# сохранить кэш сборки в реестр рядом с образом
docker buildx build \
--cache-from type=registry,ref=ghcr.io/acme/app:buildcache \
--cache-to type=registry,ref=ghcr.io/acme/app:buildcache,mode=max \
-t ghcr.io/acme/app:1.4.2 --push .
В GitHub Actions вместо реестра берут встроенное хранилище (type=gha), в других системах — каталог на постоянном диске исполнителя (type=local). Без одной из этих настроек «правильный Dockerfile» в конвейере собирается с нуля каждый раз, и разговоры про порядок инструкций там бессмысленны.
И последняя деталь, ради которой это всё: mode=max сохраняет кэш всех этапов, включая этап сборки, а без него — только слои финального образа. Для многоэтапной сборки, где вся работа происходит в первом этапе, разница между «кэш есть» и «кэша нет» — ровно этот параметр.
Этапы: тесты, --target и параллельная сборка
Многоэтапная сборка нужна не только для того, чтобы выбросить JDK из финального образа. Этапы — это способ разложить всю сборку на части, и дальше ими можно пользоваться по отдельности.
Собрать до нужного этапа. Флаг --target останавливает сборку на указанном этапе:
docker build --target build -t my-app-build .
Зачем это надо: собрать образ с инструментами, чтобы залезть внутрь и разобраться, почему сборка падает; получить образ для отладки с JDK и утилитами; или запустить в конвейере только проверки, не собирая финальный образ.
Отдельный этап для тестов. Если тесты должны идти внутри сборки (одинаковое окружение, ничего не зависит от исполнителя), для них делают свой этап:
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN --mount=type=cache,target=/root/.gradle ./gradlew bootJar --no-daemon -x test -q
FROM build AS test
RUN --mount=type=cache,target=/root/.gradle ./gradlew test --no-daemon
FROM eclipse-temurin:21-jre AS runtime
COPY --from=build /app/build/libs/*.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
Здесь docker build --target test . прогоняет тесты, а обычная сборка их не выполняет — потому что этап test не нужен для финального образа, и BuildKit его просто не строит. Это важное свойство: строятся только те этапы, от которых зависит цель. Оговорка про тесты, которым нужна база: контейнер сборки не умеет поднимать соседние контейнеры, поэтому интеграционные тесты с Testcontainers внутри сборки не запускают — их место в конвейере, рядом со сборкой, а не внутри неё.
Независимые этапы собираются параллельно. Это то, что многоэтапная сборка даёт бесплатно, и о чём обычно не знают. Если этапы не зависят друг от друга, BuildKit строит их одновременно:
FROM eclipse-temurin:21-jdk AS backend
...
FROM node:22 AS frontend
...
FROM eclipse-temurin:21-jre
COPY --from=backend /app/build/libs/*.jar /app.jar
COPY --from=frontend /ui/dist/ /static/
Сборка бэкенда и фронтенда пойдёт в два потока, и общее время окажется равно длительности медленного, а не сумме. Проверить, что так и происходит, можно по выводу сборки: BuildKit печатает шаги обоих этапов вперемешку с номерами.
Как посмотреть размер слоёв
После сборки можно проверить, что получилось:
# размер образов
docker images
# история слоёв с размерами
docker image history myapp:latest
Команда docker image history показывает каждый слой, команду, которая его создала, и его размер:
IMAGE CREATED BY SIZE
<missing> COPY build/libs/app.jar app.jar 48.2MB
<missing> RUN apt-get update && apt-get install -y curl 96MB
<missing> WORKDIR /app 0B
<missing> ... (слои eclipse-temurin:21-jre)
Подозрителен слой, который весит больше самого приложения: здесь RUN apt-get принёс 96 МБ — это списки пакетов и кэш apt, которые не удалили в той же инструкции. Слой WORKDIR в 0 байт — норма: метаданные ничего не весят.
Свой runtime: jlink вместо полного JRE
Таблица размеров заканчивается на distroless, а есть шаг дальше, который для Java работает лучше любого выбора базового образа: собрать свой runtime, содержащий только те модули платформы, которые нужны приложению.
Работает это так. Утилита jdeps анализирует jar и его зависимости и выводит список нужных модулей платформы. Утилита jlink собирает из JDK минимальный runtime ровно с этими модулями — получается каталог на 50–90 МБ вместо полного JRE на 180–280 МБ. Его копируют в финальный образ, и JDK там больше не нужен вовсе:
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN --mount=type=cache,target=/root/.gradle ./gradlew bootJar --no-daemon -x test -q
FROM eclipse-temurin:21-jdk AS runtime-build
COPY --from=build /app/build/libs/*.jar /app.jar
RUN jlink --add-modules $(jdeps --print-module-deps --ignore-missing-deps /app.jar) \
--strip-debug --no-man-pages --no-header-files --compress=zip-6 \
--output /javaruntime
FROM debian:12-slim
COPY --from=runtime-build /javaruntime /opt/java
COPY --from=build /app/build/libs/*.jar /app.jar
ENTRYPOINT ["/opt/java/bin/java", "-jar", "/app.jar"]
Итог обычно 120–160 МБ вместе с базовым образом — меньше, чем -jre на distroless, и без потери совместимости, потому что это та же платформа, только урезанная.
Цена у приёма есть, и о ней стоит знать до того, как это уедет в прод. Список модулей определяется статически, а Spring грузит классы отражением: jdeps не увидит модуль, который нужен только в рантайме (типичные пропуски — java.sql, java.naming, jdk.crypto.ec для TLS, java.management для метрик). Проявляется это не при сборке, а при запуске — NoClassDefFoundError на первом обращении к базе или к защищённому соединению. Поэтому модули добавляют явным списком, а не только выводом jdeps, и обязательно прогоняют приложение целиком на собранном runtime до выката. И второй момент: --compress экономит место ценой распаковки при старте, что для короткоживущих задач может оказаться дороже, чем сэкономленные мегабайты.
Когда брать: когда образ едет на тысячи узлов, когда реестр и трафик стоят денег, когда время скачивания образа входит в скорость выката. Для обычного сервиса, который выкатывается несколько раз в день на десяток узлов, разница между 200 и 140 МБ не стоит усложнения сборки.
Итог: насколько образ уменьшается
Для типичного Spring Boot-приложения:
| Подход | Примерный размер |
|---|---|
| JDK + Maven + исходники в одном образе | 700–900 МБ |
Multi-stage: JDK для сборки, eclipse-temurin:21-jre для запуска | 300–350 МБ |
| Multi-stage + alpine-вариант JRE | ≈ 220 МБ |
| Multi-stage + distroless | ≈ 230 МБ |
Цифры зависят от приложения, но порядок величин сохраняется.
Мусор после сборки: кэш и промежуточные этапы
«Многоэтапная сборка экономит место» — верно про итоговый образ и неверно про машину сборки. Всё, что было в промежуточных этапах, остаётся там в виде кэша сборки: слои с JDK, скачанные зависимости, результаты каждого RUN. Через месяц ежедневных сборок это десятки гигабайт, и первым это замечает ноутбук, а не сервер.
Смотреть надо на отдельную строку в выводе docker system df — Build Cache. Чистится она своей командой:
docker builder prune # спросит подтверждение, удалит неиспользуемый кэш
docker builder prune --filter until=168h # аккуратнее: только то, чему больше недели
Две оговорки. Полная очистка означает, что следующая сборка пойдёт с нуля — это минуты, поэтому по возрасту чистить приятнее, чем целиком. И docker system prune -a тоже трогает кэш сборки, а с флагом --volumes заодно уносит тома с данными локальных баз — команда, которой не стоит пользоваться по привычке.
Сюда же относится и настройка, которая избавляет от ручной уборки: у сборщика можно задать предел размера кэша, и тогда он вытесняет старое сам. В Docker Desktop это поле в настройках, для отдельного сборщика — конфигурация BuildKit с gc и ограничением объёма.
Коротко
- Multi-stage сборка разделяет образ для сборки (JDK + Maven) и образ для запуска (JRE + jar) — финальный образ не содержит инструментов разработки.
FROM ... AS <имя>именует этап;COPY --from=<имя>копирует файлы из него. - Слои кэшируются: инструкция не выполняется повторно, если она и её входные данные не изменились.
- Порядок инструкций определяет качество кэширования: сначала то, что меняется редко (зависимости), потом то, что меняется часто (исходники).
.dockerignoreсокращает контекст сборки и предотвращает лишние инвалидации кэша. - После разделения образ Spring Boot-приложения уменьшается с 700–900 МБ до 300–350 МБ.
- Кэш слоёв живёт на машине сборки: в конвейере его нет, пока не подключён
--cache-from/--cache-to(реестр,type=ghaили локальный каталог), причёмmode=maxнужен, чтобы сохранился кэш этапа сборки, а не только финальные слои. RUN --mount=type=cacheдержит каталог зависимостей вне слоёв: добавленная библиотека качается одна, а образ не растёт.- Этапы — не только способ выбросить JDK:
--targetсобирает до нужного этапа, отдельный этап для тестов не строится при обычной сборке, независимые этапы BuildKit собирает параллельно. - Свой runtime через
jdepsиjlinkдаёт образ на 120–160 МБ, но модули, которые Spring грузит отражением, приходится дописывать вручную и проверять запуском. - Промежуточные этапы остаются кэшем сборки на машине сборщика: смотреть строку
Build Cacheвdocker system df, чиститьdocker builder prune --filter until=168h.
Что почитать дальше
- Образы и Dockerfile — как устроены образы, синтаксис основных инструкций.
- Контейнеризация Spring Boot-приложения — полный путь от кода до работающего контейнера.
- Лучшие практики работы с образами — дополнительные способы уменьшить размер и повысить безопасность образа.