- はじめに — コメントを1行直しただけでなぜnpm ciが再実行されるのか
- キャッシュの無効化にはルールが1つしかない
- COPYのキャッシュキーはパスではなくファイル内容である
- 依存レイヤーの分離 — 定石パターンとその罠
- ARG、そしてキャッシュを毎回壊すもの
- CIランナーにはキャッシュがない — キャッシュマウントとレジストリキャッシュ
- 再現性とキャッシュは互いを押しのける
- おわりに — キャッシュが効かないならまずどこで壊れたかを見る
はじめに — コメントを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 ciはpackage-lock.jsonがそのままかどうかと無関係に再実行されます。Dockerはコマンドの意味を知らず、その位置にあるレイヤーの親が変わったという事実しか把握していません。
このルールの系が最適化のすべてです。よく変わるものを下へ下ろし、めったに変わらないものを上へ上げます。
COPYのキャッシュキーはパスではなくファイル内容である
コマンドごとにキャッシュキーの計算方法が異なります。この違いを知らないと、見当違いの場所を直すことになります。
RUNのキャッシュキーはコマンド文字列そのものです。そのコマンドが何をするかは見ません。ですから以下の2行は、何か月経っても永遠にキャッシュにヒットします。
RUN apt-get update
RUN curl -fsSL https://get.example.com/install.sh | sh
リモートリポジトリのパッケージインデックスが更新されても、インストールスクリプトが変わっても、Dockerは知りません。これがapt-get updateとapt-get installを必ず同じRUNにまとめるべき本当の理由です。サイズのためではなくキャッシュのためです。
COPYとADDのキャッシュキーは、対象ファイル群の内容ハッシュです。ここでよくある誤解が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.txtにrequestsとだけ書かれていれば、キャッシュが生きている間は半年前のバージョンが使われ続け、キャッシュが一度壊れた瞬間に最新バージョンへ跳びます。ロックファイルを使う理由はここにもあります。
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 \
npm ci
FROM python:3.12-slim AS py
RUN \
pip install -r requirements.txt
FROM golang:1.23 AS go
RUN \
go build ./...
ここで最も多く踏まれる罠をはっきりさせておく必要があります。キャッシュマウントはレジストリキャッシュへエクスポートされません。--cache-to type=registryやtype=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=gha | Actionsキャッシュ | 対応 | はい | GitHub Actions | リポジトリあたり10GB上限 |
type=local | ディスクのパス | 対応 | 条件付き | セルフホスト | 無限に増加、手動で整理 |
RUN --mount=cache | ビルダーローカル | エクスポート不可 | いいえ | 永続ビルダー | ランナーが新しく起動すると無効 |
マルチアーキテクチャビルドでは、キャッシュがプラットフォームごとに分離される点を知っておく必要があります。linux/amd64とlinux/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 \
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で付け、キャッシュマウントはビルダーが生きている環境でだけ期待すべきです。
현재 단락 (1/157)
CIパイプラインは最初90秒でした。今は8分です。ログを見ると、毎回同じ行で時間を使っています。