Skip to content
ioob.dev
Go back

Docker 8편 — 멀티스테이지 빌드로 이미지 다이어트

· 7분 읽기
Docker 시리즈 (8/13)
  1. Docker 입문 1편 — Docker란
  2. Docker 입문 2편 — 이미지와 레이어
  3. Docker 입문 3편 — Dockerfile 작성법
  4. Docker 입문 4편 — 컨테이너 생명주기
  5. Docker 입문 5편 — 볼륨과 데이터 영속성
  6. Docker 입문 6편 — 네트워크
  7. Docker 7편 — Docker Compose로 다중 컨테이너 오케스트레이션
  8. Docker 8편 — 멀티스테이지 빌드로 이미지 다이어트
  9. Docker 9편 — 레지스트리, 이미지는 어디에 두는가
  10. Docker 10편 — 컨테이너 보안, 터지기 전에 막는 것들
  11. Docker 11편 — BuildKit과 고급 빌드
  12. Docker 12편 — 프로덕션 모범 사례
  13. Docker 13편 — 트러블슈팅과 대안
Table of contents

Table of contents

단일 스테이지의 문제

Go 예제로 본다. 단순한 단일 스테이지 Dockerfile은 이렇다.

FROM golang:1.22-alpine
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app/bin/server ./cmd/server
CMD ["/app/bin/server"]

이걸 빌드하면 최종 이미지에 golang:1.22-alpine 전체가 들어간다. Go 컴파일러, 표준 라이브러리 소스, 모든 의존 소스 코드, go 명령어까지. 런타임에는 컴파일된 바이너리 하나만 실행되면 되는데 말이다.

docker build -t app:single .
docker images app:single
# REPOSITORY   TAG      SIZE
# app          single   ~350MB

350MB 내외다. 바이너리 자체는 고작 10~20MB인데.

멀티스테이지의 기본형

FROM을 여러 번 쓴다. 각 FROM이 새 스테이지다. 마지막 스테이지만 실제 이미지로 저장되고, 나머지는 버려진다.

# 1단계 — 빌드
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server

# 2단계 — 런타임
FROM alpine:3.20
RUN adduser -D -u 10001 app
COPY --from=builder /out/server /usr/local/bin/server
USER app
ENTRYPOINT ["/usr/local/bin/server"]

AS builder로 1단계에 이름을 붙여놓고, 2단계에서 COPY --from=builder로 바이너리만 가져온다. 런타임 이미지에는 Go 컴파일러가 아예 없다.

docker build -t app:multi .
docker images app:multi
# REPOSITORY   TAG      SIZE
# app          multi    ~15MB

350MB → 15MB. 스테이지를 나눈 것만으로 크기가 95% 넘게 줄었다.

스테이지가 빌드에서 하는 일

흐름을 그림으로 보면 단순하다.

flowchart LR
    SRC["소스 코드"] --> B1["Stage: builder<br/>(golang:1.22)"]
    B1 --> ART["/out/server<br/>(컴파일된 바이너리)"]
    ART -->|COPY --from=builder| B2["Stage: runtime<br/>(alpine:3.20)"]
    B2 --> IMG["최종 이미지"]
    B1 -.->|버려짐| X["Builder 레이어"]

빌더 스테이지는 컴파일러/빌드 도구로 가득하지만 최종 이미지에는 안 남는다. 복사된 산출물과 런타임 베이스만 남는다.

언어별 패턴

멀티스테이지 빌드의 핵심은 빌드 도구와 런타임을 분리한다는 것이지만, 언어마다 나름의 포인트가 있다.

Java (Gradle)

FROM gradle:8.7-jdk21 AS builder
WORKDIR /src
COPY build.gradle.kts settings.gradle.kts ./
COPY src ./src
RUN gradle clean bootJar --no-daemon

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /src/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

gradle:8.7-jdk21은 JDK + Gradle + 의존 캐시를 포함해서 수백 MB 수준이다. 런타임은 jre-alpine 하나만 있으면 되니 이미지가 훨씬 가볍다. Spring Boot라면 layertools로 레이어를 더 잘게 나누는 것도 유효하지만, 일단 멀티스테이지만 해도 절반 이상 줄어든다.

Node.js

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build && npm prune --production

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
USER node
CMD ["node", "dist/server.js"]

Node는 node_modules가 특히 비대하기 때문에 deps 스테이지에서 설치만 따로 빼두면 package.json이 안 바뀔 때 레이어 캐시가 히트한다. 빌드 후 npm prune --production으로 devDependencies를 털어내면 최종 크기도 줄어든다.

Python

FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "-m", "myapp"]

Python은 컴파일되는 언어가 아니라 이득이 다른 언어보다 작다. 그래도 빌드 시점에만 필요한 컴파일러나 dev 헤더(gcc, libpq-dev 등)를 런타임에서 뺄 수 있다는 게 크다.

캐싱 레이어 — 순서가 곧 속도

Dockerfile의 각 명령은 레이어를 만든다. 해당 레이어의 입력이 바뀌지 않으면 캐시가 히트돼서 재빌드 없이 재사용된다. 이 캐시 규칙만 잘 이해해도 CI 시간이 절반이 된다.

flowchart TB
    L1["COPY go.mod go.sum"] --> L2["RUN go mod download"]
    L2 --> L3["COPY . ."]
    L3 --> L4["RUN go build"]

    subgraph KEY["캐시 히트 판정"]
      L1
      L2
      L3
      L4
    end

    NOTE1["의존성만 바뀔 때\n→ L1, L2만 재실행"]
    NOTE2["소스 한 줄 바뀔 때\n→ L3, L4만 재실행"]

의존성 파일을 먼저 복사하고 설치한 다음, 소스를 복사하는 이 순서가 핵심이다. 소스 한 줄 고치는 게 빈번한 일이니까 그 아래 레이어가 무거워야 한다.

반대 순서면 어떻게 되는지 보자.

# 나쁜 순서 — 소스 한 줄만 바뀌어도 의존성부터 재설치
COPY . .
RUN go mod download
RUN go build -o /out/server ./cmd/server

이렇게 쓰면 소스가 바뀔 때마다 go mod download부터 다시 돈다. 팀 전체 CI 빌드 시간을 몇 분씩 잡아먹는 원인이 대개 이 패턴이다.

BuildKit 캐시 마운트

BuildKit이 기본 엔진이 되면서 더 강력한 캐싱 방식이 생겼다. 빌드 도구의 캐시 디렉토리를 마운트 형태로 유지할 수 있다.

# syntax=docker/dockerfile:1.7
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -o /out/server ./cmd/server

--mount=type=cache는 레이어 캐시 히트와는 별개로, 빌드 도구가 쓰는 캐시 디렉토리를 빌드 간에 유지한다. 클린 빌드라도 Go 모듈 캐시는 재사용할 수 있다. 이 기능은 11편에서 더 깊게 다룬다.

distroless와 scratch — 최소 베이스

alpine도 작지만 더 줄일 여지가 있다. 정적 바이너리라면 scratchgcr.io/distroless/*를 노릴 수 있다.

scratch — 진짜 아무것도 없음

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -ldflags='-s -w' -o /out/server ./cmd/server

FROM scratch
COPY --from=builder /out/server /server
ENTRYPOINT ["/server"]

scratch는 빈 이미지다. 셸도, libc도 없다. Go에서 CGO_ENABLED=0으로 빌드한 완전 정적 바이너리라면 이걸로도 동작한다. 대신 docker exec ... sh로 접속이 불가능하니 디버깅이 까다롭다.

distroless — 최소한의 런타임

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

distroless는 Google이 제공하는 “앱 실행에 필요한 최소한의 것만” 담은 이미지다. 셸이 없어서 공격 표면이 좁고, nonroot 태그는 비루트 사용자로 시작한다. static-debian12는 Go 같은 정적 바이너리용, java-debian12는 JRE 포함 버전 등 용도별로 나뉜다.

비교

베이스크기libc용도
ubuntu:22.04~77MByesglibc범용
debian:12-slim~75MByesglibc범용
alpine:3.20~7MByesmusl경량
distroless/base~20MBnoglibc런타임
distroless/static~2MBno없음정적 바이너리
scratch0MBno없음정적 바이너리

크기 숫자는 상황에 따라 몇 MB씩 달라질 수 있는 근사값이다. 요점은 “필요한 최소한만 남긴다”가 답이라는 것.

빌드 스테이지 타겟팅

멀티스테이지에서는 특정 스테이지만 빌드할 수도 있다. 테스트 스테이지를 따로 두는 패턴에 유용하다.

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server

FROM builder AS tester
RUN go test ./...

FROM alpine:3.20 AS runtime
COPY --from=builder /out/server /server
ENTRYPOINT ["/server"]

CI에서는 --target tester로 테스트만 돌리고, 배포용 빌드는 --target runtime을 빌드한다.

docker build --target tester -t app:test .
docker build --target runtime -t app:prod .

실무 체크리스트

여기까지의 상태

이제 같은 앱을 350MB짜리 거대 이미지로 말지, 15MB짜리 날렵한 이미지로 말지 선택할 수 있게 됐다. 이미지가 작아지면 배포 속도, 네트워크 비용, 공격 표면 모두가 줄어든다.


다음 편에서는 이 이미지를 어디에 두느냐, 즉 레지스트리를 다룬다. Docker Hub부터 ECR/GCR/ACR, 사내 Harbor까지 — 인증과 태깅 전략을 포함해서 실무 관점으로 정리한다.

9편: 레지스트리


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Docker 7편 — Docker Compose로 다중 컨테이너 오케스트레이션
Next Post
Docker 9편 — 레지스트리, 이미지는 어디에 두는가