Docker Multi-Stage Build 실전

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% 단축합니다.

실전 체크리스트

  1. 최종 스테이지에 컴파일러, 빌드 도구가 포함되지 않았는지 확인
  2. .dockerignorenode_modules, .git, dist 등 추가
  3. 각 스테이지에 의미 있는 이름(AS builder, AS runner) 부여
  4. 변경 빈도 낮은 파일(lock 파일)을 먼저 COPY하여 캐시 히트율 확보
  5. 프로덕션 이미지에서 USER 지시어로 non-root 실행 설정
  6. docker image ls로 최종 이미지 크기를 단일 스테이지 대비 검증

관련 글

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) 관련 글

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux