🐳 Section 14 · Question #6

What is Multi-Stage Build

A Multi-Stage Build is a Docker feature that enables using multiple FROM instructions in a single Dockerfile to cleanly separate the build environment from the production runtim...

🟢 Junior Level

30-Second Interview Answer

A Multi-Stage Build is a Docker feature that enables using multiple FROM instructions in a single Dockerfile to cleanly separate the build environment from the production runtime environment.

Each FROM statement initiates a new isolated build stage with its own clean filesystem. The defining capability is COPY --from=<stage_name>, which allows you to extract compiled artifacts (e.g., app.jar) from a heavyweight build container into a minimal runtime image.

Only the final stage forms the published Docker image:

  • Heavyweight compilers (JDK 21), build tools (Maven, Gradle), intermediate caches (.m2), source code files, and build secrets are discarded.
  • The production image contains only a slim JRE and the compiled artifact, slashing image sizes from ~900 MB down to ~150–200 MB and dramatically reducing vulnerabilities.

Real-World Analogy: The Bakery Kitchen

Imagine ordering a custom wedding cake:

  • Builder Stage (The Commercial Kitchen): The baker uses heavy industrial ovens, messy stand mixers, bags of flour, measuring bowls, and spatulas.
  • Runtime Stage (The Delivery Box): Once baked, the finished cake is transferred onto a clean cardboard base and placed inside an elegant presentation box.
  • The customer receives only the cake in the box. No one would ever expect the bakery to ship the dirty mixer, bags of flour, and iron baking pans inside the delivery box!

Clean Java 21 / Spring Boot 3 Example

# ==========================================
# STAGE 1: Compilation Environment (Heavy JDK + Maven)
# ==========================================
FROM maven:3.9-eclipse-temurin-21-jammy AS builder
WORKDIR /build

# Cache third-party Maven dependencies independently
COPY pom.xml .
RUN mvn dependency:go-offline -B

# Copy application source code and compile fat-jar
COPY src ./src
RUN mvn clean package -DskipTests -B

# ==========================================
# STAGE 2: Minimal Production Runtime (Lightweight JRE)
# ==========================================
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app

# Create unprivileged system user
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser

# Copy ONLY the compiled jar from the 'builder' stage
COPY --from=builder /build/target/application.jar app.jar

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

🟡 Middle Level

The Evolution: Why Multi-Stage Builds Were Created

Prior to Docker 17.05, developers had to choose between two flawed paradigms:

  1. The “Fat” Monolithic Image: Compiling code directly inside the production Dockerfile. Images exceeded 1 GB, and attackers exploiting an application vulnerability gained access to javac, Maven, git, and original source files inside the container.
  2. The “Builder Pattern” (Multiple Dockerfiles): Teams maintained Dockerfile.build (to compile code into a host mount) and Dockerfile.runtime (to package the artifact), glued together by complex, fragile Bash scripts. This broke build reproducibility across development machines and CI runners.

Multi-stage builds resolved this completely: a single declarative file, a single docker build invocation, and a clean, secure runtime image.


Stage Targeting (--target) in CI/CD Pipelines

You can selectively execute any intermediate stage using the --target CLI flag:

# Stage 1: Automated Testing
FROM maven:3.9-eclipse-temurin-21-jammy AS test-runner
WORKDIR /app
COPY . .
RUN mvn test

# Stage 2: Packaging
FROM maven:3.9-eclipse-temurin-21-jammy AS builder
WORKDIR /app
COPY . .
RUN mvn package -DskipTests

# Stage 3: Production Image
FROM eclipse-temurin:21-jre-jammy AS prod
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

In your GitLab CI / GitHub Actions pipeline:

# 1. Run unit and integration tests; fails CI if tests fail:
docker build --target test-runner -t order-service:test .

# 2. Build the production image (test reports and test dependencies are omitted):
docker build --target prod -t order-service:latest .

Copying from External Docker Images (COPY --from=<external_image>)

The COPY --from syntax is not restricted to local stages—it can pull files directly from any public or private image in a container registry:

FROM eclipse-temurin:21-jre-jammy
WORKDIR /app

# Extract the 'wait-for-it' utility binary directly from its official image
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

Parallel Stage Execution in BuildKit (Directed Acyclic Graph)

Docker’s modern build engine (BuildKit) models multi-stage Dockerfiles as a Directed Acyclic Graph (DAG):

  • Independent stages execute in parallel across multiple CPU cores.
  • Unreferenced Stage Pruning: If a stage is not depended upon by the final stage and is not explicitly requested via --target, BuildKit skips its execution entirely, consuming zero CPU and disk I/O.

Polyglot Fullstack Parallel Build Example:

# Branch A: Compile React Frontend (Node.js) - Executes in parallel!
FROM node:20-alpine AS frontend-builder
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build

# Branch B: Compile Spring Boot Backend (Java 21) - Executes in parallel!
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 clean package -DskipTests

# Merge Point: Final Lightweight Production Runtime
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
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Spring Boot 3 AOT & GraalVM Native Image

For serverless workloads (AWS Lambda, Knative) requiring sub-second cold starts, multi-stage builds are paired with GraalVM Native Image Ahead-Of-Time (AOT) compilation:

# ==========================================
# STAGE 1: Heavyweight AOT Compilation (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

# ==========================================
# STAGE 2: Ultra-Minimal Production Runtime (Distroless)
# ==========================================
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"]
  • Image Size: Reduced from ~400 MB to ~45 MB.
  • Cold-Start Time: Drops from 3–5 seconds to < 50 milliseconds.
  • Base Memory Footprint: Runs on ~35 MB RAM idle.

