Published on

Docker 멀티스테이지로 이미지 80% 줄이기

Authors

서버 배포에서 체감 성능을 가장 빠르게 끌어올리는 방법 중 하나는 컨테이너 이미지 용량을 줄이는 것입니다. 이미지가 작아지면 레지스트리 업로드/다운로드가 빨라지고, CI 시간과 배포 시간(특히 롤링/오토스케일)이 동시에 줄어듭니다.

이번 글에서는 Docker 멀티스테이지 빌드로 빌드 도구·소스·캐시를 런타임 이미지에서 제거해 이미지 크기를 80% 수준까지 줄이는 전형적인 접근을 다룹니다. 또한 GitHub Actions에서 Buildx + 캐시 + 레지스트리 푸시까지 연결해 실전에서 바로 재현할 수 있게 구성합니다.

관련해서 빌드가 느릴 때 캐시를 어떻게 잡아야 하는지도 함께 보면 좋습니다.

왜 멀티스테이지가 이미지 크기를 크게 줄이나

단일 스테이지 Dockerfile에서 흔히 하는 실수는 다음과 같습니다.

  • 빌드에 필요한 gcc, make, node_modules, devDependencies가 런타임 이미지에 그대로 남는다
  • 소스 전체와 테스트 파일, 문서, .git 등 불필요한 파일이 들어간다
  • 패키지 매니저 캐시(예: apt 리스트, pip 캐시)가 레이어에 남는다

멀티스테이지는 이를 구조적으로 해결합니다.

  • builder 스테이지: 컴파일/번들/의존성 설치 등 “무거운 작업”을 수행
  • runtime 스테이지: 실행에 필요한 산출물만 COPY --from=builder로 가져옴

즉, 빌드에 필요한 무게를 builder에 몰아넣고, 최종 이미지는 “실행에 필요한 파일만 있는 최소 상태”로 만드는 방식입니다.

목표: 80% 줄이기 위한 체크리스트

