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

При сборке Java-приложения в Docker первый инстинкт — положить в контейнер всё: JDK, Maven, исходники, скомпилированные классы. Образ получается гигантским и несёт инструменты, которые в рабочем контейнере не нужны. Multi-stage сборка решает эту проблему: строим в одном образе, запускаем в другом, меньшем.

Дальше — та же история по тактам: что уезжает в прод, а что остаётся на сборочной машине, и что пересобирается после правки одного Java-класса.

один Dockerfile: что уезжает в прод и что пересобирается наивный образ · один FROMFROM temurin:21-jdk450 МБCOPY . . — весь проект20 МБMaven тянет зависимости260 МБRUN package → app.jar40 МБитого в одном образе770 МБ уезжает в прод — целиком770 МБвнутри: JDK, Maven, исходникиинструменты сборки — в продеполезных байт — только 40 МБ этап build · сборочная машинаFROM temurin:21-jdk450 МБCOPY pom.xml · mvnwRUN dependency:go-offline260 МБCOPY src/ src/20 МБRUN package → app.jar40 МБ финальный образ · продFROM temurin:21-jre280 МБCOPY --from=build app.jar40 МБитого 320 МБ770 МБ — один образ320 МБ — multi-stage плохой порядок: src/ выше pom.xmlFROM temurin:21-jdkиз кэшаCOPY src/ src/изменилсяCOPY pom.xmlпересобратьRUN go-offlineзависимости зановоRUN packageпересобрать что делает docker buildпересобрать 4 слоя из 5COPY src/ стоит выше pom.xmlи сбрасывает кэш всего, что ниже260 МБ зависимостей — зановохотя pom.xml не менялся верный порядок: pom.xml выше src/FROM temurin:21-jdkиз кэшаCOPY pom.xmlиз кэшаRUN go-offlineиз кэшаCOPY src/ src/изменилсяRUN packageпересобрать что делает docker buildпересобрать 2 слоя из 5pom.xml и go-offline выше src/их слои кэш не трогает260 МБ зависимостей — из кэшапересобирается только новый код один FROM — в прод уезжают все 770 МБ вместе с JDK COPY --from=build берёт только app.jar — в прод 320 МБ слой изменился — пересобирается он и всё, что ниже редкое выше, частое ниже — зависимости остаются в кэше

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 отбросит.

Короткая формула: собрать артефакт там, где есть инструменты; запустить артефакт там, где нет ничего лишнего.

этап build (JDK) JDK, Maven, src остаётся у сборщика app.jar 40 МБ единственный COPY --from FROM 21-jre 280 МБ + app.jar 40 МБ образ 320 МБ

Развилка сборки: слева всё, что остаётся на сборочной машине, справа единственное, что переносит мостик 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/.

без .dockerignore весь каталог контекст 500 МБ кэш COPY сброшен с .dockerignore pom.xml, src/, mvnw контекст 2 МБ кэш COPY цел

Контекст сборки с файлом исключений и без него: смотрите на третью колонку, лишние каталоги не только раздувают контекст, но и сбрасывают кэш слоя 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 байт — норма: метаданные ничего не весят.

Таблица размеров заканчивается на 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.

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