CI/CD パイプラインで Docker を使っている場合の高速化について、実際に取り組んだ内容をまとめます。
パイプラインが遅くなる原因
パイプラインが遅くなる要因はいろいろありますが、代表的なものは次の 3 つです。
- 依存パッケージのダウンロード / インストール
- アプリケーションのビルド
- テストコードの実行
今回はこのうち、依存関係のダウンロード・インストールとアプリケーションのビルドが遅いケースへの対策を扱います。
高速化の 2 つの方針
高速化の方針は大きく 2 つに分けられます。
- 根本対策 — 依存のダウンロード / インストールそのものを速くする、ビルドそのものを速くする
- キャッシュ活用 — 2 回目以降はキャッシュを使って処理をスキップする
1 つ目は必ず効くので、やるに越したことはありません。ただし大きいアプリケーションになると、それだけでは頭打ちになります。そこで今回は Docker のキャッシュに方針を寄せて高速化を進めました。
Docker のキャッシュはどう効いているか
パイプラインのコンソールログを眺めていると、CACHED という文字が流れることがあります。これは Docker が「このステップは実行し直す必要がない」と判断して、キャッシュ済みのレイヤーを再利用している状態です。
意識しなくても効いてくれるケースは多いのですが、何も考えずに書いていると、本来キャッシュできるはずの処理を毎回 1 から実行してしまうことがよくあります。というより、ほぼすべてのプロジェクトで何かしら無駄が残っているのではないかというくらいです。
とはいえ、キャッシュ戦略をきちんと立てようとすると Dockerfile や docker-compose ファイルはどうしても複雑になりますし、前提知識も必要になります。そこが難しさの原因かなと思います。
キャッシュの鍵は COPY と RUN
Docker のキャッシュの仕組みは、主に次の 2 つのコマンドで決まります。
| コマンド | 内容 |
|---|---|
COPY | ホストからコンテナ内にファイルをコピーする |
RUN | コンテナ内で任意のコマンドを実行する |
依存関係の解決もアプリケーションのビルドも、コンテナ内で行っているなら RUN で実行されます。そして、その RUN がキャッシュされるかどうかは、直前の COPY でコンテナに送ったファイルの内容が変わっているかどうかでほぼ決まります。
つまり「何を、どのタイミングで COPY するか」を意識するだけで、高速化が見込めるということです。
基本方針:依存解決とビルドのステップを分ける
いちばん効きやすく、かつ簡単な戦略がこれです。
その理由は、変更頻度の違いにあります。
- 依存パッケージ — 開発中にそう頻繁には変わらない → キャッシュできる
- プロダクトコード — コミット・プッシュのたびに変わる → キャッシュできない
性質が違うものを同じステップに混ぜてしまうと、コードを 1 行変えただけで依存の再インストールまで走ってしまいます。そこで、先に依存定義ファイルだけを COPY してインストールし、そのあとにソースコード全体を COPY する、という順序にします。
私のプロジェクトの場合、クライアント側は npm(package.json)、サーバー側は ASP.NET / C# なので NuGet を使っています。
# 1. 依存定義ファイルだけを先にコピー(変更頻度が低い)
COPY package.json package-lock.json ./
RUN npm ci
# 2. そのあとにソースコードをコピー(変更頻度が高い)
COPY . .
RUN npm run build
こうしておけば、package.json に変更がない限り npm ci はキャッシュされ、毎回の依存解決が不要になります。
さらに分ける:クライアントとサーバー
クライアントとサーバーのコードが分かれている構成では、もう一段分割すると効きます。
まとめて扱っていると、
- クライアントのコードしか触っていないのにサーバーをビルドし直す
- サーバーしか触っていないのにクライアントをビルドし直す
といった無駄が発生しがちです。
やることは先ほどと同じで、
- クライアントのソースを COPY → クライアントをビルド
- サーバーのソースを COPY → サーバーをビルド
というようにステップを分けるだけです。コードの変更頻度がチームによって高くない場合は不要かもしれませんが、分かれている構成なら効果は大きいはずです。
言うのは簡単ですが、実際にはプロジェクトのフォルダー構成を見越して戦略的に配置を決めたり、.dockerignore の内容を調整したりといった作業が必要になります。
ハマったポイント:ビルド成果物がキャッシュを壊す
「分ければいいだけ」と書きましたが、実際にやると「なんでキャッシュが効かないんだ」ということがよく起こります。
私がハマったのはビルドやテストで生成されるファイルが原因のケースです。
パイプラインの中でコンテナ内に成果物が生成されることがあります。たとえば E2E テストなら、
- Playwright のレポートファイル
test-resultsフォルダー
などです。これらをコンテナ内に生成し、ボリューム経由でホストに戻すような構成にしていると、実行のたびに中身が変わるフォルダーがホスト側に増えることになります。
その結果、次回の COPY 時にコンテキストの内容が毎回変わってしまい、キャッシュが効かなくなります。
C# の bin フォルダーや obj フォルダーのような、ビルドで生成される類のものも同じです。
対策:.dockerignore に入れる
コンテナ内で必要ないものであれば、.dockerignore に定義しておきます。
# ビルド成果物
**/bin
**/obj
**/node_modules
# テスト成果物
**/test-results
**/playwright-report
node_modules もコンテナ内の npm install で生成されるものなので、.dockerignore に入れておくのがおすすめです。
こうしておくだけで、「キャッシュが効かないぞ」とハマることはかなり減ると思います。
まとめ
- 高速化には根本対策とキャッシュ活用の 2 方向がある。規模が大きいならキャッシュ活用が効く
- Docker のキャッシュは
COPYとRUNの組み合わせで決まる - 変更頻度の違うものはステップを分ける(依存解決 / ビルド、クライアント / サーバー)
- 毎回変わる生成物は
.dockerignoreに入れる。これを忘れるとキャッシュが壊れる
