테마 전환

Docker 멀티 스테이지 빌드 실전: 프로덕션 이미지를 1GB에서 10MB로 줄이기

Easton editorial illustration: tradeoff balance table

“이미지 푸시에 실패했습니다. 시간 초과입니다.”

지난해 어느 금요일 오후의 일이었습니다. CI/CD 파이프라인은 온통 빨간색이었고, 화면에 표시된 980MB짜리 Go 애플리케이션 이미지를 보자 가슴이 철렁했습니다. 운영 담당 동료가 다가와 한숨을 쉬며 말했습니다. “이 이미지는 내가 점심에 받은 영화보다도 크네요.”

그 뒤 멀티 스테이지 빌드를 적용했습니다.

10MB. 애플리케이션도 기능도 같았지만 이미지 크기는 980MB에서 10MB로 줄었습니다. 99%가 사라졌고 CI/CD 푸시 시간도 3분에서 3초로 단축됐습니다.

이 글에서는 Go, Node.js, Python 세 언어의 완전한 Dockerfile 템플릿과 시행착오 끝에 정리한 자주 발생하는 5가지 오류를 포함해 멀티 스테이지 빌드의 실전 기법을 공유합니다. 프로덕션 이미지를 ‘비대한 상태’에서 ‘날렵한 상태’로 바꾸고 싶다면 계속 읽어 보세요.

이미지가 이렇게 비대해지는 이유

솔직히 말해 대부분의 Docker 이미지가 비대해지는 이유는 비슷합니다.

예전에 다음과 같은 Dockerfile을 작성한 적이 있습니다.

FROM ubuntu:20.04
RUN apt-get update && apt-get install -y golang
COPY . /app
WORKDIR /app
RUN go build -o myapp
CMD ["./myapp"]

보기에는 꽤 정상적이지 않나요? 하지만 docker images로 확인하니 무려 980MB였습니다.

무엇이 문제였을까요? 한마디로 남겨야 할 것은 남기지 않고, 버려야 할 것은 버리지 않은 것입니다.

구체적으로는 다음과 같습니다.

  1. 베이스 이미지가 너무 큽니다: ubuntu:20.04 자체가 77MB이고 Go 툴체인까지 설치하면 단숨에 900MB를 넘습니다.
  2. 컴파일 도구가 남아 있습니다: gcc, make, git 같은 빌드 도구는 프로덕션 환경에서 전혀 필요하지 않습니다.
  3. 캐시를 정리하지 않았습니다: apt/apk 패키지 관리 캐시가 모두 이미지 레이어에 남습니다.
  4. 불필요한 의존성이 포함됩니다: 개발 의존성과 테스트 프레임워크까지 함께 들어갑니다.

비유하자면 여행을 떠나면서 여행 가방뿐 아니라 침낭, 텐트, 조리 도구까지 챙겼는데, 정작 목적지는 호텔인 셈입니다. 멀티 스테이지 빌드는 실제로 필요한 옷과 세면도구만 챙기고 나머지는 모두 집에 두게 해 줍니다.

Docker 공식 문서의 데이터에 따르면 일반적인 Go 애플리케이션의 최적화 전 이미지는 약 800MB-1GB이지만 최적화 후에는 10-20MB까지 압축할 수 있습니다. 차이가 이 정도로 큽니다.

멀티 스테이지 빌드의 핵심 원리

멀티 스테이지 빌드의 핵심 발상은 간단합니다. 빌드 환경과 실행 환경을 분리하는 것입니다.

기존 Dockerfile은 컴파일, 패키징, 실행을 모두 하나의 이미지에 담습니다. 멀티 스테이지 빌드에서는 여러 개의 FROM 명령을 정의할 수 있고, 각 FROM이 새로운 빌드 스테이지를 시작합니다.

가장 간단한 예를 살펴보겠습니다.

# 첫 번째 스테이지: 빌드
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp

# 두 번째 스테이지: 실행
FROM alpine:3.18
WORKDIR /app
COPY --from=builder /app/myapp .
CMD ["./myapp"]

핵심 구문은 두 줄뿐입니다.

  • FROM ... AS builder: 이 스테이지에 이름을 지정합니다.
  • COPY --from=builder: builder 스테이지에서 파일을 복사합니다.

원리상 Docker는 각 스테이지를 순서대로 실행하지만 최종 이미지에는 마지막 스테이지의 내용만 포함됩니다. 앞 스테이지의 비대한 컴파일 도구와 의존성 캐시는 모두 버려집니다.

