Docker 中級 #2 ビルドキャッシュ — BuildKit とレイヤー順序の最適化

読了 8分

基礎 #6 で押さえた「依存 → コード」の順序がキャッシュの最初のボタンだとすれば、この記事はその上に BuildKit 時代のビルドキャッシュ道具 を本格的に重ねます。

Docker 中級 シリーズでこの記事の位置:

BuildKit がデフォルト #

Docker のビルドエンジンが二世代に分かれているという事実から。旧ビルダー (legacy builder) と BuildKit。機能差が大きいです。

LegacyBuildKit
並列ビルドしないする
--mount=type=cache
--mount=type=secret
COPY --link
マルチプラットフォーム難しい自然
キャッシュインポート / エクスポート

最近の Docker バージョン (20.10+) では BuildKit がデフォルト なので別途設定なしに上の機能を使えます。強制的に有効にしたいなら:

BuildKit を明示
DOCKER_BUILDKIT=1 docker build -t myapp .

または buildx コマンドで:

buildx ビルダー
docker buildx build -t myapp .

buildx は BuildKit の上で動く拡張 CLI です。単一ビルドは docker build と同じで、マルチプラットフォーム / キャッシュエクスポートのような高度な機能は buildx の方が自然です。

この記事の全ての例は BuildKit / buildx が有効な状態を仮定します。

レイヤーキャッシュ — もう一度深く #

基礎で依存コピーをコードコピーより上に置けというルールを見ました。これを一段解きほぐすと:

キャッシュキー決定
このレイヤーのキャッシュキー = 前のレイヤーダイジェスト
                          + 命令自体 (変数含む)
                          + (COPY/ADD の場合) コピーするファイル内容のハッシュ

三つのうち どれか一つでも変わればキャッシュ無効化 です。これを頭に入れておくとキャッシュがなぜよく壊れるか追跡しやすくなります。

見落としやすいキャッシュ無効化の箇所 #

ARG の位置 — よく出会う落とし穴
FROM python:3.14-slim
ARG GIT_SHA            # この位置の ARG は以降の全レイヤーキャッシュキーに含まれる
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
ARG BUILD_TIME         # この位置の ARG は下のレイヤーからだけ影響
LABEL build-time=$BUILD_TIME

ARG は宣言された位置 次のレイヤーから キャッシュキーに入ります。毎ビルドで値が変わる BUILD_TIME のような引数を上に置くと、全レイヤーが毎回再ビルドされます。よく変わる ARG は 最後に 置いてください。

ENV も同じ
ENV APP_VERSION=1.2.3   # この位置以降の全レイヤーがこの値をキャッシュキーに含む

ENV も同じです。よく変わる環境変数は上に置かないでください。

--mount=type=cache — ビルド間でキャッシュ共有 #

ここからが BuildKit の最大の贈り物。レイヤーキャッシュが壊れたときも パッケージマネージャのダウンロードキャッシュは生かせます。

npm #

npm cache 共有
# syntax=docker/dockerfile:1.7
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY . .

--mount=type=cache,target=/root/.npm の意味:

  • この RUN コマンドが実行されている間だけ /root/.npm の位置に ビルド間で永続的なキャッシュディレクトリ をマウント
  • 同じホストで次のビルドを回すとき同じキャッシュをまたマウント
  • キャッシュ自体は イメージに刻まれない (イメージサイズが増えない)

package-lock.json だけが変われば RUN 自体は再実行されますが、npm が見るダウンロードキャッシュ (~/.npm/_cacache) が生きているのでネットワークを経由せず素早く終わります。

最初の行の # syntax=docker/dockerfile:1.7 は BuildKit にこの Dockerfile の文法バージョンを明示します。新機能 (特に --mount) を安全に使うための慣用一行です。

pip #

pip cache 共有
# syntax=docker/dockerfile:1.7
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt
COPY . .

pip も同じパターン。ただし — 以前 --no-cache-dir を付けてキャッシュを作らないようにしていた慣用 とは反対の流れです。mount cache を使うなら --no-cache-dir は外します。

apt #

apt cache 共有
# syntax=docker/dockerfile:1.7
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
    apt-get update && \
    apt-get install -y --no-install-recommends curl

apt の二つのキャッシュ位置を一緒にマウントします。sharing=locked は同時にビルドされる別ステージが同じキャッシュに触れないようロックをかけます。apt のように同時アクセスに弱い道具に必要です。

