Что такое multi-stage build
Каждая инструкция FROM начинает новый изолированный этап (Stage) с чистой файловой системой. Главная фишка: вы можете скопировать скомпилированный артефакт (например, app.jar) и...
🟢 Junior Level
30-секундный ответ
Multi-stage build (Многоэтапная сборка) — это механизм Docker, позволяющий использовать несколько инструкций FROM в одном Dockerfile для разделения процесса сборки приложения и его запуска в продакшене.
Каждая инструкция FROM начинает новый изолированный этап (Stage) с чистой файловой системой. Главная фишка: вы можете скопировать скомпилированный артефакт (например, app.jar) из предыдущего этапа сборщика в финальный runtime-этап с помощью команды COPY --from=<stage_name>.
В итоговый образ попадает ТОЛЬКО содержимое последнего этапа:
- Все компиляторы (JDK 21), сборочные утилиты (Maven, Gradle), кэш зависимостей (
.m2), исходный код и приватные ключи остаются в промежуточных слоях и выбрасываются. - Финальный образ содержит только минимальную JRE и готовый
.jar, сокращая размер с 900 МБ до 150–200 МБ.
Наглядная аналогия из жизни
Представьте приготовление свадебного торта на заказ:
- На кухне шеф-повар использует миксеры, тяжелые духовки, грязные венчики, мешки с мукой и мерные стаканы (Этап сборщика — Builder Stage).
- Когда торт готов, его перекладывают на чистую картонную подложку и упаковывают в красивую праздничную коробку (Финальный этап — Runtime Stage).
- Клиенту отдают только готовый торт в коробке. Никому не придёт в голову класть клиенту в коробку чугунную форму для выпечки и грязный миксер.
Наглядный пример для Java 21 / Spring Boot 3
# ==========================================
# Этап 1: Сборка артефакта (Тяжелый JDK 21 + Maven)
# ==========================================
FROM maven:3.9-eclipse-temurin-21-jammy AS builder
WORKDIR /build
# Кэширование зависимостей:
COPY pom.xml .
RUN mvn dependency:go-offline -B
# Компиляция кода:
COPY src ./src
RUN mvn clean package -DskipTests -B
# ==========================================
# Этап 2: Финальный продакшен-образ (Легковесная JRE 21)
# ==========================================
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
# Создание безопасного пользователя:
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser
# Копируем ТОЛЬКО готовый jar из этапа builder:
COPY --from=builder /build/target/application.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
🟡 Middle Level
Эволюция проблемы: Почему появился Multi-stage
До появления многоэтапных сборок (до Docker 17.05) разработчикам приходилось выбирать между двумя плохими решениями:
- Fat Image (Всё в одном): сборка прямо в продакшен-образе. Образ весил под 1 ГБ, а любой взломщик получал доступ к исходному коду, Maven и утилитам сборки прямо в контейнере.
- Паттерн Builder (Два Dockerfile): писался
Dockerfile.build, компилировал код в локальную папку хоста, а затем сложный bash-скрипт запускалDockerfile.run. Это ломало воспроизводимость в CI/CD.
Multi-stage build решил проблему элегантно: один файл, один запуск docker build, чистый результат.
Таргетинг этапов сборки (--target) в CI/CD
Многоэтапный Dockerfile позволяет запускать промежуточные этапы независимо с помощью флага --target:
FROM maven:3.9-eclipse-temurin-21 AS test-runner
WORKDIR /app
COPY . .
RUN mvn test # Запуск Unit и интеграционных тестов
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
FROM eclipse-temurin:21-jre-jammy AS prod
COPY --from=builder /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
В пайплайне GitLab CI / GitHub Actions:
# 1. Прогон тестов: если упали, сборка завершится с ошибкой
docker build --target test-runner -t myapp:test .
# 2. Сборка продакшен-образа (тестовые зависимости не попадут в prod)
docker build --target prod -t myapp:latest .
Копирование из внешних образов (COPY --from=<external_image>)
Инструкция COPY --from умеет забирать файлы не только из соседних этапов, но и из любых внешних Docker-образов:
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
# Копируем готовый бинарник утилиты wait-for-it из официального образа:
COPY --from=ghcr.io/vishnubob/wait-for-it:latest /wait-for-it /usr/local/bin/wait-for-it
COPY target/app.jar app.jar
🔴 Senior Level
Параллельное выполнение этапов в BuildKit (DAG Execution)
Современный сборщик Docker (BuildKit) строит направленный ациклический граф зависимостей (DAG — Directed Acyclic Graph):
- Если этапы в Dockerfile независимы друг от друга, BuildKit выполняет их параллельно на разных ядрах CPU.
- Пример: Polyglot Fullstack сборка:
# Ветка A: Сборка React фронтенда на Node.js (параллельно!) FROM node:20-alpine AS frontend-builder WORKDIR /frontend COPY frontend/package*.json ./ RUN npm ci COPY frontend/ ./ RUN npm run build # Ветка B: Сборка Spring Boot бэкенда на Java 21 (параллельно!) FROM maven:3.9-eclipse-temurin-21 AS backend-builder WORKDIR /backend COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests # Слияние: Финальный образ FROM eclipse-temurin:21-jre-jammy WORKDIR /app COPY --from=backend-builder /backend/target/app.jar app.jar COPY --from=frontend-builder /frontend/dist /app/static ENTRYPOINT ["java", "-jar", "app.jar"] - Пропуск неиспользуемых этапов (Pruning): если этап нигде не используется финальным шагом и не запрошен через
--target, BuildKit вообще не тратит ресурсы на его выполнение!
Spring Boot 3 AOT и GraalVM Native Image
Для достижения микросекундного старта в Serverless (AWS Lambda, Knative) multi-stage build объединяют с компиляцией в нативный машинный код (GraalVM Native Image):
# ==========================================
# Этап 1: Тяжёлая AOT-компиляция (GraalVM JDK 21)
# ==========================================
FROM ghcr.io/graalvm/native-image-community:21 AS native-builder
WORKDIR /app
RUN microdnf install -y findutils gcc glibc-devel zlib-devel
COPY . .
RUN ./mvnw -Pnative native:compile -DskipTests
# ==========================================
# Этап 2: Ультралегковесный дистрибутив (Distroless / Scratch)
# ==========================================
FROM gcr.io/distroless/base-debian12:nonroot
WORKDIR /app
COPY --from=native-builder /app/target/payment-service payment-service
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/payment-service"]
Результаты:
- Размер финального образа: $\approx 45$ МБ (вместо 400 МБ).
- Время холодного старта: $< 0.05$ секунды (50 мс против 3–5 секунд в JVM).
- Потребление базовой оперативной памяти: $\approx 35$ МБ RAM.
Создание кастомной ультракомпактной JRE через jlink
С помощью инструмента jlink из состава JDK 21 можно отсечь ненужные модули JVM (например, модули десктопного GUI java.desktop):
# Этап генерации кастомной JRE под конкретный JAR:
FROM eclipse-temurin:21-jdk-jammy AS jre-builder
WORKDIR /app
COPY target/app.jar app.jar
# Анализ требуемых модулей:
RUN $JAVA_HOME/bin/jdeps \
--ignore-missing-deps \
--multi-release 21 \
--print-module-deps \
--class-path 'BOOT-INF/lib/*' app.jar > modules.txt
# Сборка кастомной JRE:
RUN $JAVA_HOME/bin/jlink \
--add-modules $(cat modules.txt),jdk.crypto.ec \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=zip-6 \
--output /custom-jre
# Финальный образ:
FROM debian:bookworm-slim
COPY --from=jre-builder /custom-jre /opt/jre
COPY target/app.jar /app/app.jar
ENV PATH="/opt/jre/bin:$PATH"
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
4 Tricky Questions
1. Как Docker BuildKit оптимизирует выполнение этапов в multi-stage Dockerfile, и в каких случаях промежуточный этап будет полностью проигнорирован?
Ответ:
BuildKit строит ориентированный ациклический граф (DAG) от целевого этапа (по умолчанию последнего) к исходным.
Если в Dockerfile объявлен промежуточный этап (например, FROM node:20 AS linter), результаты которого не копируются через COPY --from=linter ни в один последующий этап, и сборка запущена без явного флага --target linter, то BuildKit полностью исключает этот этап из исполнения.
Ни образ node:20 не будет скачан из реестра, ни команды линтинга не будут выполнены, что экономит процессорное время в CI/CD.
2. Что произойдёт, если выполнить инструкцию COPY --from=builder /app/target/*.jar app.jar, если в целевой папке Maven сгенерировал два файла: app.jar и app-plain.jar?
Ответ: Произойдёт трудноуловимая ошибка:
- Если под маску
*.jarпопадает более одного файла, Docker интерпретирует целевой путьapp.jarне как имя файла, а как имя директории! - Docker создаст директорию с именем
/app/app.jar/и скопирует оба файла внутрь неё:/app/app.jar/app.jarи/app/app.jar/app-plain.jar. - При последующем запуске команды
ENTRYPOINT ["java", "-jar", "app.jar"]JVM выдаст фатальную ошибку:Error: Unable to access jarfile app.jar (is a directory). Решение: отключать генерацию plain-jar вpom.xml(<classifier>exec</classifier>) или указывать точное имя артефакта в инструкцииCOPY.
3. Почему кэширование Maven-зависимостей на этапе builder может сбрасываться при сборке на разных агентах CI/CD, и как это решает BuildKit Cache Mounts?
Ответ:
Стандартное кэширование слоёв Docker привязано к локальному хранилищу демона на конкретной машине. В облачных CI/CD (GitLab Runner, Kubernetes Pods, GitHub Actions) сборка запускается на чистых эфемерных виртуальных машинах, где локальный кэш отсутствует, и mvn dependency:go-offline каждый раз скачивает 500 МБ библиотек заново.
Решение: использование технологии BuildKit Cache Mount:
RUN --mount=type=cache,target=/root/.m2 mvn clean package -DskipTests
Директория /root/.m2 монтируется из постоянного сетевого хранилища хоста. Зависимости сохраняются между сборками независимо от пересборки предыдущих слоёв Dockerfile.
4. В чём уязвимость безопасности образов, собранных БЕЗ multi-stage build, даже если код приложения не содержит багов?
Ответ:
Образ без multi-stage содержит полную среду сборки: компилятор javac, систему сборки Maven, утилиты git, curl, tar, пакетный менеджер и исходные .java файлы.
Если злоумышленник найдёт уязвимость удалённого выполнения кода (RCE) в самом приложении (например, через уязвимую зависимость типа Log4Shell или сериализацию):
- Наличие компилятора и Maven позволяет атакующему скомпилировать и запустить произвольный нативный эксплойт или вредоносный Java-агент прямо в памяти контейнера.
- Наличие исходного кода позволяет злоумышленнику проанализировать бизнес-логику и найти уязвимости к смежным системам. В multi-stage runtime-образе нет компиляторов и нет shell (в случае Distroless), что сводит поверхность атаки (Attack Surface) к абсолютному минимуму.
🎯 Шпаргалка для интервью
Главное о Multi-stage build
- Что это: несколько директив
FROMв одном Dockerfile. - Главный профит: разделение фазы сборки (JDK, Maven) и фазы рантайма (JRE, jar).
- Размер образа: снижается в 3–10 раз (с 900 МБ до 150 МБ для JRE и до 45 МБ для Native Image).
- Безопасность: удаление исходников, компиляторов и сборочных утилит из продакшен-образа.
- Синтаксис связи:
COPY --from=<stage_name> /source/path /dest/path. - Фичи BuildKit: параллельная сборка независимых этапов и игнорирование неиспользуемых.
Сравнение этапов в Java-приложениях
| Этап | Базовый образ | Назначение | Что остаётся в prod? |
| :— | :— | :— | :— |
| Builder Stage | maven:3.9-eclipse-temurin-21 | Скачивание .m2, компиляция, тесты | Ничего (выбрасывается) |
| Runtime Stage | eclipse-temurin:21-jre-jammy | Запуск JVM, порты, запуск non-root | Только JRE и готовый JAR |
| Distroless Runtime | gcr.io/distroless/java21-debian12| Максимальная защита (без shell) | Только JRE, бинарники и JAR |
Красные флаги на собеседовании (Чего говорить нельзя)
- ❌ «Multi-stage build нужен только для уменьшения размера образа, на безопасность он не влияет» — отсутствие инструментов сборки радикально снижает вектор атаки при RCE.
- ❌ «Каждый этап multi-stage build попадает в итоговый образ отдельным слоем» — в итоговый образ попадает ТОЛЬКО последний этап.
- ❌ «В multi-stage нельзя запустить только юнит-тесты» — можно с помощью флага
--target <test_stage>.