Skip to content

필사 모드: Docker BuildKit & 이미지 레이어 완전 가이드 2025: LLB, Cache Mount, Multi-Stage, OCI, 빌드 최적화 심층 분석

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며: `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 빌드:

작성 글자: 0원문 글자: 24,561작성 단락: 0/965