본문으로 건너뛰기
Previous
Next
Docker Multi-Stage Build Guide | Optimization

Docker Multi-Stage Build Guide | Optimization

Docker Multi-Stage Build Guide | Optimization

이 글의 핵심

Multi-stage builds eliminate build tools from production images ??a Node.js app goes from 1.2GB to 85MB. This guide covers stage design, layer caching, .dockerignore, security hardening, and production-ready Dockerfiles.

Why Multi-Stage?

# Single-stage (before)
FROM node:20
WORKDIR /app
COPY . .
RUN npm install          # Installs dev dependencies too
RUN npm run build        # TypeScript ??JavaScript
EXPOSE 3000
CMD ["node", "dist/index.js"]
# Image size: ~1.2GB (node image + all node_modules including devDeps)
# Multi-stage (after)
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production   # Production deps only
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]
# Image size: ~85MB

The build stage is discarded. Only the compiled output and production dependencies end up in the final image.


Layer Caching ??The Key to Fast Builds

Docker caches layers. A cached layer is reused if its inputs didn’t change. Order instructions from least-to-most frequently changing:

# ??Bad order ??source code change invalidates npm install cache
FROM node:20-alpine
WORKDIR /app
COPY . .                 # Changes every commit ??cache miss
RUN npm ci               # Re-runs every time = slow CI

# ??Good order ??npm install only re-runs when package.json changes
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./    # Only changes when adding/removing packages
RUN npm ci               # Cached until package.json changes
COPY . .                 # Source code ??cache miss here is fine
RUN npm run build
Typical CI build with good caching:
  Commit 1 (first build):   npm install: 45s, build: 12s = 57s total
  Commit 2 (code change):   npm install: cached, build: 12s = 12s total
  Commit 3 (new package):   npm install: 45s, build: 12s = 57s total

.dockerignore ??Exclude Unnecessary Files

.dockerignore prevents files from being sent to Docker during COPY commands:

# .dockerignore
node_modules/           # Never copy ??will be installed inside container
dist/                   # Build output ??will be regenerated
.git/                   # Git history not needed in image
.env                    # Secrets ??never copy
.env.local
coverage/
.nyc_output
*.log
.DS_Store
README.md
*.md
__tests__/
**/*.test.ts
**/*.spec.ts
.github/
.vscode/
docker-compose*.yml
Dockerfile*

Without .dockerignore, COPY . . sends all files including node_modules/ (which can be hundreds of MB) to the Docker daemon before they’re evaluated.


Node.js: Production-Ready Dockerfile

# === Stage 1: Dependencies ===
FROM node:20-alpine AS deps
WORKDIR /app

# Copy package files (cached until packages change)
COPY package*.json ./
# npm ci = clean install, exact versions from package-lock.json
RUN npm ci

# === Stage 2: Build ===
FROM node:20-alpine AS builder
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN npm run build      # TypeScript ??JavaScript

# Prune dev dependencies
RUN npm prune --production

# === Stage 3: Production ===
FROM node:20-alpine AS runner
WORKDIR /app

# Security: create non-root user
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 --ingroup nodejs nodeuser

# Copy only what's needed
COPY --from=builder --chown=nodeuser:nodejs /app/dist ./dist
COPY --from=builder --chown=nodeuser:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodeuser:nodejs /app/package.json ./

# Metadata
LABEL maintainer="[email protected]"
LABEL version="1.0"

# Run as non-root
USER nodeuser

EXPOSE 8000

# Healthcheck
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
  CMD wget -qO- http://localhost:8000/health || exit 1

CMD ["node", "dist/index.js"]

Python: Optimized Dockerfile

# === Stage 1: Build dependencies ===
FROM python:3.12-slim AS builder
WORKDIR /app

# Install build tools (only needed for compiling wheels)
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc libpq-dev \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# === Stage 2: Production ===
FROM python:3.12-slim AS runner
WORKDIR /app

