도커 중급 강좌 #1 멀티스테이지 빌드와 이미지 슬리밍

7 분 소요

도커 기초 시리즈에서 한 컨테이너를 정의하고 실행하는 기본 흐름을 잡았습니다. 중급 시리즈는 그 위에 운영 단계의 도구를 한 겹 더 얹겠습니다. 첫 글은 가장 큰 효과를 빠르게 볼 수 있는 멀티스테이지 빌드입니다.

도커 중급 강좌 시리즈에서 이번 글의 위치:

  • #1 멀티스테이지 빌드와 이미지 슬리밍 ← 이번 글
  • #2 빌드 캐시 — 레이어 순서 최적화
  • #3 docker-compose 기초 — web + db
  • #4 compose 심화 — depends_on, healthcheck, profiles
  • #5 환경변수와 secrets 관리
  • #6 로깅과 디버깅

빌드 시점에만 필요한 것 vs 런타임에 필요한 것 #

기초 시리즈의 Dockerfile 한 줄짜리 예는 작게 가다가, 실제 프로젝트로 옮기면 금세 비대해집니다. 이유는 거의 항상 같습니다.

  • 컴파일러 / 빌드 도구가 이미지에 그대로 남아 있음
  • 개발 의존성(테스트 러너, 린터)이 같이 설치됨
  • node_modules / .venv / 빌드 산출물이 두 번씩 들어감
  • apt 캐시가 청소되지 않음

특히 컴파일러 같은 빌드 도구는 빌드 시점에만 필요한데 이미지에는 그대로 남아 있는 일이 흔합니다. Go의 go build, Node의 tsc, C의 gcc 모두 빌드 결과물만 있으면 끝나는데도, 이전 단계 도구들이 이미지에 그대로 굳어 있습니다.

종류빌드 시점런타임
컴파일러 (gcc, tsc, go)필요불필요
패키지 매니저 (pip, npm)필요보통 불필요
빌드 산출물 (dist/, build/)만들기필요
런타임 라이브러리 (libssl, libpq)필요필요
앱 코드필요 (Go 같은 언어는 빌드 후엔 불필요)언어에 따라

이 분리를 한 Dockerfile 안에서 자연스럽게 하는 도구가 멀티스테이지 빌드 입니다.

기본 문법 — FROM ... AS name #

한 Dockerfile에 FROM을 여러 번 쓸 수 있습니다. 각 FROM은 새로운 스테이지를 시작합니다.

가장 단순한 멀티스테이지
FROM python:3.14 AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --target=/build/deps -r requirements.txt

FROM python:3.14-slim
WORKDIR /app
COPY --from=builder /build/deps /app/deps
COPY app.py .
ENV PYTHONPATH=/app/deps
CMD ["python", "app.py"]
  • FROM python:3.14 AS builder — 첫 스테이지에 builder 라는 이름을 붙임
  • 두 번째 FROM python:3.14-slim — 새로운 깨끗한 스테이지 시작
  • COPY --from=builder /build/deps /app/deps — 첫 스테이지의 결과물만 가져옴

최종 이미지에는 마지막 FROM 이후의 스테이지만 들어갑니다. builder 안의 컴파일러, 빌드 도구, 캐시는 모두 버려집니다. --from=builder로 명시적으로 가져온 것만 옮겨오는 구조입니다.

Go — 가장 극적인 효과 #

Go는 정적 바이너리를 만드는 언어라 멀티스테이지 효과가 가장 분명합니다.

Go — 단순 빌드 (비대함)
FROM golang:1.23
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]
# 최종 이미지: ~900MB (Go 툴체인 통째로)

같은 앱을 멀티스테이지로:

Go — 멀티스테이지
FROM golang:1.23 AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o myapp

FROM gcr.io/distroless/static-debian12
COPY --from=builder /build/myapp /myapp
CMD ["/myapp"]
# 최종 이미지: ~15MB

900MB → 15MB. 60배 차이입니다. 풀어 보면:

  • CGO_ENABLED=0 — C 라이브러리 의존을 끔. 정적 바이너리만 만듦
  • gcr.io/distroless/static-debian12 — 셸도 패키지 매니저도 없는 최소 이미지(뒤에서 다룸)
  • 최종 스테이지에는 빌드된 바이너리 한 개와 그 의존 OS 파일만

