Moment Note

Docker 이미지 경량화: 멀티스테이지 빌드로 용량 80% 줄이기

서버 & 인프라 ·

컨테이너 이미지 용량은 배포 속도, 저장 비용, 공격 표면(attack surface) 세 가지에 직접 영향을 미칩니다. CI/CD 파이프라인에서 이미지를 매번 풀(pull)해야 한다면 500 MB짜리 이미지와 50 MB짜리 이미지의 차이는 누적 시간으로 수십 분에 달할 수 있습니다. 특히 ECR·GCR·Docker Hub 같은 레지스트리의 데이터 전송 비용을 고려하면 이미지 경량화는 비용 최적화와 직결됩니다.

Docker 멀티스테이지 빌드(multi-stage build)는 빌드 환경과 런타임 환경을 분리해 최종 이미지에 실행에 필요한 아티팩트만 포함시키는 기법입니다. Go·Java·Node.js 등 컴파일·번들링 과정이 있는 언어에서 이미지 크기를 기존 대비 70~90%까지 줄인 사례가 실무에서 자주 보고됩니다. 이 글에서는 Before/After Dockerfile 예시, .dockerignore 설정, 레이어 캐시 전략, distroless와 Alpine 선택 기준까지 한 번에 정리합니다.

Docker 멀티스테이지 빌드란 무엇인가

전통적인 단일 스테이지 빌드는 하나의 FROM 베이스 이미지 안에서 패키지 설치, 빌드, 실행을 모두 처리합니다. 결과적으로 컴파일러, 빌드 도구, 중간 캐시 파일이 최종 이미지에 그대로 남습니다. 멀티스테이지 빌드는 Dockerfile 안에 여러 개의 FROM 블록을 두고, 이전 스테이지에서 생성된 파일만 선택적으로 다음 스테이지로 복사합니다. Docker 17.05 이상에서 기본 지원하며 추가 플러그인이 필요하지 않습니다.

핵심 개념: COPY –from

COPY --from=<스테이지명 또는 인덱스> 문법이 핵심입니다. 이전 스테이지는 이미지로 레지스트리에 푸시되지 않으며, 빌드 캐시로만 남습니다. 빌더(BuildKit)가 활성화된 환경에서는 사용되지 않는 스테이지를 자동으로 건너뛰어 빌드 시간도 단축됩니다.

Before/After: Node.js 애플리케이션 예시

아래는 Node.js Express 앱을 기준으로 한 단일 스테이지 vs 멀티스테이지 비교입니다. 동일한 애플리케이션 기준으로 단일 스테이지 이미지는 약 1.1 GB(node:20 기반), 멀티스테이지 + Alpine 조합은 약 120 MB 수준으로 줄어드는 것을 흔히 볼 수 있습니다.

Before: 단일 스테이지 Dockerfile

# 단일 스테이지 — 빌드 도구가 그대로 최종 이미지에 포함됨
FROM node:20

WORKDIR /app
COPY package*.json ./
RUN npm install          # devDependencies 포함 설치
COPY . .
RUN npm run build        # TypeScript → JS 변환

EXPOSE 3000
CMD ["node", "dist/index.js"]

After: 멀티스테이지 Dockerfile (Alpine + distroless 선택 예시)

# ── 스테이지 1: 빌드 ──────────────────────────────────────
FROM node:20-alpine AS builder

WORKDIR /app
COPY package*.json ./
# ci는 package-lock.json을 그대로 사용, --omit=dev 없이 전체 설치
RUN npm ci
COPY . .
RUN npm run build

# ── 스테이지 2: 프로덕션 의존성만 추출 ───────────────────
FROM node:20-alpine AS deps

WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev    # devDependencies 제외

# ── 스테이지 3: 최종 런타임 이미지 ──────────────────────
FROM node:20-alpine AS runtime

# 보안: root 대신 전용 사용자 실행
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app
COPY --from=deps  /app/node_modules ./node_modules
COPY --from=builder /app/dist       ./dist

USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]

Alpine vs distroless: 베이스 이미지 선택 기준

경량 베이스 이미지로 가장 자주 거론되는 두 가지가 Alpine Linux와 Google distroless입니다. 둘 다 이미지 크기를 줄이는 목적이지만 철학과 트레이드오프가 다릅니다.

항목 Alpine Linux Google distroless Ubuntu Slim
기본 이미지 크기 ~5 MB ~2 MB (static-debian12 기준) ~30 MB
패키지 관리자 apk (musl libc) 없음 apt (glibc)
셸(shell) ash 포함 없음 (디버그 태그 예외) bash 포함
디버깅 편의성 중간 (apk add curl 등 가능) 낮음 (별도 :debug 태그 필요) 높음
CVE 노출 면적 낮음 매우 낮음 중간
glibc 호환 musl — 일부 바이너리 호환 이슈 glibc 기반 태그 별도 제공 완전 호환
주 적합 언어 Node.js, Python, Go 등 범용 Go static, Java, Python 범용

실무 권장: 셸 디버깅이 필요 없고 정적 바이너리(Go 등)를 배포한다면 distroless가 최선입니다. Node.js·Python처럼 동적 링크 라이브러리가 필요한 앱은 Alpine이 현실적인 선택입니다. Alpine의 musl libc 호환성 문제가 우려된다면 node:20-slim(Debian slim) 계열도 중간 대안이 됩니다.

.dockerignore로 빌드 컨텍스트 줄이기

멀티스테이지 빌드만큼 자주 간과되는 것이 빌드 컨텍스트 크기입니다. docker build .를 실행하면 현재 디렉토리 전체가 Docker 데몬에 전송됩니다. node_modules, .git, 로컬 로그 파일 등이 포함되면 전송 시간이 크게 늘어납니다. .dockerignore.gitignore와 같은 문법으로 제외 패턴을 정의합니다.

# .dockerignore
node_modules
npm-debug.log*
dist
.git
.gitignore
*.md
.env
.env.*
coverage
.DS_Store
*.test.ts
__tests__
.nyc_output

빌드 컨텍스트를 수십 MB에서 수 MB로 줄이면 로컬 빌드는 물론 CI 파이프라인에서도 체감 속도 차이가 납니다. docker buildSending build context to Docker daemon 메시지의 숫자를 확인해 최적화 전후를 비교해보세요.

레이어 캐시 전략: 빌드 속도와 이미지 크기를 동시에

