Питання 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, мінімальна поверхня атаки.

Моніторинг

  • 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
  • [[Що таке контейнеризація і навіщо вона потрібна]] — безпека образів