インフラ・DevOps
Dockerビルドキャッシュの物理学
The physics of Docker build caching (blacksmith.sh)
要約
Dockerのビルドキャッシュに関する一般的なアドバイスは、しばしば経験則や「魔法」のように語られますが、実際には3種類以上のキャッシュが存在し、それぞれ異なるライフタイム、無効化ルール、失敗モードを持っています。この記事では、これらのキャッシュの概念と、数百件のCIビルドを分析した結果に基づいた実践的なルールを解説します。ビルドの形状、使用する言語(Go, Python, Rust, Node, Java, Bazel)、キャッシュバックエンドによって最適な戦略は異なり、レイヤーの順序付けやマウントキャッシュの活用がビルド時間の最適化に不可欠であることが示されています。
全文翻訳
Dockerビルドキャッシュの物理学
Blacksmith Sandboxes 近日公開メニュー閉じるログイン[ 2026年7月31日 ]Dockerビルドキャッシュの物理学概念、経験則、そして数百件のCIビルドを分析した結果がそれらについて語ること。
Piotr Bejda Technical Staffメンバー
TL;DR
概念、経験則、そして数百件のCIビルドを分析した結果がそれらについて語ること。
始めましょう!
無料で試す
Dockerビルドキャッシュに関するアドバイスは、しばしば伝承のように届きます。「COPYを遅らせる」「キャッシュアクションを有効にする」「願う」。問題は、「Dockerキャッシュ」が1つのキャッシュではないことです。それは少なくとも3つあり、異なるライフタイム、異なる無効化ルール、そして異なる失敗モードを持っています。どれが重要で、伝承が役立つのか、何も機能しないのか、あるいは積極的に害になるのかは、目の前のビルドの形状によって異なります。
ビルドの形状は多すぎて一つ一つカバーできません。すべてのスタック、モノレポのレイアウト、オーケストレーションには独自のひねりがあります。そのため、この記事は異なる種類の完全性を目指します。あらゆるシナリオで結果を決定する概念と経験則であり、それぞれが私たちが構築したベンチマークラボからの測定値で示されます。ラボは、Go、Python、Rust、Node、Java、Bazelの18のシナリオにまたがり、実際のCIランナーで実行され、一般的に使用されるすべてのキャッシュバックエンドを比較します。私たちの製品が勝利する場所ではそれを示し、本当に欠けている場所(私たちの製品を含む)ではそれを言います。
メンタルモデル:3つのキャッシュ、1つの無効化ウェーブ
ここでの概念はそれ自体で成り立っているので、フォローするためにDockerfileの専門知識は必要ありません。例が特定の命令(FROM、COPY、RUN、マルチステージビルド)を参照する場合、DockerのDockerfile概要とビルドのベストプラクティスは、頼るべき良い文章の入門書です。
Dockerビルドは命令の連鎖であり、各命令は命令とその入力をキーとするレイヤーを生成します。変更は、そのレイヤーとその後のすべてのレイヤーを無効化し、波がチェーンの最後まで伝播します。そのグローバルな到達範囲は、最適化する価値のある波です。それがどれだけ長く移動するかが、ビルドのどれだけが再実行されるかを、すべての変更ごとに決定します。
(厳密には、ビルドグラフはDAGです。マルチステージビルドでは、各ステージは独自のチェーンであり、COPY --fromエッジで結合され、波は変更に依存するステージにのみ到達します。しかし、ステージ内では常にチェーンであり、ほとんどのDockerfileは実質的に単一のチェーンであり、ここでの例はそれを表しています。波について言及されているすべては、チェーンごとに適用されます。)
チェーンは最初のキャッシュにすぎません。他に2つが並んでいます。
マウントキャッシュ(RUN --mount=type=cache):ビルド間で永続化されるディレクトリ。それらは波を止めませんが、クッションになります。波が命令を再実行するとき、命令はそのマウントがまだ温かいのを見つけるので、再実行はゼロからの実行ではありません。ここにパッケージマネージャーやコンパイラの状態があります(GOCACHE、cargoのレジストリとターゲット/、pipのホイール、pnpmのストア)。
イメージストア(/var/lib/docker):プルされたベースイメージ、ビルドされたイメージ、デーモン自体がキャッシュするすべて。それはチェーンにちょうど1つの点で触れます、FROMレイヤーです。ベースイメージが変更されると、波は一番上から始まります。新しいベースが高速なローカル読み取りか、遅いレジストリプルかは、このストアに依存します。波は決してその中の何も無効化しません。そしてそれはビルドを超えて存続します:docker run、サービスコンテナ、そしてイメージ配信はすべてこのストアに対する読み取りです。
これらのキャッシュのそれぞれについて、以下のすべてのセットアップで繰り返し出てくる質問:それはどこにあり、次のCIジョブまで生き残るか?続くすべては、この絵のどこかの一部をズームインしたものです。
3つのキャッシュ、1つの無効化ウェーブ — Blacksmith
共有語彙のもう1つのピース:上記の図の変更クラスは、この記事のすべての測定値が構造化されている方法でもあります。各シナリオは4回ビルドされ、各ビルドは1つのクラスをプローブします。
コールド:最初のビルド、どこにもキャッシュされていない。
ウォーム:何も変更せずに再ビルド。すべてのレイヤーはキャッシュから来るはずです。
ソース変更:コード編集、依存関係マニフェストは変更なし。波はインストールステップの下から始まります。
依存関係変更:マニフェスト/ロックファイルの更新。インストールステップとその後のすべてが再実行されます。
経験則#1:ビルド単位ではなく、変更クラスで考える。あなたの平均的なCIビルドは、コールドビルドではなく、ソース変更です。「同じコミットの2回目のビルド」デモでは素晴らしく、「1行のコード編集」ではひどく見えるキャッシュ設定は、あなたが最もヒットしないフェーズを最適化しています。
レイヤーを変更頻度順に並べる
一般的なガイドライン:コストが高く安定しているステップを特定し、頻繁に変更されるコンテンツがそれらの下に来るようにDockerfileを並べ替えます。最も一般的な例は、マニフェストファーストの順序付けです。依存関係マニフェストを最初にコピーし、依存関係をインストールしてから、ソースをコピーします。コストの高いインストールレイヤーは、コミットごとではなく、マニフェストが変更されたときにのみ無効化されます。
私たちは同じサービスを5つのスタックで両方の方法(インストール前のCOPY . . vs マニフェストファースト)で測定しました。
ソース変更ビルド時間:
マニフェストファースト vs COPY . . ファースト — Blacksmith
スタック | マニフェストファースト | COPY . . ファースト | ギャップ
Go | 1.1秒 | 5.7秒 | ~5倍
Python (numpy/pandas/scipy) | 0.9秒 | 22.7秒 | ~24倍
Node (express) | 0.8秒 | 2.6秒 | ~3倍
Rust (axum) | 2.1秒 | 10.3秒 | ~5倍
Java (Spring Boot) | 3.2秒 | 15.9秒 | ~5倍
注目すべき点が2つあります。第一に、コールドタイムと最終イメージは両方のバリアントで同一です。この最適化は無料です。第二に、ギャップはまさにあなたの依存関係ステップのコストです。安価なnpm ciは3倍、Pythonネイティブホイールは24倍、そして間違ったレイヤーにコピーされた1GiBのモデルファイルは、すべてのソース編集を約23秒の純粋なI/Oに変えます(それも測定しました)。
経験則#2:レイヤー順序付けのペイオフは、それが保護するステップのコストに等しい。最もコストの高い安定したステップを知り、頻繁に変更されるものがその上に置かれないようにしてください。
マウントキャッシュはもう半分であり、それらは移動しません
レイヤーキャッシュはすべてか無しかです。波がインストールステップに当たると、ステップ全体が再実行されます。マウントキャッシュは、再実行を安価にするものです。パッケージマネージャーはダウンロードを見つけ、コンパイラは増分状態を見つけます。コンパイル言語では、これは通常、「再実行はすべてを再コンパイルすることを意味する」と「再実行は1つのファイルを再コンパイルすることを意味する」の違いです。
これが、「キャッシュを有効にしたのにまだ遅い」という苦情の大部分を説明するキャッチです。キャッシュマウントが波を生き残ることを可能にするもの(ビルダーのローカルディスク上のディレクトリであり、レイヤーファイルシステムの外部にある)は、キャッシュエクスポートが処理するものからも外れています。キャッシュエクスポート(type=gha, type=registry, type=inline)はレイヤーブロブのバンドルです。それは各命令の結果のレイヤーをシリアル化するので、別のビルダーは再実行をスキップできます。マウントの内容はどのレイヤーにも含まれていないため、バンドルには決して含まれません。
2つのメカニズムは問題の異なる部分を攻撃します。エクスポートされたレイヤーキャッシュは波に沿って機能します。それは、新しいマシンが波が到達しなかったすべてのステップをスキップできるようにし、マシン間で移動する唯一のメカニズムです。それができないのは、波が到達したステップを助けることです。その再実行を安価にすることはマウントキャッシュの仕事です。したがって、エクスポートされたキャッシュを持つ新しいランナーでは、生き残ったレイヤーはウォームですが、すべての--mount=type=cacheディレクトリは空から始まります。高速にしたかったそのステップはゼロから実行されます。
Rustのシナリオはそれを具体的にします。依存関係の変更は、マウントが生き残る場合(永続的なビルダー状態)、GHAキャッシュバックエンドの場合164秒、キャッシュなしの場合19秒で3.6秒かかります。もう一度読んでください。エクスポートバックエンドは、変更によって無効化されたレイヤーのインポートとエクスポートにコストがかかり、役立つ状態がエクスポートに含まれていなかったため、何もしないよりも8倍遅かったです。
経験則#3:ビルドの勝利が実際にどこにあるかを知る。それがマウントキャッシュ(コンパイル言語、Bazel、増分可能なもの)にある場合、レイヤーエクスポートバックエンドは、それがどのように調整されても、それを配信できません。
バックエンドの風景
モデルがあれば、バックエンドは2つのファミリーに分かれます。
エクスポート/インポート:ビルド後、キャッシュ(の一部)を外部ストアにシリアル化し、次回のビルド前に復元します:gha、レジストリ、インライン、およびbuildkit-cache-dance、whのような回避策。