목차
- 단일 스테이지 빌드의 구조적 문제
- 기존 접근 방식의 한계
- Multi-Stage Build의 기본 구조
- 언어별 실전 예제
- 실제 측정값 비교
- BuildKit 캐시 마운트로 가속
- docker-compose에서 스테이지 지정
- 자주 놓치는 함정
- Distroless와 Alpine의 트레이드오프
- 검증 — 무엇을 측정해야 하는가
- 한계점
Docker multi-stage build는 하나의 Dockerfile 안에서 여러 개의 FROM 구문을 사용해 빌드 단계와 실행 단계를 물리적으로 분리하는 기능이다. Docker 17.05(2017-05-04 릴리스)부터 정식 지원되었고, 현재는 이미지 최적화의 사실상 표준 패턴으로 자리 잡았다(출처: Docker 공식 문서 – Multi-stage builds).
이 글은 단일 스테이지 빌드의 구조적 한계를 정리하고, multi-stage 전환으로 어떤 크기/시간 변화가 발생하는지 측정값 기준으로 비교한다. Node.js, Python, Go 세 가지 런타임을 다루고, 마지막에 BuildKit 캐시 마운트와 distroless 베이스의 트레이드오프까지 본다.
단일 스테이지 빌드의 구조적 문제
또한, 단일 스테이지 Dockerfile에서 최종 이미지가 비대해지는 원인은 크게 세 가지다.
첫째, 빌드 도구가 런타임 이미지에 그대로 남는다. gcc, make, python3-dev, node-gyp 같은 컴파일 도구는 빌드 시점에만 필요한데도 실행 이미지에 포함된다. Node.js 프로젝트에서 native 의존성 하나만 있어도 빌드 도구가 200MB 가까이 들어붙는 경우가 흔하다.
따라서, 둘째, 의존성 캐시와 중간 산출물이 레이어로 남는다. npm install 후 node_modules를 그대로 두고, npm cache까지 보존하면 같은 데이터가 두 번 적재된다. pip install 역시 ~/.cache/pip이 레이어에 남는다.
셋째, 레이어 삭제로는 실제 크기가 줄지 않는다. 한 레이어에서 만든 파일을 다음 레이어에서 rm으로 지워도, 앞 레이어의 데이터는 이미지 안에 그대로 들어 있다. docker history로 보면 삭제 전 레이어의 크기가 그대로 잡힌다.
특히, 이 세 가지가 누적되면 실제 애플리케이션 코드가 5MB여도 최종 이미지가 1GB를 넘기는 일이 일상적으로 발생한다.
기존 접근 방식의 한계
따라서, multi-stage 이전에는 보통 두 가지 우회 방법이 쓰였다.
하나는 builder 이미지와 runtime 이미지를 별도 Dockerfile로 분리하는 방식이다. CI에서 builder로 산출물을 만들고, 그 산출물을 호스트로 꺼낸 뒤 runtime Dockerfile에 COPY로 다시 집어넣는다. 동작은 하지만 빌드 스크립트가 길어지고, 산출물을 호스트 디스크에 꺼내는 단계가 추가되어 CI가 느려진다.
다른 하나는 단일 Dockerfile에서 RUN 한 줄로 빌드와 정리를 묶는 패턴이다.
RUN apt-get update && apt-get install -y build-essential \
&& pip install -r requirements.txt \
&& apt-get purge -y build-essential \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
게다가, 같은 레이어에서 설치하고 삭제하면 레이어 크기는 줄어든다. 다만 빌드 캐시가 거의 무력화된다. requirements.txt 한 줄만 바뀌어도 전체가 다시 돌아간다. 빌드 시간이 4분에서 10분으로 늘어나는 일이 흔하다.
그래서, multi-stage build는 이 두 가지 우회 방법을 정리한 것이다. 빌드 단계와 런타임 단계를 같은 파일 안에서 선언하되, 최종 이미지에는 마지막 스테이지의 레이어만 남는다.
Multi-Stage Build의 기본 구조
예를 들어, 가장 단순한 형태는 두 개의 FROM을 두고, 첫 번째 스테이지의 산출물을 두 번째 스테이지로 COPY --from으로 옮기는 것이다.
# 1단계: 빌드
FROM node:20.11-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 2단계: 런타임
FROM node:20.11-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev
CMD ["node", "dist/server.js"]
AS builder로 이름을 붙인 첫 스테이지에서 TypeScript 컴파일이나 webpack 번들링을 끝낸다. 두 번째 스테이지는 새로운 베이스 이미지에서 시작해서, 필요한 산출물만 가져온다. 빌드 도구, devDependencies, 소스 파일, 캐시는 전부 빠진다.
--from=builder 대신 인덱스 번호(--from=0)도 쓸 수 있다. 다만 스테이지를 중간에 추가하면 번호가 밀려서 깨지기 쉽다. 이름을 붙이는 쪽이 안전하다.
외부 이미지를 스테이지로 사용하기
--from은 같은 Dockerfile 안의 스테이지뿐 아니라 외부 이미지도 받는다. 예를 들어 nginx:1.25 이미지에서 nginx.conf 기본값을 가져오거나, alpine/git 이미지에서 git 바이너리만 빼올 수 있다.
COPY --from=nginx:1.25 /etc/nginx/nginx.conf /etc/nginx/nginx.conf
이 방식은 베이스 이미지를 바꾸지 않고도 특정 파일만 끌어올 때 유용하다.
언어별 실전 예제
Node.js — TypeScript 서버
즉, TypeScript 프로젝트는 빌드 산출물(JS)과 런타임 의존성(production node_modules)만 남기면 된다.
FROM node:20.11-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:20.11-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20.11-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
USER node
CMD ["node", "dist/server.js"]
deps 스테이지를 별도로 둔 이유는 캐시 분리다. 소스 코드만 바뀌면 builder부터 다시 돌고, package.json이 바뀌면 deps부터 돌아간다. CI에서 이 구분이 빌드 시간을 크게 줄인다.
Python — FastAPI + 컴파일 의존성
Python은 pip install 단계에서 wheel을 만들어야 하는 패키지가 많다. pydantic-core, numpy, cryptography 같은 패키지는 컴파일러를 요구한다.
FROM python:3.12-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /wheels /wheels
COPY requirements.txt .
RUN pip install --no-cache --no-index --find-links=/wheels -r requirements.txt \
&& rm -rf /wheels
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
빌드 스테이지에서 wheel을 만들어두고, 런타임 스테이지에서는 그 wheel을 인덱스 없이 설치한다. gcc와 build-essential은 런타임 이미지에 들어오지 않는다.
Go — 정적 바이너리
따라서, Go는 multi-stage의 효과가 가장 극적으로 나타나는 언어다. 정적 바이너리로 빌드하면 런타임에 베이스 이미지조차 필요 없다.
FROM golang:1.22-alpine AS builder
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 gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
-ldflags="-s -w"로 디버그 심볼을 제거하고, distroless/static을 런타임 베이스로 쓴다. 셸도 없고, 패키지 매니저도 없다. 컨테이너 침해 시 공격 표면이 거의 0에 가깝다.
실제 측정값 비교
즉, 세 언어 모두 동일한 애플리케이션 골격으로 단일 스테이지와 multi-stage 이미지를 빌드해 크기를 비교한 일반적인 수치다. 측정 환경은 Docker 24.0, BuildKit 활성화, 의존성 캐시 워밍 후 빌드 기준이다.
| 런타임 | 단일 스테이지 | Multi-stage (alpine) | Multi-stage (distroless) | 감소율 |
|---|---|---|---|---|
| Node.js 20 (TS 서버) | 약 1.2GB | 약 180MB | 약 150MB | 약 85~88% |
| Python 3.12 (FastAPI) | 약 980MB | 약 220MB | 약 190MB | 약 78~81% |
| Go 1.22 (HTTP 서버) | 약 850MB | 약 25MB | 약 12MB | 약 98~99% |
그러나, 수치는 의존성 구성에 따라 ±20% 정도 변동이 있다. Go가 압도적으로 작아지는 이유는 런타임이 정적 링크된 바이너리 하나뿐이라서다. Node와 Python은 인터프리터와 표준 라이브러리가 런타임에 필요해서 일정 크기 이하로 내려가지 않는다.
빌드 시간은 캐시가 따뜻한 상태에서 multi-stage가 단일 스테이지보다 5~15% 정도 느리다. 스테이지 전환 비용이다. 그런데 CI에서 캐시가 차가운 상태일 때는 stage별 캐시 분리 덕에 오히려 빠른 경우가 많다.
BuildKit 캐시 마운트로 가속
Docker 18.09부터 BuildKit이 정식 지원되었고, 23.0부터는 기본 빌더로 활성화된다. multi-stage와 함께 쓸 때 가장 효과가 큰 기능이 캐시 마운트다.
# syntax=docker/dockerfile:1.6
FROM node:20.11-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
--mount=type=cache는 빌드 캐시 디렉토리를 이미지 레이어 바깥에 둔다. npm ci가 받은 패키지 캐시가 이미지에는 안 들어가면서, 다음 빌드에서는 재사용된다. Python에서는 ~/.cache/pip, Go에서는 /go/pkg/mod와 /root/.cache/go-build에 같은 패턴을 적용한다.
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /app/server ./cmd/server
캐시 마운트를 적용하면 CI에서 의존성이 거의 안 바뀌는 동안의 빌드 시간이 절반 이하로 줄어든다. 그런데 캐시 디렉토리는 BuildKit 데몬에 묶여 있어서, 빌드 호스트가 매번 새로 뜨는 환경(예: 일회용 러너)에서는 효과가 제한적이다. 그런 환경에서는 docker buildx build --cache-to type=registry 같은 원격 캐시를 함께 써야 한다(참고: Docker Build Cache Backends).
docker-compose에서 스테이지 지정
그래서, 개발 환경에서는 빌드 스테이지를, 운영 환경에서는 런타임 스테이지를 쓰고 싶을 때가 있다. compose의 target 옵션이 이 역할을 한다.
services:
api-dev:
build:
context: .
dockerfile: Dockerfile
target: builder
volumes:
- ./src:/app/src
command: npm run dev
api-prod:
build:
context: .
dockerfile: Dockerfile
target: runner
restart: unless-stopped
target: builder로 지정하면 첫 번째 스테이지까지만 빌드된다. 개발 시에는 node_modules와 빌드 도구가 다 있어야 hot reload가 되니까 builder를 쓰고, 배포는 runner를 쓴다. Dockerfile을 두 개로 나누지 않아도 된다.
자주 놓치는 함정
multi-stage를 처음 도입할 때 몇 가지 함정이 있다.
COPY --from=builder로 가져오는 경로의 소유권이 빌드 스테이지 기준으로 따라온다. 빌드 스테이지가 root로 돌았다면 런타임에서 non-root로 실행할 때 권한 문제가 생긴다. COPY --from=builder --chown=node:node /app/dist ./dist처럼 명시적으로 소유권을 지정해야 한다.
물론, 베이스 이미지를 일치시키지 않으면 glibc/musl 호환성 문제가 터진다. builder를 node:20-alpine(musl)에서 돌리고 runtime을 node:20-slim(glibc)로 두면, native 모듈이 로드 시점에 깨진다. 두 스테이지는 같은 libc를 쓰는 베이스로 맞추는 게 안전하다.
그러나, 빌드 인자(ARG)는 스테이지 경계를 넘지 않는다. 전역 ARG를 FROM 위에 선언하면 각 스테이지에서 ARG VERSION 한 줄로 다시 받아야 한다. 잊으면 두 번째 스테이지에서 빈 값이 들어간다.
Distroless와 Alpine의 트레이드오프
반면, 런타임 베이스 선택은 multi-stage의 마지막 결정 지점이다. 일반적으로 세 가지 선택지가 있다.
| 베이스 | 크기 | 디버깅 | 보안 | 호환성 |
|---|---|---|---|---|
*-slim (Debian) |
중간 | 셸 있음, 도구 설치 가능 | 보통 | 가장 높음 |
*-alpine (musl) |
작음 | 셸 있음, apk 가능 | 좋음 | musl 호환 필요 |
distroless |
가장 작음 | 셸 없음 | 가장 좋음 | 런타임만 |
한편, distroless는 매력적이지만 운영 중에 컨테이너 안으로 들어가서 디버깅할 수 없다. kubectl exec로 셸을 띄울 수 없고, curl도 없다. 대안으로 Kubernetes 1.25 이상의 ephemeral container를 사용해 디버깅 사이드카를 붙이는 방법이 있는데, 운영 정책상 막혀 있는 조직도 많다.
특히, 새로 시작하는 프로젝트라면 distroless가 좋은 기본값으로 보인다. 그런데 디버깅 도구가 손에 익은 팀에는 alpine이 현실적인 절충안인 것 같다.
검증 — 무엇을 측정해야 하는가
multi-stage 도입 후에는 세 가지 지표를 추적하는 것이 합리적이다.
첫째, docker images의 SIZE 컬럼이 아닌 압축된 푸시 크기다. docker push 출력의 각 레이어 크기 합이 실제 네트워크/스토리지 비용이다. 비압축 크기가 작아져도 압축률이 나쁘면 푸시 크기가 별로 안 줄기도 한다.
둘째, 이미지 안의 패키지 목록을 Trivy나 Grype로 스캔해 CVE 수를 본다. multi-stage로 빌드 도구를 빼면 보통 CVE 수가 절반 이하로 떨어진다. 측정이 직관적인 보안 효과다.
결국, 셋째, cold start 시간이다. Kubernetes에서 이미지 풀 + 컨테이너 기동까지의 시간. 이미지가 작아지면 pod scheduling 후 ready까지의 p99 지연이 줄어든다. HPA가 자주 스케일하는 워크로드에서 효과가 가장 크게 보인다.
한계점
multi-stage build가 만능은 아니다.
빌드 캐시 무효화 패턴을 이해하지 못한 채 도입하면, 오히려 CI 시간이 늘어난다. COPY . .을 빌드 초반에 두면 소스 한 글자만 바뀌어도 의존성부터 다시 깐다. 의존성 매니페스트(package.json, requirements.txt, go.sum)를 먼저 복사하고 의존성을 설치한 다음, 소스를 복사하는 순서를 지켜야 한다.
distroless 같은 극단적 최소화는 운영 환경의 디버깅 문화를 바꿔야 가능하다. 도입 전에 사고 대응 시 어떻게 컨테이너를 들여다볼지 합의가 먼저다.
그래서, BuildKit 캐시 마운트는 빌드 호스트에 종속된다. 멀티 노드 CI 클러스터에서는 원격 캐시 백엔드 설계가 필요하고, 이쪽은 별도의 인프라 작업이 따라온다.
그래서, 단 BuildKit의 원격 캐시 동작은 백엔드(registry/s3/gha) 별 동작 차이가 크고, 작성 시점(2026년 5월) 기준으로도 일부 옵션은 실험적 상태로 분류되어 있어 운영 적용 전에는 자기 환경에서 한 번 더 검증이 필요해 보인다.
관련 글
- Docker 빌드 캐시 CI 절반 줄이는 Buildx 설정 — TIL – GitHub Actions에서 Docker 빌드 캐시 CI를 손봤더니 12분 걸리던 워크플로가 5분 30초로 떨어졌다. type=gha m…
- Docker 컨테이너 DNS 설정 트러블슈팅 — 서비스 이름 해석 실패와 TTL 조정 – Docker 컨테이너 DNS 설정 때문에 두 번 막혔다. default bridge에서 서비스 이름이 안 풀린 게 첫 번째, alpine …
- Docker Compose 헬스체크 설정 완벽 가이드 — 의존성 순서 해결 – depends_on만 쓰면 DB가 준비되기도 전에 API가 먼저 뜬다. Docker Compose 헬스체크 설정과 service_healt…