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

Что такое multi-stage build?

В одном Dockerfile может быть несколько инструкций FROM -- каждая начинает новый этап. Из финального образа попадают только файлы последнего этапа. На первом этапе вы собираете...

Версии по языкам: English Russian Ukrainian

🟢 Junior Level

Простое объяснение

Multi-stage build (Многоэтапная сборка) — это способ создать маленький Docker-образ, используя несколько этапов в одном Dockerfile.

В одном Dockerfile может быть несколько инструкций FROM – каждая начинает новый этап. Из финального образа попадают только файлы последнего этапа. На первом этапе вы собираете приложение, на втором — запускаете его. В итоговый образ попадает только готовый файл, а все инструменты сборки остаются «за бортом».

Аналогия

Представьте, что вы печёте торт. Для приготовления нужны: миксер, форма, духовка, ингредиенты. Но когда вы отдаёте торт клиенту — вы не отдаёте миксер и форму. Клиент получает только готовый торт. Multi-stage build — это то же самое: для сборки нужен Maven и JDK, для запуска — только JRE и jar-файл.

Проблема без multi-stage

FROM maven:3.8-openjdk-17
COPY src ./src
COPY pom.xml .
RUN mvn package -DskipTests
# Образ весит ~800 МБ (Maven + JDK + исходники + зависимости)
CMD ["java", "-jar", "target/app.jar"]

Как работает multi-stage

# Этап 1: Сборка
FROM maven:3.8-openjdk-17-slim AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

# Этап 2: Запуск
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Результат: образ весит ~300 МБ вместо ~800 МБ!

Объяснение по шагам

  1. FROM ... AS builder — первый этап с именем «builder»
  2. COPY --from=builder — копируем файл из первого этапа во второй
  3. Второй этап — финальный образ, который идёт в продакшен

Что запомнить

  • Multi-stage build = несколько FROM в одном Dockerfile
  • Каждый FROM — отдельный этап
  • COPY --from=имя копирует файлы из другого этапа
  • Итоговый образ содержит только последний этап
  • Потому что только последний FROM попадает в финальный образ. Все предыдущие этапы (компиляторы, зависимости, исходники) остаются «за бортом».
  • Это уменьшает размер и повышает безопасность

🟡 Middle Level

Зачем появился multi-stage build?

До этой технологии было два пути:

  1. Всё в одном образе — итоговый образ содержал Maven, JDK, исходники. Раздутый размер, большая поверхность атаки.
  2. Builder Pattern — сложные скрипты для переноса артефактов между образами. Неудобно, требует внешних скриптов.

Multi-stage build решил обе проблемы, позволив описать всё в одном Dockerfile.

Детальный разбор

# ЭТАП 1: Сборка (называем 'builder')
FROM maven:3.8-openjdk-17-slim AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline     # Кэшируем зависимости
COPY src ./src
RUN mvn package -DskipTests

# ЭТАП 2: Финальный образ (Runtime)
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Типичные ошибки

Ошибка Последствие Как избежать
Неправильный путь в COPY --from Файл не найден при сборке Используйте абсолютные пути
Копирование кода до зависимостей Кэш не работает, медленная сборка Сначала pom.xml, потом src
Забыв указать AS для этапов Нельзя ссылаться по имени Всегда давайте этапам имена
Использование latest на любом этапе Недетерминированность Фиксируйте версии всех базовых образов
Копирование всего /build Лишние файлы в финальном образе Копируйте только артефакт

Преимущества

  1. Минимальный размер — финальный образ содержит только JRE и .jar.
  2. Безопасность — нет инструментов сборки, меньше поверхность атаки.
  3. Удобство CI/CD — весь процесс в одном файле.
  4. Кэширование — Docker кэширует каждый этап независимо.

Продвинутые техники

Остановка на определённом этапе:

docker build --target builder -t my-app-test .

Использование внешних образов:

COPY --from=nginx:latest /etc/nginx/nginx.conf /my/path

Несколько промежуточных этапов:

FROM node:18 AS frontend-build
# ... сборка фронтенда

FROM maven:17 AS backend-build
# ... сборка бэкенда

FROM openjdk:17-slim
COPY --from=backend-build /app.jar .
COPY --from=frontend-build /dist ./static/

Оптимизация кэширования

FROM maven:3.8-openjdk-17-slim AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -B  # Кэш зависимостей
COPY src ./src
RUN mvn package -DskipTests -B

При изменении кода слой зависимостей берётся из кэша.

