본문으로 건너뛰기 Docker Multi-Stage Build Optimization | Practical Guide to ~90% Smaller Images

Docker Multi-Stage Build Optimization | Practical Guide to ~90% Smaller Images

Docker Multi-Stage Build Optimization | Practical Guide to ~90% Smaller Images

이 글의 핵심

How to shrink Docker images by ~90% with multi-stage builds: separating build tools, layer caching, and distroless bases. Hands-on examples for Node.js, Go, and Rust.

Introduction

Docker multi-stage builds separate the build environment from the runtime to dramatically reduce image size. Compilers, build tools, and dev-only dependencies belong in the build stage; the final image should contain only the executable and runtime dependencies.

An analogy: a normal build is like putting all kitchen tools and ingredients on the dining table; a multi-stage build is like cooking in the kitchen and serving only the finished dishes.

After reading this post

  • You will understand multi-stage builds and their benefits
  • You will see hands-on examples for Node.js, Go, and Rust
  • You will learn layer-caching optimization techniques
  • You will know how to cut image size by roughly 90%

Reality in production

When you learn development, everything feels neat and theoretical. Production is different. You wrestle with legacy code, tight deadlines, and unexpected bugs. The topics in this article also started as theory for me, but applying them to real projects is when I thought, “Ah, that is why it is designed this way.”

What stuck with me was trial and error on an early project. I followed the book and still could not understand why it failed for days. A senior developer’s code review finally surfaced the issue, and I learned a lot in the process. This article covers not only theory but also pitfalls and fixes you are likely to hit in practice.

Table of contents

  1. Concepts
  2. Hands-on implementation
  3. Advanced optimization
  4. Performance comparison
  5. Real-world cases
  6. Troubleshooting
  7. Conclusion

Concepts

Normal build vs multi-stage build

Normal build (single stage):

FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]

Problems:

  • Entire node_modules (hundreds of MB)
  • Build tools included (TypeScript compiler, etc.)
  • Source code included (unnecessary)
  • Image size: ~1GB Multi-stage build:
# Build stage
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

Benefits:

  • No build-only tooling in the final image
  • No source in the final image
  • Image size: ~200MB (~80% reduction)

Multi-stage structure

# Stage 1: build
FROM <build-image> AS <stage-name>
# build steps
# Stage 2: runtime
FROM <runtime-image>
COPY --from=<stage-name> <build-output> <destination>
# run command

Hands-on implementation

Node.js application

Basic multi-stage:

# Build stage
FROM node:20 AS builder
WORKDIR /app
# Dependencies (cache-friendly)
COPY package*.json ./
RUN npm ci --only=production
# Copy source and build
COPY . .
RUN npm run build
# Runtime stage
FROM node:20-slim
WORKDIR /app
# Copy only build artifacts
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
# Non-root user
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nodejs
USER nodejs
EXPOSE 3000
CMD ["node", "dist/index.js"]

Image size:

  • Normal build: ~1.2GB
  • Multi-stage: ~200MB (~83% reduction)

Go application

Optimized multi-stage:

# Build stage
FROM golang:1.22 AS builder
WORKDIR /app
# Dependencies (cached)
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# Runtime stage (scratch)
FROM scratch
WORKDIR /app
# Copy only the binary
COPY --from=builder /app/main .
# CA certs (HTTPS)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
CMD [./main]

Image size:

  • Normal build: ~800MB
  • Multi-stage: ~10MB (~99% reduction)

Rust application

Optimized multi-stage:

# Build stage
FROM rust:1.77 AS builder
WORKDIR /app
# Dependency caching (dummy project)
RUN cargo new --bin dummy
WORKDIR /app/dummy
COPY Cargo.toml Cargo.lock ./
RUN cargo build --release
RUN rm src/*.rs
# Build real sources
COPY src ./src
RUN touch src/main.rs
RUN cargo build --release
# Runtime stage
FROM debian:bookworm-slim
WORKDIR /app
# Runtime deps
RUN apt-get update && \
    apt-get install -y ca-certificates && \
    rm -rf /var/lib/apt/lists/*
# Copy binary
COPY --from=builder /app/dummy/target/release/myapp .
# Non-root user
RUN useradd -m -u 1001 appuser
USER appuser
EXPOSE 8080
CMD [./myapp]

Image size:

  • Normal build: ~2GB
  • Multi-stage: ~80MB (~96% reduction)

Python application

Optimized multi-stage:

# Build stage
FROM python:3.12 AS builder
WORKDIR /app
# Dependencies
COPY requirements.txt ./
RUN pip install --user --no-cache-dir -r requirements.txt
# Runtime stage
FROM python:3.12-slim
WORKDIR /app
# Copy installed packages
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
# Copy source
COPY . .
# Non-root user
RUN useradd -m -u 1001 appuser && \
    chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["python", "main.py"]

Image size:

  • Normal build: ~1GB
  • Multi-stage: ~200MB (~80% reduction)

Advanced optimization

Layer caching

Bad:

FROM node:20 AS builder
WORKDIR /app
# Source copied first (invalidates cache every time)
COPY . .
RUN npm install
RUN npm run build

Good:

FROM node:20 AS builder
WORKDIR /app
# Lockfiles first
COPY package*.json ./
RUN npm ci
# Source later
COPY . .
RUN npm run build

Why:

  • npm ci reruns only when package.json changes
  • Source changes rebuild only compile steps
  • Much faster CI/CD builds

Distroless images

Google distroless:

# Build stage
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o main .
# Runtime (distroless)
FROM gcr.io/distroless/static-debian12
WORKDIR /app
COPY --from=builder /app/main .
CMD [./main]

Benefits:

  • No shell or package manager (stronger security)
  • Only required files
  • Minimal image size

Alpine Linux

Alpine-based:

# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

Caveats:

  • Alpine uses musl; glibc-linked binaries may not run — match libc to how you built
  • Native modules may need extra packages to build

BuildKit cache mounts

Enable BuildKit:

export DOCKER_BUILDKIT=1

Cache mount:

# syntax=docker/dockerfile:1.4
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY . .
RUN npm run build

Benefits:

  • Share npm cache across builds
  • Faster dependency downloads

Performance comparison

Image size

LanguageNormal buildMulti-stageReduction
Node.js1.2GB200MB83%
Go800MB10MB99%
Rust2GB80MB96%
Python1GB200MB80%

Build time

Without layer caching:

First build: 5 minutes
Rebuild after source edit: 5 minutes (full rebuild)

With layer caching:

First build: 5 minutes
Rebuild after source edit: 30 seconds (build stage only)

Benchmark (Node.js app)

Normal build:

FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]
Image size: 1.2GB
Build time: 3m 30s
Layers: 12

Multi-stage + optimization:

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
Image size: 150MB
Build time: 1m 10s (20s when cache hits)
Layers: 8

Real-world cases

Case 1: Next.js application

# Dependencies stage
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Runtime stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

Results:

  • Normal build: 1.5GB
  • Multi-stage: 180MB (~88% reduction)

Case 2: Go microservice

# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /app
# Dependency cache
COPY go.mod go.sum ./
RUN go mod download
# Build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o main .
# Runtime (scratch)
FROM scratch
COPY --from=builder /app/main /main
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT [/main]

Results:

  • Normal build: 800MB
  • Multi-stage: 8MB (~99% reduction)

Case 3: Rust web server

# Build stage
FROM rust:1.77 AS builder
WORKDIR /app
# Dependency cache
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src
# Real build
COPY src ./src
RUN touch src/main.rs
RUN cargo build --release
# Runtime stage
FROM debian:bookworm-slim
WORKDIR /app
RUN apt-get update && \
    apt-get install -y ca-certificates && \
    rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/myapp .
RUN useradd -m -u 1001 appuser
USER appuser
EXPOSE 8080
CMD [./myapp]

Results:

  • Normal build: 2.1GB
  • Multi-stage: 85MB (~96% reduction)

Troubleshooting

COPY —from fails

Problem:

COPY --from=builder /app/dist ./dist
# Error: COPY failed: file not found

Causes:

  • Artifact not produced in the build stage
  • Wrong path Fix:
# Verify in build stage
FROM node:20 AS builder
WORKDIR /app
COPY . .
RUN npm run build && ls -la dist/
# Runtime stage
FROM node:20-slim
COPY --from=builder /app/dist ./dist

Cache keeps invalidating

Problem:

  • A one-line source change reinstalls dependencies Cause:
  • Wrong COPY order Fix:
# Bad
COPY . .
RUN npm install
# Good
COPY package*.json ./
RUN npm ci
COPY . .

Image still large

Problem:

  • Multi-stage but image is still big Causes:
  • Copying unnecessary files
  • Large base image Fix:
# .dockerignore
node_modules
.git
.env
*.md
tests
# Use slim or alpine bases
FROM node:20-slim  # or node:20-alpine

Missing runtime libraries

Problem:

  • Missing library errors at runtime Cause:
  • Runtime deps only present in the build stage Fix:
# Install runtime deps in the final stage
FROM debian:bookworm-slim
RUN apt-get update && \
    apt-get install -y \
    ca-certificates \
    libssl3 \
    && rm -rf /var/lib/apt/lists/*

Conclusion

Docker multi-stage builds are essential for much smaller images and stronger security.

Core principles:

  1. Separate build and runtime
  2. Optimize layer caching (dependencies first, source later)
  3. Minimal base images (slim, alpine, distroless, scratch)
  4. Exclude junk (.dockerignore)
  5. Non-root user (security)

Effects:

  • Image size: often 80–99% smaller
  • Build time: 50–80% faster with caching
  • Security: smaller attack surface
  • Cost: lower registry storage and transfer

They work especially well alongside Kubernetes and CI/CD pipelines.