Docker Multi-Stage Build란?
Docker Multi-Stage Build는 하나의 Dockerfile 안에 여러 빌드 단계(stage)를 정의하고, 최종 이미지에는 실행에 필요한 파일만 복사하는 기법입니다. 빌드 도구, 소스 코드, 중간 산출물이 최종 이미지에 포함되지 않아 이미지 크기를 50~90% 줄일 수 있습니다.
이 글에서는 Multi-Stage Build의 동작 원리, 실전 패턴 4가지, 흔한 실수와 방지법을 공식 문서 기준으로 정리합니다.
왜 Multi-Stage Build가 필요한가
전통적인 단일 스테이지 Dockerfile은 빌드 의존성(컴파일러, 패키지 매니저, dev 라이브러리)이 최종 이미지에 그대로 남습니다. 이로 인해 발생하는 문제는 세 가지입니다.
| 문제 | 영향 | Multi-Stage로 해결 |
|---|---|---|
| 이미지 크기 비대 | 배포 속도 저하, 레지스트리 비용 증가 | 최종 스테이지에 런타임만 포함 |
| 보안 표면 확대 | gcc, make 등 공격 벡터 노출 | 빌드 도구가 최종 이미지에 없음 |
| 캐시 무효화 범위 확대 | 소스 변경 시 불필요한 레이어 재빌드 | 스테이지별 독립 캐시 |
기본 구조와 동작 원리
Multi-Stage Build의 핵심은 FROM 명령어를 여러 번 사용하는 것입니다. 각 FROM은 새로운 스테이지를 시작하며, COPY --from= 지시어로 이전 스테이지의 산출물을 가져옵니다.
# 스테이지 1: 빌드
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production=false
COPY . .
RUN npm run build
# 스테이지 2: 실행
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]
위 예시에서 builder 스테이지의 소스 코드, devDependencies, 빌드 캐시는 최종 runner 이미지에 포함되지 않습니다.
실전 패턴 4가지
패턴 1: 의존성 캐시 분리
소스 코드 변경 시 npm ci가 다시 실행되는 것을 방지하려면 package*.json을 먼저 복사하고 설치한 뒤 소스를 복사합니다. 이 레이어 순서가 Docker 캐시 히트율을 결정합니다.
COPY package*.json ./
RUN npm ci
# 이 위까지는 package.json이 안 바뀌면 캐시 히트
COPY . .
RUN npm run build
패턴 2: distroless 또는 scratch 베이스 사용
Go, Rust처럼 정적 바이너리를 생성하는 언어는 최종 스테이지를 scratch(빈 이미지) 또는 gcr.io/distroless로 설정해 이미지를 10MB 이하로 줄일 수 있습니다.
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o server .
FROM scratch
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
패턴 3: 테스트 스테이지 삽입
CI 파이프라인에서 빌드와 테스트를 하나의 Dockerfile로 관리할 수 있습니다. docker build --target=test로 테스트 스테이지만 실행하면 됩니다.
FROM node:20-alpine AS builder
# ... 빌드 단계 ...
FROM builder AS test
RUN npm run test
FROM node:20-alpine AS runner
COPY --from=builder /app/dist ./dist
# ... 실행 단계 ...
패턴 4: 빌드 인자(ARG)로 스테이지 제어
ARG를 활용하면 동일한 Dockerfile로 개발/운영 이미지를 분기할 수 있습니다.
ARG NODE_ENV=production
FROM node:20-alpine AS builder
ARG NODE_ENV
RUN if [ "$NODE_ENV" = "development" ]; then npm ci; else npm ci --production; fi
이미지 크기 비교: 단일 스테이지 vs Multi-Stage
| 시나리오 | 단일 스테이지 | Multi-Stage | 감소율 |
|---|---|---|---|
| Node.js API (NestJS) | 1.2 GB | 180 MB | 85% |
| Go CLI 도구 | 350 MB | 8 MB (scratch) | 97% |
| Java Spring Boot | 680 MB | 210 MB (JRE only) | 69% |
| Python FastAPI | 900 MB | 150 MB (slim) | 83% |
흔한 실수 5가지와 방지법
| 실수 | 증상 | 방지법 |
|---|---|---|
| 최종 스테이지에 빌드 도구 남김 | 이미지 크기 감소 효과 없음 | COPY --from으로 필요한 파일만 명시 복사 |
.dockerignore 미설정 |
node_modules, .git이 빌드 컨텍스트에 포함 |
.dockerignore에 불필요 디렉터리 추가 |
| 스테이지 이름 미지정 | COPY --from=0으로 순서 의존, 유지보수 어려움 |
FROM ... AS builder처럼 이름 지정 |
| 레이어 순서 무시 | 소스 한 줄 변경에도 의존성 재설치 | 변경 빈도 낮은 파일을 먼저 COPY |
| 시크릿을 ARG로 전달 | 빌드 히스토리에 비밀 노출 | --mount=type=secret 사용 (BuildKit) |
BuildKit 캐시 마운트로 빌드 속도 높이기
Docker BuildKit의 --mount=type=cache를 활용하면 패키지 매니저 캐시를 스테이지 간에 유지할 수 있습니다.
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
이 방식은 CI 환경에서 npm ci 실행 시간을 평균 40~60% 단축합니다.
실전 체크리스트
- 최종 스테이지에 컴파일러, 빌드 도구가 포함되지 않았는지 확인
.dockerignore에node_modules,.git,dist등 추가- 각 스테이지에 의미 있는 이름(
AS builder,AS runner) 부여 - 변경 빈도 낮은 파일(lock 파일)을 먼저 COPY하여 캐시 히트율 확보
- 프로덕션 이미지에서
USER지시어로 non-root 실행 설정 docker image ls로 최종 이미지 크기를 단일 스테이지 대비 검증
관련 글
- GitHub Actions Self-Hosted Runner 실전 구축 가이드 — Multi-Stage Build와 Self-Hosted Runner를 결합하면 빌드 시간을 대폭 줄일 수 있습니다.
- Kubernetes NetworkPolicy 심화: Default Deny부터 3-Tier 정책 설계까지 — 컨테이너 이미지 경량화와 함께 네트워크 정책으로 보안을 강화하는 방법을 확인하세요.
Docker Multi-Stage Build에 대해 궁금한 점이 있거나, 현재 프로젝트의 Dockerfile 최적화가 필요하시면 문의 페이지를 통해 연락해 주세요.
7) 실전 Dockerfile: NestJS Multi-Stage Build
# Stage 1: 의존성 설치 (캐시 최적화)
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
# Stage 2: 빌드
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable && pnpm build
# 프로덕션 의존성만 재설치
RUN pnpm install --frozen-lockfile --prod
# Stage 3: 실행 (최소 이미지)
FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup -g 1001 -S nodejs &&
adduser -S nestjs -u 1001
COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist
COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nestjs:nodejs /app/package.json ./
USER nestjs
EXPOSE 3000
CMD ["node", "dist/main.js"]
# 이미지 크기 비교:
# 단일 스테이지: ~800MB
# Multi-Stage: ~180MB (78% 감소)
8) .dockerignore와 빌드 캐시 최적화
# .dockerignore — 빌드 컨텍스트에서 제외
node_modules
dist
.git
.env*
*.md
.vscode
coverage
test
.turbo
# 캐시 최적화 팁:
# 1. COPY package.json → install → COPY . 순서 유지
# → 코드 변경 시 install 캐시 재사용
# 2. BuildKit 활성화
DOCKER_BUILDKIT=1 docker build -t myapp .
# 3. 캐시 마운트 (BuildKit)
RUN --mount=type=cache,target=/root/.local/share/pnpm/store
pnpm install --frozen-lockfile
9) 관련 글
- Docker Multi-Stage Build 기초 — Multi-Stage Build의 개념과 기본 패턴을 설명합니다.
- Docker BuildKit + Distroless — BuildKit 캐시와 Distroless 이미지로 더 작은 이미지를 만드는 방법입니다.
- Docker Compose 운영 — Multi-Stage로 빌드한 이미지를 Compose로 운영하는 패턴입니다.
- GitHub Actions CI/CD — Multi-Stage Build를 CI 파이프라인에 통합하는 방법입니다.