# Runtime dependencies only (no gcc, no build tools)
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    && rm -rf /var/lib/apt/lists/*

# Copy installed packages from builder
COPY --from=builder /root/.local /root/.local

# Copy application
COPY . .

# Non-root user
RUN useradd --create-home --shell /bin/bash appuser
USER appuser

ENV PATH=/root/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1

EXPOSE 8000

CMD ["gunicorn", "app.main:app", "--workers", "4", "--bind", "0.0.0.0:8000"]

Go: Tiny Static Binary

Go compiles to a single static binary ??the final image can be FROM scratch:

# === Build stage ===
FROM golang:1.22-alpine AS builder
WORKDIR /app

# Download dependencies (cached until go.mod/go.sum change)
COPY go.mod go.sum ./
RUN go mod download

COPY . .

# Build static binary ??no CGO, fully self-contained
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \          # Strip debug info (smaller binary)
    -o /app/server ./cmd/server

# === Final stage: minimal image ===
FROM scratch
# Or use: FROM gcr.io/distroless/static-debian12 (adds CA certs, timezone data)

COPY --from=builder /app/server /server

# Copy TLS certificates for HTTPS outbound requests
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

EXPOSE 8080
ENTRYPOINT ["/server"]
# Final image size: ~10MB (just the binary)

Secrets in Build ??Don’t Bake Them In

# ??NEVER ??secret visible in docker history
RUN echo "machine github.com login user password $GITHUB_TOKEN" > ~/.netrc

# ??BuildKit secrets ??available during build, not in final image
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
RUN --mount=type=secret,id=github_token \
    GITHUB_TOKEN=$(cat /run/secrets/github_token) \
    npm install --registry https://npm.pkg.github.com
# Build with secret
docker build \
  --secret id=github_token,env=GITHUB_TOKEN \
  -t my-app .

Multi-Platform Builds

Build for multiple architectures (AMD64 + ARM64 for M-series Macs and AWS Graviton):

# Enable multi-platform builder
docker buildx create --use --name multiplatform

# Build and push for both architectures
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t myrepo/my-app:latest \
  --push \
  .
# GitHub Actions: multi-platform CI build
- name: Build and push
  uses: docker/build-push-action@v5
  with:
    platforms: linux/amd64,linux/arm64
    push: true
    tags: myrepo/my-app:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

Build Arguments

FROM node:20-alpine AS runner

# ARG for build-time variables
ARG APP_VERSION=unknown
ARG BUILD_DATE

# Convert to ENV to make it available at runtime
ENV APP_VERSION=$APP_VERSION
ENV BUILD_DATE=$BUILD_DATE

# ...
docker build \
  --build-arg APP_VERSION=$(git describe --tags) \
  --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
  -t my-app .

Image Size Reference

App typeBefore multi-stageAfter multi-stage
Node.js (TypeScript)~1.2 GB~85 MB
Python (FastAPI)~900 MB~150 MB
Go (static binary)~350 MB~10 MB
Java (Spring Boot)~500 MB~150 MB (distroless)

Quick Reference: Security Checklist

# ??Use specific version tags (not :latest)
FROM node:20.11-alpine3.19

# ??Non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

# ??Read-only filesystem (set at runtime)
# docker run --read-only --tmpfs /tmp my-app

# ??No secrets in ENV at build time
# Use runtime env vars or Docker secrets

# ??Minimal base image
# Prefer: alpine, slim, distroless, scratch

# ??Scan for vulnerabilities
# docker scout cves my-app:latest
# trivy image my-app:latest

Related posts:

  • [Docker Compose Complete Guide](/en/blog/docker-compose-complete-guide/
  • [Kubernetes Practical Guide](/en/blog/kubernetes-practical-guide/
  • [GitHub Actions CI/CD Guide](/en/blog/github-actions-complete-guide/

?�주 묻는 질문 (FAQ)

Q. ???�용???�무?�서 ?�제 ?�나??

A. Shrink Docker images from 1GB to under 100MB with multi-stage builds. Covers stage structure, layer caching strategies, ???�무?�서????본문???�제?� ?�택 가?�드�?참고???�용?�면 ?�니??

Q. ?�행?�로 ?�으�?좋�? 글?�?

A. �?글 ?�단???�전 글 ?�는 관??글 링크�??�라가�??�서?��?배울 ???�습?�다. C++ ?�리�?목차?�서 ?�체 ?�름???�인?????�습?�다.

Q. ??깊이 공�??�려�?

A. cppreference?� ?�당 ?�이브러�?공식 문서�?참고?�세?? 글 말�???참고 ?�료 링크???�용?�면 좋습?�다.


같이 보면 좋�? 글 (?��? 링크)

??주제?� ?�결?�는 ?�른 글?�니??


??글?�서 ?�루???�워??(관??검?�어)

Docker, DevOps, Containers, Performance, Security, Node.js, CI/CD ?�으�?검?�하?�면 ??글???��????�니??