🐳 Раздел 14 · Вопрос #6

Что такое 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) разработчикам приходилось выбирать между двумя плохими решениями:

  1. Fat Image (Всё в одном): сборка прямо в продакшен-образе. Образ весил под 1 ГБ, а любой взломщик получал доступ к исходному коду, Maven и утилитам сборки прямо в контейнере.
  2. Паттерн 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.

С помощью инструмента 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 или сериализацию):

  1. Наличие компилятора и Maven позволяет атакующему скомпилировать и запустить произвольный нативный эксплойт или вредоносный Java-агент прямо в памяти контейнера.
  2. Наличие исходного кода позволяет злоумышленнику проанализировать бизнес-логику и найти уязвимости к смежным системам. В 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>.

Связанные вопросы