Go 컨테이너를 운영 환경에서 보면 거의 항상 이 패턴입니다.

Node.js — tsc 결과만 가져오기 #

TypeScript 프로젝트는 tsc로 빌드해서 dist/를 만들고, 운영에는 그 결과물만 있으면 됩니다.

Node TypeScript — 멀티스테이지
# 1) 의존성 스테이지 — 캐시 잘 타게
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# 2) 빌드 스테이지 — tsc 실행
FROM node:20-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# 3) 운영 의존성만 따로
FROM node:20-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# 4) 최종 이미지
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json .
USER node
CMD ["node", "dist/server.js"]

스테이지가 4개로 늘었지만 각자의 구분이 분명합니다.

  • deps — 모든 의존성(dev 포함) 설치. 빌드와 캐시에 쓰임
  • buildertscdist/만듦
  • prod-deps — 운영 의존성만 별도로 설치. dev 의존성을 결과 이미지에 넣지 않기 위해
  • 최종 — dist/와 prod node_modules만 복사. 빌드 도구는 다 버려짐

USER node는 비특권 사용자로 떨어트리는 한 줄입니다. node 공식 이미지가 이 사용자를 미리 만들어두고 있어 한 줄로 끝납니다.

Python — wheel로 빌드 의존성 분리 #

Python은 Go만큼 극적이지 않지만, C 확장 빌드 도구(gcc, headers)를 분리하면 효과가 큽니다.

Python — 멀티스테이지
FROM python:3.14 AS builder
WORKDIR /build
RUN pip wheel --no-cache-dir --wheel-dir /wheels \
    psycopg2 cryptography
# 위 패키지들은 C 확장이 있어 빌드에 gcc, libpq-dev, libssl-dev 등이 필요

FROM python:3.14-slim
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links=/wheels \
    psycopg2 cryptography \
    && rm -rf /wheels

COPY app.py .
CMD ["python", "app.py"]

builder는 풀(full) 이미지로 컴파일을 돌리고, 결과물(.whl 휠)을 만들어 둡니다. 최종 이미지는 slim 기반에 휠만 가져와 설치하니 컴파일러가 들어가지 않습니다.

운영에서 이미지 슬리밍이 정말 중요한 경우에만 이 패턴까지 가고, 일반적으로는 python:3.14-slim + requirements.txt만으로도 충분히 작아집니다.

--from의 다른 사용법 #

--from은 외부 이미지에서도 가져올 수 있습니다.

외부 이미지에서 직접
FROM ubuntu:24.04
COPY --from=ghcr.io/curtis/myapp:1.0 /myapp /usr/local/bin/myapp

다른 이미지의 특정 파일만 가져오는 방식. 정적 자산 이미지를 따로 만들고 운영 이미지에 합치는 식의 구성에서 가끔 씁니다.

--target — 특정 스테이지까지만 빌드 #

개발 / CI 환경에서 일부 스테이지만 빌드하고 싶을 때 --target을 씁니다.

builder 스테이지까지만
docker build --target builder -t myapp:builder .

테스트 단계만 따로 두고 CI에서 그 스테이지로만 빌드하는 패턴이 자주 보입니다.

test 스테이지 분리
FROM node:20-slim AS deps
# ...

FROM node:20-slim AS builder
COPY --from=deps /app/node_modules ./node_modules
# ...

FROM builder AS test
RUN npm run test
RUN npm run lint

FROM node:20-slim AS runner
COPY --from=builder /app/dist ./dist
# ...

CI가 docker build --target test .으로 테스트만, 배포 빌드는 --target runner로(또는 default로) 끝까지 빌드. 두 빌드가 같은 의존성 캐시를 공유합니다.

Distroless — Google의 최소 이미지 #

gcr.io/distroless/... 라는 이미지군이 있습니다. Google이 만든, 앱 실행에 꼭 필요한 것만 들어 있는 이미지들입니다.

