[Docker 시리즈 4편] 멀티 스테이지 빌드로 빌드 환경과 실행 환경 나누기

English version

3편에서 "어떤 베이스 이미지를 고를 것인가"를 고민했다면, 이제는 "빌드와 실행을 하나의 Dockerfile 안에서 어떻게 분리할 것인가"가 다음 질문입니다. 초보자들이 흔히 작성하는 Dockerfile은 모든 도구를 한 이미지에 섞어 넣기 때문에 무겁고 관리하기 어렵습니다. 멀티 스테이지 빌드를 쓰면 빌드 단계와 실행 단계를 분리하여 가볍고 안전한 이미지를 만들 수 있습니다. 이번 글은 Node + nginx 예제를 중심으로 패턴을 익히고, 직접 실습할 수 있는 과제를 함께 제공합니다.

이 글의 흐름

  1. 멀티 스테이지 빌드가 필요한 이유
  2. 기본 구조: builder vs runner
  3. Node 앱 실습 Dockerfile 작성
  4. 실행해 보며 캐시와 이미지 크기 비교
  5. 실전 체크포인트와 확장 아이디어
  6. 실전에서 어떻게 응용하는가

이번 글에서 새로 나오는 용어

  1. 스테이지(Stage): 하나의 FROM 블록으로 정의되는 빌드 단위입니다.
  2. 아티팩트(Artifact): 빌드 결과물로 다음 단계에 전달할 파일 묶음입니다.

읽기 카드

  • 예상 소요 시간: 20분
  • 사전 준비: Dockerfile 기초, Node.js 빌드 경험
  • 읽고 나면: 멀티 스테이지 구조를 직접 설계하고 COPY --from 패턴을 설명할 수 있습니다.

멀티 스테이지 빌드가 필요한 이유

싱글 스테이지 Dockerfile은 빌드 도구와 실행 도구가 한 이미지에 섞여 들어갑니다. Node.js 앱을 예로 들면, npm ci, npm run build, nginx 실행 파일이 한 컨테이너 안에 공존하게 됩니다. 이렇게 되면 이미지가 무거워지고, 빌드 도구의 보안 패치까지 운영 중에 신경 써야 합니다.

멀티 스테이지 빌드는 간단한 아이디어로 문제를 해결합니다.

  • 빌드에는 필요한 모든 도구를 쓰되, 그 결과물만 다음 단계로 넘긴다.
  • 실행 이미지는 최소한의 런타임만 남긴다.
  • 스테이지별 명확한 책임을 통해 캐시 전략을 세우기 쉽다.

기본 구조: builder vs runner

멀티 스테이지의 최소 단위는 두 단계입니다.

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

FROM nginx:alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
  • builder 단계: Node 런타임, 빌드 도구, 테스트 도구 등 개발에 필요한 모든 것을 담습니다.
  • runner 단계: 실제 배포에 필요한 파일만 복사합니다. 이 예시에서는 정적 웹 자산만 남습니다.

COPY --from=builder ... 구문이 핵심입니다. 이전 단계의 파일을 선택적으로 가져오므로, 실행 이미지가 필요 이상으로 커지지 않습니다.

Node 앱 실습 Dockerfile 작성

아래 미션을 따라 해 보세요.

  1. 샘플 앱 준비
npx degit sveltejs/template docker-multi-stage-app
cd docker-multi-stage-app
npm install
npm run build

로컬 빌드가 통과하는지 먼저 확인합니다.

  1. Dockerfile 만들기
# Dockerfile
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

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

FROM nginx:alpine AS runner
COPY --from=builder /app/build /usr/share/nginx/html
EXPOSE 4173
CMD ["nginx", "-g", "daemon off;"]
  1. 빌드 & 실행
docker build -t multi-stage-lab .
docker run -d -p 4173:80 --name multi-stage multi-stage-lab
curl http://localhost:4173

실행 후 docker image ls multi-stage-lab으로 이미지 크기를 확인하고, 동일한 프로젝트를 싱글 스테이지 Dockerfile로 빌드했을 때보다 얼마나 줄어들었는지 비교해 보세요.

실행해 보며 캐시와 이미지 크기 비교

1. 캐시 포인트

  • deps 단계는 package-lock.json이 바뀔 때만 다시 실행됩니다.
  • builder 단계는 소스가 바뀔 때마다 실행되지만, 의존성 설치는 캐시를 재사용합니다.
docker build -t multi-stage-lab:v1 .
docker build -t multi-stage-lab:v2 .

두 번째 빌드에서 deps 단계가 "CACHED"로 표시되는지 확인합니다. 이 경험이 있어야 빌드 시간이 왜 줄어드는지 체감할 수 있습니다.

2. 이미지 크기 비교

docker build -t single-stage-lab -f Dockerfile.single .
docker images | grep lab

싱글 스테이지 버전(Dockerfile.single)을 따로 만들어 비교하고, 멀티 스테이지가 몇 MB나 절약했는지 기록해 보세요.

실전 체크포인트와 확장 아이디어

  1. 환경 변수 전달: 빌드 단계와 실행 단계가 필요로 하는 환경 변수를 구분하세요. 예: NODE_ENV=production은 runner 단계에서만 필요할 수 있습니다.
  2. 테스트 스테이지 추가: test라는 별도 스테이지를 추가해 단위 테스트를 실행하고, 실패하면 빌드가 멈추게 할 수 있습니다.
  3. 캐시 마운트 활용: Docker BuildKit을 켜고 RUN --mount=type=cache 옵션을 쓰면 npm cache 같은 디렉터리를 더 빠르게 재사용할 수 있습니다.
  4. 보안 표면 축소: runner 단계에는 꼭 필요한 파일만 남기고 쉘도 제거할 수 있습니다. 예: FROM gcr.io/distroless/nodejs 같은 이미지를 사용.

실전에서 어떻게 응용하는가

멀티 스테이지 빌드는 특정 프로젝트 전용 기술이 아닙니다. 정적 사이트, React/Vue 앱, API 서버, Go 바이너리, Java 애플리케이션까지 거의 모든 배포 환경에서 같은 원리로 응용할 수 있습니다. 핵심은 "빌드 도구와 실행 도구를 분리한다"는 사고방식입니다. 다음 글에서는 이렇게 만들어진 정적 자산을 nginx로 어떻게 서빙하는지, 헬스체크와 포트 노출은 어떻게 설계하는지 이어서 살펴보겠습니다.

💬 댓글

이 글에 대한 의견을 남겨주세요