apt キャッシュを mount cache にすると、基礎で見た rm -rf /var/lib/apt/lists/* がもう必要ありません。キャッシュがイメージの外にあるからです。

Go module キャッシュ #

Go module + build キャッシュ
# syntax=docker/dockerfile:1.7
FROM golang:1.23 AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -o myapp

Go は二つのキャッシュ (/go/pkg/mod — モジュールキャッシュ、/root/.cache/go-build — コンパイルキャッシュ) を両方マウントすると再ビルドがとても速くなります。

COPY --link — ビルド並列化 #

デフォルト COPY は前のレイヤーの上に新しいファイルを乗せる方式なので、前のレイヤーが完成した後で 実行されます。--link を付けると親レイヤーから独立して作れるので BuildKit が並列化する余地ができます。

COPY --link
FROM node:20-slim AS deps
WORKDIR /app
COPY --link package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM node:20-slim
WORKDIR /app
COPY --link --from=deps /app/node_modules ./node_modules
COPY --link . .

体感差が一番大きいのはマルチステージ + 大きい --from=other コピーです。使わない理由がほぼないので新しく書くなら --link をデフォルトにする方が良いです。

--mount=type=secret — 秘密をイメージに刻まない #

ビルド時にだけ必要な秘密 (例: プライベートパッケージレジストリのトークン) をイメージに固めずに使う道具。

secret mount
# syntax=docker/dockerfile:1.7
FROM python:3.14-slim
WORKDIR /app
RUN --mount=type=secret,id=pypi,target=/root/.pypirc \
    pip install --extra-index-url https://... my-private-pkg

ビルドコマンドでシークレットファイルを渡す:

シークレットファイルと一緒にビルド
docker build --secret id=pypi,src=$HOME/.pypirc -t myapp .

/root/.pypirc はこの RUN の間だけ存在し、イメージのどのレイヤーにも残りません。 ARGENV で秘密を渡す古いパターンは docker history で露出するので、秘密は常にこの方法で。

RUN --mount=type=ssh — プライベート Git リポジトリ #

go get / pip install git+ssh://... のようにビルド中 SSH でプライベートリポジトリに届かなければならないとき。

SSH agent フォワーディング
# syntax=docker/dockerfile:1.7
FROM python:3.14
RUN --mount=type=ssh \
    pip install git+ssh://git@github.com/myorg/private-repo.git
ssh agent と一緒にビルド
docker build --ssh default -t myapp .

イメージに SSH 鍵が刻まれず、ホストの ssh-agent をビルド時間だけ少し借りる流れです。

外部キャッシュ — --cache-from / --cache-to #

ここまでのキャッシュは ビルドを回す同じホスト の上でだけ動作します。CI マシンは毎回新しく立ち上がったり、複数のランナーが分散しているので役に立ちません。外部キャッシュ がその穴を埋めます。

レジストリキャッシュ #

ビルドキャッシュ自体をイメージのようにレジストリにアップロードする方式。

レジストリキャッシュ
docker buildx build \
  --cache-to=type=registry,ref=ghcr.io/curtis/myapp:cache,mode=max \
  --cache-from=type=registry,ref=ghcr.io/curtis/myapp:cache \
  -t ghcr.io/curtis/myapp:1.0 \
  --push .
  • --cache-to — このビルドのキャッシュをどこに出すか
  • --cache-from — このビルド開始前にどこからキャッシュを受け取るか
  • mode=max — 全レイヤーキャッシュ (デフォルト min は結果レイヤーだけ)

CI が毎回新しいマシンで動いても、キャッシュがレジストリに生きているので速いビルドを維持します。

GitHub Actions キャッシュ #

GHA の上で動くビルドは GHA のキャッシュバックエンドを直接使えます。

.github/workflows/build.yml (要約)
- uses: docker/build-push-action@v5
  with:
    push: true
    tags: ghcr.io/curtis/myapp:${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

GHA キャッシュは GitHub が無料で提供し (リポジトリあたり 10GB ほど)、関連ワークフローの中だけで素早くアクセスされます。外部レジストリへの push なしにキャッシュ共有ができるので最も手軽なオプションです。

inline キャッシュ #

イメージ自体にキャッシュメタデータを埋め込む最も軽い方式。

inline キャッシュ
docker buildx build \
  --cache-to=type=inline \
  --cache-from=ghcr.io/curtis/myapp:latest \
  -t ghcr.io/curtis/myapp:latest \
  --push .

別途のキャッシュストレージが要りません。--cache-from で取ってくるイメージ自体がキャッシュ情報を持っています。ただし mode=min だけ可能なのでマルチステージの全中間レイヤーまではキャッシュできません。単純な単一ステージ / 小さなプロジェクトに適しています。

キャッシュ方針一表 #

状況推奨キャッシュ
ローカル開発mount cache (type=cache)
単一 CI マシン、頻繁ビルドmount cache + レイヤーキャッシュ
分散 CI (GitHub Actions)type=gha
分散 CI (セルフホスト)type=registry
単純プロジェクト、外部ストレージを増やしたくないtype=inline

--no-cache とキャッシュ強制更新 #

キャッシュが古い結果を握っているとき:

キャッシュ無視
docker build --no-cache -t myapp .
docker build --no-cache-filter=builder -t myapp .   # 特定ステージだけ

--no-cache-filter は BuildKit の機能で、どのステージのキャッシュだけ無視するか を選べます。依存はキャッシュを使いつつビルドは最初から回したいときに有効です。

キャッシュが効かない典型的な理由 #

  • syntax= 行がない — 古い文法で解釈されて mount が無視される
  • ARG / ENV が上の方にある — 毎ビルドキーが変わる
  • COPY . . が上の方にある — コードを一行変えるだけで全レイヤー無効化
  • CI マシンが毎回新しく立ち上がる — 外部キャッシュ (type=ghatype=registry) なしには生かせない
  • BuildKit が有効になっていない — Docker Desktop はデフォルトだが古い CI 環境では明示が必要
  • --no-cache がどこかに刻まれている — Makefile / CI スクリプトを確認

まとめ #

この記事で掴んだ絵:

  • BuildKit がデフォルトビルダー。# syntax=docker/dockerfile:1.7 一行で新機能を有効化
  • レイヤーキャッシュキー = 前のレイヤー + 命令 + コピーファイル内容。よく変わる ARG/ENV/COPY は下に
  • --mount=type=cache で npm/pip/apt/Go キャッシュをビルド間共有 — レイヤーキャッシュが壊れてもパッケージダウンロードは速い
  • COPY --link で BuildKit の並列化を活用 — 新しく書くならデフォルトに
  • --mount=type=secret/ssh で秘密と SSH 鍵をイメージに刻まずビルド
  • 分散 CI では 外部キャッシュtype=ghatype=registrytype=inline の中から環境に合わせて選択

次の記事 (#3 docker-compose 基礎 — web + db) では一つのコンテナからもう一歩進んで、複数コンテナを一つのファイルで定義 する道具に入ります。今シリーズの半分は Compose が占めます。

X