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