
肥大化する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 --link、RUN --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は単なる手順書ではなく、依存関係を宣言した設計図になる。
COPY --linkが切り離すレイヤー依存関係
BuildKit v0.10(2022年3月リリース)で導入されたCOPY --linkは、派手さはないがキャッシュ効率において非常に効く改善だ。
通常のCOPY命令は、コピー先の親イメージのファイルシステムを前提にレイヤーを生成する。言い換えれば、親イメージのいずれかのレイヤーが変わると、その上のCOPYレイヤーも無効化される。CI環境ではベースイメージのプルだけでも数十秒かかるため、レイヤーキャッシュが効かなくなるのは痛い。
COPY --linkを使うと、コピー内容が親イメージとは独立したレイヤーとして記録される。コピー先の親イメージのファイルシステムに依存しないため、ベースイメージ側のレイヤーが更新されても、COPY --linkで作成したレイヤーは無効化されにくい。キャッシュヒット率が安定し、CIでの無駄な再ビルドが減る。
特に威力が出るのはソースコードのコピーだ。COPY --link . /appのように書いておけば、ビルドステージ全体のキャッシュが破棄されるような変更があっても、最終成果物レイヤーは再利用可能なケースが増える。
導入コストは低い。既存のCOPYをCOPY --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/apt | aptのキャッシュ再利用 | システムパッケージの追加インストール |
--mount=type=cache,target=/root/.composer/cache | Composerキャッシュ再利用 | PHPの依存追加・更新 |
--mount=type=cache,target=/root/.npm | npmキャッシュ再利用 | Node.jsの依存追加・更新 |
--mount=type=cache,target=/root/.cache/pip | pipキャッシュ再利用 | 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-actionのcache-fromとcache-toにtype=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 --linkとRUN --mount=type=cacheを全面的に入れること。LaravelのComposerキャッシュとnpmのキャッシュをそれぞれマウントし、mode=maxでGitHub Container Registryにキャッシュを共有する構成を組む。そしてビルド時間の推移とイメージサイズの変化を計測する。
理論としては筋が通っているこの構成も、実プロダクトの依存関係やCIランナーの性能次第で体感は大きく変わる。だからこそ、仮説で終わらせずに、自分の環境で検証して数字を取るところまで持っていきたい。失敗したらその数字ごと公開する。それが個人開発者の実験場としてのあり方だ。
Related reading: Docker・インフラ構築をわかりやすく解説.