🐳 Розділ 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>.

Пов’язані питання