Сравнение подходов

Подход Размер Безопасность Сложность
Single-stage ~800 МБ Низкая Низкая
Multi-stage (slim) ~300 МБ Средняя Средняя
Multi-stage (alpine) ~120 МБ Высокая Средняя
Multi-stage (distroless) ~80 МБ Очень высокая Высокая

Что запомнить

  • Multi-stage build — индустриальный стандарт
  • Разделяйте «инструменты сборки» и «среду выполнения»
  • Используйте slim или alpine на финальной стадии
  • Можно копировать файлы из внешних образов
  • --target позволяет собрать конкретный этап

Когда НЕ использовать multi-stage build

Для Python/Node.js без этапа компиляции multi-stage часто не нужен – достаточно COPY и RUN. Multi-stage полезен когда есть этап сборки (Java, Go, C++), который не нужен в рантайме.


🔴 Senior Level

Multi-stage build как архитектурный паттерн

Multi-stage build — это реализация принципа минимальных привилегий на уровне образов: runtime-образ содержит только то, что необходимо для выполнения, ничего лишнего.

Анализ безопасности

Без multi-stage:

Образ: maven:3.8-openjdk-17 (~800 МБ)
Содержит: Maven, компилятор Java, исходный код, все зависимости
Риск: взломщик может перекомпилировать код, использовать компилятор для эксплойтов

С multi-stage:

Образ: openjdk:17-jre-slim (~300 МБ)
Содержит: JRE + jar-файл
Риск: минимальный — нет инструментов для модификации кода

Trade-offs

Подход Размер Безопасность Сложность Debug
Single-stage ~800 МБ Низкая Низкая Легко
Multi-stage (slim) ~300 МБ Средняя Средняя Нормально
Multi-stage (jlink) ~100 МБ Высокая Высокая Сложно
Multi-stage (distroless) ~80 МБ Очень высокая Высокая Очень сложно
Multi-stage (native) ~50 МБ Максимальная Очень высокая Требует отладчик

jlink – инструмент JDK для создания минимальной версии Java только с нужными модулями.

FROM ubuntu AS jre-build
RUN apt-get update && apt-get install -y openjdk-17-jdk-headless
RUN jlink \
    --add-modules java.base,java.sql,java.xml \
    --strip-debug \
    --no-man-pages \
    --no-header-files \
    --compress=2 \
    --output /jre

FROM eclipse-temurin:17-jdk-slim AS builder
# ... сборка ...

FROM debian:buster-slim
COPY --from=jre-build /jre /jre
COPY --from=builder /build/target/app.jar /app.jar
ENV JAVA_HOME=/jre
ENV PATH="$JAVA_HOME/bin:$PATH"
ENTRYPOINT ["java", "-jar", "/app.jar"]

Кастомный JRE весит 40-60 МБ вместо 300+ МБ полного JDK.

Distroless образы

FROM maven:3.9-eclipse-temurin-17 AS build
# ... сборка ...

FROM gcr.io/distroless/java17-debian12
COPY --from=build /build/target/app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

Distroless образы содержат только runtime и не имеют shell/package manager. Атакующий не может выполнить sh внутри контейнера.

