
はじめに
Docker ビルドがかなり遅かった。
数GB〜数十GB規模のMLモデルを含むDockerコンテナイメージを運用しています。
リリース間近のアプリを開発している際、backend/ 配下のコードをたった1行変更しただけのビルド&pushが87分もかかっておりました。
モデルの重さを考えれば多少時間がかかるのは覚悟していましたが、リリースまで時間がない状況でこの時間コストを払い続けるのはNGですし、今後のためにも改善する必要があると考えました。
今回は、この原因を計測で特定し、対策を打つまでの流れを共有します。
原因: ローカルキャッシュを見に行けていなかった
BUILDKIT_PROGRESS=plain でビルドログを取得し、どのレイヤーが CACHED になっているかを確認しました。結果、モデルをダウンロードするレイヤーが軒並み再実行されており、コードを変えていないモデル層まで、毎回HuggingFaceから33GBを再取得していました。
ビルドスクリプトを見直すと、原因は単純でした。デフォルトの docker build で、キャッシュ元を明示的に指定していなかったのです。BuildKitは(レガシービルダと違い)--cache-from で明示的に渡さない限り、ローカルに完成イメージがどれだけ残っていても、それを新しいビルドのキャッシュ候補として自動的には見に行きません。実際、このビルド環境には直前にビルドした:latestイメージ(39.4GB)が残っていましたが、それは一切参照されないまま、コードだけ変えたビルドがフルでやり直されていました。
別の懸念要因: ローカルのキャッシュ自体も消えることがある
キャッシュ元を指定すれば直る話、と思いかけたところで、もう1つ確認すべきことがありました。docker system df と docker buildx du を見ると次のようになっていました。
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 119 36 548.2GB 479.6GB (87%)
Build Cache 504 0 0B 0BBuildKitが自動で使う内部キャッシュ(Build Cache)が実質0Bでした。この環境は複数人で使う共有のビルドサーバーで、ディスク使用量がしきい値を超えるとBuildKitのガベージコレクション(GC)がビルドキャッシュを真っ先に破棄します。他のプロジェクトのイメージも溜まり続けていたため、せっかくキャッシュができても次のビルドまでに消えている、という状態でした。
つまり「キャッシュ元を明示的に指定する」だけでは不十分で、ローカルに置いた情報自体が、この環境では消えることを前提に対策する必要がありました。
対策: キャッシュ元を明示的に指定し、取得元をレジストリ(ECR)にする
使ったのは --cache-from によるレジストリキャッシュです。
docker pull "${IMAGE}:latest" || true
docker build \
--cache-from "${IMAGE}:latest" \
--build-arg BUILDKIT_INLINE_CACHE=1 \
...ビルド前に docker pull でECR上の既存イメージを取りに行き、それを --cache-from でキャッシュ源として明示的に指定します。これで最初の原因(キャッシュ元を指定していなかったこと)を解消しつつ、参照先をローカルではなくレジストリにすることで、GCで消えてもdocker pullで取り戻せるようにしました。BUILDKIT_INLINE_CACHE=1 は、レジストリのイメージをキャッシュとして読み取れるようにするために必要なメタデータをpush時に埋め込むためのオプションです。ビルドスクリプトの変更は数行で済み、Dockerfile自体には手を入れていません。
ここで誤解しやすいのですが、速くなっている主因はローカルのイメージストアであって、ECRから毎回33GBを転送しているわけではありません。ローカルに直前のイメージが残っていれば docker pull は差分なしでほぼ一瞬で終わり、そのローカルイメージがキャッシュとして使われます。ECRへのpullは「ローカルのイメージが万一消えていた場合に、同じものを取り戻せるようにする」ための保険です。ローカルキャッシュが不安定な環境だからこそ、そのバックアップ先としてレジストリを使う、という位置づけになります。
なお、導入直後の1回目だけは、既存イメージにまだメタデータが埋め込まれていなかったため効果が出ず、従来どおり時間がかかりました(実測121分)。この1回でメタデータ付きのイメージをpushできれば、以降は正常にキャッシュが効くようになります。
測定結果
Docker ビルド 高速化に成功!
| ケース | Before | After |
|---|---|---|
| コード一部変更時のbuild+push | 約87分 | 約2分 |
| 変更なし(完全キャッシュヒット) | ー | 約35秒 |
上表は一度ビルド完了後、2回目以降のビルド実施した際の数値です。導入直後の1回だけは前述の理由で121分かかりましたが、それ以降は安定してこの速度になっています。
余談: マルチステージビルドは、ビルド時間にも効く
マルチステージビルドは本来、コンパイラやビルド専用の依存関係など「実行時には不要なもの」を最終イメージから除外し、イメージを軽量化するための仕組みです。FROM ... AS builder で作業用ステージを立て、必要な成果物だけを最終ステージへ COPY --from=builder する、というのが典型パターンです。
このステージ分割は、副次的にビルドキャッシュの効率化にもつながります。BuildKitはステージごとに独立してキャッシュを管理するため、ステージを跨がない限り、あるステージの変更が別のステージのキャッシュを壊すことはありません。
たとえば、モデルのダウンロードや依存関係のインストールを行うステージと、アプリケーションコードを扱うステージを分けておけば、コードをどれだけ書き換えても、モデル・依存関係側のキャッシュは無傷のまま残ります。
単一ステージで全レイヤーを積んでいる場合、レイヤーの並び順を工夫してもステージという明確な境界がないぶん、キャッシュ設計の見通しは悪くなりがちです。
--cache-from がレジストリを使って「キャッシュを失っても取り戻せるようにする」対策だったのに対し、マルチステージ化は「変化しやすい部分と変化しにくい部分を構造的に分離する」対策です。
目的は異なりますが、両方とも「不要な再ビルドを起こさない」というゴールに向かう手段として、組み合わせて検討する価値があります。
まとめ
今回は計測してみると原因は単純なキャッシュ喪失で、レジストリキャッシュという最小の変更で解決できました。
対策を検討する前に、まず現状を数値で押さえること。それだけで打ち手の優先順位が大きく変わることを実感した事例でした。
また、マルチステージビルドは本来イメージ軽量化のための仕組みですが、ステージの切り方次第でキャッシュの独立性も高められます。
「軽量化のための機能」で終わらせず、キャッシュ設計の観点からもステージ構成を見直す価値がある、という点も持ち帰りたい学びです。