Docker・インフラ構築

Dockerマルチステージビルドの仕組み:BuildKitによるキャッシュ最適化とレイヤー複製の裏側

自分のプロダクトのDockerfileを見直したとき、最終イメージのサイズに違和感を覚えた。本来であれば数百MBで収まるはずのコンテナが、依存関係の積み重なりによって想定外に大きくなっていた。原因は単純明快で、ビルド時にしか使わないツールチェーンを最終イメージにそのまま同梱していたからだ。…

Dockerマルチステージビルドの仕組み:BuildKitによるキャッシュ最適化とレイヤー複製の裏側

肥大化するDockerイメージの「ユーザーの痛み」

Node.js、Composer、テスト用の依存、Lintツール。こうした「ビルドの舞台裏で動く役者たち」が、プロダクションコンテナの中に居座り続ける。これが積み重なると、イメージのサイズはGB級に到達することすらある。

これは個人の開発環境ではギリギリ許容できる。しかしCI/CDのパイプラインでは致命的だ。レジストリへのプル、デプロイ時間、そしてストレージコスト。あらゆる場面で「待ち」が増える。検証サイクルが遅くなればなるほど、ユーザーの反応から学ぶ速度も鈍る。結果として、プロダクトの改善スピードそのものが蝕まれる。

肥大したイメージは「動けばいい」の対岸にある。デプロイのたびに数分待つ開発体験は、検証を回すテンポを壊し、ユーザーの痛みから目をそらさせる。

この痛みを解消する手段が、Dockerのマルチステージビルドと、それを支えるBuildKitだ。

マルチステージビルドの基本動作とCOPY --fromの仕組み

マルチステージビルドは、Dockerfile内に複数のFROM命令を置き、それぞれのステージに異なる役割を持たせる仕組みだ。Docker 17.05で導入され、宣言的に「ビルド環境」と「実行環境」を分離できる。

基本的な流れはこうだ。最初のFROMではビルドステージを定義し、必要なツールチェーンごと依存関係をインストールする。次のFROMでは実行ステージを定義し、ビルドステージから成果物だけをCOPY --from=<stage>で取り込む。

たとえばLaravel + Vite構成の場合、ビルドステージではPHPとNode.jsの両方を含んだイメージを使い、composer installでバックエンドの依存を取得、npm ciでフロントエンドの依存をインストールする。そしてnpm run buildで本番アセットを生成する。実行ステージではPHP-FPMのみのスリムなイメージを選び、ビルドステージの/var/www/htmlとビルド済みアセットだけをCOPY --from=builderで持ってくる。

このCOPY --from=<stage>の肝は、前のステージのファイルシステム全体ではなく「指定した成果物だけを別レイヤーで取り込む」点にある。ビルドツールもソースコードもテストも最終イメージには残らない。残るのは実行に必要なバイナリと、設定ファイルと、ビルド済みのアセットだけだ。

こうしてビルドコンテキストと実行コンテキストが分離される。マルチステージビルドは単なる最適化ではなく、Dockerfileを「ビルドの宣言」と「実行の宣言」に書き分ける設計手法だ。

BuildKitが再定義するビルドの前提

マルチステージビルドの考え方はDocker 17.05からあったが、その潜在能力を本格的に引き出したのがBuildKitだ。BuildKitは2018年11月にDocker 18.09で実験的に導入され、2023年にリリースされたDocker Engine 23.0以降はデフォルトのビルダーになっている。

従来のビルダーとBuildKitの最大の違いは、内部にビルドグラフを持つ点にある。Dockerfileを逐次実行するのではなく、まず依存関係を解析し、ビルドグラフとして表現してから実行する。この抽象が加わったことで、いくつか強力な最適化が可能になった。

一つ目は未使用ステージの自動スキップだ。最終イメージに必要な成果物を逆算し、寄与しない中間ステージはビルド自体が省略される。デバッグ用のステージをDockerfileに残しておいても、ビルド時間を圧迫しない。

二つ目はステージの並列ビルドだ。互いに依存しないステージは、BuildKitが自動的に並列実行する。フロントエンドビルドとバックエンドビルドが独立したステージになっていれば、従来は逐次だった処理が同時に走る。CI時間の短縮に直結する。

三つ目は新しいディレクティブ群だ。COPY --linkRUN --mount=type=cacheといった、キャッシュ戦略を強化する命令が使えるようになった。これらがイメージ軽量化とビルド高速化の決定打になる。

有効化は環境変数DOCKER_BUILDKIT=1を立てるか、/etc/docker/daemon.json{"features":{"buildkit":true}}を記述するだけでいい。Docker Engine 23.0以降の環境では、意識せずとも既にBuildKitが動いている。