Edge Cases

  • Файл не найден на втором этапе: используйте абсолютные пути. COPY --from=builder /build/target/*.jar app.jar — если jar-файл один, glob работает. Если несколько — все скопируются в /app.jar как директория.
  • Кэш не работает: код скопирован до зависимостей. Решение: сначала pom.xml, затем RUN dependency:go-offline, потом src.
  • Временные файлы сборки: если RUN создаёт временные файлы, они остаются в слое builder-этапа. Это нормально — они не попадут в финальный образ.
  • Секреты на этапе сборки: если на этапе сборки нужен доступ к private Maven repo, используйте BuildKit --mount=type=ssh или --mount=type=secret. Не передавайте токены через ARG.
  • Cross-compilation: сборка ARM-образа на amd64 хосте. Используйте docker buildx с QEMU или remote builders.

Native Image (GraalVM) + Multi-stage

FROM ghcr.io/graalvm/native-image:ol8-java17 AS builder
WORKDIR /build
COPY pom.xml .
COPY src ./src
RUN native-image -jar target/app.jar -o app

FROM debian:buster-slim
COPY --from=builder /build/app /app
ENTRYPOINT ["/app"]

Итоговый образ: ~50-100 МБ, мгновенный запуск (< 100ms), минимальная память. Trade-off: долгое время сборки Native Image (минуты), не все фичи Spring поддерживаются (нужен Spring Native / AOT).

// Native Image – компиляция Java в нативный бинарник. // Плюс: запуск < 100ms. Минус: сборка занимает минуты, // не все фичи Spring поддерживаются (рефлексия, прокси).

Производительность

Стратегия Размер образа Время запуска RAM footprint
Full JDK ~500 МБ ~5-8s ~400-600 MB
JRE slim ~300 МБ ~5-8s ~300-500 MB
jlink custom JRE ~100 МБ ~4-6s ~200-400 MB
Distroless ~80 МБ ~4-6s ~200-400 MB
GraalVM Native ~50 МБ < 0.1s ~50-150 MB

Troubleshooting

Проблема: файл не найден.

COPY --from=builder /build/target/app.jar app.jar
# ERROR: file not found

Решение: проверьте абсолютные пути. Используйте RUN ls -la /build/target/ на этапе builder для отладки. Или docker build --target builder для инспекции промежуточного образа.

Проблема: кэш не работает.

# ПЛОХО
COPY src ./src
COPY pom.xml .
RUN mvn package

Решение: поменяйте порядок. Сначала pom.xml, потом RUN dependency:go-offline, потом src.

Production Story

Команда микро-сервисов (40+ сервисов) использовала single-stage образы по 700-900 МБ. Деплой одного сервиса занимал 3-5 минут (pull образа). Общий registry потреблял 35 ГБ. Внедрение multi-stage с distroless образами сократило средний размер до 90 МБ, время деплоя — до 30 секунд, registry — до 4 ГБ. Экономия: 88% storage, 90% времени деплоя. Дополнительный бонус: distroless образы прошли security audit без замечаний — нет shell, нет package manager, минимальная поверхность атаки.

Monitoring

  • docker images — отслеживайте размеры образов
  • dive <image> — анализ содержимого каждого слоя
  • Registry size metrics — мониторинг хранилища образов
  • Build cache hit rate — эффективность кэширования в CI/CD
  • Image scan results (Trivy, Snyk) — количество уязвимостей на образ

Резюме

  • Multi-stage build — стандарт создания production-образов.
  • Лучший способ соблюсти баланс между удобством разработки и компактностью образа.
  • Всегда разделяйте «инструменты сборки» и «среду выполнения».
  • Для максимальной оптимизации: jlink, distroless, GraalVM Native Image.
  • Кэширование зависимостей отдельно от кода — ключ к быстрым CI/CD сборкам.
  • Безопасность: чем меньше образ, тем меньше поверхность атаки.
  • Используйте --target для тестирования промежуточных этапов.

🎯 Шпаргалка для интервью

Обязательно знать:

  • Multi-stage build = несколько FROM в одном Dockerfile; только последний попадает в финальный образ
  • Разделяем «инструменты сборки» (Maven, JDK) и «среду выполнения» (JRE, jar)
  • Уменьшение размера: single-stage ~800MB → multi-stage slim ~300MB → distroless ~80MB
  • Безопасность: нет инструментов сборки в production-образе, меньше attack surface
  • Кэширование зависимостей отдельно от кода — ключ к быстрым CI/CD сборкам
  • Для максимальной оптимизации: jlink (custom JRE), distroless, GraalVM Native Image
  • --target позволяет собрать и протестировать промежуточный этап

Частые уточняющие вопросы:

  • «Почему образ уменьшается?» — Компиляторы, исходники, зависимости сборки не попадают в финальный образ
  • «Что такое distroless?» — Образ без shell/package manager; атакующий не может выполнить sh внутри контейнера
  • «Когда НЕ нужен multi-stage?» — Python/Node.js без этапа компиляции; достаточно COPY + RUN
  • «Что даёт jlink?» — Кастомный JRE только с нужными модулями (40-60MB вместо 300MB)

Красные флаги (НЕ говорить):

  • «Использую один образ для сборки и запуска» (раздутый размер, security risk)
  • «Multi-stage замедляет сборку» (кэширование делает его быстрее)
  • «Копирую весь каталог builder’а» (нужен только артефакт)
  • «Distroless образы невозможно отлаживать» (ephemeral debug containers решают)

Связанные темы:

  • [[Что такое Dockerfile]] — основы Dockerfile
  • [[Какие основные инструкции используются в Dockerfile]] — инструкция COPY –from
  • [[Что такое контейнеризация и зачем она нужна]] — безопасность образов