Docker Multi-Stage Build: 빌드

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-builderbackend-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까지 줄일 수 있으므로, 배포 속도와 인프라 비용 모두에 직접적인 영향을 줍니다.

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