BuildKitが変えるもの従来のビルダーBuildKit
ステージの評価逐次実行ビルドグラフで並列実行
未使用ステージ実行される自動スキップ
キャッシュ戦略レイヤーキー単位のみRUN --mount/COPY --linkが使える
並列性命令単位の限定的な並列ステージ単位の並列ビルド
BuildKitの真価は「レイヤーの積み重ね」からの脱却だ。ビルドグラフという抽象が加わることで、Dockerfileは単なる手順書ではなく、依存関係を宣言した設計図になる。

BuildKit v0.10(2022年3月リリース)で導入されたCOPY --linkは、派手さはないがキャッシュ効率において非常に効く改善だ。

通常のCOPY命令は、コピー先の親イメージのファイルシステムを前提にレイヤーを生成する。言い換えれば、親イメージのいずれかのレイヤーが変わると、その上のCOPYレイヤーも無効化される。CI環境ではベースイメージのプルだけでも数十秒かかるため、レイヤーキャッシュが効かなくなるのは痛い。

COPY --linkを使うと、コピー内容が親イメージとは独立したレイヤーとして記録される。コピー先の親イメージのファイルシステムに依存しないため、ベースイメージ側のレイヤーが更新されても、COPY --linkで作成したレイヤーは無効化されにくい。キャッシュヒット率が安定し、CIでの無駄な再ビルドが減る。

特に威力が出るのはソースコードのコピーだ。COPY --link . /appのように書いておけば、ビルドステージ全体のキャッシュが破棄されるような変更があっても、最終成果物レイヤーは再利用可能なケースが増える。

導入コストは低い。既存のCOPYCOPY --linkに置き換えるだけ。BuildKitが有効な環境であれば、追加の設定は不要だ。

ただし注意点もある。COPY --linkはBuildKit専用の機能であり、BuildKitが無効な従来のビルダーではビルドが通らない。.dockerignoreを整備してビルドコンテキストを最小化したうえで、COPY --linkと組み合わせるのが効果的だ。

RUN --mount=type=cacheの裏口の仕組み

もう一つのBuildKitの目玉が、RUN --mount=type=cacheだ。これはパッケージマネージャのキャッシュを、レイヤー内に保存せず一時領域として再利用する仕組み。Dockerのレイヤーは本来「ビルド時点の不変スナップショット」であるべきだが、キャッシュマウントはこの制約を裏口から回避する。

