3편에서 "어떤 베이스 이미지를 고를 것인가"를 고민했다면, 이제는 "빌드와 실행을 하나의 Dockerfile 안에서 어떻게 분리할 것인가"가 다음 질문입니다. 초보자들이 흔히 작성하는 Dockerfile은 모든 도구를 한 이미지에 섞어 넣기 때문에 무겁고 관리하기 어렵습니다. 멀티 스테이지 빌드를 쓰면 빌드 단계와 실행 단계를 분리하여 가볍고 안전한 이미지를 만들 수 있습니다. 이번 글은 Node + nginx 예제를 중심으로 패턴을 익히고, 직접 실습할 수 있는 과제를 함께 제공합니다.
이 글의 흐름
- 멀티 스테이지 빌드가 필요한 이유
- 기본 구조: builder vs runner
- Node 앱 실습 Dockerfile 작성
- 실행해 보며 캐시와 이미지 크기 비교
- 실전 체크포인트와 확장 아이디어
- 실전에서 어떻게 응용하는가
이번 글에서 새로 나오는 용어
- 스테이지(Stage): 하나의 FROM 블록으로 정의되는 빌드 단위입니다.
- 아티팩트(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 작성
아래 미션을 따라 해 보세요.
- 샘플 앱 준비
npx degit sveltejs/template docker-multi-stage-app
cd docker-multi-stage-app
npm install
npm run build
로컬 빌드가 통과하는지 먼저 확인합니다.
- 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;"]
- 빌드 & 실행
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나 절약했는지 기록해 보세요.
실전 체크포인트와 확장 아이디어
- 환경 변수 전달: 빌드 단계와 실행 단계가 필요로 하는 환경 변수를 구분하세요. 예:
NODE_ENV=production은 runner 단계에서만 필요할 수 있습니다. - 테스트 스테이지 추가:
test라는 별도 스테이지를 추가해 단위 테스트를 실행하고, 실패하면 빌드가 멈추게 할 수 있습니다. - 캐시 마운트 활용: Docker BuildKit을 켜고
RUN --mount=type=cache옵션을 쓰면npm cache같은 디렉터리를 더 빠르게 재사용할 수 있습니다. - 보안 표면 축소: runner 단계에는 꼭 필요한 파일만 남기고 쉘도 제거할 수 있습니다. 예:
FROM gcr.io/distroless/nodejs같은 이미지를 사용.
실전에서 어떻게 응용하는가
멀티 스테이지 빌드는 특정 프로젝트 전용 기술이 아닙니다. 정적 사이트, React/Vue 앱, API 서버, Go 바이너리, Java 애플리케이션까지 거의 모든 배포 환경에서 같은 원리로 응용할 수 있습니다. 핵심은 "빌드 도구와 실행 도구를 분리한다"는 사고방식입니다. 다음 글에서는 이렇게 만들어진 정적 자산을 nginx로 어떻게 서빙하는지, 헬스체크와 포트 노출은 어떻게 설계하는지 이어서 살펴보겠습니다.
💬 댓글
이 글에 대한 의견을 남겨주세요