# Docker 멀티스테이지 빌드 완벽 가이드: 이미지 크기를 10분의 1로 줄이는 방법
Table of Contents
“1.2GB짜리 이미지를 매번 배포할 수는 없잖아요.”
Node.js 애플리케이션을 Docker로 빌드했더니 이미지가 1GB를 넘는 경우, 흔히 겪는 상황입니다. 빌드 도구, 개발 의존성, 소스 코드가 전부 포함되어 있기 때문입니다. 배포 시간은 길어지고, 저장소 비용은 늘어나고, 보안 취약점도 증가합니다.
멀티스테이지 빌드는 이 문제를 해결하는 Docker의 핵심 기능입니다. 빌드에 필요한 것과 실행에 필요한 것을 분리하여, 최종 이미지에는 실행에 필요한 최소한만 포함시킵니다.
멀티스테이지 빌드란?
멀티스테이지 빌드는 하나의 Dockerfile에 여러 개의 FROM 명령을 사용하는 기법입니다. 각 FROM은 새로운 빌드 스테이지를 시작하며, 이전 스테이지에서 필요한 파일만 복사해올 수 있습니다.
기본 구조
# 스테이지 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/main.js"]
핵심은 COPY --from=builder입니다. 이전 스테이지에서 빌드된 결과물만 가져옵니다.
이미지 크기 비교
| 방식 | 이미지 크기 | 포함된 내용 |
|---|---|---|
| 싱글스테이지 | ~1.2GB | Node.js, npm, 소스코드, devDependencies, 빌드 도구 |
| 멀티스테이지 | ~150MB | Node.js Alpine, 빌드 결과물, production dependencies |
약 8배 차이가 납니다.
왜 이미지 크기가 중요한가?
1. 배포 속도
CI/CD 파이프라인에서 이미지를 레지스트리에 푸시하고, Kubernetes 노드에서 풀 받는 시간이 크게 줄어듭니다.
1.2GB 이미지: 푸시 45초 + 풀 60초 = 105초
150MB 이미지: 푸시 6초 + 풀 8초 = 14초
배포마다 90초 절약, 하루 10번 배포하면 15분입니다.
2. 저장소 비용
AWS ECR, GCR, Docker Hub 모두 저장 용량에 따라 비용이 발생합니다. 이미지가 작을수록 비용이 줄어듭니다.
3. 보안
이미지에 포함된 패키지가 적을수록 **공격 표면(attack surface)**이 줄어듭니다. 빌드 도구, 컴파일러, 불필요한 시스템 유틸리티가 없으면 취약점도 없습니다.
4. 시작 시간
이미지가 작으면 컨테이너 시작 시간도 빨라집니다. Kubernetes에서 Pod 스케일 아웃 시 중요합니다.
언어별 최적화 패턴
Node.js / TypeScript
# 스테이지 1: 의존성 설치
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json./
RUN npm ci --only=production
# 스테이지 2: 빌드
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json./
RUN npm ci
COPY..
RUN npm run build
# 스테이지 3: 실행
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# 비root 사용자로 실행
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=deps /app/node_modules./node_modules
COPY --from=builder /app/dist./dist
COPY --from=builder /app/package.json./
USER nextjs
EXPOSE 3000
CMD ["node", "dist/main.js"]
포인트:
deps스테이지에서 production 의존성만 설치builder스테이지에서 전체 의존성으로 빌드- 최종 이미지에 devDependencies 미포함
Go
Go는 정적 바이너리를 생성하므로 scratch 이미지를 사용할 수 있습니다:
# 스테이지 1: 빌드
FROM golang:1.22-alpine AS builder
WORKDIR /app
# 의존성 먼저 (캐시 활용)
COPY go.mod go.sum./
RUN go mod download
COPY..
# 정적 바이너리 빌드
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o main.
# 스테이지 2: 실행 (scratch = 빈 이미지)
FROM scratch
COPY --from=builder /app/main /main
# SSL 인증서 (HTTPS 호출 시 필요)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/main"]
결과: 약 10-15MB 이미지
-ldflags="-w -s"는 디버그 정보를 제거하여 바이너리 크기를 줄입니다.
:::tip
scratch 이미지는 셸도 없어서 디버깅이 어렵습니다. 디버깅이 필요하면 alpine 또는 distroless를 사용하세요.
:::
Python
# 스테이지 1: 빌드
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
# 스테이지 2: 실행
FROM python:3.12-slim AS runner
WORKDIR /app
# 가상환경 복사
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY..
# 비root 사용자
RUN useradd --create-home appuser
USER appuser
CMD ["python", "main.py"]
포인트:
- 가상환경 전체를 복사하여 의존성 재설치 불필요
--no-cache-dir로 pip 캐시 제거
Java / Spring Boot
# 스테이지 1: 빌드
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
# Gradle wrapper와 설정 먼저 복사 (캐시 활용)
COPY gradlew.
COPY gradle gradle
COPY build.gradle.kts settings.gradle.kts./
# 의존성 다운로드
RUN./gradlew dependencies --no-daemon
# 소스 코드 복사 및 빌드
COPY src src
RUN./gradlew bootJar --no-daemon
# JAR 레이어 추출 (Spring Boot 2.3+)
RUN java -Djarmode=layertools -jar build/libs/*.jar extract
# 스테이지 2: 실행
FROM eclipse-temurin:21-jre-alpine AS runner
WORKDIR /app
# 비root 사용자
RUN addgroup --system --gid 1001 spring
RUN adduser --system --uid 1001 spring
# 레이어별로 복사 (변경 빈도 낮은 순)
COPY --from=builder /app/dependencies/./
COPY --from=builder /app/spring-boot-loader/./
COPY --from=builder /app/snapshot-dependencies/./
COPY --from=builder /app/application/./
USER spring
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
포인트:
- JDK(빌드) → JRE(실행)로 전환
- Spring Boot의 레이어 추출로 캐시 효율 극대화
- 의존성 레이어는 거의 변경되지 않으므로 캐시 적중률 높음
Rust
# 스테이지 1: 빌드
FROM rust:1.75-alpine AS builder
RUN apk add --no-cache musl-dev
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 # 타임스탬프 갱신
RUN cargo build --release
# 스테이지 2: 실행
FROM scratch
COPY --from=builder /app/target/release/myapp /myapp
ENTRYPOINT ["/myapp"]
결과: 약 5-10MB 이미지
Rust도 Go처럼 정적 바이너리를 생성하므로 scratch 이미지 사용 가능합니다.
빌드 캐시 최적화
멀티스테이지 빌드의 효과를 극대화하려면 레이어 캐싱을 이해해야 합니다.
캐시 작동 원리
Docker는 각 명령을 레이어로 저장합니다. 이전 레이어가 변경되지 않았으면 캐시를 사용합니다.
# 레이어 1: 변경 드묾
COPY package.json./
# 레이어 2: package.json 변경 시만 재실행
RUN npm ci
# 레이어 3: 소스 코드 자주 변경
COPY..
# 레이어 4: 소스 변경 시 재실행
RUN npm run build
원칙: 변경이 적은 것을 먼저 복사하세요.
나쁜 예 vs 좋은 예
# 나쁜 예: 소스 변경 시 npm ci도 재실행
COPY..
RUN npm ci
RUN npm run build
# 좋은 예: package.json 변경 시만 npm ci 재실행
COPY package*.json./
RUN npm ci
COPY..
RUN npm run build
###.dockerignore 설정
불필요한 파일을 제외하여 빌드 컨텍스트를 줄이고, 캐시 무효화를 방지합니다:
#.dockerignore
node_modules
dist
.git
.gitignore
*.md
.env*
coverage
.nyc_output
*.log
Dockerfile
docker-compose*.yml
특히 중요: node_modules를 제외하지 않으면, 로컬 변경이 캐시를 무효화합니다.
BuildKit 캐시 마운트
Docker BuildKit을 사용하면 패키지 매니저 캐시를 영구적으로 유지할 수 있습니다:
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json./
# npm 캐시를 마운트로 유지
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY..
RUN npm run build
BuildKit 활성화:
DOCKER_BUILDKIT=1 docker build -t myapp.
보안 강화 패턴
1. 비root 사용자 실행
FROM node:20-alpine
# 사용자 생성
RUN addgroup --system --gid 1001 appgroup
RUN adduser --system --uid 1001 appuser
WORKDIR /app
COPY --chown=appuser:appgroup..
USER appuser
CMD ["node", "index.js"]
2. Distroless 이미지 사용
Google의 distroless 이미지는 셸, 패키지 매니저가 없어 보안이 강화됩니다:
FROM node:20-alpine AS builder
WORKDIR /app
COPY..
RUN npm ci && npm run build
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist./dist
COPY --from=builder /app/node_modules./node_modules
CMD ["dist/main.js"]
3. 시크릿 처리
빌드 시 필요한 시크릿(npm token 등)이 이미지에 남지 않도록 합니다:
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json./
# 시크릿을 마운트로 전달 (이미지에 저장 안됨)
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) \
npm ci
COPY..
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist./dist
CMD ["node", "dist/main.js"]
빌드 명령:
docker build --secret id=npm_token,src=.npmrc -t myapp.
실전 최적화 체크리스트
이미지 크기 분석
# 이미지 히스토리로 레이어별 크기 확인
docker history myapp:latest
# dive 도구로 상세 분석
docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
wagoodman/dive:latest myapp:latest
최적화 체크리스트
- 멀티스테이지 빌드 사용
- Alpine 또는 slim 베이스 이미지 사용
- [ ].dockerignore 설정
- 변경 빈도 낮은 레이어 먼저 복사
- production 의존성만 포함
- 비root 사용자 실행
- 불필요한 파일 제거 (
apt clean,rm -rf /var/lib/apt/lists/*) - BuildKit 캐시 마운트 활용
최적화 전후 비교 예시
| 항목 | 최적화 전 | 최적화 후 |
|---|---|---|
| 베이스 이미지 | node:20 (1.1GB) | node:20-alpine (135MB) |
| 의존성 | 전체 (180MB) | production만 (45MB) |
| 소스 코드 | 전체 복사 | 빌드 결과만 |
| 최종 크기 | 1.4GB | 180MB |
CI/CD 통합
GitHub Actions 예시
name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push
uses: docker/build-push-action@v5
with:
context:.
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from과 cache-to로 GitHub Actions 캐시를 활용하면 빌드 시간이 크게 단축됩니다.
정리
Docker 멀티스테이지 빌드의 핵심 포인트:
-
빌드와 실행을 분리하세요. 빌드 도구는 최종 이미지에 필요 없습니다.
-
Alpine 또는 distroless를 사용하세요. 기본 이미지 크기가 10배 이상 차이납니다.
-
레이어 캐싱을 이해하세요. 변경이 적은 것을 먼저 복사하면 빌드 시간이 크게 줄어듭니다.
-
.dockerignore를 반드시 설정하세요.
node_modules,.git등을 제외합니다. -
보안을 신경 쓰세요. 비root 사용자, 시크릿 마운트를 활용합니다.
1.2GB 이미지를 150MB로 줄이면, 배포 속도, 비용, 보안 모두 개선됩니다. 이미 운영 중인 서비스가 있다면, Dockerfile을 멀티스테이지로 리팩토링하는 것부터 시작해보세요. 생각보다 간단하지만 효과는 극적입니다.