By leveraging Java’s module system and jlink, you can strip unneeded JDK/JRE modules (e.g., desktop GUI java.desktop) to produce a tailored 40 MB JRE:

# Stage 1: Generate stripped custom JRE
FROM eclipse-temurin:21-jdk-jammy AS jre-builder
WORKDIR /app
COPY target/app.jar app.jar

# Detect required modules via jdeps
RUN $JAVA_HOME/bin/jdeps \
    --ignore-missing-deps \
    --multi-release 21 \
    --print-module-deps \
    --class-path 'BOOT-INF/lib/*' app.jar > modules.txt

# Link custom runtime containing only needed modules
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

# Stage 2: Final minimal production image
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 with In-Depth Answers

1. How does Docker BuildKit optimize execution in a multi-stage Dockerfile, and in which cases is an intermediate stage completely skipped?

Answer: BuildKit models multi-stage Dockerfiles as a Directed Acyclic Graph (DAG) analyzed backwards from the target stage (by default, the last stage in the file). If an intermediate stage (e.g., FROM node:20 AS linter) does not have its output consumed by any subsequent stage via COPY --from=linter, and the user did not run docker build --target linter, BuildKit prunes the stage entirely from the execution graph. The base image node:20 is never pulled, commands inside the stage are never evaluated, and zero CI/CD build resources are consumed.

2. What happens if you run COPY --from=builder /app/target/*.jar app.jar when Maven generates both app.jar and app-plain.jar?

Answer: This triggers a notorious Dockerfile failure:

  • When a wildcard pattern matches more than one file, Docker treats the target path app.jar as a directory rather than a file destination.
  • Docker creates a directory named /app/app.jar/ and copies both files into it: /app/app.jar/app.jar and /app/app.jar/app-plain.jar.
  • When the container executes ENTRYPOINT ["java", "-jar", "app.jar"], the JVM fails immediately with: Error: Unable to access jarfile app.jar (is a directory).
  • Solution: Explicitly disable plain JAR generation in pom.xml (<classifier>exec</classifier>), or specify an exact artifact name instead of using wildcards in COPY.

3. Why can Maven dependency caching on a builder stage reset across ephemeral CI/CD runners, and how do BuildKit Cache Mounts solve it?

Answer: Standard Docker layer caching relies on the local Docker daemon’s storage. In modern ephemeral CI/CD environments (GitLab Runner on Kubernetes, GitHub Actions runners, ephemeral AWS EC2 instances), each pipeline runs on a freshly provisioned host with an empty layer cache. Even though pom.xml has not changed, the runner has no parent layer cached, forcing mvn dependency:go-offline to re-download hundreds of megabytes of libraries on every commit.

  • Solution: Use BuildKit Cache Mounts:
    RUN --mount=type=cache,target=/root/.m2 mvn clean package -DskipTests
    

    The cache mount persists the local repository directory across build runs independently of layer cache invalidation.

4. What security vulnerabilities exist in container images built WITHOUT multi-stage builds, even if the application code contains no bugs?

Answer: A single-stage container image packages the full compilation toolchain: javac, Maven, git, curl, compilers, package managers, and complete Java source code. If an attacker discovers a Remote Code Execution (RCE) vulnerability in a third-party dependency (e.g., Log4Shell, Spring4Shell, deserialization flaws):

  1. Live Exploitation: The presence of javac and package managers allows the attacker to compile custom native payloads, download malicious dependencies, or inject custom Java agents directly into the running container process memory.
  2. Intellectual Property & Secrets: The attacker can read original source code, internal configurations, and database credentials that were accessible during the build phase. In contrast, a multi-stage distroless image lacks compilers, package managers, and shells, stopping post-exploitation attacks in their tracks.

🎯 Interview Cheat Sheet

Summary of Multi-Stage Builds

  • Core Concept: Multiple FROM statements in one Dockerfile separating build from runtime.
  • Key Syntax: COPY --from=<stage_name> /source /destination.
  • Image Size Reduction: Typically 70% to 90% reduction (from ~850 MB to ~150 MB for JRE, down to ~45 MB for Native Image).
  • Security Hardening: Strips compilers, package managers, build secrets, and source code from production images.
  • BuildKit DAG Optimization: Executes independent stages in parallel and prunes unused stages automatically.

Multi-Stage Comparison for Java

| Stage Variant | Base Image | Role | Artifacts in Final Image | |—|—|—|—| | Builder Stage | maven:3.9-eclipse-temurin-21 | Dependency resolution, compilation, testing | None (Discarded) | | Slim Runtime | eclipse-temurin:21-jre-jammy | Standard enterprise runtime | JRE + Application JAR | | Custom JRE (jlink) | debian:bookworm-slim | Ultra-compact modular runtime | Stripped ~40 MB JRE + JAR | | Distroless Runtime | gcr.io/distroless/java21-debian12 | Hardened runtime (no shell/package manager) | Minimal binaries + JRE + JAR | | GraalVM Native | gcr.io/distroless/base-debian12 | Sub-50ms instant boot | Standalone native binary (~45 MB) |

Red Flags (DO NOT Say)

  • ❌ “Multi-stage builds are purely for saving disk space; they have nothing to do with security.” (Removing compilers and package managers removes critical post-exploitation attack vectors).
  • ❌ “Every stage in a multi-stage Dockerfile adds layers to the final image.” (Only the final FROM stage produces layers in the published image).
  • ❌ “You can only run tests by building the full production image.” (Use docker build --target <test_stage> to execute test stages independently in CI).