iximiuz Labs의 2026년 튜토리얼에 따르면 멀티 스테이지 빌드는 본질적으로 Docker의 레이어 메커니즘을 활용합니다. 각 FROM 명령은 독립된 빌드 컨텍스트를 시작하며, 어떤 스테이지에서든 파일을 이후 스테이지로 복사할 수 있지만 관련 없는 파일은 최종 이미지에 들어가지 않습니다.

집을 꾸미는 과정에 비유할 수 있습니다. 첫 번째 스테이지의 작업팀은 전동 드릴, 망치, 톱을 들고 옵니다. 두 번째 스테이지에서는 입주자인 여러분이 가구와 가전만 들여놓습니다. 작업팀이 떠날 때 도구도 함께 가져가므로 집에는 필요한 것만 남습니다.

실전 사례: 세 언어의 멀티 스테이지 빌드 템플릿

Go: 980MB에서 10MB로

Go는 정적 바이너리로 컴파일할 수 있어서 멀티 스테이지 빌드에 가장 적합한 언어입니다.

전체 Dockerfile은 다음과 같습니다.

# 빌드 스테이지
FROM golang:1.21-alpine AS builder

WORKDIR /app

# 캐시를 활용하기 위해 go.mod와 go.sum을 먼저 복사
COPY go.mod go.sum ./
RUN go mod download

# 소스 코드를 복사하고 컴파일
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' -o myapp .

# 실행 스테이지
FROM scratch

# builder에서 바이너리 파일 복사
COPY --from=builder /app/myapp /myapp

# CA 인증서 복사(HTTPS 호출이 필요한 경우)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

EXPOSE 8080
ENTRYPOINT ["/myapp"]

여기에는 몇 가지 요령이 있습니다.

  1. FROM scratch: 0바이트에서 시작하는 빈 이미지로, 애플리케이션 바이너리만 포함합니다.
  2. CGO_ENABLED=0: CGO를 비활성화하여 완전한 정적 바이너리를 생성합니다.
  3. CA 인증서: 애플리케이션에서 HTTPS API를 호출해야 한다면 인증서 파일을 반드시 복사해야 합니다.
  4. 의존성 캐시 최적화: go.mod/go.sum을 먼저 복사한 뒤 go mod download를 실행하면 소스 코드가 바뀌어도 의존성을 다시 다운로드하지 않습니다.

빌드를 마치면 이미지 크기는 약 10MB입니다. 기존 980MB와 비교하면 99% 줄어든 셈입니다.

scratch가 너무 극단적이라고 느껴진다면(shell이 없어서 디버깅이 어렵습니다) alpine을 사용할 수 있습니다.

FROM alpine:3.18
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]

이미지가 약 15MB로 조금 커지지만, docker exec로 진입해 디버깅할 수 있는 환경을 얻게 됩니다.

Node.js: 900MB에서 120MB로

Node.js의 멀티 스테이지 빌드는 node_modules를 처리해야 하므로 조금 더 복잡합니다.

전체 Dockerfile은 다음과 같습니다.

# 빌드 스테이지
FROM node:18-alpine AS builder

WORKDIR /app

# package.json 복사
COPY package*.json ./

# 모든 의존성 설치(devDependencies 포함)
RUN npm ci

# 소스 코드 복사
COPY . .

# 빌드 단계가 있다면 실행(예: TypeScript 컴파일)
RUN npm run build

# 프로덕션 스테이지
FROM node:18-alpine

WORKDIR /app

# Node 환경 변수 설정
NODE_ENV=production

# 프로덕션 의존성만 설치
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

# 빌드 결과물 복사
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules

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

핵심 사항은 다음과 같습니다.

  1. npm ci --only=production: dependencies만 설치하고 devDependencies는 건너뛰므로 크기가 곧바로 절반으로 줄어듭니다.
  2. npm cache clean --force: npm 캐시가 이미지 레이어에 남지 않도록 정리합니다.
  3. 빌드와 실행 분리: TypeScript 컴파일은 builder 스테이지에서 끝내고 프로덕션 이미지에는 JS 파일만 둡니다.

Oak Oliver Engineering의 실측 데이터에 따르면 일반적인 Express 애플리케이션은 최적화 전 약 900MB에서 멀티 스테이지 빌드 후 약 120MB가 됩니다. 약 87% 줄어든 수치입니다.

Python: 300MB에서 100MB로

Python은 조금 다릅니다. 컴파일 단계는 없지만 numpy, pandas처럼 수백 MB에 달하는 거대한 의존성 패키지가 있습니다.

전체 Dockerfile은 다음과 같습니다.

# 빌드 스테이지
FROM python:3.9-slim AS builder

WORKDIR /app

# 사용자 디렉터리에 의존성 설치
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# 프로덕션 스테이지
FROM python:3.9-alpine

WORKDIR /app

# 의존성 복사
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH

# 애플리케이션 코드 복사
COPY . .

EXPOSE 8000
CMD ["python", "app.py"]

여기서는 pip install --user를 사용해 의존성을 /root/.local에 설치한 뒤, 해당 디렉터리 전체를 프로덕션 이미지로 복사합니다.

핵심 요령은 다음과 같습니다.

  1. --no-cache-dir: pip는 기본적으로 다운로드한 패키지를 캐시합니다. 이 옵션을 추가하면 캐시가 남지 않습니다.
  2. slim vs alpine: 빌드 스테이지에서는 호환성이 좋은 slim을, 프로덕션 스테이지에서는 크기가 작은 alpine을 사용합니다.
  3. 가상 환경: 의존성이 복잡하다면 --user 대신 venv 사용을 고려할 수 있습니다.

실측 데이터에 따르면 FastAPI + SQLAlchemy 프로젝트의 원본 이미지는 약 300MB였고 멀티 스테이지 빌드 후에는 약 100MB가 됐습니다.

베이스 이미지 선택: Alpine vs Distroless vs Slim

실행 스테이지의 베이스 이미지를 고르는 일에는 여러 요소를 따져 봐야 합니다.

세 가지 주요 선택지를 표로 정리했습니다.

특성AlpineDistrolessSlim
기본 크기3-5MB20-65MB50-100MB
보안성보통매우 높음보통
디버깅 난이도낮음(shell 있음)높음(shell 없음)낮음(shell 있음)
호환성주의 필요(glibc)좋음좋음
적합한 환경Go 정적 바이너리높은 보안 요구Node.js/Python

Alpine: 크기는 가장 작지만 glibc에 주의

Alpine Linux는 표준 glibc 대신 musl libc를 사용합니다. Go는 정적 컴파일이 가능하므로 문제가 없지만 Python과 Node.js의 일부 의존성에는 문제가 생길 수 있습니다.

저도 이 문제를 겪었습니다. numpy를 사용한 Python 프로젝트가 Alpine에서 실행되지 않고 ImportError: cannot import name 'random' 오류를 냈습니다. 한참을 살펴본 뒤에야 musl과 glibc의 호환성 문제라는 것을 알았습니다.

해결 방법은 두 가지입니다.

  • libc6-compat 설치: apk add libc6-compat
  • 또는 alpine 대신 slim 사용

Distroless: 보안의 기준이지만 디버깅이 어려움

Distroless는 Google에서 만든 이미지 제품군으로, shell과 패키지 관리자가 없으며 애플리케이션 실행에 꼭 필요한 요소만 포함합니다.

danieldemmel.me의 분석에 따르면 Distroless는 공격자가 shell로 명령을 실행할 수 없기 때문에 고위험 CVE 취약점 대부분을 제거할 수 있습니다.

대신 문제가 생겼을 때 docker exec로 진입해 로그를 확인하거나 디버깅할 수 없습니다. 로그 출력과 모니터링에 의존해야 합니다.

최고 수준의 보안을 원한다면 Distroless가 가장 좋은 선택입니다.

FROM gcr.io/distroless/static-debian11
COPY --from=builder /app/myapp /
ENTRYPOINT ["/myapp"]

Slim: 균형 잡힌 선택

공식 -slim 이미지(예: node:18-slim, python:3.9-slim)는 Alpine과 전체 이미지 사이의 절충안입니다.

Alpine보다 크기는 조금 더 크지만 호환성이 좋고 디버깅할 수 있는 shell도 있습니다. musl/glibc 문제로 고생하고 싶지 않다면 slim이 편한 선택입니다.

제안은 다음과 같습니다.

  • Go 애플리케이션: scratch 또는 alpine 우선 사용
  • Node.js/Python: 먼저 slim을 사용하고 문제가 없는지 확인한 뒤 alpine 시도
  • 높은 보안 요구: distroless를 사용하되 디버깅 방안을 미리 준비

문제 해결 가이드: 자주 발생하는 5가지 오류와 해결책

Dockerfile을 많이 작성하면서 겪은 문제를 모으면 수영장을 채울 정도입니다. 그중 가장 흔한 5가지를 살펴보겠습니다.

오류 1: COPY —from=0으로 전체 복사

초보자가 흔히 하는 실수는 이전 스테이지의 모든 파일을 그대로 복사하는 것입니다.

# 잘못된 예
FROM builder
COPY --from=0 /app /app

이렇게 하면 Go 툴체인, npm 캐시, 임시 파일 등 builder 스테이지의 전체 디렉터리가 복사되어 이미지가 곧바로 비대해집니다.

올바른 방법은 필요한 파일만 복사하는 것입니다.

# 올바른 예
COPY --from=builder /app/myapp /myapp
COPY --from=builder /app/dist /dist

오류 2: 캐시를 정리하지 않음

apt/apk의 패키지 관리 캐시는 삭제하더라도 이미지 레이어에 남을 수 있습니다.

# 잘못된 예(캐시가 이전 레이어에 남음)
RUN apt-get update && apt-get install -y curl
RUN apt-get clean

올바른 방법은 정리 명령과 설치 명령을 같은 레이어에서 실행하는 것입니다.

# 올바른 예
RUN apt-get update && apt-get install -y curl && apt-get clean && rm -rf /var/lib/apt/lists/*

또는 --no-cache 옵션을 사용합니다.

RUN apk add --no-cache curl

오류 3: Alpine glibc 호환성 문제

앞서 설명했듯이 Alpine은 musl libc를 사용하므로 일부 Python/Node.js 의존성과 호환되지 않습니다.

대표적인 오류는 다음과 같습니다.

ImportError: cannot import name 'random' from 'numpy.random'

해결책은 libc6-compat를 설치하거나 slim으로 바꾸는 것입니다.

오류 4: root가 아닌 사용자 미설정

기본적으로 컨테이너는 root 사용자로 실행되므로 보안 위험이 큽니다.

전용 사용자를 만드는 것이 모범 사례입니다.

RUN adduser -D appuser
USER appuser

이렇게 하면 컨테이너가 공격당하더라도 공격자는 일반 사용자 권한만 갖게 됩니다.

오류 5: .dockerignore 누락

.dockerignore는 Dockerfile의 ‘제외 목록’입니다. 설정하지 않으면 COPY . ..git, node_modules, 테스트 파일을 비롯한 프로젝트 디렉터리 전체를 복사합니다.

.dockerignore를 만듭니다.

.git
.gitignore
node_modules
npm-debug.log
Dockerfile
.dockerignore
*.md
.env

이렇게 하면 빌드 컨텍스트 크기를 줄이고 이미지 빌드 속도를 높일 수 있습니다.

결론

멀티 스테이지 빌드는 Docker 이미지를 줄이는 가장 실용적인 기법입니다.

핵심 발상은 한 문장으로 정리할 수 있습니다. 빌드 환경에는 컴파일 도구를 두고, 실행 환경에는 애플리케이션만 둡니다.

수치를 다시 정리해 보겠습니다.

  • Go: 980MB → 10MB(99% 축소)
  • Node.js: 900MB → 120MB(87% 축소)
  • Python: 300MB → 100MB(67% 축소)

아직 멀티 스테이지 빌드를 사용해 보지 않았다면 지금 시도해 보세요. 프로젝트 하나를 골라 위 템플릿을 참고해 Dockerfile을 다시 작성한 다음 docker images로 전후 크기를 비교하면 됩니다.

분명 놀라운 결과를 확인할 수 있을 겁니다. 적어도 CI/CD 푸시가 다시 시간 초과되는 일은 없을 테니까요.

Docker 멀티 스테이지 빌드 이미지 최적화

비대해진 Docker 이미지를 최소 크기로 줄이는 전체 과정

⏱️ Estimated time: 30 min

  1. 1

    Step 1: 현재 이미지 구성 분석

    `docker history` 명령으로 이미지의 각 레이어 크기를 확인합니다.

    ```bash
    docker history your-image:tag
    ```

    공간을 가장 많이 차지하는 레이어를 찾습니다. 일반적으로 다음 항목입니다.
    • 베이스 이미지 자체
    • 빌드 도구와 컴파일 의존성
    • 패키지 관리 캐시
  2. 2

    Step 2: 멀티 스테이지 Dockerfile 작성

    빌드 스테이지와 실행 스테이지가 포함된 Dockerfile을 만듭니다.

    ```dockerfile
    # 빌드 스테이지
    FROM golang:1.21-alpine AS builder
    WORKDIR /app
    COPY go.mod go.sum ./
    RUN go mod download
    COPY . .
    RUN CGO_ENABLED=0 go build -o myapp .

    # 실행 스테이지
    FROM alpine:3.18
    COPY --from=builder /app/myapp /myapp
    ENTRYPOINT ["/myapp"]
    ```

    핵심 사항:
    • AS로 스테이지 이름 지정
    • COPY --from=builder로 필요한 파일만 복사
  3. 3

    Step 3: 이미지 빌드 및 크기 비교

    새 이미지를 빌드하고 크기 변화를 비교합니다.

    ```bash
    docker build -t myapp:optimized .
    docker images | grep myapp
    ```

    최적화 전후의 크기 차이를 비교합니다.
  4. 4

    Step 4: 애플리케이션 정상 동작 확인

    컨테이너를 실행하고 애플리케이션을 테스트합니다.

    ```bash
    docker run -d -p 8080:8080 myapp:optimized
    curl http://localhost:8080/health
    ```

    기능이 온전하고 누락된 의존성이 없는지 확인합니다.
  5. 5

    Step 5: 프로덕션 환경에 배포

    새 이미지를 사용하도록 CI/CD 과정을 업데이트합니다.

    • 이미지 레지스트리에 푸시
    • Kubernetes Deployment 또는 docker-compose.yml 업데이트
    • 배포 성공 여부 확인

FAQ

멀티 스테이지 빌드는 빌드 속도에 영향을 주나요?
멀티 스테이지 빌드는 두 스테이지를 빌드하므로 빌드 시간이 늘어날 수 있지만, 최종 이미지 크기가 크게 줄어 배포와 전송 속도는 대폭 향상됩니다. CI/CD 과정 전체로 보면 일반적으로 소요 시간이 줄어듭니다.
Alpine과 Distroless 중 무엇을 선택해야 하나요?
정적으로 컴파일한 Go 애플리케이션은 Alpine 또는 scratch를 우선 선택합니다. Node.js/Python은 먼저 slim으로 호환성을 확인한 뒤 Alpine을 시도하는 것이 좋습니다. 보안 요구가 매우 높다면 Distroless를 선택하되 로그와 모니터링 방안을 미리 준비해야 합니다.
멀티 스테이지 빌드는 어떤 언어에 적합한가요?
거의 모든 프로그래밍 언어에 적용할 수 있습니다. 특히 Go(10MB까지 축소 가능), Node.js(80% 이상 축소), Python(60% 이상 축소), Rust, Java처럼 컴파일 단계나 의존성 관리가 있는 언어에서 효과가 큽니다.
멀티 스테이지 빌드에서 설정 파일은 어떻게 처리하나요?
설정 파일은 일반적으로 실행 스테이지에 별도로 마운트하며, 이미지에 포함하는 것은 권장하지 않습니다. Docker volume 또는 Kubernetes ConfigMap을 사용할 수 있습니다. 반드시 포함해야 한다면 실행 스테이지에서 설정 파일을 COPY하면 됩니다.
멀티 스테이지 빌드로 이미지 크기를 얼마나 줄일 수 있나요?
언어와 애플리케이션 유형에 따라 다릅니다. Go 애플리케이션은 보통 90%-99%(1GB에서 10MB), Node.js 애플리케이션은 70%-90%, Python 애플리케이션은 50%-70% 줄일 수 있습니다. 핵심은 실행에 꼭 필요한 파일만 남기는 것입니다.
FROM scratch를 사용할 때 주의할 점은 무엇인가요?
scratch는 빈 이미지라서 shell, 패키지 관리자, CA 인증서가 없습니다. 애플리케이션에서 HTTPS 호출이 필요하면 builder 스테이지에서 /etc/ssl/certs/ca-certificates.crt를 복사해야 합니다. 디버깅이 어려우므로 먼저 alpine에서 기능을 검증하는 것이 좋습니다.

3분 읽기 · 게시일: 2026년 4월 19일 · 수정일: 2026년 9월 4일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog