본문으로 건너뛰기 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="team@example.com"
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:


Frequently Asked Questions (FAQ)

Q. When would I use this in practice?

A. 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.

Q. What should I read before this?

A. Follow the previous article or related articles links at the bottom of each post to learn in sequence.

Q. Where can I study this more deeply?

A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.


Other articles related to this topic.


Keywords Covered in This Article (Related Search Terms)

This article covers Docker, DevOps, Containers, Performance, Security, Node.js, CI/CD.