典型的な例を見てほしい。Debian系のイメージでRUN apt-get update && apt-get install -y ...を実行する場合を考えてみる。命令文が変化しない限り、ビルドキャッシュが効いて再インストールはスキップされる。しかし、パッケージリストを更新するためにapt-get updateだけを別の行に分離すると、その行は毎回フルでネットワーク取得を走らせる。さらに、インストール後にrm -rf /var/lib/apt/lists/*を実行すると、aptのメタデータも消える。

RUN --mount=type=cache,target=/var/cache/aptを使うと、aptのキャッシュディレクトリが「レイヤー外のマウント」として扱われる。ビルド中はそこにパッケージキャッシュが残り、次のビルドでも再利用される。rmで消しても問題ない。再ビルド時には自動的に復元される。

PHPのComposerでも同じだ。RUN --mount=type=cache,target=/root/.composer/cache composer install --no-dev --no-scriptsと書けば、Composerのキャッシュがレイヤー外に保持され、依存追加のたびにフルダウンロードが発生しない。

ディレクティブ効果典型的な用途
--mount=type=cache,target=/var/cache/aptaptのキャッシュ再利用システムパッケージの追加インストール
--mount=type=cache,target=/root/.composer/cacheComposerキャッシュ再利用PHPの依存追加・更新
--mount=type=cache,target=/root/.npmnpmキャッシュ再利用Node.jsの依存追加・更新
--mount=type=cache,target=/root/.cache/pippipキャッシュ再利用Pythonの依存追加・更新
キャッシュマウントは「保存できないはずのものを、保存したまま使える」逆転の発想だ。レイヤーの不変性という制約を裏口から回避し、パッケージマネージャのキャッシュ戦略をDockerに持ち込む。

注意点としては、キャッシュマウントのパスはイメージ内に永続化されない点だ。実行時には存在しないため、ビルド中にだけ存在する一時領域として扱う。最終的にイメージに残したい成果物には使ってはいけない。

CI/CDで全レイヤーを共有するmode=max

個人のローカル環境では、BuildKitのキャッシュが効くだけで十分に恩恵を受けられる。しかしCI/CDでは状況が少し違う。CIランナーはクリーンな環境で毎回ビルドするため、ローカルのキャッシュが効かない。かといって毎回フルビルドしていては、CIの実行時間が増えていくばかりだ。

BuildKitには、ビルドキャッシュをリモートレジストリへプッシュし、次回のビルドでプルする仕組みがある。docker buildx build--cache-from--cache-toオプションを組み合わせる。

ここで重要になるのが--cache-to type=registry,mode=maxという指定だ。BuildKitのキャッシュ共有には大きく二つのモードがある。

モード共有対象用途
mode=min最終ステージのキャッシュのみ単純な最終イメージ再利用
mode=max中間ステージを含む全レイヤーマルチステージビルドの中間成果物も再利用

mode=minでは最終レイヤーのキャッシュだけがレジストリに送られる。一方、mode=maxを指定すると、マルチステージビルドの中間ステージが作ったキャッシュもすべて送信される。これにより、Laravelプロジェクトでビルドステージが生成したvendorディレクトリのキャッシュを、次回のCIビルドで再利用できるようになる。

具体的に言えば、composer installに毎回2分かかっていたビルドが、キャッシュヒット時には数秒で終わる。個人のプロダクトをGitHub Actionsの無料枠で運用するなら、ビルド時間の短縮はそのままコスト削減になる。

実装は.github/workflows/build.ymlの例で言えば、docker/build-push-actioncache-fromcache-totype=registry,ref=ghcr.io/<user>/<repo>:buildcacheのような参照を指定するだけで動く。GitHub Actions標準のキャッシュ機能よりも細かい制御ができ、BuildKitの機能をフル活用できる。

レジストリのストレージ消費が増える点だけは注意が必要だ。mode=maxは中間層も送るため、イメージ本体と同等かそれ以上のキャッシュデータがレジストリに蓄積される。プライベートリポジトリなら許容できるが、容量管理は意識しておきたい。

次に検証したい仮説

ここまでの話を整理する。マルチステージビルドは、Dockerfileを「ビルド段階」と「実行段階」に分ける宣言的な書き方だ。BuildKitはその宣言を解釈してビルドグラフを作り、並列実行と不要なステージのスキップを実現する。

その上で、COPY --linkは親イメージとの依存を切り離してキャッシュヒット率を上げる。RUN --mount=type=cacheはパッケージマネージャのキャッシュをレイヤー外に逃がす。--cache-to type=registry,mode=maxはCI/CDの中間キャッシュを共有可能にする。

これらを組み合わせると、ビルド時間とイメージサイズの両面で劇的な改善が得られる。個人のプロダクトでも「ローカルでは動くがCIで詰まる」という痛みはよく発生するが、BuildKitのキャッシュ戦略をきちんと組めばその多くが解消する。

次に試したいのは、自分のプロダクトのDockerfileにCOPY --linkRUN --mount=type=cacheを全面的に入れること。LaravelのComposerキャッシュとnpmのキャッシュをそれぞれマウントし、mode=maxでGitHub Container Registryにキャッシュを共有する構成を組む。そしてビルド時間の推移とイメージサイズの変化を計測する。

理論としては筋が通っているこの構成も、実プロダクトの依存関係やCIランナーの性能次第で体感は大きく変わる。だからこそ、仮説で終わらせずに、自分の環境で検証して数字を取るところまで持っていきたい。失敗したらその数字ごと公開する。それが個人開発者の実験場としてのあり方だ。

Related reading: Docker・インフラ構築をわかりやすく解説.

よくある質問

マルチステージビルドを使うメリットは何ですか?
ビルド環境と実行環境を分離できるため、最終的なコンテナイメージにビルドツールやテスト用依存関係を含めず、サイズを最小限に抑えられます。
BuildKitを有効にするにはどうすればいいですか?
Docker Engine 23.0以降ではデフォルトで有効になっています。それ以前の環境では、環境変数DOCKER_BUILDKIT=1を設定するか、daemon.jsonで設定を有効にする必要があります。
COPY --linkと通常のCOPY命令の違いは何ですか?
COPY --linkはコピー内容を親イメージから独立したレイヤーとして記録するため、ベースイメージが更新されてもキャッシュが無効化されにくくなります。
RUN --mount=type=cacheはどのような用途に適していますか?
apt、Composer、npm、pipなどのパッケージマネージャのキャッシュをビルド間で再利用するために使用します。これにより、毎回フルダウンロードを行う必要がなくなります。
CI/CDでビルド時間を短縮するためのキャッシュ共有設定は?
docker buildx buildコマンドで--cache-to type=registry,mode=maxを指定します。これにより、中間ステージを含むすべてのレイヤーがレジストリに保存され、次回のビルドで再利用可能になります。

参考情報