이미지들어 있는 것안 들어 있는 것
staticglibc, ca-certificates, /etc/passwd셸, 패키지 매니저, 코어유틸
basestatic + 동적 링커위 +
ccbase + libgcc위 +
nodejs20-debian12Node 런타임빌드 도구
python3-debian12Python 런타임pip, venv 도구

장점: 작고, 공격 표면이 좁음 (셸 / 코어유틸 익스플로잇이 없음). 단점: docker exec -it ... sh가 안 됨. 셸 자체가 없기 때문입니다. 디버깅이 까다롭습니다.

운영 컨테이너의 보안이 중요하거나, 컨테이너 안에 셸이 있을 이유가 없을 때 적절합니다. 일반적인 시작점은 distroless보다 slim 변형이 무난합니다.

Scratch — 정말로 비어 있는 이미지 #

scratch는 도커가 제공하는 완전히 빈 가상 이미지입니다. 파일이 0개입니다. 거기에 정적 바이너리를 하나만 넣는 식으로 씁니다.

Go + scratch
FROM golang:1.23 AS builder
WORKDIR /build
COPY . .
RUN CGO_ENABLED=0 go build -o myapp

FROM scratch
COPY --from=builder /build/myapp /myapp
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/myapp"]
# 최종 이미지: ~7MB

scratch는 ca-certificates, timezone 데이터, /etc/passwd 같은 파일도 없으니, 필요한 것을 명시적으로 함께 복사해야 합니다. distroless의 static이 사실 이 작업을 미리 해둔 베이스 이미지입니다. 순수 정적 Go 바이너리처럼 매우 단순한 앱이 아니면 distroless부터 가는 편이 안전합니다.

한곳에 정리 — 슬리밍 체크리스트 #

이미지 크기를 줄이는 도구를 한곳에 모으면:

  1. 베이스 이미지slim 변형부터. (alpine은 musl 호환성 주의)
  2. 멀티스테이지 — 빌드 도구를 마지막 스테이지에서 떨어내기
  3. 빌드 산출물만 복사 — Go의 단일 바이너리, Node의 dist/, Python의 wheels
  4. 개발 의존성 분리npm ci --omit=dev, pip install --no-deps
  5. apt-get clean / rm -rf /var/lib/apt/lists/* — 한 RUN 안에서 청소
  6. .dockerignore — 빌드 컨텍스트 자체를 작게 (기초 #6)
  7. distroless / scratch — 정말 슬림이 중요할 때

작은 Go API가 ~15MB, Node API가 ~120MB, Python API가 ~150MB 정도가 되면 잘 깎인 편입니다.

실전 예 — Next.js standalone #

Next.js의 standalone 출력을 활용한 멀티스테이지 패턴은 자주 만나니 한 번 적어둡니다.

Next.js standalone
FROM node:20-slim AS deps
WORKDIR /app
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 . .
RUN npm run build

FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

Next.js의 next.config.js에서 output: 'standalone'을 켜야 .next/standalone/이 만들어지고, 그 디렉터리에 운영에 필요한 최소 의존성만 들어옵니다. 이걸 그대로 복사하면 작은 이미지가 됩니다.

정리 #

이번 글에서 잡은 그림:

  • 멀티스테이지 빌드는 빌드 의존성과 런타임 의존성을 한 Dockerfile 안에서 분리하는 도구
  • FROM ... AS name으로 스테이지를 만들고, COPY --from=name으로 결과물만 가져온다
  • Go는 정적 바이너리 + scratch/distroless로 GB → 수십 MB
  • Node TypeScript는 deps / builder / prod-deps / runner 4 스테이지가 표준 패턴
  • Python은 wheel로 C 확장 빌드 도구를 떼어내는 데 강점
  • --target으로 일부 스테이지만 빌드해 CI에서 테스트 / 배포를 분기
  • distroless / scratch는 슬림이 절대로 중요한 경우에. 디버깅이 어려워지니 트레이드오프

다음 글(#2 빌드 캐시 — 레이어 순서 최적화)에서는 이 멀티스테이지 위에 한 겹을 더 얹겠습니다. BuildKit의 캐시 마운트, COPY --link, 외부 캐시(GHA / 레지스트리)로 빌드를 더 빠르게 만드는 방법입니다.

X