본문으로 건너뛰기
wooncloud

Docker 멀티스테이지 빌드로 이미지 90% 줄이기

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

·17 min read· views·

Docker 멀티스테이지 빌드로 이미지 90% 줄이기

왜 이미지 크기가 문제인가

컨테이너 이미지는 한 번 만들고 끝나는 산출물이 아니다. 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 가 여기 들어가지만 최종 이미지엔 영향이 없다.
  • runnernode: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 ci

COPY . . 가 소스 변경마다 무효화되므로, 바로 아래 RUN npm ci 도 매번 다시 실행된다. 의존성은 그대로인데 설치만 반복되는 셈이다.

# 좋은 예: 매니페스트를 먼저 복사해 install 레이어를 분리
COPY package.json package-lock.json ./
RUN npm ci            # package.json/lock 이 그대로면 캐시 재사용
COPY . .              # 소스만 바뀌면 여기서부터 무효화
RUN npm run build

package.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
.dockerignore

node_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:slim

dive 는 각 레이어가 더한 바이트, 레이어 간 중복/삭제된 파일, 그리고 효율 점수(efficiency)를 보여준다. 어떤 COPY/RUN 이 예상보다 무거운지, 앞 레이어에서 만든 뒤 뒤 레이어에서 지워 결국 이미지에 두 번 박힌 파일은 없는지 짚어낼 수 있다. CI 에 dive --ci 로 효율 임계값을 걸어 회귀를 막을 수도 있다.

정리

이미지 슬림화는 세 축으로 압축된다.

  1. 빌드/런타임 분리 — 멀티스테이지로 컴파일러·devDependencies·소스를 최종 이미지에서 빼고, COPY --from 으로 산출물만 가져온다. 보통 가장 큰 감소를 만든다.
  2. 베이스 슬림화 — 런타임 베이스를 slim/alpine/distroless 로 낮춘다. 호환성·디버깅 난이도와의 트레이드오프를 보고 고르되, 무난함은 slim, 보안 극단은 distroless.
  3. 캐시 친화적 순서 — 매니페스트를 소스보다 먼저 복사·설치하고, .dockerignore 로 컨텍스트를 줄인다. 크기뿐 아니라 빌드 속도까지 같이 잡는다.

여기에 비루트 실행과 최소 파일 복사로 보안 표면까지 좁히면, 단순히 "작은 이미지"가 아니라 빠르고 안전하게 굴러가는 런타임이 된다. 마지막으로 docker imagesdive 로 실제 숫자를 확인하는 습관을 들이면, 슬림화가 추측이 아니라 측정 가능한 작업이 된다.