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

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

Проблема: образ с «кухней» внутри

Представьте, что вы готовите блюдо и отвозите гостю не тарелку с едой, а всю кухню целиком — со столом, плитой и ножами. Именно это происходит, когда в финальный Docker-образ попадают Maven, JDK и исходники.

Типичный «наивный» Dockerfile выглядит так:

FROM eclipse-temurin:21-jdk
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests
ENTRYPOINT ["java", "-jar", "target/app.jar"]

Проблемы:

  • Размер. Eclipse Temurin JDK 21 весит около 400 МБ. Maven скачивает ещё сотни МБ зависимостей. В итоге образ легко переваливает за 700 МБ.
  • Безопасность. JDK, Maven и исходный код попадают в продуктовый контейнер. Если в образе найдётся уязвимость в инструменте сборки, она окажется на рабочем сервере.
  • Медленная передача. Большие образы дольше скачиваются на каждом сервере.

Multi-stage: собираем отдельно, запускаем отдельно

Multi-stage сборка — это когда один Dockerfile содержит несколько инструкций FROM. Каждая FROM начинает новый этап (stage). Из предыдущего этапа можно скопировать только то, что нужно, — всё остальное Docker отбросит.

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

# ── Этап 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/app.jar app.jar

ENTRYPOINT ["java", "-jar", "app.jar"]

Что здесь происходит:

  • AS build — имя первого этапа. Имя произвольное, используется в COPY --from=.
  • COPY --from=build /app/target/app.jar app.jar — берём файл из этапа build, кладём в текущий образ.
  • Финальный образ собирается на базе eclipse-temurin:21-jre — это только среда выполнения, без компилятора и инструментов сборки. Весит около 200 МБ вместо 700+.

В финальный образ не попадают: Maven, JDK, исходники, .git, тесты, кэш зависимостей.

Как работают слои и почему порядок инструкций важен

Каждая инструкция 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 не скачивает зависимости повторно — они уже в кэше. Сборка ускоряется в несколько раз на CI.

.dockerignore: что не должно попасть в контекст сборки

Когда вы запускаете docker build, Docker отправляет контекст сборки — содержимое текущей директории — своему демону. Если не настроить исключения, туда попадут target/, .git/, IDE-файлы и всё остальное.

Файл .dockerignore работает по тем же правилам, что .gitignore, и решает три задачи:

  • уменьшает контекст → сборка начинается быстрее;
  • предотвращает инвалидацию кэша из-за несвязанных файлов;
  • не допускает чувствительные данные в образ.

Минимальный .dockerignore для проекта на Maven:

target/
.git/
.idea/
*.iml
.DS_Store

Для Gradle аналогично добавьте build/ и .gradle/.

Как посмотреть размер слоёв

После сборки можно проверить, что получилось:

# размер образов
docker images

# история слоёв с размерами
docker image history myapp:latest

Команда docker image history показывает каждый слой, команду, которая его создала, и его размер. Это помогает найти слои, которые неожиданно много весят.

Итог: насколько образ уменьшается

Для типичного Spring Boot-приложения:

ПодходПримерный размер
JDK + Maven + исходники в одном образе700–900 МБ
Multi-stage: JDK для сборки, JRE для запуска200–300 МБ
Multi-stage + JRE distroless/slim100–180 МБ

Цифры зависят от приложения, но порядок величин сохраняется.

Коротко

  • Multi-stage сборка разделяет образ для сборки (JDK + Maven) и образ для запуска (JRE + jar) — финальный образ не содержит инструментов разработки.
  • FROM ... AS <имя> именует этап; COPY --from=<имя> копирует файлы из него.
  • Слои кэшируются: инструкция не выполняется повторно, если она и её входные данные не изменились.
  • Порядок инструкций определяет качество кэширования: сначала то, что меняется редко (зависимости), потом то, что меняется часто (исходники).
  • .dockerignore сокращает контекст сборки и предотвращает лишние инвалидации кэша.
  • После разделения образ Spring Boot-приложения уменьшается с 700–900 МБ до 200–300 МБ.

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

  • Образы и Dockerfile — как устроены образы, синтаксис основных инструкций.
  • Контейнеризация Spring Boot-приложения — полный путь от кода до работающего контейнера.
  • Лучшие практики работы с образами — дополнительные способы уменьшить размер и повысить безопасность образа.