ローカルでは一瞬なのに、CI の docker build はなぜ毎回フルビルドになるのか?
ローカルで docker build を叩くと 2 回目は一瞬で終わるのに、CI に push した瞬間だけ毎回フルビルドになって数分待たされる——そんな経験はないだろうか。手元と CI で同じ Dockerfile を同じように実行しているはずなのに、この差はどこから生まれているのか。
- Dockerfile の各命令は 1 レイヤーを生成し、直前のビルドの同じ位置の命令と一致すればそのレイヤーをそのまま再利用する。
COPY/ADDはコピー対象ファイルの内容チェックサムで、RUNはコマンド文字列そのもので一致を判定する(ファイルの更新日時は見ない) - 一度どこかのレイヤーでキャッシュミスが起きると、それ以降の全レイヤーが連鎖的に作り直しになる。だから依存関係のインストールをアプリコード全体の
COPYより先に済ませる順序が定石になっている - キャッシュはローカルディスクに保存されるが、CI のランナーは毎回まっさらな環境で立ち上がり前回ビルドのレイヤーを持たない。BuildKit のレジストリキャッシュなどで明示的に外部へ保存しない限り、CI は原理的に「常に初回ビルド」になる
Dockerfile の1命令が1レイヤーになる、が前提
Dockerfile に書いた FROM / COPY / RUN などの命令は、実行されるたびに1つのレイヤー(イメージを構成する差分の層)を生成する。ビルダーは命令を上から順に処理しながら、「この命令は前回のビルドと同じ結果になるはずか」を毎回チェックし、一致すると判断できればそのレイヤーを実行せずに前回の結果をそのまま使い回す。これがビルドキャッシュだ。
多くの命令については、Dockerfile 上の命令文そのものが前回のビルドと一致するかどうかを比べるだけで十分に判定できる。命令の文字列が1文字でも変われば、その時点でキャッシュは使えないと判断される。
COPY/ADD と RUN で判定方法が違う
COPY と ADD だけは命令文の比較だけでは済まない。コピー元に指定したファイルの中身が変わっていないかを、内容から計算したチェックサムで判定する。更新日時(mtime)は判定に使われないため、ファイルの中身を1文字も変えずに touch しただけならキャッシュは効いたままになる。
一方 RUN はコマンド文字列そのものが前回と同じかどうかしか見ない。たとえば RUN apt-get update は、コマンド文が変わらない限り、実際に外部のパッケージリポジトリの中身が更新されていてもキャッシュを再利用する。「同じコマンドを書けば常に同じ結果になる」という前提の上にキャッシュは成り立っている。
同じ間取りの部屋へ何度も荷造りする引っ越し業者を想像してほしい。段ボールは下から「めったに開けない参考書」「季節ものの服」「毎日使う充電器」の順に積んでいく。今日使った充電器の箱だけ入れ替えるなら一番上の箱をやり直すだけでいいが、下の方にある参考書の箱の中身を1冊入れ替えると、その上に積んだ箱は中身が変わっていなくても一度全部降ろして積み直す必要がある。Docker のレイヤーも同じで、下の命令(依存関係のインストールなど)が変わると、それより上の命令は結果が同じになるはずでも問答無用でやり直しになる。
一度ズレると全部やり直し — カスケード無効化と命令順序
あるレイヤーでキャッシュミスが起きると、それより後ろの全レイヤーは無条件に作り直しになる。前のレイヤーの中身が変わった以上、後続の RUN がその変化の影響を受けないと保証する手段がビルダー側には無いからだ。この「1箇所のミスが後続すべてに波及する」現象を本記事ではカスケード無効化と呼ぶ。これを最小限に抑えるため、Dockerfile は「変更頻度の低い命令を先に、高い命令を後に」書くのが定石になっている。
┌ Dockerfile を上から実行 ────┐ │ FROM alpine ✅ 再利用 │ │ COPY requirements.txt │ │ 変更あり → ❌ ここでミス │ │ RUN pip install ... │ │ ↓連鎖 → 🔄 巻き添えで再実行 │ │ COPY app.py │ │ ↓連鎖 → 🔄 巻き添えで再実行 │ └───────────────────────────────┘
典型的なのが依存関係ファイルとアプリコードを分けて COPY するパターンだ。依存関係の定義ファイルだけ先にコピーしてインストールを済ませ、変更頻度の高いアプリコード本体は最後にコピーする。こうしておけば、アプリコードだけを直した通常の変更では依存関係のインストール以降のレイヤーがすべてキャッシュから再利用され、実行されるのは最後の COPY だけになる。
# 避けたい書き方: コード全体を先にコピーしてしまう COPY . . RUN pip install -r requirements.txt # → app.py を1行直しただけでも requirements.txt ごと COPY され、 # pip install から下が丸ごと再実行される # 定石: 変更頻度の低いものから順にコピーする COPY requirements.txt . RUN pip install -r requirements.txt COPY . . # → app.py だけ直した場合、pip install までのレイヤーは再利用される
CI で毎回フルビルドになる理由 — キャッシュはどこに保存されているか
ここまでの仕組みは「前回のビルド結果と比較できる」ことが前提になっている。ローカル環境ではビルドキャッシュがディスク上に残り続けるので、2 回目以降のビルドは前回との比較が成立する。ところが CI のランナーの多くはジョブごとに使い捨てのまっさらな環境で起動するため、そもそも「前回のビルド結果」を保持していない。Dockerfile の内容が一切変わっていなくても、比較対象が存在しない以上ビルダーは全レイヤーを実行するしかなく、CI 上では常に初回ビルドと同じ状態になる。
これを解決するのが BuildKit のレジストリキャッシュだ。--cache-to でビルドキャッシュをコンテナレジストリへ書き出し、次回のジョブで --cache-from により同じ場所からキャッシュを取り込めば、使い捨てのランナー同士でもキャッシュを引き継げる。何も設定しなければ CI は原理的に毎回フルビルドのままで、この 2 つのフラグを明示しない限りローカルで体感しているキャッシュの速さは再現されない。
docker buildx build --push -t myregistry/app:latest \ --cache-to type=registry,ref=myregistry/app:cache \ --cache-from type=registry,ref=myregistry/app:cache . # --cache-to: ビルド後にキャッシュをレジストリへ push # --cache-from: 次回のビルドでそのキャッシュを取り込む
GitHub Actions や CircleCI の Docker ビルドジョブが、コードを 1 行直しただけなのに毎回 npm install や pip install から数分かけてやり直している状況はこの仕組みの結果だ。反対に、依存関係ファイルとアプリコードの COPY 順序を分けるだけで CI のビルド時間が数分から数十秒に縮むこともある。マルチステージビルドで最終イメージを軽量化する際も、どのステージまでキャッシュが効いているかを意識しないと、意図せず重いビルドステージを毎回やり直す構成になってしまう。
依存関係ファイルとアプリコードを分けて COPY した Dockerfile で、変更するファイルによってキャッシュの効き方がどう変わるかを確かめる。
mkdir -p /tmp/cache-demo && cd /tmp/cache-demo
echo "flask==3.0" > requirements.txt
echo "print('v1')" > app.py
cat > Dockerfile << 'DOCKERFILE'
FROM alpine:3.20
COPY requirements.txt .
RUN echo "requirements をインストール中..." && sleep 2
COPY app.py .
DOCKERFILE
docker build -t cache-demo . # 1回目: 全レイヤーが実行される
echo "print('v2')" > app.py # アプリコードだけ変更
docker build -t cache-demo . # COPY requirements.txt と RUN は CACHED のまま
echo "flask==3.1" > requirements.txt # 依存関係を変更
docker build -t cache-demo . # RUN 以降が全部再実行される(カスケード)
2 回目のビルドでは COPY requirements.txt と RUN echo ... の行に CACHED と表示され、sleep 2 の待ち時間なしに一瞬で終わるはずだ。3 回目は requirements.txt を変えたことで COPY requirements.txt の時点からキャッシュミスになり、RUN の sleep 2 が再び実行されて数秒待たされる——これが本文で説明したカスケード無効化だ。
- ビルドキャッシュはファイルの更新日時(mtime)で判定している — 実際は
COPY/ADDのキャッシュ判定はファイル内容から計算したチェックサムで行われ、mtime は見ない。touch しただけで中身が同じならキャッシュは効いたままになる。 COPY . .と1行で書いても、変更したファイルが少なければ差分だけ再利用される —COPY/ADDはコピー対象全体をまとめて1つのキャッシュキーとして扱うため、対象内のどれか1ファイルでも変われば、そのCOPY命令のレイヤー全体とそれ以降が無効化される。- CI で同じジョブを繰り返し実行すれば、自動的にローカルと同じようにキャッシュが効くようになる — ランナーが使い捨てだと前回のビルド結果自体を保持していないため、
--cache-to/--cache-fromのような外部キャッシュを明示的に設定しない限り、CI は何度実行してもフルビルドのままになる。
- レイヤー
- Dockerfile の1命令が生成する、イメージを構成する差分の層。イメージはレイヤーを積み重ねたものとして保存される。
- ビルドキャッシュ
- 前回のビルドで生成したレイヤーを保存しておき、同じ結果になると判断できる命令の再実行を省略する仕組み。
- チェックサム
COPY/ADDのキャッシュ判定に使われる、ファイル内容から計算される値。内容が同じであれば同じ値になる。- カスケード無効化
- あるレイヤーでキャッシュミスが起きると、それ以降の全レイヤーが連鎖的に作り直しになる現象。
- レジストリキャッシュ
- BuildKit がビルドキャッシュをコンテナレジストリへ保存・取得する仕組み。使い捨てのCIランナー間でもキャッシュを引き継げる。
- Build cache invalidation — Docker Docs — COPY/ADD と RUN でキャッシュ判定方法が異なることの一次ソース
- Using the build cache — Docker Docs — レイヤーとキャッシュの基本概念、依存関係を先にCOPYする定石の解説
- Registry cache — Docker Docs — CIのようにローカルキャッシュが引き継げない環境向けのレジストリキャッシュ設定