Docker Multi-Stage Build 최적화

Docker Multi-Stage Build란 무엇인가

Docker Multi-Stage Build는 하나의 Dockerfile 안에서 여러 개의 FROM 문을 사용해 빌드 단계와 런타임 단계를 분리하는 기법입니다. 빌드에 필요한 컴파일러, 패키지 매니저, 소스 코드는 최종 이미지에 포함되지 않으므로 이미지 크기를 50~80% 줄일 수 있습니다.

이 글에서는 Multi-Stage Build의 동작 원리, 실전 패턴 5가지, 캐시 최적화 전략, 그리고 흔한 실수와 해결법까지 다룹니다.

왜 Multi-Stage Build가 필요한가

전통적인 단일 스테이지 Dockerfile은 빌드 도구와 런타임 바이너리가 같은 이미지에 공존합니다. 이로 인해 발생하는 문제는 다음과 같습니다.

항목 단일 스테이지 Multi-Stage
이미지 크기 800MB~1.2GB 80~200MB
보안 공격 표면 gcc, make, npm 등 포함 런타임만 포함
CI/CD 전송 시간 3~5분 30초~1분
컨테이너 시작 시간 느림 빠름

기본 구조: FROM … AS 스테이지 이름

Multi-Stage Build의 핵심은 FROM ... AS로 스테이지에 이름을 붙이고, COPY --from=으로 이전 스테이지의 결과물만 가져오는 것입니다.

# 스테이지 1: 빌드
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production=false
COPY . .
RUN npm run build

# 스테이지 2: 런타임
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
EXPOSE 3000
CMD ["node", "dist/main.js"]

builder 스테이지에서 TypeScript 컴파일이 끝나면, runner 스테이지는 dist 폴더와 node_modules만 복사합니다. 소스 코드, devDependencies, TypeScript 컴파일러는 최종 이미지에 포함되지 않습니다.

실전 패턴 1: Go 애플리케이션 — scratch 베이스

FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server

FROM scratch
COPY --from=build /app/server /server
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/server"]

Go는 정적 바이너리를 생성하므로 scratch(빈 이미지)를 베이스로 사용할 수 있습니다. 최종 이미지 크기는 10~20MB 수준입니다.

실전 패턴 2: Java Spring Boot — JRE만 포함

FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY gradlew build.gradle.kts settings.gradle.kts ./
COPY gradle ./gradle
RUN ./gradlew dependencies --no-daemon
COPY src ./src
RUN ./gradlew bootJar --no-daemon

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

빌드에는 JDK가 필요하지만 실행에는 JRE면 충분합니다. JDK 포함 이미지(~400MB) 대신 JRE 이미지(~150MB)로 크기를 줄입니다.

실전 패턴 3: Python — pip install 결과만 복사

FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
EXPOSE 8000
CMD ["gunicorn", "app:create_app()", "-b", "0.0.0.0:8000"]

--prefix=/install로 설치 경로를 분리한 뒤, 런타임 스테이지에서 해당 경로만 복사하면 빌드 캐시와 임시 파일이 제거됩니다.

실전 패턴 4: React/Next.js 프론트엔드

FROM node:20-alpine AS deps
WORKDIR /app
COPY package-lock.json package.json ./
RUN npm ci

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

3단계로 분리하면 package.json이 변경되지 않는 한 deps 스테이지가 캐시됩니다. 빌드 시간이 크게 단축됩니다.

실전 패턴 5: 테스트 스테이지 삽입

FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build

FROM builder AS tester
RUN npm run test -- --ci --coverage

FROM node:20-alpine AS runner
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/main.js"]

docker build --target tester .로 테스트만 실행하거나, 기본 빌드에서는 runner까지 진행합니다. CI 파이프라인에서 유용합니다.

캐시 최적화 전략

Multi-Stage Build의 빌드 속도는 Docker 레이어 캐시 활용에 달려 있습니다. 핵심 원칙은 다음과 같습니다.

전략 설명 효과
의존성 먼저 복사 package.json / go.mod를 소스보다 먼저 COPY 의존성 레이어 캐시 유지
.dockerignore 활용 node_modules, .git, dist 등 제외 불필요한 캐시 무효화 방지
BuildKit 캐시 마운트 RUN --mount=type=cache,target=/root/.npm npm ci npm/pip 캐시 재사용
DOCKER_BUILDKIT=1 병렬 스테이지 빌드 활성화 빌드 시간 단축

BuildKit 캐시 마운트 예시

# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build

흔한 실수와 해결법

실수 1: 최종 스테이지에 불필요한 파일 복사

COPY --from=builder /app .처럼 전체 디렉토리를 복사하면 소스 코드까지 포함됩니다. 반드시 필요한 경로만 지정하세요.

실수 2: root 사용자로 실행

런타임 스테이지에서 USER 지시문으로 비-root 사용자를 설정하세요. 보안 사고 시 피해 범위를 줄일 수 있습니다.

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

실수 3: alpine과 glibc 호환성 무시

빌드 스테이지에서 glibc 기반 이미지를 사용하고 런타임에서 alpine(musl)을 사용하면 바이너리가 실행되지 않습니다. 두 스테이지의 libc를 통일하세요.

실수 4: 시크릿을 빌드 인자로 전달

ARG로 전달한 시크릿은 이미지 히스토리에 남습니다. BuildKit의 --mount=type=secret을 사용하세요.

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci

운영 체크리스트

  1. 최종 이미지에 컴파일러, 빌드 도구가 포함되지 않았는지 docker exec로 확인
  2. .dockerignore에 .git, node_modules, 테스트 파일 추가
  3. docker image history로 각 레이어 크기 점검
  4. CI에서 --target 옵션으로 테스트 스테이지만 실행하는 파이프라인 구성
  5. BuildKit 활성화 (DOCKER_BUILDKIT=1 또는 Docker Desktop 기본 설정)
  6. 런타임 스테이지에 HEALTHCHECK 지시문 추가
  7. Trivy, Grype 등으로 최종 이미지 취약점 스캔 자동화

이미지 크기 비교 정리

언어/프레임워크 단일 스테이지 Multi-Stage 절감률
Node.js (NestJS) 1.1GB 180MB 84%
Go (net/http) 350MB 12MB 97%
Java (Spring Boot) 450MB 180MB 60%
Python (FastAPI) 350MB 120MB 66%
React (nginx serve) 500MB 25MB 95%

마무리

Docker Multi-Stage Build는 설정 한 번으로 이미지 크기, 보안, 빌드 속도를 동시에 개선하는 가장 효율적인 방법입니다. 특히 Kubernetes 환경에서는 이미지 크기가 Pod 스케줄링 속도에 직접 영향을 주므로 반드시 적용해야 합니다. 아직 단일 스테이지 Dockerfile을 사용하고 있다면, 위 패턴 중 하나를 골라 지금 바로 전환해 보세요.

Kubernetes 서비스 타입별 운영 설계가 궁금하다면 Kubernetes Service 심화 가이드를 참고하세요. Spring Boot 환경별 설정 분리는 Spring Boot Profiles 심화에서 다루고 있습니다.

지금 사용 중인 Dockerfile에 Multi-Stage Build를 적용해 보세요. 이미지 크기 변화를 댓글로 공유해 주시면, 추가 최적화 팁을 드리겠습니다.

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