멀티스테이지 빌드란?
하나의 Dockerfile에서 여러 개의 FROM 문을 사용하여 빌드 단계를 분리하는 기법입니다. 빌드에 필요한 도구(컴파일러, 빌드 도구, 개발 의존성)는 빌드 단계에서만 사용하고, 최종 이미지에는 실행에 필요한 파일만 포함하여 이미지 크기를 대폭 줄입니다.
일상적인 비유로, 집을 지을 때 크레인과 시멘트 믹서가 필요하지만 입주할 때는 가져가지 않는 것과 같습니다. 멀티스테이지 빌드는 건축 장비(빌드 도구)와 완성된 집(실행 파일)을 분리합니다.
싱글스테이지 vs 멀티스테이지
먼저 싱글스테이지 빌드의 문제점을 살펴봅니다.
# 싱글스테이지 빌드 — 빌드 도구가 최종 이미지에 포함됨
FROM node:22
WORKDIR /app
# 의존성 설치 (devDependencies 포함)
COPY package*.json ./
RUN npm ci
# 소스 복사 및 빌드
COPY . .
RUN npm run build
# TypeScript 소스, node_modules의 devDependencies,
# 빌드 도구 등이 모두 포함된 채로 이미지 생성
EXPOSE 3000
CMD ["node", "dist/server.js"]
# 결과: 이미지 크기 약 1.2GB
# 불필요한 것: TypeScript 컴파일러, 빌드 도구, 소스 파일, devDependencies
멀티스테이지 빌드로 개선합니다.
# === 멀티스테이지 빌드 — 빌드와 실행 환경 분리 ===
# Stage 1: 빌드 단계 (이름: builder)
FROM node:22-alpine AS builder
WORKDIR /app
# 의존성 설치 (캐시 최적화를 위해 package.json 먼저 복사)
COPY package*.json ./
RUN npm ci
# 소스 복사 및 TypeScript 빌드
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# 프로덕션 의존성만 재설치
RUN npm ci --omit=dev
# Stage 2: 실행 단계 (최소 이미지)
FROM node:22-alpine AS runner
WORKDIR /app
# 보안: 비루트 사용자 생성
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
# builder에서 필요한 파일만 복사
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# 비루트 사용자로 전환
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
# 결과: 이미지 크기 약 180MB (약 85% 감소)
COPY --from=builder가 핵심입니다. 이전 스테이지(builder)에서 빌드 결과물만 선택적으로 복사합니다. 빌드 도구, TypeScript 소스, devDependencies는 최종 이미지에 포함되지 않습니다.
언어별 멀티스테이지 패턴
Go — 정적 바이너리
Go는 정적 바이너리를 생성하므로 최종 이미지에 런타임이 필요 없습니다. scratch(빈 이미지) 또는 distroless를 사용하면 극단적으로 작은 이미지를 만들 수 있습니다.
# Go 멀티스테이지 빌드
FROM golang:1.23-alpine AS builder
WORKDIR /app
# 의존성 다운로드 (캐시 최적화)
COPY go.mod go.sum ./
RUN go mod download
# 소스 복사 및 빌드
COPY . .
# CGO_ENABLED=0: C 라이브러리 의존성 제거 (정적 바이너리)
# -ldflags="-s -w": 디버그 정보 제거 (바이너리 크기 감소)
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/server ./cmd/server
# 실행 단계: scratch (OS 없음, 바이너리만)
FROM scratch
# SSL 인증서 복사 (HTTPS 요청에 필요)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# 바이너리 복사
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
# 결과: 이미지 크기 약 15MB (Go SDK 이미지 약 800MB 대비)
Python — pip install 결과만 복사
# Python 멀티스테이지 빌드
FROM python:3.12-slim AS builder
WORKDIR /app
# 가상환경 생성 및 의존성 설치
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
# 빌드 의존성이 필요한 패키지를 위해 build-essential 설치
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
&& pip install --no-cache-dir -r requirements.txt \
&& apt-get purge -y build-essential && apt-get autoremove -y
# 실행 단계
FROM python:3.12-slim AS runner
WORKDIR /app
# 가상환경만 복사 (빌드 도구 제외)
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# 비루트 사용자
RUN useradd --system appuser
COPY . .
USER appuser
EXPOSE 8000
CMD ["gunicorn", "app:create_app()", "--bind", "0.0.0.0:8000"]
빌드 캐시 최적화
멀티스테이지 빌드에서 캐시를 효율적으로 활용하는 전략입니다.
# 캐시 최적화 Dockerfile (Node.js 예제)
FROM node:22-alpine AS deps
WORKDIR /app
# package.json만 먼저 복사 (소스 변경 시 의존성 캐시 유지)
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
# deps 스테이지에서 node_modules 복사
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# 프로덕션 의존성만 재설치
RUN npm ci --omit=dev
FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
.dockerignore 파일도 캐시 효율에 영향을 줍니다.
# .dockerignore — 불필요한 파일을 빌드 컨텍스트에서 제외
node_modules
dist
.git
.env
*.md
.vscode
coverage
이미지 크기 비교
실제 프로젝트에서 측정한 이미지 크기 비교입니다.
# 이미지 크기 확인
docker images
# REPOSITORY TAG SIZE
# my-app single-stage 1.2GB
# my-app multi-stage 180MB
# my-go-app multi-stage 15MB
# 이미지 레이어 분석 (각 레이어의 크기 확인)
docker history my-app:multi-stage
# IMAGE CREATED SIZE COMMENT
# a1b2c3d4 2 minutes ago 0B CMD ["node" "dist/server.js"]
# e5f6a7b8 2 minutes ago 0B EXPOSE 3000
# c9d0e1f2 2 minutes ago 0B USER appuser
# 12345678 2 minutes ago 45MB COPY dir:... in /app/node_modules
# 9abcdef0 2 minutes ago 2.5MB COPY dir:... in /app/dist
# dive 도구로 레이어 상세 분석 (https://github.com/wagoodman/dive)
dive my-app:multi-stage
특정 스테이지만 빌드
디버깅이나 테스트 시 특정 스테이지만 빌드할 수 있습니다.
# builder 스테이지까지만 빌드 (빌드 결과물 확인용)
docker build --target builder -t my-app:builder .
# 빌드 스테이지 컨테이너에 접속하여 디버깅
docker run -it --rm my-app:builder sh
# 빌드 인수 전달
docker build --build-arg NODE_ENV=production -t my-app:prod .
실전 팁
- Alpine 이미지 사용: 베이스 이미지를
node:22(약 350MB) 대신node:22-alpine(약 50MB)으로 변경하는 것만으로도 크기가 크게 줄어듭니다. 다만 Alpine은 musl libc를 사용하므로 glibc 의존 패키지에서 호환 문제가 발생할 수 있습니다. - distroless 이미지: Google의 distroless 이미지(
gcr.io/distroless/nodejs22)는 셸도 없는 최소 이미지입니다. 보안이 중요한 프로덕션 환경에 적합하지만, 컨테이너 내부에서 디버깅이 어려운 단점이 있습니다. - 레이어 순서 최적화: 변경 빈도가 낮은 것(의존성 설치)을 먼저, 변경 빈도가 높은 것(소스 코드)을 나중에 복사하면 빌드 캐시 히트율이 높아집니다.
- 불필요한 파일 정리: 빌드 스테이지에서
RUN rm -rf /var/cache/apk/* /tmp/*같은 정리 명령을 추가하면 중간 레이어 크기를 줄일 수 있습니다. 다만 멀티스테이지에서는 최종 이미지에 영향이 없으므로 runner 스테이지에서만 신경 쓰면 됩니다. - BuildKit 활용:
DOCKER_BUILDKIT=1 docker build .로 BuildKit을 활성화하면 병렬 빌드, 캐시 마운트(--mount=type=cache) 등 고급 최적화가 가능합니다. Docker 23.0 이상에서는 기본 활성화되어 있습니다.