멀티 스테이지 빌드와 이미지 경량화
CI는 통과했지만 배포 시간이 길고, 이미지 스캔 결과에는 불필요한 패키지 취약점이 쏟아집니다. 원인을 보면 빌드 도구와 런타임이 한 이미지에 섞여 있어 크기와 공격면이 함께 커진 상태입니다. 멀티 스테이지 빌드는 "작게 만드는 기술"을 넘어 운영 안정성과 보안 품질을 동시에 올리는 패턴입니다. 이 모듈에서 스테이지 분리, 베이스 선택, 캐시 최적화를 실무 기준으로 익힙니다.
Go 애플리케이션을 단순하게 빌드하면 이미지 크기가 1 GB를 넘기도 합니다. 하지만 멀티 스테이지 빌드를 적용하면 동일한 애플리케이션을 10~20 MB 이미지로 만들 수 있습니다. 이 챕터에서는 빌드 환경과 실행 환경을 완전히 분리하는 방법, 그리고 그 과정에서 활용하는 다양한 최적화 기법을 다룹니다.
단일 스테이지 빌드의 한계를 이해하고, 멀티 스테이지 빌드로 프로덕션급 경량 이미지를 만드는 전 과정을 학습합니다.
- 1빌드 레이어와 런타임 레이어를 분리하는 이유로 멀티 스테이지 빌드 원리를 설명할 수 있다
- 2FROM ... AS 문법과 COPY --from으로 스테이지 간 파일을 전달할 수 있다
- 3distroless / alpine 베이스 이미지의 선택 기준과 트레이드오프를 판단할 수 있다
- 4.dockerignore로 빌드 컨텍스트를 최적화해 빌드 속도와 보안을 동시에 개선할 수 있다
- 5BuildKit 병렬 빌드와 --target 플래그로 CI 파이프라인을 효율화할 수 있다
Go와 Node.js 두 가지 언어로 멀티 스테이지 빌드를 실습합니다. Go는 단일 바이너리 특성 덕분에 경량화 효과가 극적으로 나타납니다.
docker buildx versionmkdir -p ~/multistage-lab && cd ~/multistage-labgo version || echo 'Go not installed locally — will build inside Docker'별도 레포 클론 불필요
docker pull golang:1.22-alpine && docker pull gcr.io/distroless/static-debian12왜 빌드 환경과 실행 환경을 분리해야 하는가
확대
단일 스테이지 빌드의 문제점
전통적인 방식으로 Go 앱을 패키징하면 Dockerfile은 다음과 같습니다.
# 단일 스테이지 — 빌드 도구가 최종 이미지에 그대로 남음
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]
이 이미지를 빌드하면 약 800 MB~1 GB 크기가 됩니다. golang:1.22 베이스 이미지 자체가 Go 컴파일러, 표준 라이브러리, 빌드 캐시, OS 유틸리티를 모두 포함하기 때문입니다. 실제 프로덕션에서 실행되는 바이너리는 단 수 MB에 불과한데도 말이죠.
이 문제는 보안 측면에서도 심각합니다. 불필요한 도구가 이미지에 남아 있으면 침해 사고 시 공격자에게 더 많은 수단을 제공합니다.
멀티 스테이지 빌드 원리
멀티 스테이지 빌드는 하나의 Dockerfile에 여러 개의 FROM 을 사용해 각 단계를 독립적으로 정의합니다. 이전 스테이지에서 필요한 파일만 COPY --from 으로 골라 다음 스테이지로 가져옵니다.
확대
최종 이미지에는 런타임 스테이지만 포함됩니다. 빌드 스테이지는 중간 단계로만 사용되고 최종 이미지에는 흔적을 남기지 않습니다.
docker build가 이미지를 만드는 5단계 — 레이어와 캐시
docker build가 Dockerfile을 레이어로 쌓고 캐시로 재사용하는 5단계
Dockerfile에서 소스 한 줄만 고쳤는데 그 아래 npm ci가 수백 개 패키지를 처음부터 다시 받고, 어떤 빌드는 몇 초 만에 끝납니다. 이 차이는 전부 "레이어와 캐시"라는 한 가지 메커니즘에서 나옵니다. docker build가 명령을 레이어로 쌓고 언제 캐시를 재사용하는지를 단계로 알면, 멀티 스테이지의 COPY --from이 왜 이미지를 줄이는지, COPY 순서를 왜 그렇게 잡는지가 규칙이 아니라 원리로 이해됩니다.
docker build .
│
① 빌드 컨텍스트 전송: 현재 디렉토리를 데몬으로 (.dockerignore로 걸러진 것 제외)
│
② FROM: 베이스 이미지 레이어를 시작점(스택의 바닥)으로 놓음
│
③ 명령마다 레이어 1개: RUN/COPY/ADD가 각각 "이전 레이어 + 이번 변경분(diff)"
│ 레이어는 읽기전용, 최종 이미지는 이 레이어들의 스택(CoW)
│
④ 캐시 판단: (명령 문자열 + 입력 내용 해시)가 이전 빌드와 같은가?
│ 같으면 → 캐시 HIT: 레이어 재사용(실행을 건너뜀)
│ 다르면 → 캐시 MISS: 이 레이어를 새로 실행
│
⑤ 캐시 무효화 전파: 한 레이어가 MISS면 그 아래(이후) 모든 레이어도 재실행
▼
최종 이미지 = FROM부터 마지막 명령까지 레이어의 스택
각 단계가 하는 일과, 빌드를 빠르게/작게 만드는 지점:
| 단계 | 하는 일 | 최적화 포인트 |
|---|---|---|
| ① 컨텍스트 전송 | . 전체를 데몬에 올림 | node_modules·.git을 .dockerignore로 빼 전송량과 캐시 오염을 줄임 |
| ② FROM | 베이스 레이어를 스택 바닥에 깜 | 런타임 베이스가 작을수록(distroless 등) 최종 이미지가 작아짐 |
| ③ 레이어 생성 | 명령별로 diff 레이어를 쌓음 | RUN을 &&로 묶으면 레이어와 중간 산출물이 줄어듦 |
| ④ 캐시 판단 | 명령 + 입력 해시로 HIT/MISS 결정 | 자주 안 바뀌는 것(의존성 정의)을 앞에, 자주 바뀌는 것(소스)을 뒤에 COPY |
| ⑤ 무효화 전파 | MISS 아래를 전부 재실행 | 그래서 소스보다 package.json·go.mod를 먼저 COPY해야 소스만 바뀔 때 의존성 레이어가 살아남음 |
멀티 스테이지가 바로 이 메커니즘 위에서 동작합니다 — COPY --from=builder는 빌드 스테이지에서 만든 산출물(바이너리·dist)만 최종 스테이지 레이어로 복사하므로, 컴파일러·SDK가 쌓은 무거운 빌드 레이어는 최종 이미지 스택에 아예 들어가지 않습니다. 그리고 ④~⑤의 규칙 때문에 COPY go.mod ./ → RUN go mod download → COPY . . 순서가 중요합니다: 소스만 고쳐도 의존성 다운로드 레이어는 캐시 HIT로 재사용됩니다. 한 걸음 더 나가면, package.json이 바뀌어 이 레이어가 통째로 MISS나도 다운로드 자체를 아끼는 방법이 있는데, 이는 아래 심화의 캐시 마운트에서 다룹니다.
FROM ... AS와 COPY --from 문법 완전 정복
개념은 이해했는데 막상 Dockerfile을 쓰다 보면 "builder 스테이지에서 만든 바이너리를 다음 스테이지로 어떻게 가져오지?"가 막힙니다. COPY --from=builder를 써봤는데 "file not found" 에러가 나면, 빌드 스테이지의 WORKDIR과 파일 경로를 정확히 지정하지 않은 것입니다. 같은 Dockerfile 안에 FROM이 두 개 있는 구조가 처음에는 낯설지만, AS로 이름을 붙이고 --from으로 참조하는 패턴만 익히면 어떤 언어에도 적용할 수 있습니다. 이 ConceptBlock에서는 스테이지 이름 지정과 COPY --from 문법의 동작 방식을 다룹니다.
확대
스테이지 이름 지정과 참조
FROM ... AS 이름 으로 각 스테이지에 이름을 붙이고, COPY --from=이름 으로 해당 스테이지의 결과물만 골라 다음 스테이지로 전달합니다. 아래 예시에서 builder 스테이지의 컴파일러와 소스코드는 최종 이미지에 전혀 들어가지 않습니다.
# AS 키워드로 스테이지에 이름을 붙인다
FROM golang:1.22-alpine AS builder
WORKDIR /app
# 의존성 먼저 복사 — 캐시 최적화
COPY go.mod go.sum ./
RUN go mod download
# 소스 복사 및 빌드
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .
# --
# 런타임 스테이지 — 최소한의 베이스 이미지에서 시작
FROM gcr.io/distroless/static-debian12
WORKDIR /
# builder 스테이지에서 바이너리만 복사
COPY --from=builder /app/server /server
# non-root 사용자로 실행 (distroless에서는 65532가 nonroot)
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/server"]
COPY --from의 다양한 활용
# 이전 스테이지에서 복사 (스테이지 이름 사용)
COPY --from=builder /app/server .
# 이전 스테이지 인덱스로 참조 (0부터 시작)
COPY --from=0 /app/server .
# 완전히 다른 이미지에서 파일을 가져올 수도 있음
# (예: 특정 버전의 바이너리를 레지스트리에서 직접 추출)
COPY --from=alpine:3.19 /usr/bin/wget /usr/bin/wget
빌드 최적화 옵션
# -ldflags="-s -w": 심볼 테이블(-s)과 DWARF 디버그 정보(-w) 제거
# CGO_ENABLED=0: C 라이브러리 링크 비활성화 → 완전 정적 바이너리
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -trimpath -o server .
-ldflags="-s -w" 옵션만으로도 바이너리 크기가 30~40% 줄어듭니다. upx 도구로 추가 압축하면 더 작아지지만 시작 시간이 늘어나는 트레이드오프가 있습니다.
베이스 이미지 선택 — distroless vs Alpine vs scratch
멀티 스테이지 빌드로 바이너리를 분리했는데 런타임 스테이지에 FROM ubuntu:22.04를 쓰면 최종 이미지가 여전히 80MB입니다. 실제 바이너리는 10MB인데 말이죠. 반대로 FROM scratch를 쓰면 glibc가 없어서 "exec format error"가 납니다. 보안 감사에서는 "셸이 있는 이미지는 침해 시 공격 표면이 넓다"고 지적합니다. 베이스 이미지 선택은 크기, 호환성, 보안 세 축의 트레이드오프이고, 어떤 언어와 링킹 방식을 쓰느냐에 따라 선택지가 달라집니다. 이 ConceptBlock에서는 주요 런타임 베이스 이미지의 특성과 언어별 선택 기준을 다룹니다.
확대
런타임 베이스 이미지 비교
| 이미지 | 크기 | 셸 포함 | 패키지 관리자 | glibc | 권장 용도 |
|---|---|---|---|---|---|
ubuntu:22.04 | ~77 MB | bash | apt | O | 개발/디버깅 |
debian:bookworm-slim | ~74 MB | bash | apt | O | 범용 앱 |
alpine:3.19 | ~7 MB | sh | apk | X (musl) | 경량화 필요 앱 |
distroless/static | ~2 MB | X | X | X | Go 정적 바이너리 |
distroless/base | ~20 MB | X | X | O | glibc 필요 앱 |
scratch | 0 MB | X | X | X | 완전 정적 바이너리 |
Alpine 사용 시 주의사항
Alpine은 glibc 대신 musl libc를 사용합니다. 대부분의 Go/Python 앱에서는 문제없지만, glibc에 의존하는 C 확장 모듈(예: 일부 numpy 의존성)은 Alpine에서 실행 시 오류가 날 수 있습니다.
# Python + Alpine: 컴파일이 필요한 패키지는 빌드 의존성 설치 필요
FROM python:3.11-alpine AS builder
RUN apk add --no-cache gcc musl-dev libffi-dev
RUN pip install --prefix=/install -r requirements.txt
FROM python:3.11-alpine
COPY --from=builder /install /usr/local
COPY app/ /app
CMD ["python", "/app/main.py"]
Node.js 앱에 적합한 멀티 스테이지 패턴
# -- 1단계: 의존성 설치 --
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# ci는 lock 파일을 엄격하게 따름 (install보다 빠르고 재현 가능)
RUN npm ci --only=production
# -- 2단계: 빌드 (TypeScript 컴파일 등) --
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# -- 3단계: 최종 런타임 이미지 --
FROM node:20-alpine AS runtime
WORKDIR /app
# 프로덕션 의존성만 복사
COPY --from=deps /app/node_modules ./node_modules
# 빌드 결과물만 복사
COPY --from=build /app/dist ./dist
COPY package.json ./
# non-root 사용자로 실행
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
이 패턴에서 최종 이미지는 devDependencies(TypeScript 컴파일러, ESLint 등)를 포함하지 않습니다.
.dockerignore로 빌드 컨텍스트 최적화
docker build .를 실행했는데 "Sending build context to Docker daemon 450MB"가 뜨고 빌드가 시작도 하기 전에 30초가 흐릅니다. node_modules 디렉토리가 빌드 컨텍스트에 포함됐기 때문입니다. Dockerfile 안에서 npm ci로 의존성을 재설치하는데도 호스트의 node_modules까지 전송하는 것은 순수한 낭비입니다. .env 파일이 빌드 컨텍스트에 포함되면 docker history로 읽힐 수 있어 보안 감사에서 문제가 됩니다. .dockerignore는 .gitignore와 비슷한 역할을 하지만 Docker 빌드 성능과 보안 두 가지를 동시에 잡습니다. 이 ConceptBlock에서는 효과적인 .dockerignore 작성 기준과 빌드 컨텍스트 크기를 측정하는 방법을 다룹니다.
확대
빌드 컨텍스트란?
docker build . 를 실행하면 . 경로의 모든 파일이 Docker 데몬으로 전송됩니다. 이것을 빌드 컨텍스트라고 합니다. 로컬 Docker는 빠르지만, CI 서버나 원격 BuildKit 데몬을 사용하는 경우 수백 MB의 컨텍스트 전송이 병목이 됩니다.
효과적인 .dockerignore 작성
# -- 버전 관리 --
.git
.gitignore
.gitattributes
# -- Node.js --
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# -- 빌드 아티팩트 --
dist
build
*.o
*.a
*.so
# -- 환경 설정 (보안 중요) --
.env
.env.*
*.pem
*.key
secrets/
# -- 테스트/개발 파일 --
**/*_test.go
**/*.test.js
**/*.spec.ts
coverage/
.nyc_output
# -- IDE/도구 --
.idea
.vscode
*.swp
.DS_Store
# -- Docker 파일 자체 (자기 참조 방지) --
Dockerfile*
docker-compose*.yml
.dockerignore
.dockerignore 효과 측정
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part4/exam_17 && cd /tmp/docker/part4/exam_17
# 빌드 전 컨텍스트 크기 확인 (실제 전송 크기 확인)
du -sh . --exclude=.git
# .dockerignore 적용 후 실제 전송 크기는 빌드 로그에서 확인
docker build . 2>&1 | grep "Sending build context"
# 예시 출력: Sending build context to Docker daemon 2.048kB
BuildKit 병렬 빌드와 --target 플래그
프론트엔드와 백엔드를 함께 빌드하는 Dockerfile이 있는데 전체 빌드가 8분 걸립니다. 두 빌드는 서로 완전히 독립적인데도 순서대로 실행되기 때문입니다. BuildKit을 사용하면 독립적인 스테이지를 자동으로 병렬 실행해 전체 빌드 시간을 절반으로 줄일 수 있습니다. CI에서 "테스트 통과한 것만 프로덕션 이미지로 빌드한다"는 파이프라인을 만들고 싶을 때는 --target 플래그로 원하는 스테이지까지만 빌드하는 것이 핵심입니다. 이 ConceptBlock에서는 BuildKit 병렬 빌드의 동작 원리와 --target을 활용한 CI 파이프라인 설계를 다룹니다.
확대
BuildKit 활성화
BuildKit은 Docker 23.0부터 기본 활성화되어 있습니다. 구버전 환경이라면 환경변수로 활성화합니다.
# 일회성 활성화
DOCKER_BUILDKIT=1 docker build .
# 영구 활성화 (Docker 데몬 설정)
# /etc/docker/daemon.json
{
"features": { "buildkit": true }
}
# docker buildx는 항상 BuildKit 사용
docker buildx build .
병렬 빌드 활용 예시
BuildKit은 서로 의존하지 않는 스테이지를 자동으로 병렬 실행합니다.
# frontend와 backend는 서로 독립 — BuildKit이 동시에 빌드
FROM node:20-alpine AS frontend-builder
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
FROM golang:1.22-alpine AS backend-builder
WORKDIR /backend
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ .
RUN CGO_ENABLED=0 go build -o api-server .
# 최종 이미지 — 두 스테이지의 결과를 조합
FROM nginx:alpine AS runtime
COPY --from=frontend-builder /frontend/dist /usr/share/nginx/html
COPY --from=backend-builder /backend/api-server /usr/local/bin/
COPY nginx.conf /etc/nginx/nginx.conf
--target으로 CI 단계별 빌드
# 테스트 스테이지까지만 빌드 (테스트 실행용)
docker build --target test -t myapp:test .
# 최종 프로덕션 이미지 빌드
docker build --target runtime -t myapp:prod .
# CI 파이프라인 예시 (GitHub Actions)
# - name: Run tests
# run: docker build --target test --tag app:test .
# && docker run --rm app:test npm test
기본 실습
실습 디렉토리를 만들고 간단한 Go HTTP 서버를 작성합니다.
실습 전 디렉토리와 예제 파일을 먼저 준비합니다.
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part3/exam_1 && cd /tmp/docker/part3/exam_1
# 멀티 스테이지 빌드 비교용 기본 Dockerfile 생성
cat > Dockerfile.single << 'EOF'
FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]
EOF
cat > Dockerfile.multi << 'EOF'
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .
FROM scratch
COPY --from=builder /app/server /server
CMD ["/server"]
EOF
이제 실습을 진행합니다.
mkdir -p ~/multistage-lab/go-app
cd ~/multistage-lab/go-app
# main.go 작성
cat > main.go << 'EOF'
package main
import (
"fmt"
"net/http"
"os"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello from Go! Running as UID: %d\n", os.Getuid())
})
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok")
})
fmt.Printf("Server listening on :%s\n", port)
http.ListenAndServe(":"+port, nil)
}
EOF
# go.mod 초기화
cat > go.mod << 'EOF'
module example.com/goapp
go 1.22
EOF
# 단일 스테이지 Dockerfile
cat > Dockerfile.single << 'EOF'
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]
EOF
# 빌드
docker build -f Dockerfile.single -t goapp:single .
# 이미지 크기 확인
docker images goapp:single
golang:1.22 베이스 이미지 때문에 약 850 MB~1 GB 크기의 이미지가 만들어지는 것을 확인합니다.
mkdir -p ~/multistage-lab/go-app && cd ~/multistage-lab/go-app이제 멀티 스테이지 Dockerfile을 작성하고 크기를 비교합니다.
cd ~/multistage-lab/go-app
cat > Dockerfile.multi << 'EOF'
# -- 빌드 스테이지 --
FROM golang:1.22-alpine AS builder
WORKDIR /app
# 의존성 먼저 복사 (go.sum이 없으면 go.mod만 복사)
COPY go.mod ./
RUN go mod download
COPY . .
# 정적 바이너리 빌드: CGO 비활성화, 디버그 정보 제거
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -trimpath -o server .
# -- 런타임 스테이지 --
FROM gcr.io/distroless/static-debian12
WORKDIR /
# builder 스테이지의 바이너리만 복사
COPY --from=builder /app/server /server
# non-root 실행 (distroless nonroot UID)
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/server"]
EOF
# 멀티 스테이지 이미지 빌드
docker build -f Dockerfile.multi -t goapp:multi .
# 두 이미지 크기 비교
echo "=== 이미지 크기 비교 ==="
docker images goapp:single goapp:multi
# 상세 레이어 정보
echo "=== 멀티 스테이지 이미지 레이어 ==="
docker history goapp:multi
실제 프로젝트에서 더 자주 쓰는 Node.js + TypeScript 패턴을 실습합니다.
mkdir -p ~/multistage-lab/node-app
cd ~/multistage-lab/node-app
# package.json
cat > package.json << 'EOF'
{
"name": "node-multistage",
"version": "1.0.0",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"express": "^4.18.2"
},
"devDependencies": {
"typescript": "^5.3.0",
"@types/express": "^4.17.21",
"@types/node": "^20.0.0"
}
}
EOF
# tsconfig.json
cat > tsconfig.json << 'EOF'
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true
}
}
EOF
mkdir -p src
cat > src/index.ts << 'EOF'
import express from 'express';
const app = express();
const port = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.json({ message: 'Hello from TypeScript!', uid: process.getuid?.() });
});
app.get('/health', (req, res) => res.status(200).send('ok'));
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
EOF
# .dockerignore
cat > .dockerignore << 'EOF'
node_modules
dist
.git
*.md
.env
EOF
cat > Dockerfile << 'EOF'
# -- 1단계: 프로덕션 의존성만 설치 --
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
# -- 2단계: 빌드 (devDependencies 포함) --
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY tsconfig.json .
COPY src/ ./src/
RUN npm run build
# -- 3단계: 최종 런타임 이미지 --
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json .
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
EOF
# 빌드 및 크기 확인
docker build -t nodeapp:multi .
docker images nodeapp:multi
# TypeScript 컴파일러가 최종 이미지에 없음을 확인
docker run --rm nodeapp:multi sh -c "ls node_modules/.bin/tsc 2>/dev/null || echo 'tsc not found — devDeps excluded'"
CI 파이프라인에서 테스트와 프로덕션 이미지를 분리하는 방법입니다.
cd ~/multistage-lab/node-app
# 빌드 스테이지까지만 빌드 (개발/디버그용)
docker build --target build -t nodeapp:build-stage .
# 빌드 스테이지 이미지에서 TypeScript 파일 확인 (개발 의존성 포함)
docker run --rm nodeapp:build-stage ls node_modules/.bin/tsc
echo "=== 전체 스테이지 이미지 크기 비교 ==="
docker images nodeapp
# BuildKit 캐시 활용 — 두 번째 빌드는 캐시 히트로 빠름
time docker build -t nodeapp:multi .
- 단일 스테이지 대비 멀티 스테이지 결과에서 이미지 크기 차이를 수치로 확인했는가?
- `COPY --from` 경로와 스테이지 이름을 정확히 맞춰 재현 가능한 빌드를 만들었는가?
- `--target`으로 중간 스테이지를 분리 실행해 디버깅/CI 시간을 줄였는가?
트러블슈팅
빌드 스테이지에서 생성한 파일 경로가 잘못 지정되었을 때 발생합니다.
원인 진단: 빌드 스테이지에서 실제로 생성된 파일 경로를 확인합니다.
# 빌드 스테이지에서 실제 파일 위치 확인
docker build --target builder -t debug-builder .
docker run --rm debug-builder find /app -name "server" -o -name "*.js" 2>/dev/null | head -20
해결 방법: COPY --from에서 실제 바이너리 경로를 맞춥니다.
# 잘못된 예시 — 바이너리 이름이 실제와 다름
COPY --from=builder /app/myserver /server # 파일이 없음
# 올바른 예시 — go build의 -o 옵션과 일치시킴
RUN go build -o server .
COPY --from=builder /app/server /server
빌드 스테이지의 WORKDIR과 빌드 출력 경로를 정확히 일치시켜야 합니다. -o 플래그로 출력 파일명을 명시적으로 지정하면 혼란을 줄일 수 있습니다.
node_modules나 .git 디렉토리가 빌드 컨텍스트에 포함되면 발생합니다.
원인 진단: 빌드 컨텍스트에 포함된 파일 목록을 먼저 확인합니다.
# 빌드 컨텍스트에 무엇이 포함되는지 확인
# .dockerignore 없이 빌드 컨텍스트 크기 측정
du -sh node_modules .git dist 2>/dev/null
# docker build의 첫 번째 출력 줄 확인
docker build . 2>&1 | head -3
해결 방법: .dockerignore로 불필요한 파일을 빌드 컨텍스트에서 제외합니다.
# .dockerignore 생성
cat >> .dockerignore << 'EOF'
node_modules
.git
dist
.next
.nuxt
coverage
*.log
EOF
# 컨텍스트 크기 재확인
docker build . 2>&1 | head -3
# 예상 출력: Sending build context to Docker daemon 12.29kB
.dockerignore를 추가하는 것만으로도 빌드 시간이 30초에서 2초로 단축되는 경우도 있습니다.
distroless/static 이미지에서 CGO가 활성화된 바이너리를 실행할 때 발생합니다.
원인 진단: 바이너리의 링킹 방식을 확인합니다. 동적 링킹이면 필요한 라이브러리가 없어 실패합니다.
# 바이너리가 동적 링킹인지 정적 링킹인지 확인
docker run --rm --entrypoint sh golang:1.22-alpine -c \
"cd /tmp && go build -o test main.go && ldd test"
# 정적이면: not a dynamic executable
# 동적이면: libpthread.so.0, libc.so.6 등 표시됨
해결 방법: CGO를 비활성화해서 정적 바이너리를 만들거나 필요한 라이브러리를 포함합니다.
# CGO 비활성화로 완전 정적 바이너리 생성
RUN CGO_ENABLED=0 GOOS=linux go build -o server .
# CGO가 필요하다면 distroless/static 대신 distroless/base 사용
# (glibc 포함)
FROM gcr.io/distroless/base-debian12
C 라이브러리에 의존하는 Go 패키지(SQLite 드라이버 등)는 CGO_ENABLED=0으로 빌드할 수 없습니다. 이 경우 pure Go 대안 라이브러리를 사용하거나 distroless/base를 선택합니다.
심화 — 캐시와 아키텍처, 이미지가 작아진 다음의 문제들
심화: BuildKit 캐시 마운트 — 레이어 캐시가 깨져도 살아남는 캐시
앞서 '의존성 파일을 먼저 COPY'하는 레이어 캐시를 배웠습니다. 하지만 이 캐시는 package.json 한 글자만 바뀌어도 통째로 무효화돼 npm ci가 수백 개 패키지를 처음부터 다시 내려받습니다. BuildKit의 캐시 마운트는 이 한계를 레이어 캐시와 다른 계층에서 넘습니다.
- 레이어 캐시 vs 캐시 마운트: 레이어 캐시는 'RUN 명령의 결과 파일시스템 전체'를 재사용할지 말지의 이진 판단입니다. 반면
RUN --mount=type=cache,target=/root/.npm은 패키지 매니저의 다운로드 캐시 디렉토리를 빌드 사이에 영속시키는 별도 볼륨입니다. 레이어가 무효화돼npm ci가 다시 실행돼도, 네트워크로 다시 받는 대신 마운트된 캐시에서 꺼내 씁니다. - 최종 이미지에 남지 않습니다: 캐시 마운트는 빌드 중에만 존재하고 이미지 레이어로 커밋되지 않습니다. 그래서
/root/.npm·/go/pkg/mod·~/.cache/pip같은 무거운 캐시를 최종 이미지 크기를 늘리지 않고 재사용할 수 있습니다. - CI에서 특히 큽니다: 러너가 매번 새로 뜨는 CI에선 로컬 레이어 캐시가 잘 맞지 않습니다. 캐시 마운트를 registry 캐시(
--cache-to/--cache-from)와 함께 쓰면, 러너가 바뀌어도 패키지 다운로드 캐시까지 복원됩니다.
# 레이어가 무효화돼도 캐시 마운트가 다운로드를 아낀다
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
# /root/.npm은 빌드 간 영속되는 캐시 마운트 — 이미지에는 안 남음
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
즉 '의존성 먼저 COPY'는 소스만 바뀔 때를 위한 것이고, 캐시 마운트는 의존성 정의가 바뀔 때조차 다운로드를 아끼는 한 단계 더 깊은 최적화입니다(BuildKit 필요).
상황: Apple Silicon(M1/M2) 맥북에서 docker build로 만든 이미지를 레지스트리에 올리고, amd64 클라우드 서버(EC2/ECS)에서 pull해 실행하면 exec format error로 컨테이너가 즉시 죽습니다. 로컬에선 멀쩡히 돌던 같은 이미지입니다.
원인: 앞의 no such file or directory(동적 링커 없음)와 달리, 이건 CPU 아키텍처 불일치입니다. Apple Silicon에서 빌드하면 기본으로 linux/arm64 바이너리가 만들어집니다. 이 이미지를 linux/amd64 서버에서 실행하면 커널이 ELF 헤더의 아키텍처가 달라 실행 자체를 거부합니다. 멀티 스테이지로 아무리 잘 경량화해도 타깃 아키텍처가 틀리면 실행이 안 됩니다.
진단: 이미지가 어떤 아키텍처로 빌드됐는지 확인합니다.
# 이미지의 실제 아키텍처 확인
docker image inspect myapp:latest --format '{{.Architecture}}/{{.Os}}'
# arm64/linux ← 서버(amd64)와 불일치
# 서버의 실행 아키텍처 확인
uname -m # x86_64 → amd64 이미지가 필요
해결: buildx로 타깃 플랫폼을 명시하거나 멀티 아키텍처 이미지를 만듭니다.
# 서버 아키텍처에 맞춰 빌드 (QEMU 에뮬레이션)
docker buildx build --platform linux/amd64 -t myapp:latest --push .
# 한 태그에 두 아키텍처를 담는 매니페스트 리스트
docker buildx build --platform linux/amd64,linux/arm64 -t myapp:latest --push .
--platform으로 여러 아키텍처를 빌드하면 QEMU 에뮬레이션 탓에 크로스 빌드가 느려질 수 있어, 실무에선 네이티브 러너(ARM 러너+amd64 러너)를 나눠 쓰거나 캐시를 적극 활용합니다.
실무 맥락
실제 스타트업 현장에서 흔히 발생하는 상황입니다. CI/CD 파이프라인에서 이미지 빌드 → ECR push → ECS 배포까지 전체 사이클이 10분을 넘어가면 개발자 생산성이 크게 떨어집니다.
대표적인 개선 포인트:
-
이미지 크기 감소 → pull 시간 단축: AWS ECS는 배포 시 새 태스크 인스턴스에서 이미지를 pull합니다. 1 GB 이미지가 50 MB로 줄면 pull 시간이 약 60초에서 3초로 줄어듭니다.
-
레이어 캐시 최적화: 의존성 레이어(go.mod, package.json)가 소스코드 레이어보다 앞에 오면, 소스만 변경된 경우 의존성 설치 레이어를 재사용합니다. 자주 변경되지 않는 것 → 자주 변경되는 순서로 Dockerfile을 배치합니다.
-
빌드 캐시 마운트 활용: GitHub Actions나 GitLab CI에서 BuildKit 인라인 캐시를 ECR에 저장하면 CI 서버가 바뀌어도 캐시를 재사용할 수 있습니다.
# CI에서 캐시를 레지스트리에 저장/로드
docker buildx build \
--cache-from type=registry,ref=123456789.dkr.ecr.ap-northeast-2.amazonaws.com/myapp:cache \
--cache-to type=registry,ref=123456789.dkr.ecr.ap-northeast-2.amazonaws.com/myapp:cache,mode=max \
-t 123456789.dkr.ecr.ap-northeast-2.amazonaws.com/myapp:$GIT_SHA \
--push .
한 팀에서 이 최적화를 적용한 후 전체 배포 사이클을 12분에서 3분 30초로 단축한 사례가 있습니다. 이미지 크기 감소, 레이어 캐시 활용, CI 캐시 저장 세 가지를 모두 적용한 결과입니다.
핵심 요약
| 개념 | 명령/설정 | 설명 |
|---|---|---|
| 스테이지 정의 | FROM image AS name | Dockerfile에 여러 FROM 사용, 이름으로 참조 |
| 스테이지 간 복사 | COPY --from=name /src /dst | 이전 스테이지 결과물만 선택적으로 가져옴 |
| 정적 바이너리 | CGO_ENABLED=0 go build -ldflags="-s -w" | distroless/static과 함께 사용 가능한 Go 바이너리 |
| 특정 스테이지 빌드 | docker build --target name . | CI에서 테스트/프로덕션 이미지 분리 |
| 빌드 컨텍스트 최적화 | .dockerignore 파일 | node_modules, .git 등 제외 → 빌드 속도 향상 |
| 병렬 빌드 | BuildKit 자동 처리 | 독립적인 스테이지는 동시 실행 |
| 의존성 캐시 | 패키지 파일 먼저 COPY | 소스 변경 시에도 의존성 설치 레이어 캐시 재사용 |
| distroless | gcr.io/distroless/static-debian12 | 셸 없는 최소 이미지, 보안 강화 |
명령어·단축키 빠른 참조
멀티 스테이지 빌드·경량화에서 실제로 쓴 빌드·검사 명령을 실전 옵션과 함께 모았습니다.
| 명령어/단축키 | 용도 | 자주 쓰는 예 |
|---|---|---|
docker build --target | 특정 스테이지까지만 빌드 | docker build --target build -t app:build . (테스트/디버그용) |
docker build -f | 여러 Dockerfile 중 지정 빌드 | docker build -f Dockerfile.multi -t goapp:multi . |
docker buildx build --platform | 타깃/멀티 아키텍처 빌드·푸시 | docker buildx build --platform linux/amd64,linux/arm64 -t app:1.0 --push . |
docker buildx version | BuildKit(buildx) 사용 가능 확인 | docker buildx version |
DOCKER_BUILDKIT=1 | 구버전에서 BuildKit 일회성 활성화 | DOCKER_BUILDKIT=1 docker build . |
RUN --mount=type=cache | 레이어 캐시 밖 다운로드 캐시 영속 | RUN --mount=type=cache,target=/root/.npm npm ci |
--cache-from / --cache-to | CI에서 레지스트리 캐시 재사용 | docker buildx build --cache-to type=registry,ref=<repo>:cache,mode=max --push . |
docker images | 단일 vs 멀티스테이지 크기 비교 | docker images goapp:single goapp:multi |
docker history | 최종 이미지 레이어 구성 확인 | docker history goapp:multi |
docker image inspect | 아키텍처 확인(exec format error 진단) | docker image inspect app:latest --format '{{.Architecture}}/{{.Os}}' |
docker run --rm | 최종 이미지에 devDeps 제외 검증 | docker run --rm nodeapp:multi sh -c "ls node_modules/.bin/tsc || echo excluded" |
관련 모듈로 더 깊이:
- 백엔드 애플리케이션의 컨테이너 이미지 빌드와 작성 요령 — 멀티 스테이지의 기반이 되는 FROM/COPY/RUN 명령과 레이어 캐시 원리
- 대규모 빌드 속도를 10배 끌어올리는 캐시 튜닝 가이드 —
--cache-from/--cache-to로 CI 빌드를 더 빠르게 만드는 캐시 전략 - 루트 권한 탈피 및 최소 권한 원칙을 적용한 이미지 보안 — distroless·최소 이미지로 공격 표면을 줄이는 보안 관점의 최적화
다음 모듈에서는 Nginx + WAS + DB를 Docker Compose로 묶어 프로덕션 수준의 멀티 서버 아키텍처를 배포하는 워크플로우를 다룹니다.