Theme:

빌드에 필요한 도구(컴파일러, 패키지 매니저, SDK)와 실제 실행에 필요한 파일은 전혀 다릅니다. 빌드 도구까지 포함된 이미지를 프로덕션에 올리면 왜 문제가 될까요?

멀티 스테이지 빌드란

하나의 Dockerfile에 FROM을 여러 번 사용하여 빌드 단계를 분리하는 기법입니다. 빌드에 필요한 무거운 도구(SDK, 컴파일러)는 첫 번째 스테이지에서만 사용하고, 최종 이미지에는 실행에 필요한 파일만 복사합니다.

DOCKERFILE
# 스테이지 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"]

왜 필요한가

싱글 스테이지의 문제

DOCKERFILE
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
포함된 파일소스 + 빌드도구 + 결과물결과물만

동작 원리

PLAINTEXT
┌──────────────────┐     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(완전히 빈 이미지)를 사용할 수 있습니다.

DOCKERFILE
# 빌드 스테이지
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 분리

DOCKERFILE
# 빌드 스테이지
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 — 가상환경 복사

DOCKERFILE
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 활용

DOCKERFILE
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

DOCKERFILE
FROM scratch
  • 파일 시스템이 완전히 비어 있음
  • 셸도 없어서 docker exec로 접속 불가
  • 정적 바이너리 전용 (Go, Rust)
  • 디버깅이 어려움

distroless

DOCKERFILE
FROM gcr.io/distroless/static-debian12
# 또는
FROM gcr.io/distroless/java21-debian12
  • Google이 관리하는 경량 이미지
  • 셸, 패키지 매니저 없음 (보안 공격 표면 최소화)
  • 언어별 런타임만 포함
  • scratch보다 약간 크지만 CA 인증서, 타임존 등 기본 파일 포함

alpine

DOCKERFILE
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
scratch0MB

고급 패턴

여러 스테이지에서 파일 가져오기

DOCKERFILE
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"]

외부 이미지에서 파일 가져오기

DOCKERFILE
FROM alpine:3.19
# 다른 이미지에서 직접 복사 (스테이지 없이)
COPY --from=nginx:1.25-alpine /etc/nginx/nginx.conf /etc/nginx/

특정 스테이지만 빌드

DOCKERFILE
# builder 스테이지까지만 빌드
docker build --target builder -t myapp:builder .

CI에서 테스트 스테이지만 실행할 때 유용합니다.

DOCKERFILE
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"]
BASH
# 테스트만 실행
docker build --target test .

# 전체 빌드
docker build .

실무에서의 팁

1. 의존성 레이어를 먼저 캐시

DOCKERFILE
# 좋은 패턴: 의존성 파일만 먼저 복사
COPY package.json package-lock.json ./
RUN npm ci

# 이후에 소스 복사
COPY . .
RUN npm run build

소스 코드가 변경되어도 의존성 레이어는 캐시에서 재사용됩니다.

2. 프로덕션 의존성만 설치

DOCKERFILE
# 런타임 스테이지
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

3. 불필요한 파일 제거 확인

BASH
# 최종 이미지의 파일 목록 확인
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 파이프라인에서 유연하게 활용됩니다.
댓글 로딩 중...