필사 모드: Docker BuildKit & 이미지 레이어 완전 가이드 2025: LLB, Cache Mount, Multi-Stage, OCI, 빌드 최적화 심층 분석
한국어들어가며: `docker build`의 진화
10년 전 vs 지금
**2015년**의 Docker 빌드:
$ docker build -t myapp .
Sending build context to Docker daemon 45.2MB
Step 1/12 : FROM node:14
Step 2/12 : WORKDIR /app
Step 3/12 : COPY package.json .
...
- **순차 실행**: 한 레이어씩 차례로.
- **빌드 컨텍스트 전송**: 모든 파일을 daemon으로.
- **캐시 깨짐**: 파일 하나 바뀌면 나머지 전체 재빌드.
- **매번 5-10분**.
**2025년**의 동일한 빌드 (BuildKit):
$ DOCKER_BUILDKIT=1 docker build -t myapp .
[+] Building 12.3s (15/15) FINISHED
=> [internal] load build definition 0.0s
=> [internal] load metadata 0.3s
=> [build 1/4] FROM node:14 0.0s (cached)
=> [build 2/4] COPY package.json . 0.1s
=> [build 3/4] RUN --mount=type=cache... npm install 8.2s
...
- **병렬 실행**: 독립 단계 동시.
- **Cache mount**: npm/maven/pip 재사용.
- **세밀한 캐시**: 바뀐 파일만 무효화.
- **수 초**.
**같은 Dockerfile**에 **10배 이상 빠름**. 핵심 차이: **BuildKit**.
이 글에서 다룰 것
1. **Docker 이미지 구조**: OCI 스펙.
2. **레이어와 UnionFS**: 컨테이너의 기반.
3. **Legacy builder vs BuildKit**: 무엇이 바뀌었나.
4. **LLB (Low-Level Builder)**: BuildKit의 DSL.
5. **Multi-stage builds**: 작고 안전하게.
6. **Cache strategies**: Layer, mount, registry.
7. **Reproducible builds**.
8. **보안: distroless, Scanning, SBOM**.
9. **실전 최적화 기법**.
1. OCI 이미지 포맷
Open Container Initiative
**OCI (Open Container Initiative)** 는 컨테이너 표준을 정의하는 Linux Foundation 프로젝트. 두 가지 주요 명세:
1. **Runtime Spec**: 컨테이너 실행 방법.
2. **Image Spec**: 이미지 포맷.
Docker, Podman, containerd, CRI-O 모두 OCI 준수. 상호 호환 가능.
OCI 이미지의 구조
OCI 이미지는 **몇 가지 파일의 모음**:
manifest.json # 이미지 메타데이터
config.json # 컨테이너 설정
layers/ # 파일시스템 레이어
├── blob1.tar.gz
├── blob2.tar.gz
└── blob3.tar.gz
Manifest
**Image manifest**: 이미지의 모든 구성 요소 목록.
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:abc123...",
"size": 1234
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:def456...",
"size": 54321
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:ghi789...",
"size": 123456
}
]
}
각 엔트리는 **content-addressable**: SHA-256 digest로 식별.
Image Config
{
"architecture": "amd64",
"os": "linux",
"config": {
"Env": ["PATH=/usr/local/sbin:..."],
"Cmd": ["node", "server.js"],
"WorkingDir": "/app",
"ExposedPorts": {
"3000/tcp": {}
}
},
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:aaa...",
"sha256:bbb..."
]
},
"history": [
{
"created": "2025-01-01T00:00:00Z",
"created_by": "/bin/sh -c #(nop) ADD file:... in /"
}
]
}
- **rootfs.diff_ids**: 레이어들의 **압축 해제된** 해시. Manifest의 digest와 다름.
- **history**: 각 레이어의 생성 이력.
Content Addressable Storage
**모든 blob은 해시로 식별**:
- Layer 파일: `sha256:def456...`
- Config: `sha256:abc123...`
- Manifest: 자체도 해시.
**이점**:
- **중복 제거**: 같은 레이어는 한 번만 저장.
- **무결성**: 해시로 검증.
- **불변성**: 같은 해시 = 같은 내용.
Image Index (Multi-arch)
여러 플랫폼용 이미지:
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:amd64...",
"platform": {
"architecture": "amd64",
"os": "linux"
}
},
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:arm64...",
"platform": {
"architecture": "arm64",
"os": "linux"
}
}
]
}
**docker pull** 시 호스트 플랫폼에 맞는 manifest 선택.
2. 레이어와 UnionFS
레이어의 본질
Docker 이미지의 **각 레이어는 tar 아카이브**다. 파일시스템의 **변경사항 (delta)** 을 담는다.
**예시**:
FROM alpine:3.19 # Layer 1: 기본 Alpine 파일시스템
COPY app.js /app/ # Layer 2: /app/app.js 추가
RUN apk add nodejs # Layer 3: nodejs 패키지 + 의존성
RUN chmod +x /app/start.sh # Layer 4: 파일 권한 변경
각 레이어는 **이전 상태에서의 변경**만:
- Layer 1: 알파인 전체 파일.
- Layer 2: `/app/app.js` 파일 하나.
- Layer 3: `/usr/bin/node`, 라이브러리 등 수백 개 파일.
- Layer 4: `/app/start.sh` 파일의 메타데이터만.
Union Filesystem
**Union FS** (OverlayFS, AUFS, Btrfs)는 여러 레이어를 **하나의 뷰로 합친다**:
merged view: /app/app.js, /usr/bin/node, ...
↑
OverlayFS
/ | \
Layer 1 Layer 2 Layer 3 (read-only)
\
container writable layer (read-write)
**작동**:
- **읽기**: 위 레이어부터 검색. 찾으면 반환.
- **쓰기**: 최상위 writable 레이어에 저장 (copy-on-write).
- **삭제**: Whiteout 파일로 표시.
Copy-on-Write
파일 수정 시:
1. **원본 레이어에서 파일 읽기**.
2. **Writable 레이어로 복사**.
3. **복사본 수정**.
4. 원본은 **변경 없음**.
**결과**:
- 같은 이미지를 여러 컨테이너가 **공유**.
- 각 컨테이너는 **자기 변경분**만 유지.
- 수 백 컨테이너가 같은 이미지 → **한 번만 저장**.
Layer Sharing
**중요한 이점**: 여러 이미지가 **레이어 공유**.
Image A: ubuntu:22.04 + node + myapp
Image B: ubuntu:22.04 + node + other-app
Shared layers:
- ubuntu:22.04 (한 번만 저장)
- node (한 번만 저장)
Unique layers:
- myapp (A 전용)
- other-app (B 전용)
**디스크 절약**:
- 10개 이미지가 같은 base 공유 → 1배의 base + 10배의 앱 레이어.
- 수십 GB가 수 GB로.
Layer 수의 트레이드오프
**많은 레이어**:
- 빌드 캐시 세밀 → 빌드 빠름.
- Pull 시 병렬 다운로드 가능.
- 단, 각 레이어마다 메타데이터 오버헤드.
**적은 레이어**:
- 작은 이미지.
- Pull이 더 효율적일 수 있음.
- 캐시 세밀도 낮음.
**실전 권장**: 10~20 레이어. 너무 적거나 너무 많지 않게.
Layer 크기 제한
기술적으로 **제한 없음**. 하지만:
- **단일 레이어 > 10 GB**: 문제 발생 가능.
- **전체 이미지 > 5 GB**: pull 시간 문제.
- **파일 수 > 100만**: 성능 저하.
실전에선 **수 GB 이하**, **수만 파일**이 적정.
3. Legacy Builder vs BuildKit
Legacy Docker Builder
2017년 이전의 Docker 빌드 방식:
Dockerfile → Docker Daemon → Sequential steps
**문제점**:
1. **Sequential만**: 독립 단계도 순차 실행.
2. **Client-server 아키텍처**: 파일을 daemon으로 모두 전송.
3. **모놀리식**: 빌드 그래프 없음.
4. **제한적 캐시**: layer 단위만.
5. **확장 어려움**.
BuildKit의 등장
2017년 Docker 18.06에서 공개. 현재 Docker Desktop 기본. Containerd, Podman, Kaniko 등 여러 도구에서 사용.
**핵심 혁신**:
1. **Build Graph**: 빌드를 DAG로 표현.
2. **병렬 실행**: 독립 단계 동시 빌드.
3. **Cache mount**: 빌드 도중 사용하는 캐시 디렉토리.
4. **Secret mount**: 빌드 도중 비밀 사용 (이미지에 남지 않음).
5. **Multi-stage 최적화**: 필요한 stage만 빌드.
6. **원격 캐시**: Registry를 캐시로.
7. **멀티 플랫폼**: 한 번에 여러 arch.
BuildKit 활성화
**Docker 23+**: 기본 활성.
**명시적 활성화** (이전 버전):
export DOCKER_BUILDKIT=1
docker build .
**설정 파일**:
// ~/.docker/config.json
{
"features": {
"buildkit": true
}
}
성능 차이
동일 Dockerfile의 실제 측정:
| 빌드 | Legacy | BuildKit |
|---|---|---|
| **Cold (캐시 없음)** | 180s | 120s |
| **Warm (파일 1개 변경)** | 180s | 15s |
| **Warm (독립 단계)** | 60s | 5s |
BuildKit의 **세밀한 캐시**와 **병렬화**가 만드는 차이.
4. BuildKit의 LLB
Low-Level Builder
**LLB (Low-Level Builder)** 는 BuildKit의 내부 표현:
Dockerfile → Parser → LLB → Executor → Image
LLB는 **빌드 그래프**를 표현하는 저수준 DSL. Dockerfile은 프론트엔드 중 하나. 다른 프론트엔드도 가능:
- **Dockerfile frontend**: 기본.
- **Buildpack frontend**: Cloud Native Buildpacks.
- **커스텀 frontend**: 자체 DSL 가능.
DAG 기반 빌드
BuildKit이 Dockerfile을 **DAG**로 변환:
FROM node:18 AS base
RUN apt-get update && apt-get install -y python3
FROM base AS deps
COPY package.json .
RUN npm install
FROM base AS build
COPY . .
RUN npm run build
FROM nginx:alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html
**DAG 표현**:
node:18 (FROM)
│
▼
┌─── base ───┐
│ │
▼ ▼
deps build
│ │
▼ ▼
[unused] nginx:alpine (FROM)
│
▼
runtime
**주목**: `deps` stage는 **최종 이미지에 없음**. BuildKit이 감지해 **빌드 안 함**. Legacy builder는 무조건 빌드.
Parallelism
독립적인 stage는 **병렬 실행**:
FROM alpine AS a
RUN sleep 10
FROM alpine AS b
RUN sleep 10
FROM alpine
COPY --from=a /tmp /a
COPY --from=b /tmp /b
- Legacy: 20초 (10 + 10).
- BuildKit: **10초** (병렬).
LLB 직접 사용
Go 코드로 LLB 생성 가능:
st := llb.Image("alpine:3.19").
Run(llb.Shlex("apk add --no-cache curl")).
Root()
def, err := st.Marshal(ctx)
// BuildKit에 보내기
고급 사용 사례: Terraform, Bazel 같은 도구가 LLB를 백엔드로 사용.
5. Multi-Stage Builds
왜 Multi-Stage인가
**문제**: 빌드 도구가 이미지에 남음.
나쁜 예
FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]
결과 이미지: **~1 GB** (Go 컴파일러, 소스, 의존성 모두).
**해결**: Multi-stage build.
Build stage
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp
Runtime stage
FROM alpine:3.19
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]
결과 이미지: **~10 MB** (알파인 + 바이너리만).
**100배 감소**. Build 도구는 builder stage에만 있고, 최종 이미지에는 없다.
COPY --from
다른 stage나 **이미지**에서 파일 복사:
FROM alpine AS tools
RUN apk add --no-cache curl jq
FROM scratch
COPY --from=tools /usr/bin/curl /usr/bin/curl
COPY --from=tools /usr/bin/jq /usr/bin/jq
필요한 바이너리만
FROM scratch
COPY --from=python:3.11 /usr/local/bin/python3 /usr/local/bin/python3
이미지 레지스트리의 이미지에서도 가능
Stage의 독립성
각 stage는 **독립적**:
- 다른 base image.
- 다른 도구.
- 다른 목적.
**빌드 관점**: BuildKit은 stage 간 dependency를 감지. 필요한 것만 빌드.
흔한 패턴
**1. 컴파일 언어 (Go, Rust, C)**:
FROM rust:1.75 AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim
COPY --from=builder /app/target/release/myapp /usr/local/bin/
CMD ["myapp"]
**2. Node.js**:
FROM node:20 AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:20 AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
**3. Python**:
FROM python:3.11 AS builder
COPY requirements.txt .
RUN pip install --target=/deps -r requirements.txt
FROM python:3.11-slim
COPY --from=builder /deps /usr/local/lib/python3.11/site-packages
COPY . /app
WORKDIR /app
CMD ["python", "main.py"]
**4. Java**:
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests
FROM eclipse-temurin:21-jre
COPY --from=builder /app/target/*.jar /app.jar
CMD ["java", "-jar", "/app.jar"]
Stage 재사용
같은 stage를 여러 곳에서 참조:
FROM alpine AS common
RUN apk add --no-cache ca-certificates
FROM common AS app1
...
FROM common AS app2
...
`common` stage는 **한 번만 빌드**되어 공유.
6. Cache Strategies
Layer Cache (기본)
Dockerfile의 **순서가 캐시에 영향**:
FROM node:20
COPY . . # 전체 복사
RUN npm install # 매번 재실행
**문제**: 소스 코드 한 줄만 바뀌어도 `COPY .`가 캐시 무효화, 이후 `npm install`까지 다시 실행.
**올바른 순서**:
FROM node:20
COPY package*.json ./ # 의존성 파일만
RUN npm install # 의존성 설치
COPY . . # 나머지 (캐시와 무관)
**원칙**: **자주 바뀌지 않는 것**을 위로, **자주 바뀌는 것**을 아래로.
.dockerignore
빌드 컨텍스트에서 **제외할 파일**:
node_modules
.git
*.log
.env
dist
coverage
**효과**:
- 빌드 컨텍스트 크기 감소.
- `COPY .`의 캐시 안정성.
- 비밀 정보 유출 방지.
Cache Mount (BuildKit 필수 기능)
**Cache mount**는 **빌드 중에만** 사용되는 캐시 디렉토리. 레이어에 **포함되지 않음**.
syntax=docker/dockerfile:1.6
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm install
COPY . .
RUN npm run build
**작동**:
- `/root/.npm`은 **영속 캐시**. 빌드 간 유지.
- npm이 이미 다운로드한 패키지 재사용.
- **이미지에는 포함 안 됨**.
**성능 차이**:
- 첫 빌드: 60초.
- 다음 빌드 (캐시 활용): **5초**.
언어별 Cache Mount 예시
**Node.js (npm)**:
RUN --mount=type=cache,target=/root/.npm \
npm ci
**Node.js (pnpm)**:
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
**Python (pip)**:
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
**Go**:
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go build -o /app ./...
**Rust (cargo)**:
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release
**Java (Maven)**:
RUN --mount=type=cache,target=/root/.m2 \
mvn package
Registry 캐시
빌드 캐시를 **registry에 저장**해서 다른 machine과 공유:
docker buildx build \
--cache-to type=registry,ref=myregistry.com/myapp:cache \
--cache-from type=registry,ref=myregistry.com/myapp:cache \
-t myapp:latest .
**CI/CD에서 유용**:
- 매 CI 머신에서 캐시 공유.
- 빌드 시간 대폭 단축.
Inline 캐시
이미지 자체에 캐시 메타데이터 포함:
docker buildx build \
--cache-to type=inline \
--push \
-t myregistry.com/myapp:latest .
Pull한 이미지를 바로 캐시로 사용.
Git URL as Cache
원격 Git 리포지토리를 캐시로:
syntax=docker/dockerfile:1.6
FROM alpine
RUN --mount=type=bind,from=myorg/myrepo,target=/src \
...
7. Secret Management
문제: 빌드 도중 비밀
나쁨: 이미지에 토큰 남음
ARG GITHUB_TOKEN
RUN git clone https://$GITHUB_TOKEN@github.com/private/repo.git
결과:
- `docker history` 에 토큰 노출.
- 이미지 레이어에 저장됨.
- 영원히 남음.
Secret Mount
BuildKit의 **secret mount**:
syntax=docker/dockerfile:1.6
FROM alpine
RUN --mount=type=secret,id=github_token \
TOKEN=$(cat /run/secrets/github_token) && \
git clone https://$TOKEN@github.com/private/repo.git
**빌드 시**:
echo $GITHUB_TOKEN | docker buildx build \
--secret id=github_token,src=/dev/stdin \
-t myapp .
**효과**:
- 비밀이 **빌드 중에만** 사용.
- **이미지에 남지 않음**.
- 레이어에도 없음.
SSH Mount
SSH agent socket을 빌드로 전달:
syntax=docker/dockerfile:1.6
FROM alpine
RUN apk add --no-cache openssh-client git
RUN --mount=type=ssh \
git clone git@github.com:private/repo.git
**빌드 시**:
docker buildx build --ssh default -t myapp .
SSH key가 이미지에 **전혀 포함되지 않음**.
8. Reproducible Builds
문제: Non-Reproducibility
같은 Dockerfile, 같은 소스 → **다른 이미지**?
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y curl
**이유**:
- `apt-get update` 시점의 apt 저장소 상태.
- 시간에 따라 패키지 버전 변경.
- 로컬 환경 차이 (네트워크, 캐시).
**결과**: 어제 빌드한 이미지와 오늘 빌드한 이미지가 **다름**. 보안 감사, 디버깅에 문제.
Reproducible Build의 조건
**완전 재현 가능**:
1. **고정 base image**: `FROM ubuntu:22.04@sha256:...` (digest 고정).
2. **고정 패키지 버전**: `apt install curl=7.81.0-1ubuntu1.15`.
3. **Lockfile 사용**: `package-lock.json`, `requirements.txt` (version pinning).
4. **Deterministic 빌드**: 시간 독립적.
5. **Clean environment**: 외부 의존성 제거.
Digest Pinning
나쁨: 태그는 이동 가능
FROM node:20
좋음: digest는 불변
FROM node:20@sha256:8f0c5a7a1d0c7b8f...
**Digest 확인**:
docker pull node:20
docker images --digests | grep node
Package Pinning
**apt (Debian/Ubuntu)**:
RUN apt-get update && apt-get install -y \
curl=7.81.0-1ubuntu1.15 \
&& rm -rf /var/lib/apt/lists/*
**pip**:
requests==2.31.0
flask==3.0.0
**npm**:
{
"dependencies": {
"express": "4.18.2"
}
}
+ `package-lock.json` 커밋.
SOURCE_DATE_EPOCH
Go, Rust 등의 컴파일러는 **빌드 시간을 바이너리에 기록**. `SOURCE_DATE_EPOCH`으로 고정:
ARG SOURCE_DATE_EPOCH=0
FROM golang:1.21
ENV SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH
RUN go build -ldflags="-buildid=" -trimpath .
검증
여러 번 빌드
docker build -t myapp:1 .
docker build -t myapp:2 .
이미지 비교
docker inspect myapp:1 | jq .RootFS.Layers
docker inspect myapp:2 | jq .RootFS.Layers
같으면 reproducible
**실전**: 100% 재현 가능한 빌드는 드물다. **거의** 재현 가능하면 충분.
9. Image Security
Distroless Images
Google의 **distroless** 프로젝트: 필수 런타임만 포함한 미니멀 이미지.
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp
FROM gcr.io/distroless/base-debian12
COPY --from=builder /app/myapp /
CMD ["/myapp"]
**특징**:
- **shell 없음**: `/bin/sh` 없음.
- **패키지 매니저 없음**: apt, apk 없음.
- **최소 크기**: 수십 MB.
- **공격 표면 최소화**.
Scratch 이미지
**Scratch**: 완전히 **비어있는** 이미지.
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o myapp
FROM scratch
COPY --from=builder /app/myapp /
CMD ["/myapp"]
**결과**: 바이너리 하나만. **수 MB**.
**주의**:
- 정적 링크 필수 (`CGO_ENABLED=0`).
- 디버깅 어려움 (shell 없음).
- CA certificates 필요하면 복사.
Image Scanning
**Trivy** (Aqua Security):
trivy image myapp:latest
- CVE 스캔.
- Secret 검출.
- 설정 파일 분석.
**Grype** (Anchore):
grype myapp:latest
**Snyk**, **Clair**, **Docker Scout** 등 여러 도구.
SBOM (Software Bill of Materials)
이미지의 **모든 소프트웨어 목록**:
**생성**:
syft myapp:latest -o spdx-json > sbom.json
**내용**:
- 모든 패키지와 버전.
- 라이선스 정보.
- 의존성 트리.
**활용**:
- Supply chain 보안.
- License compliance.
- CVE 매칭.
Image Signing
**cosign**으로 이미지 서명:
서명
cosign sign --key cosign.key myregistry.com/myapp:latest
검증
cosign verify --key cosign.pub myregistry.com/myapp:latest
**Keyless signing** (OIDC):
cosign sign myregistry.com/myapp:latest
GitHub Actions OIDC로 자동 서명
Sigstore 프로젝트의 일환.
10. 실전 최적화
Layer 최소화
**나쁜 예**:
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y jq
RUN rm -rf /var/lib/apt/lists/*
4개 레이어. 중간 상태가 레이어에 남음.
**좋은 예**:
RUN apt-get update && \
apt-get install -y \
curl \
jq && \
rm -rf /var/lib/apt/lists/*
1개 레이어. 작은 이미지.
임시 파일 제거
**같은 레이어에서** 설치 + 정리:
RUN apt-get update && \
apt-get install -y build-essential && \
make && \
apt-get purge -y build-essential && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
.dockerignore 활용
빌드에 불필요
node_modules
.git
.github
*.log
*.md
보안 위험
.env
.env.local
**/secrets/
대용량 임시 파일
dist
build
coverage
.next
**효과**:
- 빌드 컨텍스트 크기 감소.
- Cache stability 향상.
- 보안 향상.
작은 Base Image
나쁨: 큰 base
FROM node:20 # ~1.1 GB
좋음: slim
FROM node:20-slim # ~240 MB
더 좋음: alpine
FROM node:20-alpine # ~180 MB
최고: distroless
FROM gcr.io/distroless/nodejs20-debian12 # ~180 MB, shell 없음
Layer 순서 최적화
**원칙**: **불변 → 자주 변경** 순으로.
FROM node:20-alpine
1. 시스템 의존성 (거의 안 변함)
RUN apk add --no-cache libc6-compat
2. 패키지 매니저 설정 (거의 안 변함)
WORKDIR /app
COPY package.json package-lock.json ./
3. 의존성 설치 (package.json 변경 시만)
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production
4. 애플리케이션 코드 (자주 변경)
COPY . .
5. 빌드 (코드 변경 시만)
RUN npm run build
CMD ["node", "dist/server.js"]
Multi-Platform Build
**buildx**로 여러 플랫폼 한 번에:
docker buildx create --use
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t myregistry.com/myapp:latest \
--push .
**결과**: AMD64와 ARM64 이미지가 **하나의 manifest list**로 push.
BuildKit + CI/CD
**GitHub Actions 예시**:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myregistry.com/myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
**`type=gha`**: GitHub Actions cache를 사용. CI 간 캐시 공유.
11. 흔한 함정
함정 1: Cache Invalidation
FROM node:20
WORKDIR /app
COPY . . # 한 줄 바뀌면 전체 무효화
RUN npm install
**해결**: 의존성 파일 먼저.
함정 2: 거대한 빌드 컨텍스트
$ docker build .
Sending build context: 500 MB # 이상함
**원인**: `.dockerignore` 없이 `node_modules`, `.git` 포함.
**해결**: `.dockerignore` 추가.
함정 3: 비밀 유출
ARG AWS_SECRET_KEY
ENV AWS_SECRET_KEY=$AWS_SECRET_KEY
이미지에 영원히 남음
**해결**: `--mount=type=secret`.
함정 4: Wrong Stage 선택
FROM node:20 AS builder
... build stuff ...
FROM node:20-alpine
COPY --from=builder /app . # alpine에 glibc 바이너리
실행 안 됨!
**해결**: base image architecture 맞추기.
함정 5: Platform Mismatch
Apple Silicon에서 빌드:
docker build -t myapp .
arm64로 빌드
docker push myregistry.com/myapp
서버에서 실행 시 "exec format error"
**해결**: `--platform linux/amd64` 명시.
함정 6: Root User
FROM node:20
기본 root로 실행
CMD ["node", "server.js"]
**문제**: 보안 위험.
**해결**:
RUN useradd -u 1000 appuser
USER appuser
함정 7: 불필요한 파일
COPY . . # 모든 파일
**해결**: 필요한 파일만 복사.
COPY src ./src
COPY package.json tsconfig.json ./
12. 디버깅
Build 로그 자세히
docker buildx build --progress=plain .
전체 로그 출력
Intermediate Container 검사
**Legacy builder**에선:
docker commit <intermediate_container_id> debug-image
docker run -it debug-image sh
**BuildKit**은 intermediate container가 없음. 대신:
RUN ...
실패 시 직전 상태를 이미지로
또는 `--target` 으로 특정 stage까지만:
docker build --target builder -t myapp:debug .
docker run -it myapp:debug sh
이미지 분석
**dive**: 레이어별 파일시스템 탐색.
dive myapp:latest
- 각 레이어 내용.
- 낭비되는 공간.
- 크기 통계.
**docker history**:
docker history myapp:latest
각 레이어의 명령어와 크기.
**skopeo**: 이미지 inspect.
skopeo inspect docker://nginx:latest
Benchmark
time docker build -t myapp .
**Hyperfine** 같은 도구로 반복 측정.
퀴즈로 복습하기
**A.**
**1. 병렬 실행 (Parallelism)**
Legacy builder는 Dockerfile을 **순차적**으로 실행한다:
FROM → RUN 1 → RUN 2 → RUN 3 → ...
각 단계가 이전을 기다린다. 독립적인 작업도 순차.
BuildKit은 **DAG 기반**:
FROM ─┬─ RUN 1 (독립)
└─ RUN 2 (독립)
↓
RUN 3 (depends on 1, 2)
독립 단계는 **동시 실행**. Multi-stage build에서 극명:
FROM alpine AS a
RUN sleep 10
FROM alpine AS b
RUN sleep 10
FROM alpine
COPY --from=a /x /
COPY --from=b /y /
- Legacy: **20초** (10 + 10).
- BuildKit: **10초** (병렬).
실전에선 여러 stage, 복잡한 빌드에서 **2~5배 향상**.
**2. 세밀한 Cache Invalidation**
Legacy builder의 캐시:
Step 1/5 : COPY . . ← 한 줄 바뀌면
Step 2/5 : RUN npm install ← 여기도 재실행
Step 3/5 : RUN npm build ← 여기도
**전부 재실행**.
BuildKit:
- **Cache mount**로 npm 캐시 **영속 유지**.
- **개별 파일** 변경 추적.
- 파일 단위 캐시 hash.
BuildKit with cache mount
RUN --mount=type=cache,target=/root/.npm \
npm ci
결과:
- 첫 빌드: 60초.
- 다음 빌드: **5초** (npm이 캐시에서).
**3. 불필요한 작업 스킵**
Multi-stage build에서 BuildKit은 **실제로 필요한 stage만** 빌드:
FROM node:20 AS deps
RUN npm install
FROM node:20 AS build
COPY --from=deps /app/node_modules .
RUN npm run build
FROM node:20 AS test
COPY --from=deps /app/node_modules .
RUN npm test ← 기본 빌드에선 불필요
FROM node:20-slim
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]
**Legacy**: `test` stage도 빌드.
**BuildKit**: 최종 이미지가 `build`만 참조 → `test` **스킵**.
`--target test`로 명시해서 test만 실행할 수도 있음.
**성능 비교 예시**:
10개 stage가 있는 복잡한 Dockerfile:
| 시나리오 | Legacy | BuildKit |
|---|---|---|
| Cold build | 300s | 180s |
| Warm build (소스 변경) | 300s | 20s |
| Warm build (디펜던시 변경) | 300s | 60s |
| 특정 stage만 | 불가 | 10s |
**기타 추가 이점**:
**4. Remote Cache**: Registry를 캐시로:
docker buildx build \
--cache-to type=registry,ref=myapp:cache \
--cache-from type=registry,ref=myapp:cache
CI 머신 간 캐시 공유.
**5. Secret Mount**: 빌드 도중만 비밀 사용, 이미지에 없음.
**6. Multi-platform**: 한 번에 여러 아키텍처.
**교훈**:
BuildKit은 **단순한 업그레이드가 아닌 근본적 재설계**다. Dockerfile을 "명령어 목록"이 아닌 **"DAG"** 로 보는 관점의 변화가 이 모든 최적화를 가능하게 했다.
오늘날 (2025년) Docker Desktop은 BuildKit이 기본이고, `docker build` 명령도 BuildKit을 사용한다. Legacy builder는 **옛날 이야기**다.
**실전 권장**:
- **모든 CI/CD**: BuildKit 필수.
- **로컬 개발**: 기본으로 활성화 (최신 Docker).
- **캐시 관리**: Registry 캐시 + gha cache.
- **Dockerfile 최적화**: BuildKit 기능 활용 (`# syntax=docker/dockerfile:1.6`).
**구식 Dockerfile을 BuildKit 스타일로 마이그레이션**하는 것만으로 **3~10배 빠른 빌드**를 얻을 수 있다. 코드 변경 없이.
**A.**
**기본 아이디어**: "**빌드 도구와 런타임을 분리**".
**문제 시나리오**: Go 앱 빌드
Go 앱은 컴파일 필요. Docker로 빌드하려면 Go 컴파일러 포함 이미지 필요:
**Multi-stage 없이**:
FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]
**결과**: **~1.1 GB** 이미지.
왜 큰가:
- Go 컴파일러 + 표준 라이브러리: ~600 MB.
- 빌드 캐시 (`/go/pkg`): ~200 MB.
- 소스 코드 + 의존성: ~100 MB.
- OS (Debian base): ~200 MB.
- **실행에 필요한 건 바이너리 1개뿐** (~10 MB).
**99%가 낭비**.
**Multi-stage 해결**:
Stage 1: 빌드
FROM golang:1.21 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o myapp
Stage 2: 런타임
FROM alpine:3.19
COPY --from=builder /app/myapp /usr/local/bin/
CMD ["myapp"]
**결과**: **~17 MB** 이미지.
- Alpine base: 5 MB.
- Go 바이너리: 10 MB.
- CA certificates, tzdata 등: 2 MB.
**65배 감소**. 일부 경우엔 **100배**까지 가능.
**어떻게 작동**:
Multi-stage build의 핵심:
1. **각 stage는 독립적**: 다른 base image 사용 가능.
2. **`COPY --from=stage`**: 이전 stage에서 **선택적으로 복사**.
3. **최종 stage가 결과 이미지**: 이전 stage들은 **이미지에 포함 안 됨**.
**내부적으로**:
BuildKit은 Dockerfile을 **DAG**로 변환:
builder (FROM golang:1.21)
│
│ RUN, COPY, ...
│
└→ /app/myapp (artifact)
│
│ COPY --from=builder
↓
runtime (FROM alpine:3.19)
│
└→ 최종 이미지 (배포)
**builder stage는 intermediate**. 최종 이미지에 **포함 안 됨**. 빌드 후 `docker images`에 보이지 않음.
**더 극단적 예: scratch**
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o myapp
FROM scratch
COPY --from=builder /app/myapp /
CMD ["/myapp"]
**결과**: **~10 MB** (바이너리만).
- `scratch`: 완전히 **비어있는** 이미지. 0 바이트.
- 필요한 건 바이너리뿐.
- Shell 없음, 라이브러리 없음.
**제약**:
- 정적 링크 필수 (`CGO_ENABLED=0`).
- Dynamic 의존성 없어야.
- 디버깅 어려움.
**실전 예시: Rust**
Stage 1: Cargo 의존성 캐시
FROM rust:1.75 AS chef
WORKDIR /app
RUN cargo install cargo-chef
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release
Stage 2: 최소 런타임
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/myapp /usr/local/bin/
CMD ["myapp"]
**결과**: 80 MB vs 1.5 GB (full Rust image).
**Node.js 복잡한 예**:
Stage 1: dev dependencies + 빌드
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci # 모든 deps 설치 (dev 포함)
COPY . .
RUN npm run build # TypeScript 컴파일 등
Stage 2: production dependencies만
FROM node:20 AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
Stage 3: 최소 런타임
FROM node:20-slim
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package*.json ./
CMD ["node", "dist/server.js"]
**결과**:
- `node:20`: 1.1 GB
- `node:20` + 전체 deps + 빌드: 1.5 GB
- 이 구성: **350 MB**
**4배 감소**. 더 최적화하면 alpine으로 150 MB까지.
**이점 정리**:
**1. Pull 속도**:
- 1 GB 이미지 pull: 30-60초.
- 100 MB 이미지 pull: 3-5초.
- CI/CD 속도, 배포 속도에 큰 영향.
**2. 네트워크 비용**:
- AWS egress: $0.09/GB.
- 1000 pull/day × 1 GB = $90/day.
- 100 MB 이미지: $9/day.
- **1/10 비용**.
**3. 스토리지**:
- Registry 저장.
- Node의 이미지 캐시.
- 작을수록 절약.
**4. 보안**:
- 공격 표면 감소.
- 불필요한 바이너리 = 잠재적 CVE.
- Scratch/distroless = 최소 공격 표면.
**5. 시작 시간**:
- 이미지 pull + 시작.
- 작은 이미지 = 빠른 시작.
- 서버리스, 스케일 아웃에서 중요.
**함정과 해결**:
**함정 1: 라이브러리 누락**
정적 링크 안 된 바이너리:
./myapp: error while loading shared libraries: libcurl.so.4
**해결**:
- 정적 링크 (`-static`, `CGO_ENABLED=0`).
- 또는 필요한 라이브러리 복사.
**함정 2: CA Certificates**
HTTPS 요청 시:
x509: certificate signed by unknown authority
**해결**:
FROM alpine:3.19
RUN apk add --no-cache ca-certificates
또는:
FROM scratch
COPY --from=builder /etc/ssl/certs /etc/ssl/certs
COPY --from=builder /app/myapp /
**함정 3: Timezone**
FROM scratch
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
ENV TZ=UTC
**함정 4: DNS**
일부 언어는 `/etc/nsswitch.conf` 필요:
COPY --from=builder /etc/nsswitch.conf /etc/nsswitch.conf
**함정 5: Shell 없음**
Scratch는 shell 없음. `exec` form 필수:
CMD ["/myapp"] # exec form (OK)
아래는 안 됨
CMD /myapp # shell form (NO)
**실전 권장**:
| 언어 | 권장 이미지 | 크기 |
|---|---|---|
| Go | scratch | ~10 MB |
| Rust | debian-slim | ~80 MB |
| Java | eclipse-temurin-jre | ~250 MB |
| Node.js | node:alpine | ~150 MB |
| Python | python:slim | ~120 MB |
| C/C++ | alpine | ~5 MB |
**교훈**:
Multi-stage build는 **Docker 2.0의 가장 큰 혁신** 중 하나다 (2017년 도입). 단일 Dockerfile에 **완전한 빌드 파이프라인**을 표현하면서도 **최종 이미지는 최소**로 유지.
이는 Docker의 **근본적 설계 결함**을 우회하는 영리한 방법이다. 이전에는 "빌드 이미지"와 "런타임 이미지"를 별도로 관리해야 했다. 복잡했다.
Multi-stage로:
- 하나의 Dockerfile.
- CI/CD 단순화.
- 최적 이미지 크기.
- Reproducible.
오늘날 **production Dockerfile 99%가 multi-stage**다. 안 쓰면 이유가 필요할 정도.
당신의 Dockerfile이 single-stage라면, **multi-stage로 전환**이 가장 쉬운 최적화다. 거의 항상 이미지가 작아지고, 거의 항상 더 안전해진다.
마치며: 빌드의 진화
핵심 정리
1. **OCI spec**: 컨테이너의 표준. 모든 tool이 준수.
2. **Layers + UnionFS**: 공유와 효율.
3. **BuildKit**: DAG 기반, 병렬, 캐시 강력.
4. **Multi-stage**: 작은 이미지의 열쇠.
5. **Cache mount**: 빌드 시간 획기적 단축.
6. **Secret mount**: 안전한 비밀 관리.
7. **Distroless/Scratch**: 최소 공격 표면.
8. **Reproducible builds**: 감사, 신뢰.
실전 체크리스트
**Production Dockerfile**:
- [ ] Multi-stage build.
- [ ] Slim/alpine/distroless base.
- [ ] `.dockerignore` 설정.
- [ ] Dependency 파일 먼저 COPY.
- [ ] Cache mounts (`npm`, `pip`, `cargo`).
- [ ] Non-root user.
- [ ] Secrets via `--mount=type=secret`.
- [ ] Digest pinning (`@sha256:...`).
- [ ] HEALTHCHECK 정의.
- [ ] Labels 메타데이터.
- [ ] Multi-platform 빌드.
- [ ] Image scanning.
- [ ] SBOM 생성.
마지막 교훈
Docker는 **컨테이너를 대중화**했다. BuildKit은 **빌드를 재창조**했다. 두 혁신이 만나 현대 CI/CD의 기반이 되었다.
오늘날 다음이 당연하다:
- **분 단위 빌드 → 초 단위**.
- **GB 이미지 → MB 이미지**.
- **불명확한 빌드 → Reproducible**.
- **비밀 유출 → Secret mount**.
하지만 이 모든 것은 **Dockerfile을 제대로 쓸 때만** 달성된다. 잘못 쓴 Dockerfile은 여전히 느리고, 크고, 불안전하다.
이 글의 지식은 **모든 엔지니어가 알아야 할 것**이다. 백엔드, 프론트엔드, DevOps, SRE 모두. 당신이 만드는 이미지는 **프로덕션에서 수천 번 pull되고, 수만 번 실행**된다. 작고 빠르고 안전한 이미지가 **전체 시스템의 효율**을 결정한다.
당신의 다음 Dockerfile을 작성할 때, 이 글의 원칙을 기억하자:
- **Multi-stage**.
- **Cache mount**.
- **Minimal base**.
- **Order matters**.
- **Don't leak secrets**.
- **Think reproducibility**.
이 습관이 **5배 빠른 CI**, **10배 작은 이미지**, **안전한 시스템**을 만든다.
참고 자료
- [BuildKit GitHub](https://github.com/moby/buildkit)
- [Docker Build Documentation](https://docs.docker.com/build/)
- [OCI Image Specification](https://github.com/opencontainers/image-spec)
- [OCI Runtime Specification](https://github.com/opencontainers/runtime-spec)
- [Dockerfile Best Practices](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/)
- [BuildKit LLB Documentation](https://github.com/moby/buildkit/blob/master/docs/dev/solver.md)
- [Distroless Container Images](https://github.com/GoogleContainerTools/distroless)
- [Trivy Scanner](https://github.com/aquasecurity/trivy)
- [Cosign (Sigstore)](https://github.com/sigstore/cosign)
- [Cargo Chef for Rust](https://github.com/LukeMathWalker/cargo-chef)
- [dive: Image Explorer](https://github.com/wagoodman/dive)
현재 단락 (1/965)
**2015년**의 Docker 빌드: