Docker 멀티스테이지 빌드로 이미지 90% 줄이기
빌드 도구와 런타임을 분리하는 멀티스테이지 빌드로 도커 이미지를 줄이는 법을, 베이스 이미지(slim/alpine/distroless) 트레이드오프와 레이어 캐시 순서 최적화까지 정리한다.

왜 이미지 크기가 문제인가
컨테이너 이미지는 한 번 만들고 끝나는 산출물이 아니다. CI 가 돌 때마다 빌드되고, 레지스트리에 푸시되고, 배포 노드마다 풀(pull) 된다. 이미지가 1.2GB 인지 80MB 인지에 따라 다음이 전부 달라진다.
- 빌드/전송 시간: 레이어가 크면 push/pull 에 그대로 비례한다. 오토스케일이나 롤링 업데이트처럼 노드가 동시에 같은 이미지를 끌어올 때 네트워크가 병목이 된다.
- 레지스트리 비용: 대부분의 레지스트리는 저장 용량과 egress 트래픽으로 과금한다. 태그를 자주 쌓는 환경이면 이미지 크기 × 보관 개수가 곧 청구서다.
- 콜드 스타트: 서버리스 컨테이너나 신규 노드 스케줄링에서 이미지 풀 시간이 곧 시작 지연이다. 큰 이미지는 첫 요청 지연을 늘린다.
- 취약점 표면(attack surface): 컴파일러, 패키지 매니저, 셸, 빌드용 라이브러리가 런타임 이미지에 그대로 남으면 CVE 스캔에 잡히는 패키지 수가 늘고, 컨테이너가 침해됐을 때 공격자가 쓸 도구도 늘어난다.
근본 원인은 단순하다. 빌드에 필요한 도구와, 실행에만 필요한 것을 한 이미지에 같이 담기 때문이다. 컴파일러, devDependencies, 빌드 캐시는 아티팩트를 만들고 나면 런타임에 한 줄도 쓰이지 않는다. 그런데 단일 스테이지 Dockerfile 은 이 모든 걸 최종 이미지에 박아 넣는다. 멀티스테이지 빌드는 이 둘을 물리적으로 분리한다.
멀티스테이지 빌드의 개념
하나의 Dockerfile 안에 FROM 을 여러 번 쓴다. 각 FROM 이 하나의 스테이지다. 앞 스테이지에서 빌드를 끝내고, 마지막 스테이지에서 COPY --from=<스테이지> 로 산출물만 가져온다.
# 스테이지 1: 빌드 (컴파일러·전체 의존성 포함, 무거움)
FROM node:22 AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build
# 스테이지 2: 런타임 (산출물만, 가벼움)
FROM node:22-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]핵심은 최종 이미지에 남는 건 마지막 스테이지뿐이라는 점이다. builder 스테이지에 깔린 컴파일러, devDependencies, 소스 코드, 빌드 중간 산출물은 최종 이미지에 들어가지 않는다. COPY --from=builder 로 명시적으로 가져온 /app/dist 만 넘어온다.
이게 가능한 이유는 각 스테이지가 독립된 파일시스템이기 때문이다. 빌드 시점에는 모든 스테이지가 존재하지만, 최종 결과물은 마지막 FROM 이후의 레이어만으로 구성된다. 앞 스테이지는 "도구를 들고 작업한 작업실"이고, 최종 이미지는 "작업실을 비우고 완성품만 들고 나온 깨끗한 방"이다.
Node.js 3단계 예시
실전에서 자주 쓰는 패턴은 deps → builder → runner 3단계다. 의존성 설치, 빌드, 실행을 각각 분리하면 레이어 캐시 효율과 최종 크기를 동시에 잡을 수 있다.
# syntax=docker/dockerfile:1
# ── 스테이지 1: deps ──────────────────────────────
# 프로덕션 의존성만 따로 설치한다. 매니페스트만 먼저 복사해
# package.json/lock 이 안 바뀌면 이 레이어가 캐시된다.
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ── 스테이지 2: builder ───────────────────────────
# devDependencies 포함 전체 설치 후 빌드. 이 스테이지의
# 무거운 도구들은 최종 이미지에 남지 않는다.
FROM node:22-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ── 스테이지 3: runner ────────────────────────────
# 런타임에 필요한 것만: 빌드 산출물 + 프로덕션 node_modules.
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
# 비루트 사용자로 실행 (아래 '비루트' 절 참고)
# node 이미지에는 uid 1000 'node' 사용자가 이미 있다.
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/package.json ./package.json
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]설계 의도를 짚어보면,
deps는--omit=dev로 프로덕션 의존성만 설치한다. 최종 이미지로 이node_modules만 복사하므로 빌드 전용 패키지(타입 정의, 번들러, 테스트 도구)는 런타임에 빠진다.builder는 별도로 전체 의존성을 설치해 빌드한다. devDependencies 가 여기 들어가지만 최종 이미지엔 영향이 없다.runner는node:22-slim위에 산출물과 프로덕션 의존성만 올린다. 소스.ts파일, 테스트,.git, 번들러 캐시는 전부 빠진다.
결과적으로 단일 스테이지에서 1GB 를 넘기던 이미지가 수백 MB대, 베이스를 더 줄이면 100MB 안팎까지 떨어진다. "90% 감소"는 빌드 도구와 devDependencies 의 비중이 클수록 현실적인 숫자다.
베이스 이미지 선택: full / slim / alpine / distroless
최종 스테이지의 FROM 을 무엇으로 두느냐가 크기와 운영성의 트레이드오프를 결정한다.
| 베이스 | 크기 | 특징 | 주의점 |
|---|---|---|---|
node:22 (full) | 가장 큼 | 빌드 도구·셸·glibc 완비 | 런타임용으로는 과함 |
node:22-slim | 중간 | Debian slim, glibc, 셸 있음 | 무난한 기본값 |
node:22-alpine | 작음 | musl libc, 셸 있음 | glibc 비호환 이슈 가능 |
gcr.io/distroless/nodejs22 | 매우 작음 | 셸·패키지매니저 없음 | 디버깅·예외 케이스 어려움 |
- full: 빌드 스테이지에는 적합하지만 런타임에 두면 안 쓰는 도구가 통째로 남는다.
- slim: Debian 기반에서 문서/locale 등을 덜어낸 버전. glibc 를 그대로 쓰므로 네이티브 모듈 호환성 문제가 거의 없다. 대부분의 경우 가장 안전한 기본값이다.
- alpine: 가장 작은 일반 베이스 중 하나지만 musl libc 를 쓴다. glibc 가정으로 컴파일된 네이티브 애드온(
bcrypt,sharp등 prebuilt 바이너리)이 깨지거나 소스 빌드를 강제당할 수 있고, DNS 처리나 타임존 같은 미묘한 동작 차이도 보고된다. "작다"는 이점이 디버깅 시간으로 상쇄될 수 있어, 의존성이 순수 JS 에 가까울 때 가장 잘 맞는다. - distroless: 애플리케이션과 런타임만 담고 셸·패키지 매니저·
ls조차 없다. 공격 표면이 극적으로 줄고 CVE 스캔 결과가 깨끗해지지만,docker exec로 들어가 셸을 띄울 수 없다. 디버깅은debug태그 변형이나 사이드카, ephemeral 컨테이너로 우회해야 한다. 보안을 최우선으로 두고 운영 성숙도가 받쳐줄 때 선택한다.
정리하면, 무난함은 slim, 극단적 슬림·보안은 distroless, alpine 은 "정말 작아야 하고 네이티브 의존성이 적을 때" 고른다.
레이어 캐시 최적화: 명령 순서가 속도를 좌우한다
Docker 는 각 명령(COPY, RUN 등)을 레이어로 만들고, 입력이 안 바뀌면 그 레이어를 캐시에서 재사용한다. 그리고 한 레이어가 무효화되면 그 아래 모든 레이어가 함께 무효화된다. 그래서 "자주 바뀌는 것을 뒤로" 두는 게 철칙이다.
가장 흔한 실수는 소스 전체를 먼저 복사한 뒤 설치하는 것이다.
# 나쁜 예: 소스 한 줄만 고쳐도 npm ci 가 매번 다시 돈다
COPY . .
RUN npm ciCOPY . . 가 소스 변경마다 무효화되므로, 바로 아래 RUN npm ci 도 매번 다시 실행된다. 의존성은 그대로인데 설치만 반복되는 셈이다.
# 좋은 예: 매니페스트를 먼저 복사해 install 레이어를 분리
COPY package.json package-lock.json ./
RUN npm ci # package.json/lock 이 그대로면 캐시 재사용
COPY . . # 소스만 바뀌면 여기서부터 무효화
RUN npm run buildpackage.json 과 lockfile 이 바뀌지 않는 한 npm ci 레이어가 캐시되어, 소스만 고친 빌드는 의존성 설치를 통째로 건너뛴다. 의존성 수가 많을수록 이 한 줄 순서 차이가 빌드 시간을 분 단위로 가른다.
BuildKit 을 쓴다면 패키지 매니저 캐시를 마운트해 한 단계 더 줄일 수 있다.
# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.npm npm ci이러면 lockfile 이 바뀌어 레이어가 무효화되더라도 npm 다운로드 캐시는 보존되어 재설치가 빨라진다.
.dockerignore 로 컨텍스트 줄이기
docker build 는 시작할 때 빌드 컨텍스트(디렉터리)를 데몬으로 통째로 전송한다. .dockerignore 가 없으면 node_modules, .git, 빌드 산출물, 로컬 환경 파일까지 전부 올라가 빌드가 느려지고, COPY . . 가 의도치 않은 파일을 이미지에 넣어 캐시를 깨거나 비밀을 유출할 수 있다.
# 의존성·산출물 — 이미지 안에서 다시 생성한다
node_modules
dist
build
.next
# VCS·CI
.git
.github
# 로컬 환경·비밀 — 절대 이미지에 넣지 않는다
.env
.env.*
*.pem
# 개발 부산물
*.log
coverage
.vscode
.idea
# 컨테이너 정의 자신
Dockerfile
.dockerignorenode_modules 를 제외하면 호스트의 (OS·아키텍처가 다를 수 있는) 모듈이 이미지로 새어 들어가는 것도 막아준다. 의존성은 항상 이미지 안에서 설치되도록 강제하는 효과가 있다.
비루트 사용자와 최소 런타임 파일
기본 이미지는 보통 root 로 컨테이너를 실행한다. 컨테이너가 침해됐을 때 root 권한은 그대로 위험으로 이어지므로, 런타임은 권한 없는 사용자로 돌리는 게 원칙이다.
# node 공식 이미지에는 uid 1000 'node' 사용자가 미리 있다
COPY --from=builder --chown=node:node /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]직접 사용자를 만들 수도 있다.
RUN addgroup --system --gid 1001 app \
&& adduser --system --uid 1001 --ingroup app app
USER app여기에 더해 필요한 런타임 파일만 복사하는 원칙을 지킨다. 최종 스테이지에서 COPY . . 로 전체를 가져오면 멀티스테이지의 의미가 사라진다. 빌드 산출물, 프로덕션 의존성, 시작에 필요한 설정 파일만 골라 복사해야 이미지가 작고 표면도 좁아진다.
크기 측정과 레이어 분석
줄였다고 말하려면 측정해야 한다.
# 이미지별 최종 크기 비교
docker images myapp
# 빌드 직후 한 줄로 크기 확인
docker image inspect myapp:slim --format '{{.Size}}' \
| awk '{printf "%.1f MB\n", $1/1024/1024}'레이어별로 무엇이 용량을 먹는지 보려면 dive 가 유용하다.
# 레이어별 추가 용량과 '낭비된' 파일을 시각화
dive myapp:slimdive 는 각 레이어가 더한 바이트, 레이어 간 중복/삭제된 파일, 그리고 효율 점수(efficiency)를 보여준다. 어떤 COPY/RUN 이 예상보다 무거운지, 앞 레이어에서 만든 뒤 뒤 레이어에서 지워 결국 이미지에 두 번 박힌 파일은 없는지 짚어낼 수 있다. CI 에 dive --ci 로 효율 임계값을 걸어 회귀를 막을 수도 있다.
정리
이미지 슬림화는 세 축으로 압축된다.
- 빌드/런타임 분리 — 멀티스테이지로 컴파일러·devDependencies·소스를 최종 이미지에서 빼고,
COPY --from으로 산출물만 가져온다. 보통 가장 큰 감소를 만든다. - 베이스 슬림화 — 런타임 베이스를 slim/alpine/distroless 로 낮춘다. 호환성·디버깅 난이도와의 트레이드오프를 보고 고르되, 무난함은 slim, 보안 극단은 distroless.
- 캐시 친화적 순서 — 매니페스트를 소스보다 먼저 복사·설치하고,
.dockerignore로 컨텍스트를 줄인다. 크기뿐 아니라 빌드 속도까지 같이 잡는다.
여기에 비루트 실행과 최소 파일 복사로 보안 표면까지 좁히면, 단순히 "작은 이미지"가 아니라 빠르고 안전하게 굴러가는 런타임이 된다. 마지막으로 docker images 와 dive 로 실제 숫자를 확인하는 습관을 들이면, 슬림화가 추측이 아니라 측정 가능한 작업이 된다.