Docker Multi-Stage Build란? 컨테이너 이미지 최적화의 핵심
프로덕션 Docker 이미지에서 “빌드 도구(gcc, maven, npm)가 포함되어 이미지가 1GB가 넘는다”, “node_modules의 devDependencies가 그대로 들어가 있다”, “소스 코드와 빌드 시크릿이 최종 이미지에 남아 있다” — 이런 문제의 근본 해결책이 Multi-Stage Build입니다.
Multi-Stage Build는 하나의 Dockerfile에 여러 FROM 문을 사용하여 빌드 단계와 실행 단계를 분리합니다. 빌드 단계에서 컴파일/번들링하고, 실행 단계에서는 결과물만 복사하여 최소한의 이미지를 만듭니다. 이 글에서는 기본 패턴부터 NestJS/Spring Boot/Go 언어별 최적화, 캐시 전략, BuildKit 병렬 빌드, 보안 베스트 프랙티스까지 운영 수준에서 완전히 다룹니다.
기본 개념: 단일 Stage vs Multi-Stage
❌ 단일 Stage (안티패턴)
# 빌드 도구 + 소스 코드 + 결과물이 모두 최종 이미지에 포함
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install # devDependencies 포함!
COPY . .
RUN npm run build
CMD ["node", "dist/main.js"]
# 결과: ~1.2GB (node:20 base + node_modules + 소스 코드)
✅ Multi-Stage (최적화)
# Stage 1: 빌드
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: 프로덕션 실행
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production # devDependencies 제외!
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/main.js"]
# 결과: ~180MB (alpine base + production deps + dist만)
핵심: COPY --from=builder로 빌드 Stage의 결과물만 가져옵니다. 빌드 도구, devDependencies, 소스 코드는 최종 이미지에 포함되지 않습니다.
Stage 이름과 참조: FROM … AS 활용
# Stage에 이름 부여
FROM node:20-alpine AS deps
FROM node:20-alpine AS builder
FROM node:20-alpine AS production
# 이름으로 참조
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
# 숫자 인덱스로도 참조 가능 (비권장)
COPY --from=0 /app/dist ./dist
# 외부 이미지에서 직접 복사
COPY --from=nginx:alpine /etc/nginx/nginx.conf /etc/nginx/
COPY --from=busybox:latest /bin/wget /usr/local/bin/
특정 Stage만 빌드: –target
# 개발 환경에서는 builder Stage까지만 빌드
$ docker build --target builder -t myapp:dev .
# CI에서 테스트 Stage만 실행
$ docker build --target test -t myapp:test .
# 프로덕션 전체 빌드
$ docker build --target production -t myapp:latest .
실무 패턴 1: NestJS/TypeScript 최적화 Dockerfile
# ============== Stage 1: Dependencies ==============
FROM node:20-alpine AS deps
WORKDIR /app
# package.json만 먼저 복사 → 의존성 캐시 최적화
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile
# ============== Stage 2: Build ==============
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# TypeScript 컴파일
RUN npm run build
# 빌드 후 devDependencies 제거
RUN npm prune --production
# ============== Stage 3: Production ==============
FROM node:20-alpine AS production
WORKDIR /app
# 보안: non-root 사용자
RUN addgroup -g 1001 -S appgroup &&
adduser -S appuser -u 1001 -G appgroup
# 필요한 파일만 복사
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/package.json ./
# 환경 변수
ENV NODE_ENV=production
ENV PORT=3000
# non-root로 실행
USER appuser
EXPOSE 3000
# Graceful shutdown을 위해 exec form 사용
CMD ["node", "dist/main.js"]
최적화 포인트:
- deps Stage 분리: package.json이 변경되지 않으면
npm ci레이어가 캐시됨 - npm prune –production: builder에서 devDependencies 제거 후 복사
- alpine 이미지: Debian 기반(~900MB) vs Alpine(~130MB)
- non-root 사용자: 컨테이너 탈출(escape) 공격 방어
실무 패턴 2: Spring Boot (Java) 최적화 Dockerfile
# ============== Stage 1: Build ==============
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
# Gradle Wrapper 복사 + 의존성 다운로드 (캐시 최적화)
COPY gradle/ gradle/
COPY gradlew build.gradle.kts settings.gradle.kts ./
RUN ./gradlew dependencies --no-daemon
# 소스 복사 + 빌드
COPY src/ src/
RUN ./gradlew bootJar --no-daemon -x test
# Spring Boot layertools로 JAR 분해
RUN java -Djarmode=layertools -jar build/libs/*.jar extract
# ============== Stage 2: Production ==============
FROM eclipse-temurin:21-jre-alpine AS production
WORKDIR /app
# 보안: non-root 사용자
RUN addgroup -g 1001 -S spring &&
adduser -S spring -u 1001 -G spring
# Spring Boot Layered JAR (레이어별 캐시 최적화)
COPY --from=builder --chown=spring:spring /app/dependencies/ ./
COPY --from=builder --chown=spring:spring /app/spring-boot-loader/ ./
COPY --from=builder --chown=spring:spring /app/snapshot-dependencies/ ./
COPY --from=builder --chown=spring:spring /app/application/ ./
USER spring
EXPOSE 8080
ENTRYPOINT ["java",
"-XX:+UseContainerSupport",
"-XX:MaxRAMPercentage=75.0",
"org.springframework.boot.loader.launch.JarLauncher"]
핵심: Spring Boot Layered JAR
| 레이어 | 내용 | 변경 빈도 |
|---|---|---|
| dependencies | 외부 라이브러리 (spring-web 등) | 매우 낮음 |
| spring-boot-loader | Spring Boot 로더 | 거의 없음 |
| snapshot-dependencies | SNAPSHOT 의존성 | 낮음 |
| application | 우리 코드 (.class 파일) | 매 배포마다 |
변경 빈도가 낮은 레이어를 먼저 COPY하면 Docker 레이어 캐시가 극대화됩니다. 빌드 시 application 레이어만 새로 만들어지므로 push/pull 시간이 대폭 단축됩니다.
실무 패턴 3: Go 바이너리 (scratch/distroless)
# ============== Stage 1: Build ==============
FROM golang:1.22-alpine AS builder
WORKDIR /app
# 의존성 캐시
COPY go.mod go.sum ./
RUN go mod download
# 빌드 (CGO 비활성화 → 정적 바이너리)
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64
go build -ldflags="-s -w" -o /app/server ./cmd/server
# ============== Stage 2: Production ==============
FROM gcr.io/distroless/static-debian12 AS production
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
# 결과: ~15MB! (distroless + 정적 바이너리)
베이스 이미지 크기 비교
| 베이스 이미지 | 크기 | Shell | 패키지 매니저 | 적합한 용도 |
|---|---|---|---|---|
ubuntu:22.04 |
~77MB | ✅ | ✅ apt | 디버깅 필요 시 |
alpine:3.19 |
~7MB | ✅ | ✅ apk | 대부분의 프로덕션 |
distroless |
~2MB | ❌ | ❌ | Go/Java 정적 바이너리 |
scratch |
0MB | ❌ | ❌ | Go 정적 바이너리 (최소) |
BuildKit 캐시 마운트: 빌드 속도 극대화
Docker BuildKit의 --mount=type=cache는 패키지 매니저의 캐시 디렉토리를 빌드 간에 유지합니다:
# syntax=docker/dockerfile:1
# npm 캐시 마운트
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm
npm ci --frozen-lockfile
COPY . .
RUN npm run build
# Maven 캐시 마운트
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY pom.xml ./
RUN --mount=type=cache,target=/root/.m2/repository
mvn dependency:go-offline -B
COPY src/ src/
RUN --mount=type=cache,target=/root/.m2/repository
mvn package -DskipTests -B
# Go 모듈 캐시 마운트
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod
--mount=type=cache,target=/root/.cache/go-build
go build -o /app/server ./cmd/server
# apt 캐시 마운트
RUN --mount=type=cache,target=/var/cache/apt
--mount=type=cache,target=/var/lib/apt
apt-get update && apt-get install -y curl
BuildKit 시크릿 마운트: 빌드 시 인증 정보 안전 사용
# Dockerfile: 시크릿을 임시 마운트 (이미지에 남지 않음)
RUN --mount=type=secret,id=npm_token
NPM_TOKEN=$(cat /run/secrets/npm_token)
npm ci --frozen-lockfile
# 빌드 명령
$ docker build --secret id=npm_token,src=./.npmrc -t myapp .
⚠️ 절대 하지 말 것: COPY .npmrc .이나 ARG NPM_TOKEN으로 시크릿을 전달하면 이미지 레이어에 영구 저장됩니다. --mount=type=secret만 사용하세요.
레이어 캐시 최적화: COPY 순서의 과학
Docker는 각 명령어를 레이어로 만들고, 변경된 레이어 이후의 모든 레이어를 재빌드합니다. 변경 빈도가 낮은 것을 먼저 COPY하는 것이 핵심입니다:
# ❌ 비효율: 소스 코드 변경 시 npm ci도 재실행
COPY . .
RUN npm ci
RUN npm run build
# ✅ 효율: package.json 변경 시에만 npm ci 재실행
COPY package.json package-lock.json ./ # 1. 의존성 정의 (변경 드묾)
RUN npm ci # 2. 의존성 설치 (캐시됨!)
COPY tsconfig.json nest-cli.json ./ # 3. 설정 파일 (변경 드묾)
COPY src/ src/ # 4. 소스 코드 (자주 변경)
RUN npm run build # 5. 빌드
.dockerignore: 빌드 컨텍스트 최적화
# .dockerignore
node_modules
dist
.git
.gitignore
*.md
.env
.env.*
docker-compose*.yml
Dockerfile*
.vscode
.idea
coverage
test
__tests__
*.test.ts
*.spec.ts
.dockerignore가 중요한 이유:
node_modules제외 → 빌드 컨텍스트 전송 시간 대폭 감소.git제외 → 수백 MB의 Git 이력 전송 방지.env제외 → 민감 정보가 이미지에 포함되는 것 방지
테스트 Stage 통합: CI/CD 파이프라인
# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# Stage 2: Build
FROM deps AS builder
COPY . .
RUN npm run build
# Stage 3: Test (CI에서만 사용)
FROM builder AS test
RUN npm run test -- --coverage
RUN npm run test:e2e
# Stage 4: Lint (CI에서만 사용)
FROM builder AS lint
RUN npm run lint
# Stage 5: Production
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
RUN npm prune --production
USER node
CMD ["node", "dist/main.js"]
# CI 파이프라인에서
$ docker build --target test -t myapp:test . # 테스트만 실행
$ docker build --target lint -t myapp:lint . # 린트만 실행
$ docker build --target production -t myapp . # 프로덕션 빌드
BuildKit 병렬 빌드: 독립 Stage 동시 실행
BuildKit은 의존 관계가 없는 Stage를 자동으로 병렬 실행합니다:
# frontend와 backend를 병렬로 빌드
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
FROM node:20-alpine AS backend-builder
WORKDIR /app/backend
COPY backend/package*.json ./
RUN npm ci
COPY backend/ .
RUN npm run build
# 두 결과물을 합침
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=backend-builder /app/backend/dist ./dist
COPY --from=frontend-builder /app/frontend/build ./public
COPY --from=backend-builder /app/backend/node_modules ./node_modules
CMD ["node", "dist/main.js"]
frontend-builder와 backend-builder는 서로 의존하지 않으므로 BuildKit이 동시에 빌드합니다. 빌드 시간이 합산이 아닌 최대값으로 줄어듭니다.
# BuildKit 활성화
$ DOCKER_BUILDKIT=1 docker build .
# 또는 Docker Desktop에서 기본 활성화 확인
$ docker buildx build --progress=plain .
보안 베스트 프랙티스 체크리스트
# 1. 특정 버전 태그 사용 (latest 금지)
FROM node:20.11.1-alpine3.19 # ✅ 재현 가능
FROM node:latest # ❌ 빌드마다 다를 수 있음
# 2. non-root 사용자
USER node # Node.js 공식 이미지 내장 사용자
USER nonroot # distroless 내장 사용자
RUN adduser -S appuser && USER appuser # 커스텀
# 3. 읽기 전용 파일시스템
# docker run --read-only myapp
# 4. HEALTHCHECK 추가
HEALTHCHECK --interval=30s --timeout=5s --retries=3
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# 5. 불필요한 권한 제거
RUN chmod -R 555 /app # 읽기+실행만
RUN chown -R appuser:appgroup /app
이미지 분석: 최적화 효과 확인
# 이미지 크기 확인
$ docker images myapp
REPOSITORY TAG SIZE
myapp single 1.24GB # 단일 Stage
myapp multi 182MB # Multi-Stage
myapp distroless 15MB # Go + distroless
# 레이어별 크기 분석
$ docker history myapp:multi
IMAGE CREATED SIZE COMMAND
a1b2c3d4e5f6 2 min ago 0B CMD ["node" "dist/main.js"]
b2c3d4e5f6a7 2 min ago 0B USER node
c3d4e5f6a7b8 2 min ago 45MB COPY --from=builder /app/dist ./dist
d4e5f6a7b8c9 3 min ago 120MB npm ci --only=production
e5f6a7b8c9d0 5 min ago 7.38MB alpine base
# dive로 상세 분석 (레이어별 파일 탐색)
$ dive myapp:multi
이 최적화된 이미지는 Kubernetes 환경에서 Pod 시작 시간을 단축하고, 이미지 pull 트래픽을 줄이며, 컨테이너 레지스트리 스토리지 비용도 절감합니다. 또한 Docker Compose의 build 섹션에서 target을 지정하여 개발/프로덕션 환경을 쉽게 전환할 수 있습니다.
정리: Multi-Stage Build 설계 체크리스트
| 항목 | 체크 |
|---|---|
| 빌드 Stage와 실행 Stage 분리 | ☐ |
| Alpine 또는 distroless 베이스 이미지 사용 | ☐ |
| package.json → npm ci → COPY src 순서 (캐시 최적화) | ☐ |
| devDependencies 제외 (npm prune –production) | ☐ |
| non-root 사용자로 실행 (USER node) | ☐ |
| 특정 버전 태그 사용 (latest 금지) | ☐ |
| .dockerignore로 node_modules/.git 제외 | ☐ |
| BuildKit cache mount로 빌드 가속 | ☐ |
| 시크릿은 –mount=type=secret만 사용 | ☐ |
| HEALTHCHECK 추가 | ☐ |
Docker Multi-Stage Build는 단순한 이미지 크기 줄이기가 아닙니다. 빌드 도구와 런타임 분리, 레이어 캐시 극대화, 보안 표면 최소화, CI/CD 파이프라인 통합을 한 번에 달성하는 프로덕션 필수 패턴입니다. 특히 NestJS의 경우 1.2GB → 180MB, Go의 경우 수백 MB → 15MB까지 줄일 수 있으므로, 배포 속도와 인프라 비용 모두에 직접적인 영향을 줍니다.