Table of contents
- 단일 스테이지의 문제
- 멀티스테이지의 기본형
- 스테이지가 빌드에서 하는 일
- 언어별 패턴
- 캐싱 레이어 — 순서가 곧 속도
- BuildKit 캐시 마운트
- distroless와 scratch — 최소 베이스
- 빌드 스테이지 타겟팅
- 실무 체크리스트
- 여기까지의 상태
단일 스테이지의 문제
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도 작지만 더 줄일 여지가 있다. 정적 바이너리라면 scratch나 gcr.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 | ~77MB | yes | glibc | 범용 |
| debian:12-slim | ~75MB | yes | glibc | 범용 |
| alpine:3.20 | ~7MB | yes | musl | 경량 |
| distroless/base | ~20MB | no | glibc | 런타임 |
| distroless/static | ~2MB | no | 없음 | 정적 바이너리 |
| scratch | 0MB | no | 없음 | 정적 바이너리 |
크기 숫자는 상황에 따라 몇 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 .
실무 체크리스트
- 빌드 도구/SDK는 builder 스테이지에, 런타임은 slim/alpine/distroless로
- 의존성 파일을 소스보다 먼저
COPY해서 캐시 레이어 유지 .dockerignore에node_modules,.git, 테스트 리소스 등을 넣어서 빌드 컨텍스트 축소- 이미지에 민감 정보가 들어가지 않도록 빌드 arg/시크릿 분리 (10편, 11편에서 다룸)
- 프로덕션은
USER로 비루트 지정 (10편에서 다룸) - 필요 없어진 중간 레이어는
docker builder prune으로 주기적 정리
여기까지의 상태
이제 같은 앱을 350MB짜리 거대 이미지로 말지, 15MB짜리 날렵한 이미지로 말지 선택할 수 있게 됐다. 이미지가 작아지면 배포 속도, 네트워크 비용, 공격 표면 모두가 줄어든다.
다음 편에서는 이 이미지를 어디에 두느냐, 즉 레지스트리를 다룬다. Docker Hub부터 ECR/GCR/ACR, 사내 Harbor까지 — 인증과 태깅 전략을 포함해서 실무 관점으로 정리한다.

Loading comments...