이미지 용량을 크게 줄이려면 멀티스테이지 외에도 몇 가지가 함께 맞물려야 합니다.

  1. 런타임 베이스를 슬림하게 선택
    • 예: node:20-slim, python:3.12-slim, gcr.io/distroless/*, alpine(주의점 있음)
  2. 빌드 산출물만 복사
    • 예: dist/, build/, .next/standalone
  3. devDependencies 제거
    • Node.js라면 npm ci --omit=dev 또는 pnpm --prod
  4. 패키지 캐시 제거
    • rm -rf /var/lib/apt/lists/*
  5. .dockerignore로 컨텍스트 줄이기
  6. 정적 링크/단일 바이너리(가능하면)
    • Go/Rust는 멀티스테이지 효과가 특히 큼

이 조합이 맞으면 “대부분의 웹 서비스”는 80% 가까운 감소가 어렵지 않습니다.

예제 1) Node.js(Next.js 포함) 멀티스테이지 Dockerfile

Node 계열은 node_modules가 크고, 빌드 산출물만 남기면 효과가 큽니다. 아래는 Next.js 포함 범용 패턴입니다.

# syntax=docker/dockerfile:1.6

FROM node:20-slim AS deps
WORKDIR /app

# 의존성 설치 단계는 캐시 효율을 위해 lockfile만 먼저 복사
COPY package.json package-lock.json ./
RUN npm ci

FROM node:20-slim AS builder
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Next.js라면 빌드 결과가 .next에 생성됨
RUN npm run build

# 런타임: 실행에 필요한 것만
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production

# 보안: non-root 유저 권장(이미지에 따라 useradd 필요)
# RUN useradd -m nodeuser && chown -R nodeuser:nodeuser /app

# Next.js standalone을 쓰면 더 작아짐
# next.config.js에서 output: 'standalone' 설정 후 아래처럼 복사
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

EXPOSE 3000
CMD ["node", "server.js"]

포인트

  • deps 단계에서 npm ci를 수행하면 lockfile 기반으로 재현 가능하고 캐시도 안정적입니다.
  • Next.js는 output: 'standalone'을 쓰면 런타임에 필요한 Node 모듈만 추려져 이미지가 크게 줄어듭니다.
  • 런타임 스테이지에서 COPY . .를 하지 않습니다. 이 한 줄이 이미지 용량과 보안 표면을 크게 키웁니다.

예제 2) Go 멀티스테이지(효과 극대화)

Go는 정적 바이너리로 만들면 런타임 이미지를 거의 비울 수 있어 감소 폭이 매우 큽니다.

# syntax=docker/dockerfile:1.6

FROM golang:1.22 AS builder
WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .

# 정적 빌드(필요에 따라 tags 조정)
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app ./cmd/app

# distroless는 쉘이 없어 더 작고 안전하지만 디버깅은 어려움
FROM gcr.io/distroless/static-debian12 AS runtime
WORKDIR /
COPY --from=builder /src/app /app

USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/app"]

포인트

  • distroless는 패키지 매니저/쉘이 없어 공격 표면이 작고 용량이 작습니다.
  • 대신 장애 대응 시 컨테이너 내부에서 sh로 들어가 확인하는 방식이 어렵습니다. 관측성(로그/메트릭)을 더 챙기는 쪽으로 설계를 바꾸는 게 좋습니다.

.dockerignore는 멀티스테이지와 세트다

멀티스테이지를 잘 써도 빌드 컨텍스트가 크면 CI가 느려지고, 캐시 효율도 떨어집니다. 최소한 아래는 걸러주는 편이 좋습니다.

# .dockerignore
node_modules
.next
dist
build
coverage
.git
.gitignore
Dockerfile
README.md
*.log
.env
  • 빌드 산출물은 컨테이너 내부에서 생성하므로 로컬 산출물을 보내지 않습니다.
  • .env 같은 민감 정보는 절대 컨텍스트에 포함시키지 않습니다.

GitHub Actions로 캐시·빌드·푸시 자동화

이제 멀티스테이지 Dockerfile을 CI에서 안정적으로 빌드하고, 레지스트리에 푸시하는 워크플로를 구성합니다. 여기서는 GitHub Container Registry(ghcr.io)를 예시로 듭니다.

name: build-and-push

on:
  push:
    branches: ["main"]

permissions:
  contents: read
  packages: write

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

캐시가 중요한 이유

  • 멀티스테이지 자체는 용량을 줄이지만, CI 시간을 줄이려면 캐시 전략이 필요합니다.
  • cache-tocache-fromtype=gha로 설정하면 GitHub Actions 캐시에 BuildKit 레이어가 저장되어 재빌드가 빨라집니다.

캐시가 기대만큼 안 먹거나 빌드가 느릴 때는 아래 글의 체크리스트가 그대로 도움이 됩니다.

실제로 80% 줄었는지 측정하는 방법

감으로 판단하면 최적화가 길을 잃습니다. 아래 명령으로 이미지 크기와 레이어 구성을 확인하세요.

docker images | head

docker history --no-trunc your-image:tag
  • docker history에서 큰 레이어가 무엇인지 보면 “어떤 RUN 또는 COPY가 용량을 키웠는지”가 바로 보입니다.
  • 특히 COPY . .가 런타임에 들어갔거나, 패키지 캐시가 남아 있으면 레이어가 비정상적으로 커집니다.

자주 하는 실수와 해결책

1) 런타임 스테이지에 빌드 툴이 남는다

  • 증상: 최종 이미지에 gcc, make, python, git 등이 포함되어 크기가 큼
  • 해결: 빌드 도구 설치는 builder에서만 하고, runtime은 필요한 바이너리/산출물만 복사

2) 패키지 매니저 캐시가 레이어에 남는다

  • 증상: apt 사용 후 이미지가 커짐
  • 해결: 같은 RUN 레이어에서 정리까지 수행
RUN apt-get update \
  && apt-get install -y --no-install-recommends ca-certificates \
  && rm -rf /var/lib/apt/lists/*

3) devDependencies가 포함된다

  • 증상: Node 이미지가 생각보다 안 줄어듦
  • 해결: 런타임에는 프로덕션 의존성만 남기거나, Next.js standalone 사용

4) distroless 적용 후 디버깅이 불편하다

  • 증상: 컨테이너 내부에서 쉘 실행이 안 됨
  • 해결: 운영은 distroless, 디버그용은 slim 기반 별도 태그로 운영하는 전략도 가능

보안과 운영 관점에서의 추가 이점

이미지 용량 최적화는 단지 비용/속도 문제가 아닙니다.

  • 불필요한 도구/패키지가 빠지면 취약점 스캔 결과(CVE)도 줄어드는 경향이 있습니다.
  • 이미지가 작으면 노드로 내려받는 시간이 줄어 스케일 아웃이 빨라지고, 배포 실패 창도 줄어듭니다.

특히 K8s 환경에서 이미지 pull이 병목이면 장애처럼 보이기도 합니다. 파드가 계속 재시작되거나 준비가 늦어지는 상황을 겪는다면 아래 글도 같이 점검해보세요.

마무리: “멀티스테이지 + 캐시 + 최소 런타임”이 정답

이미지 용량을 80% 줄이는 핵심은 복잡하지 않습니다.

  • 빌드는 builder에서 끝내고, runtime은 산출물만 가져오기
  • .dockerignore로 컨텍스트를 줄이기
  • GitHub Actions에서 Buildx 캐시를 켜서 반복 빌드를 빠르게 만들기

위 패턴을 그대로 적용하면, 대부분의 서비스에서 이미지 용량과 CI 시간이 동시에 눈에 띄게 내려갑니다. 다음 단계로는 distroless 적용, SBOM 생성, 취약점 스캔 자동화까지 붙이면 운영 품질이 한 단계 올라갑니다.