Skip to content
Published on

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

Authors

들어가며: 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 재사용.
  • 세밀한 캐시: 바뀐 파일만 무효화.
  • 수 초.

같은 Dockerfile10배 이상 빠름. 핵심 차이: 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 빌드 방식:

DockerfileDocker DaemonSequential 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의 실제 측정:

빌드LegacyBuildKit
Cold (캐시 없음)180s120s
Warm (파일 1개 변경)180s15s
Warm (독립 단계)60s5s

BuildKit의 세밀한 캐시병렬화가 만드는 차이.


4. BuildKit의 LLB

Low-Level Builder

LLB (Low-Level Builder) 는 BuildKit의 내부 표현:

DockerfileParserLLBExecutorImage

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 생성 가능:

import "github.com/moby/buildkit/client/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 같은 도구로 반복 측정.


퀴즈로 복습하기

Q1. BuildKit이 Legacy builder보다 빠른 세 가지 이유는?

A.

1. 병렬 실행 (Parallelism)

Legacy builder는 Dockerfile을 순차적으로 실행한다:

FROMRUN 1RUN 2RUN 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:

시나리오LegacyBuildKit
Cold build300s180s
Warm build (소스 변경)300s20s
Warm build (디펜던시 변경)300s60s
특정 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배 빠른 빌드를 얻을 수 있다. 코드 변경 없이.

Q2. Multi-stage build가 어떻게 이미지 크기를 100배 줄이는가?

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)

실전 권장:

언어권장 이미지크기
Goscratch~10 MB
Rustdebian-slim~80 MB
Javaeclipse-temurin-jre~250 MB
Node.jsnode:alpine~150 MB
Pythonpython: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배 작은 이미지, 안전한 시스템을 만든다.


참고 자료