Skip to content
Published on

Docker BuildKit & イメージレイヤー完全ガイド 2025: LLB、Cache Mount、Multi-Stage、OCI、ビルド最適化の徹底解説

Authors

はじめに: docker build の進化

10 年前と今

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 .
...
  • 順次実行: 1 レイヤーずつ順番に。
  • ビルドコンテキスト転送: すべてのファイルを daemon へ。
  • キャッシュ無効化: 1 ファイル変更で全体再ビルド。
  • 毎回 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 spec。
  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 プロジェクト。主要な 2 つの仕様:

  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: それ自体もハッシュ。

利点:

  • 重複排除 (layer deduplication): 同じレイヤーは 1 度だけ保存。
  • 完全性: ハッシュで検証。
  • 不変性: 同じハッシュ = 同じ内容。

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: Alpine の全ファイル。
  • Layer 2: /app/app.js の 1 ファイル。
  • Layer 3: /usr/bin/node、ライブラリなど数百ファイル。
  • Layer 4: /app/start.sh のメタデータのみ。

Union Filesystem

Union FS (OverlayFS、AUFS、Btrfs) は複数のレイヤーを 1 つのビューに統合する:

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. 元は 変更されない

結果:

  • 同じイメージを複数のコンテナが 共有
  • 各コンテナは 自身の変更分のみ 保持。
  • 数百コンテナが同じイメージ → 1 度だけ保存

Layer Sharing

重要な利点: 複数のイメージが レイヤーを共有

Image A: ubuntu:22.04 + node + myapp
Image B: ubuntu:22.04 + node + other-app

Shared layers:
- ubuntu:22.04 (1 度だけ保存)
- node (1 度だけ保存)

Unique layers:
- myapp (A のみ)
- other-app (B のみ)

ディスク節約:

  • 10 イメージが同じ base を共有 → 1 倍の base + 10 倍のアプリレイヤー。
  • 数十 GB が数 GB に。

レイヤー数のトレードオフ

多いレイヤー:

  • ビルドキャッシュがきめ細かい → ビルドが速い。
  • Pull 時に並列ダウンロード可能。
  • ただし、各レイヤーごとにメタデータのオーバーヘッド。

少ないレイヤー:

  • 小さいイメージ。
  • Pull がより効率的な場合がある。
  • キャッシュの粒度が低い。

実践的推奨: 10 〜 20 レイヤー。少なすぎず、多すぎず。

レイヤーサイズの制限

技術的には 制限なし。ただし:

  • 単一レイヤー > 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 build の最適化: 必要な 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 の内部表現:

Dockerfile → Parser → LLB → Executor → Image

LLB は ビルドグラフ を表現する低レベル DSL。Dockerfile は frontend の 1 つ。他の frontend も可能:

  • 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 (Alpine + バイナリのみ)。

100 倍削減。ビルドツールは 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
# registry のイメージからもコピー可能

Stage の独立性

各 stage は 独立:

  • 異なる base image。
  • 異なるツール。
  • 異なる目的。

ビルドの観点: BuildKit は stage 間の依存を検出。必要なものだけをビルド。

よくあるパターン

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 は 1 度だけビルド され共有される。


6. Cache Strategies

Layer Cache (デフォルト)

Dockerfile の 順序がキャッシュに影響する:

FROM node:20
COPY . .                    # 全体コピー
RUN npm install             # 毎回再実行

問題: ソースコードの 1 行だけでも変わると 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 に保存 して他のマシンと共有:

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 をキャッシュとして

リモートの 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

問題: 再現性のなさ

同じ 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.jsonrequirements.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"]

結果: バイナリ 1 つのみ。数 MB

注意:

  • 静的リンク必須 (CGO_ENABLED=0)。
  • デバッグが困難 (shell なし)。
  • CA certificates が必要ならコピー。

Image Scanning

Trivy (Aqua Security):

trivy image myapp:latest
  • CVE スキャン。
  • Secret 検出。
  • 設定ファイル分析。

Grype (Anchore):

grype myapp:latest

SnykClairDocker 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. 実践的な最適化

レイヤー最小化

悪い例:

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

効果:

  • ビルドコンテキストのサイズ削減。
  • キャッシュの安定性向上。
  • セキュリティ向上。

