Skip to content
Published on

Dockerのビルドキャッシュが何度も壊れる理由 — レイヤーキャッシュキー、ARG、BuildKitのキャッシュマウント

シェア
Authors

はじめに — コメントを1行直しただけでなぜnpm ciが再実行されるのか

CIパイプラインは最初90秒でした。今は8分です。ログを見ると、毎回同じ行で時間を使っています。

 => [builder 4/7] RUN npm ci                                            287.4s
 => [builder 5/7] RUN npm run build                                      41.2s

依存関係は3週間そのままなのに、毎回5分近くかけて再インストールします。ローカルでは2回目のビルドが4秒で終わるので、「キャッシュは効いているようではあるが」という曖昧な状態のまま放置されます。

原因はほぼ必ず2つのうちどちらかです。Dockerfileのコマンド順序が毎回キャッシュキーを変えているか、CIランナーにそもそもキャッシュが存在しないかです。2つの問題は解決策がまったく違うので、まずどちらなのかを見分けなければなりません。

キャッシュの無効化にはルールが1つしかない

Dockerのビルドキャッシュはチェーンです。各レイヤーのキャッシュキーは、親レイヤーのダイジェストと自分のコマンドから計算されます。そのため、親が変わると子は内容と無関係にすべて再実行されます

FROM node:22-bookworm-slim   # 1
WORKDIR /app                 # 2
COPY . .                     # 3  <- ソースが1文字変わるだけでここで壊れる
RUN npm ci                   # 4  <- だからここも壊れる
RUN npm run build            # 5  <- ここも

COPY . .が3番目にあるので、ソースファイルを1つ修正しただけで3番以降がすべて無効化されます。npm cipackage-lock.jsonがそのままかどうかと無関係に再実行されます。Dockerはコマンドの意味を知らず、その位置にあるレイヤーの親が変わったという事実しか把握していません。

このルールの系が最適化のすべてです。よく変わるものを下へ下ろし、めったに変わらないものを上へ上げます。

COPYのキャッシュキーはパスではなくファイル内容である

コマンドごとにキャッシュキーの計算方法が異なります。この違いを知らないと、見当違いの場所を直すことになります。

RUNのキャッシュキーはコマンド文字列そのものです。そのコマンドが何をするかは見ません。ですから以下の2行は、何か月経っても永遠にキャッシュにヒットします。

RUN apt-get update
RUN curl -fsSL https://get.example.com/install.sh | sh

リモートリポジトリのパッケージインデックスが更新されても、インストールスクリプトが変わっても、Dockerは知りません。これがapt-get updateapt-get installを必ず同じRUNにまとめるべき本当の理由です。サイズのためではなくキャッシュのためです。

COPYADDのキャッシュキーは、対象ファイル群の内容ハッシュです。ここでよくある誤解が1つあります。「ファイルを開いて保存しただけでもキャッシュが壊れる」という話は旧ビルダー時代のものです。クラシックビルダーはファイルのメタデータに更新時刻を含めていたので、touchひとつでキャッシュが飛びました。BuildKitは内容ハッシュとモード、所有権だけを見ます。

DOCKER_BUILDKIT=1 docker build -t t1 . >/dev/null
touch src/index.ts
DOCKER_BUILDKIT=1 docker build -t t1 . 2>&1 | grep -E 'CACHED|RUN'
 => CACHED [2/5] WORKDIR /app
 => CACHED [3/5] COPY package.json package-lock.json ./
 => CACHED [4/5] RUN npm ci
 => CACHED [5/5] COPY . .

touchだけでは壊れません。逆にCIでgit cloneをやり直すとすべてのファイルのmtimeが現在時刻になりますが、BuildKitではこれがキャッシュに影響しません。それでもキャッシュが壊れるなら、原因はmtimeではなく別の場所にあります。

依存レイヤーの分離 — 定石パターンとその罠

定石は、依存関係を宣言するファイルだけを先にコピーすることです。

FROM node:22-bookworm-slim
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

これでソースだけが変わったときはCOPY . .から無効化され、npm ciはキャッシュから出てきます。

言語ごとに対応するファイルの組み合わせはこうなります。

# Python
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

# Go
COPY go.mod go.sum ./
RUN go mod download

# Rust
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release

# Java (Maven)
COPY pom.xml ./
RUN mvn -B dependency:go-offline

ここに2つの罠があります。

第一に、モノレポでCOPY package.json ./だけをコピーすると、ワークスペース配下のパッケージのマニフェストが抜けてnpm ciが失敗します。ワイルドカードでディレクトリ構造を保ったままコピーする必要があります。