Docker는 각 명령어(RUN, COPY, ADD)를 레이어로 쌓습니다. 레이어 캐시는 이전 레이어와 동일한 명령어+컨텍스트라면 재실행하지 않고 캐시를 재사용합니다. 캐시를 최대한 활용하려면 변경 빈도가 낮은 명령을 앞에, 높은 것을 뒤에 배치해야 합니다.

  • 의존성 파일 먼저 COPY: COPY package*.json ./RUN npm ciCOPY . . 순서. 소스가 바뀌어도 package.json이 동일하면 npm install 레이어가 캐시됩니다.
  • RUN 명령 합치기: apt-get updateapt-get install을 같은 RUN에 묶어야 캐시 불일치로 인한 오래된 패키지 인덱스 사용을 막을 수 있습니다.
  • 설치 후 캐시 삭제: apt-get install ... && rm -rf /var/lib/apt/lists/* 또는 Alpine의 apk add --no-cache로 패키지 매니저 캐시를 레이어에 남기지 않습니다.
  • BuildKit CACHE mount: Go modules, pip, npm 캐시를 호스트 캐시 볼륨에 바인딩해 레이어를 만들지 않고 재사용합니다.
# BuildKit 캐시 마운트 예시 (Go 모듈)
# syntax=docker/dockerfile:1
FROM golang:1.22-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/root/go/pkg/mod 
    go mod download

COPY . .
RUN --mount=type=cache,target=/root/go/pkg/mod 
    --mount=type=cache,target=/root/.cache/go-build 
    CGO_ENABLED=0 go build -o /app/server ./cmd/server

FROM gcr.io/distroless/static-debian12 AS runtime
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

위 Go 예시에서 최종 이미지는 정적 바이너리 하나만 포함되므로 distroless static 기반 약 10~20 MB 수준으로 완성됩니다. Go 빌드 툴체인이 포함된 golang:1.22 이미지(약 800 MB)와 비교하면 95% 이상 감소합니다.

실전 체크리스트

  • 빌드 스테이지와 런타임 스테이지를 분리했는가?
  • .dockerignore가 프로젝트 루트에 존재하고 node_modules, .git이 제외되어 있는가?
  • 패키지 설치 후 캐시 파일(/var/lib/apt/lists/*, /root/.cache)을 같은 RUN 레이어에서 삭제했는가?
  • 런타임 이미지에서 root 대신 비권한 사용자를 사용하고 있는가?
  • 변경 빈도가 낮은 COPY(package.json)가 소스 COPY보다 앞에 있는가?
  • BuildKit이 활성화되어 있는가? (DOCKER_BUILDKIT=1 또는 Docker Desktop 기본값)
  • docker image inspect로 최종 이미지 레이어 수와 크기를 확인했는가?
  • Trivy, Grype 등 이미지 스캐너로 CVE 검사를 CI에 포함했는가?

마무리 요약

Docker 멀티스테이지 빌드는 Dockerfile 하나로 빌드 환경과 런타임 환경을 완전히 분리하는 가장 실용적인 이미지 경량화 방법입니다. Node.js 기준 1 GB 이상이던 이미지가 100 MB 내외로, Go 정적 바이너리는 10~20 MB까지 줄어드는 것이 일반적입니다. Alpine은 패키지 설치가 필요한 동적 앱에, distroless는 정적 바이너리 배포에 적합합니다. .dockerignore와 레이어 순서 최적화, BuildKit 캐시 마운트를 함께 적용하면 이미지 크기와 빌드 시간 모두 개선할 수 있습니다.

자주 묻는 질문

Q. 멀티스테이지 빌드를 사용하면 빌드 시간이 늘어나지 않나요?

A. 처음 빌드 시에는 스테이지가 늘어난 만큼 약간의 오버헤드가 있을 수 있지만, BuildKit의 병렬 스테이지 실행과 레이어 캐시 덕분에 두 번째 빌드부터는 오히려 더 빨라지는 경우가 많습니다. 특히 의존성 설치 레이어가 캐시되면 CI 파이프라인 전체 시간이 단축됩니다.

Q. Alpine 이미지에서 네이티브 npm 모듈이 빌드되지 않는 문제가 있습니다.

A. Alpine은 musl libc를 사용하기 때문에 glibc에 의존하는 네이티브 모듈(sharp, bcrypt 등)이 빌드 실패하거나 런타임 오류를 일으킬 수 있습니다. 이 경우 node:20-slim(Debian slim, glibc 기반, 약 80 MB)으로 교체하거나, Alpine 빌더 스테이지에서 apk add python3 make g++를 추가해 네이티브 컴파일 환경을 갖추고 빌드하는 방법을 씁니다.

Q. distroless 이미지에서 컨테이너 내부를 디버깅하려면 어떻게 하나요?

A. gcr.io/distroless/nodejs20-debian12:debug처럼 :debug 태그가 붙은 변형 이미지를 사용하면 BusyBox 셸이 포함됩니다. 프로덕션에는 일반 태그를, 긴급 디버깅 시에만 debug 태그로 일시 전환하는 방식을 권장합니다. Kubernetes 환경이라면 ephemeral container(kubectl debug)를 활용해 실행 중인 파드에 임시 디버그 컨테이너를 붙이는 방법도 있습니다.