멀티 스테이지 빌드 — 이미지 크기를 극적으로 줄이는 방법
빌드에 필요한 도구(컴파일러, 패키지 매니저, SDK)와 실제 실행에 필요한 파일은 전혀 다릅니다. 빌드 도구까지 포함된 이미지를 프로덕션에 올리면 왜 문제가 될까요?
멀티 스테이지 빌드란
하나의 Dockerfile에 FROM을 여러 번 사용하여 빌드 단계를 분리하는 기법입니다. 빌드에 필요한 무거운 도구(SDK, 컴파일러)는 첫 번째 스테이지에서만 사용하고, 최종 이미지에는 실행에 필요한 파일만 복사합니다.
# 스테이지 1: 빌드
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 스테이지 2: 실행
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
왜 필요한가
싱글 스테이지의 문제
FROM node:20
WORKDIR /app
COPY . .
RUN npm ci && npm run build
CMD ["node", "dist/server.js"]
이 이미지에는 다음이 모두 포함됩니다.
- Node.js 전체 (devDependencies 포함)
- 소스 코드 전체 (TypeScript 원본 등)
- 빌드 도구 (webpack, tsc 등)
- OS 패키지들
결과적으로 이미지 크기가 1GB를 넘기는 경우도 흔합니다.
멀티 스테이지 적용 후
| 구분 | 싱글 스테이지 | 멀티 스테이지 |
|---|---|---|
| node:20 기반 | ~1.1GB | - |
| node:20-alpine + 멀티 스테이지 | - | ~150MB |
| 포함된 파일 | 소스 + 빌드도구 + 결과물 | 결과물만 |
동작 원리
┌──────────────────┐ COPY --from ┌──────────────────┐
│ Stage 1: builder│ ──────────────────→ │ Stage 2: final │
│ FROM node:20 │ 필요한 파일만 │ FROM alpine │
│ │ │ │
│ npm ci │ │ dist/ │
│ npm run build │ │ node_modules/ │
│ (1.1GB) │ │ (150MB) │
└──────────────────┘ └──────────────────┘
↓
빌드 후 버려짐
Docker는 마지막 FROM 이후의 명령어로 만들어진 레이어만 최종 이미지에 포함합니다. 이전 스테이지는 빌드 캐시에만 남고, docker images에는 나타나지 않습니다.
언어별 패턴
Go — scratch 이미지 활용
Go는 정적 바이너리를 생성할 수 있어서 scratch(완전히 빈 이미지)를 사용할 수 있습니다.
# 빌드 스테이지
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# 정적 바이너리 컴파일 (CGO 비활성화)
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /server ./cmd/server
# 런타임 스테이지
FROM scratch
# SSL 인증서 복사 (HTTPS 요청이 필요한 경우)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /server /server
ENTRYPOINT ["/server"]
최종 이미지 크기: 약 10-20MB (바이너리 크기에 따라)
Java — JDK → JRE 분리
# 빌드 스테이지
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /app
COPY gradle/ gradle/
COPY gradlew build.gradle.kts settings.gradle.kts ./
RUN ./gradlew dependencies --no-daemon
COPY src/ src/
RUN ./gradlew bootJar --no-daemon
# 런타임 스테이지 — JRE만 포함
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
RUN addgroup -g 1001 app && adduser -u 1001 -G app -s /bin/sh -D app
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
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 .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY . .
CMD ["python", "main.py"]
Rust — scratch 활용
FROM rust:1.77 AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
# 의존성 먼저 빌드 (캐시 활용)
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src
COPY src/ src/
RUN touch src/main.rs && cargo build --release
FROM scratch
COPY --from=builder /app/target/release/myapp /myapp
ENTRYPOINT ["/myapp"]
베이스 이미지 선택 가이드
scratch
FROM scratch
- 파일 시스템이 완전히 비어 있음
- 셸도 없어서
docker exec로 접속 불가 - 정적 바이너리 전용 (Go, Rust)
- 디버깅이 어려움
distroless
FROM gcr.io/distroless/static-debian12
# 또는
FROM gcr.io/distroless/java21-debian12
- Google이 관리하는 경량 이미지
- 셸, 패키지 매니저 없음 (보안 공격 표면 최소화)
- 언어별 런타임만 포함
- scratch보다 약간 크지만 CA 인증서, 타임존 등 기본 파일 포함
alpine
FROM node:20-alpine
- 약 5MB의 경량 Linux 배포판
- 셸과 패키지 매니저(
apk) 포함 musl libc사용 (glibc와 호환성 이슈 가능)- 디버깅이 가능하면서도 경량
크기 비교 예시
| 베이스 이미지 | 크기 |
|---|---|
| ubuntu:22.04 | ~77MB |
| debian:bookworm-slim | ~74MB |
| alpine:3.19 | ~7MB |
| gcr.io/distroless/static | ~2MB |
| scratch | 0MB |
고급 패턴
여러 스테이지에서 파일 가져오기
FROM golang:1.22 AS backend
WORKDIR /app
COPY backend/ .
RUN go build -o /api-server
FROM node:20-alpine AS frontend
WORKDIR /app
COPY frontend/ .
RUN npm ci && npm run build
FROM alpine:3.19
RUN apk add --no-cache ca-certificates
COPY --from=backend /api-server /usr/local/bin/
COPY --from=frontend /app/dist /var/www/html/
CMD ["api-server"]
외부 이미지에서 파일 가져오기
FROM alpine:3.19
# 다른 이미지에서 직접 복사 (스테이지 없이)
COPY --from=nginx:1.25-alpine /etc/nginx/nginx.conf /etc/nginx/
특정 스테이지만 빌드
# builder 스테이지까지만 빌드
docker build --target builder -t myapp:builder .
CI에서 테스트 스테이지만 실행할 때 유용합니다.
FROM node:20-alpine AS deps
COPY package*.json ./
RUN npm ci
FROM deps AS test
COPY . .
RUN npm test
FROM deps AS builder
COPY . .
RUN npm run build
FROM node:20-alpine
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]
# 테스트만 실행
docker build --target test .
# 전체 빌드
docker build .
실무에서의 팁
1. 의존성 레이어를 먼저 캐시
# 좋은 패턴: 의존성 파일만 먼저 복사
COPY package.json package-lock.json ./
RUN npm ci
# 이후에 소스 복사
COPY . .
RUN npm run build
소스 코드가 변경되어도 의존성 레이어는 캐시에서 재사용됩니다.
2. 프로덕션 의존성만 설치
# 런타임 스테이지
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
3. 불필요한 파일 제거 확인
# 최종 이미지의 파일 목록 확인
docker run --rm myapp ls -la /app
# 이미지 레이어별 크기 분석
docker history myapp
# dive 도구로 상세 분석
dive myapp
정리
- 멀티 스테이지 빌드는 "빌드 환경"과 "실행 환경"을 분리하여 이미지 크기를 줄이는 핵심 기법입니다.
COPY --from=스테이지명으로 이전 스테이지의 특정 파일만 가져옵니다.- Go/Rust는
scratch, Java는 JRE 이미지, Node.js/Python은alpine이나slim을 런타임 베이스로 사용하는 것이 일반적입니다. distroless는 보안이 중요한 프로덕션 환경에서 좋은 선택입니다.--target플래그로 특정 스테이지만 빌드할 수 있어 CI/CD 파이프라인에서 유연하게 활용됩니다.
댓글 로딩 중...