COPY package.json package-lock.json ./
COPY packages/api/package.json ./packages/api/
COPY packages/web/package.json ./packages/web/
COPY packages/shared/package.json ./packages/shared/
RUN npm ci

パッケージが増えるたびにこのリストを更新するのが面倒なら、BuildKit 1.7以上でCOPY --parentsが使えます。

# syntax=docker/dockerfile:1.7-labs
COPY --parents package.json package-lock.json packages/*/package.json ./
RUN npm ci

第二に、バージョンを固定していない依存関係の宣言は、キャッシュを再現性のない状態にします。requirements.txtrequestsとだけ書かれていれば、キャッシュが生きている間は半年前のバージョンが使われ続け、キャッシュが一度壊れた瞬間に最新バージョンへ跳びます。ロックファイルを使う理由はここにもあります。

ARG、そしてキャッシュを毎回壊すもの

ARGはビルダーによってキャッシュの挙動が変わるので混乱を招きます。クラシックビルダーではARGの宣言自体が以降すべてのレイヤーのキャッシュキーに入りました。BuildKitは違います。実際にその値を参照するコマンドだけが無効化されます

# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
WORKDIR /app

ARG GIT_SHA
ARG BUILD_TIME

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

LABEL org.opencontainers.image.revision=$GIT_SHA
LABEL org.opencontainers.image.created=$BUILD_TIME

GIT_SHAがコミットのたびに変わっても、npm ciはその値を参照しないのでキャッシュにヒットします。参照しているLABELの2行だけが再計算されます。この順序を逆にしてARGを参照するENVを上のほうに置くと、すべて崩れます。

# アンチパターン
ARG GIT_SHA
ENV APP_REVISION=$GIT_SHA   # ここから下はコミットのたびにキャッシュが全滅
COPY package.json ./
RUN npm ci

キャッシュを毎回壊す典型的な原因は次のとおりです。ビルド時刻やコミットハッシュを上部のENVに入れること、COPY . .を依存関係のインストールより上に置くこと、.dockerignoreがなく.gitディレクトリの変更がコンテキストのハッシュを変えること、そしてベースイメージをlatestにしておいてリモートのダイジェストが変わるたびに最上部が無効化されることです。

CIランナーにはキャッシュがない — キャッシュマウントとレジストリキャッシュ

Dockerfileを完璧に整理しても、CIでは相変わらず毎回npm ciが回ります。当然です。キャッシュはビルダーのローカルストレージにあるのに、GitHub Actionsのランナーは実行のたびに新しいVMです。ローカルにキャッシュがなければキャッシュヒットもありません。

解決手段は2つあり、互いを代替しません。

1つ目はキャッシュマウントです。パッケージマネージャのダウンロードキャッシュをレイヤーに焼き込まず、別のボリュームに置く方式です。

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

FROM python:3.12-slim AS py
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

FROM golang:1.23 AS go
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build ./...

ここで最も多く踏まれる罠をはっきりさせておく必要があります。キャッシュマウントはレジストリキャッシュへエクスポートされません--cache-to type=registrytype=ghaをどれだけうまく設定しても、RUN --mount=type=cacheの中身は含まれません。キャッシュマウントはビルダーインスタンスに紐づくローカルな状態だからです。毎回新しく起動するランナーでは何の効果もありません。

キャッシュマウントが実際に得になるのは開発者のローカルマシン、そしてBuildKitデーモンが生き続けるセルフホストランナーやリモートビルダーです。

# チームで共有するリモートビルダーに接続
docker buildx create --name shared --driver remote \
  tcp://buildkit.internal:1234 --use
docker buildx build -t api:dev --load .

2つ目はレジストリキャッシュです。レイヤーキャッシュをOCIアーティファクトとしてレジストリへ上げ、次のビルドでダウンロードします。こちらはランナーが毎回新しく起動しても動きます。

docker buildx build \
  --cache-from type=registry,ref=registry.example.com/api:buildcache \
  --cache-to   type=registry,ref=registry.example.com/api:buildcache,mode=max \
  --tag registry.example.com/api:v312 \
  --push .
 => importing cache manifest from registry.example.com/api:buildcache      1.4s
 => CACHED [deps 3/3] RUN npm ci                                           0.0s
 => [builder 5/5] RUN npm run build                                       38.7s
 => exporting cache to registry.example.com/api:buildcache                 6.2s

mode=maxが肝心です。既定値のmode=minは最終ステージのレイヤーしかエクスポートしないので、マルチステージビルドでは肝心の高価なビルダーステージがキャッシュされません。マルチステージを使っているのにキャッシュが効かないという報告のほとんどがこれです。

GitHub Actionsならtype=ghaが便利です。

- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/org/api:${{ github.sha }}
    cache-from: type=gha,scope=api-${{ github.ref_name }}
    cache-to: type=gha,scope=api-${{ github.ref_name }},mode=max

scopeはブランチごとに分けますが、GitHubのキャッシュはリポジトリあたり10GBの上限があり、古い項目から追い出されます。スコープをコミット単位まで細かく刻むと互いを押し出して、ヒット率はかえって下がります。

方式保存場所mode=max新しいランナーで有効主な用途
ローカルレイヤーキャッシュビルダーのディスク該当なしいいえ開発者のマシンCIでは無用
type=inlineイメージ自体非対応はい単一ステージマルチステージのキャッシュが欠落
type=registryレジストリ対応はい汎用CIストレージ費用、整理が必要
type=ghaActionsキャッシュ対応はいGitHub Actionsリポジトリあたり10GB上限
type=localディスクのパス対応条件付きセルフホスト無限に増加、手動で整理
RUN --mount=cacheビルダーローカルエクスポート不可いいえ永続ビルダーランナーが新しく起動すると無効

マルチアーキテクチャビルドでは、キャッシュがプラットフォームごとに分離される点を知っておく必要があります。linux/amd64linux/arm64は別々のキャッシュチェーンを持つので、1つのキャッシュrefへmode=maxでまとめてエクスポートするほうが管理の面で楽です。

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --cache-from type=registry,ref=registry.example.com/api:buildcache \
  --cache-to   type=registry,ref=registry.example.com/api:buildcache,mode=max \
  --tag registry.example.com/api:v312 --push .

QEMUエミュレーションでarm64をビルドすると、ネイティブに比べて5倍から20倍まで遅くなります。キャッシュを調整するよりも、アーキテクチャごとのネイティブランナーに分けてビルドし、最後にマニフェストだけを統合するほうがたいてい速いです。

docker buildx imagetools create \
  --tag registry.example.com/api:v312 \
  registry.example.com/api:v312-amd64 \
  registry.example.com/api:v312-arm64

再現性とキャッシュは互いを押しのける

キャッシュを最大化しようとする圧力と、再現可能なビルドを求める圧力は方向が逆です。この緊張を認めたうえで境界を決める必要があります。

FROM node:22は楽ですが、リモートでダイジェストが更新された瞬間にすべてのレイヤーが無効化されます。そして昨日ビルドしたイメージと今日ビルドしたイメージが違うベースを使うことになります。ダイジェストで固定すれば、2つの問題が同時に消えます。

FROM node:22-bookworm-slim@sha256:9f3c1a5a4d1b7e2c8f0a6b9d3e5c7a1b4d8f2e6c0a9b3d5e7f1c4a8b2d6e0f31

更新は人ではなくツールに任せます。

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: docker
    directory: /
    schedule:
      interval: weekly

apt-get updateも同じ問題の別の顔です。同じRUNにまとめれば再現性は良くなりますが、installのリストが変わるたびにインデックスを取り直します。インデックスのダウンロードをキャッシュマウントへ移せば、両方を得られます。

# 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 \
    rm -f /etc/apt/apt.conf.d/docker-clean \
 && apt-get update \
 && apt-get install -y --no-install-recommends libpq5 ca-certificates

rm -f /etc/apt/apt.conf.d/docker-cleanが必要な理由は、公式のDebianイメージがインストール直後にキャッシュを自動削除するよう設定されているからです。その設定を消さないと、キャッシュマウントは毎回空になります。

ビルド結果をビット単位で再現する必要があるなら、BuildKit 0.13以上でタイムスタンプを固定できます。

SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) \
docker buildx build \
  --output type=registry,name=registry.example.com/api:v312,rewrite-timestamp=true .

おわりに — キャッシュが効かないならまずどこで壊れたかを見る

キャッシュの問題は推測では直りません。--progress=plainでビルドログを取り出してCACHEDが消える最初の地点を探し、その1行のキャッシュキーが何かを確認するところから始めます。

docker buildx build --progress=plain . 2>&1 | grep -E '^#[0-9]+ (CACHED|\[)'
#7 [deps 2/3] COPY package.json package-lock.json ./
#7 CACHED
#8 [deps 3/3] RUN npm ci
#8 CACHED
#9 [builder 4/6] COPY . .
#10 [builder 5/6] RUN npm run build

CACHEDが付いていない最初の段階が#9なので、その上は触る必要がなく、その下は手の打ちようがありません。直す場所は常にその1行です。

覚えておくことは1つです。キャッシュはチェーンなので、上のほうで一度壊れるだけで下はすべて無意味になります。だからDockerfileは変更頻度の昇順で書き、CIではレジストリキャッシュをmode=maxで付け、キャッシュマウントはビルダーが生きている環境でだけ期待すべきです。