小さい 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 なし

レイヤー順序の最適化

原則: 不変 → 頻繁に変更 の順序で。

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 イメージが 1 つの 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 . .  # 1 行変わると全体無効化
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: 間違った Stage の選択

FROM node:20 AS builder
# ... build stuff ...

FROM node:20-alpine
COPY --from=builder /app .  # alpine に glibc バイナリ
# 実行できない!

解決: base image のアーキテクチャを揃える。

落とし穴 5: プラットフォーム不一致

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 .
# 全ログを出力

中間コンテナの検査

Legacy builder では:

docker commit <intermediate_container_id> debug-image
docker run -it debug-image sh

BuildKit には中間コンテナがない。代わりに:

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

ベンチマーク

time docker build -t myapp .

Hyperfine などのツールで繰り返し測定。


クイズで復習

Q1. BuildKit が Legacy builder より速い 3 つの理由は?

A.

1. 並列実行 (Parallelism)

Legacy builder は Dockerfile を 順次 実行する:

FROM → RUN 1 → RUN 2 → RUN 3 → ...

各ステップは前を待つ。独立した作業も順次。

BuildKit は DAG ベース:

FROM ─┬─ RUN 1 (独立)
      └─ RUN 2 (独立)
          RUN 3 (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 . .        ← 1 行変わると
Step 2/5 : RUN npm install ← ここも再実行
Step 3/5 : RUN npm build   ← ここも

すべて再実行

BuildKit:

  • Cache mount により npm キャッシュを 永続維持
  • ファイル個別 の変更追跡。
  • ファイル単位のキャッシュハッシュ。
# 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 cache + 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  # すべての依存をインストール (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 + 全依存 + ビルド: 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 USD/GB。
  • 1000 pull/day x 1 GB = 90 USD/day。
  • 100 MB イメージ: 9 USD/day。
  • 1/10 のコスト

3. ストレージ:

  • Registry 保存。
  • Node のイメージキャッシュ。
  • 小さいほど節約。

4. セキュリティ:

  • 攻撃面の削減。
  • 不要なバイナリ = 潜在的 CVE。
  • Scratch/distroless = 最小の攻撃面。

5. 起動時間:

  • イメージ pull + 起動。
  • 小さいイメージ = 速い起動。
  • サーバーレス、スケールアウトで重要。

落とし穴と解決:

落とし穴 1: ライブラリ欠落

静的リンクされていないバイナリ:

./myapp: error while loading shared libraries: libcurl.so.4

解決:

  • 静的リンク (-staticCGO_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 形式が必須:

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 の最大の革新の 1 つ (2017 年導入)。単一の Dockerfile に 完全なビルドパイプライン を表現しつつ、最終イメージは最小 に保つ。

これは Docker の 根本的な設計欠陥 を回避する巧妙な方法。以前は「ビルドイメージ」と「ランタイムイメージ」を別々に管理する必要があった。複雑だった。

Multi-stage で:

  • 1 つの Dockerfile。
  • CI/CD の単純化。
  • 最適なイメージサイズ。
  • Reproducible。

今日 production Dockerfile の 99% が multi-stage。使わない理由が必要なほど。

あなたの Dockerfile が single-stage なら、multi-stage への移行 が最も簡単な最適化。ほぼ常にイメージが小さくなり、ほぼ常により安全になる。


おわりに: ビルドの進化

要点整理

  1. OCI spec: コンテナの標準。すべてのツールが遵守。
  2. Layers + UnionFS: 共有と効率。
  3. BuildKit: DAG ベース、並列、強力なキャッシュ。
  4. Multi-stage build: 小さなイメージの鍵。
  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 mount (npmpipcargo)。
  • Non-root user。
  • Secret を --mount=type=secret 経由で。
  • Digest pinning (@sha256:...)。
  • HEALTHCHECK の定義。
  • Label メタデータ。
  • Multi-platform ビルド。
  • Image scanning。
  • SBOM 生成。

最後の教訓

Docker は コンテナを普及させた。BuildKit は ビルドを再発明した。2 つの革新が出会い、モダン 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 倍速い CI10 倍小さいイメージ安全なシステム を作る。


参考資料