開発チームの規模が拡大し、マイクロサービスの数が増えるにつれて、CI/CDパイプラインの待ち時間は無視できないコストになります。私が最近担当したプロジェクトでは、Node.jsベースのバックエンドサービスのビルドに毎回15分以上かかっていました。「コーヒーを淹れて戻ってきてもまだ終わっていない」という状況は、開発者の生産性を著しく低下させます。
特に問題だったのは、コードの変更がわずか1行であっても、Dockerイメージのビルドプロセスが「ゼロから」走ってしまうことでした。GitHub Actionsのランナー(Runner)はエフェメラル(一時的)な環境であるため、ローカルマシンのように前回のビルドキャッシュが自然には残りません。本記事では、この課題に対してDocker Layer Cachingとbuildxを組み合わせ、ビルド時間を実測値で50%以上短縮したエンジニアリングプロセスを共有します。
CI/CD高速化の鍵:なぜ標準のDocker Buildは遅いのか
まず、なぜGitHub Actions上で単純にdocker buildを実行すると遅いのか、その構造的な原因を整理しましょう。対象となる環境は、Ubuntu-latestのランナー上で動作する、依存パッケージの多いNode.jsアプリケーション(またはGo/Rustなどコンパイルが必要な言語)です。
ローカル開発環境では、Dockerデーモンがレイヤーキャッシュをディスクに保持しているため、COPY package.json .やRUN npm installなどの命令に変更がなければ、Dockerはキャッシュされたレイヤーを再利用します。しかし、GitHub Actionsのホステッドランナーはジョブごとにクリーンな仮想マシンが立ち上がります。つまり、キャッシュが存在しない真っさらな状態から毎回ビルドが始まります。これにより、ネットワーク越しに全てのパッケージをダウンロードし、全てのソースコードを再コンパイルするコストが毎回発生するのです。
RUN npm ci や RUN go mod download のステップで数分間停止し、毎回数百MBのデータをダウンロードしている様子が観測されます。これは明らかなリソースと時間の浪費です。
公式のDocker Documentationにもある通り、ビルド時間を短縮するには、この「失われるキャッシュ」を外部ストレージに保存し、次回のビルド時に注入(Import)する必要があります。
失敗談:単純なファイルキャッシュの落とし穴
私が最初に試みた、そして多くの人が陥りがちな「GitHub Actions最適化の罠」について触れておきます。それは、GitHub Actions標準のactions/cacheを使って、Dockerのデータディレクトリ(/var/lib/dockerなど)を無理やり保存・復元しようとするアプローチです。
一見うまくいきそうですが、これは以下の理由で失敗しました:
- 復元コスト: 数GB単位のDockerイメージレイヤーをキャッシュからリストアするのに時間がかかり、ビルド時間の短縮分が相殺されてしまう。
- 権限エラー: Dockerのファイルシステムはroot権限で管理されており、GitHub Actionsのユーザー権限との競合でパーミッションエラーが多発する。
- 整合性: Dockerエンジンのバージョン差異などにより、キャッシュが破損扱いされやすい。
この経験から、ファイルシステムレベルのキャッシュではなく、Dockerネイティブのキャッシュ機能を使うべきだという結論に至りました。
解決策:BuildxとGHA Cache APIの統合
正解は、Dockerの拡張機能であるBuildxと、ストレージバックエンドとしてGitHub Actions Cache(type=gha)を使用することです。
BuildxはMoby BuildKitツールキットを使用しており、従来のDockerビルドよりも高度なキャッシュエクスポート機能を備えています。特にtype=ghaを指定することで、DockerのレイヤーキャッシュをGitHub ActionsのCache API(10GBの容量制限内)に直接読み書きできるようになります。これにより、S3などの外部ストレージを用意する必要がなくなり、ネットワークレイテンシも最小限に抑えられます。
以下に、実際に本番環境で稼働しているワークフローの抜粋を示します。これはDevOps Tipsとして非常に強力なパターンです。
# .github/workflows/build.yml
name: Build and Push
on:
push:
branches: [ "main" ]
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Buildxのセットアップ(これがないと高度なキャッシュ機能は使えません)
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Build and Push アクションの設定
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: user/app:latest
# ここが魔法のスパイスです
cache-from: type=gha
cache-to: type=gha,mode=max
設定コードの徹底解説
上記のコードにおいて、最も重要なのはcache-toとcache-fromのパラメータです。
cache-from: type=gha: GitHub Actionsのキャッシュストレージから、以前のビルドレイヤーを探しに行きます。cache-to: type=gha,mode=max: ここでmode=maxを指定することが非常に重要です。デフォルトのmode=minでは最終的なイメージに含まれるレイヤーのみがキャッシュされますが、maxを指定すると、ビルドの中間ステージ(例えば、マルチステージビルドにおけるビルド用コンテナのnode_modulesなど)も全てキャッシュされます。
マルチステージビルドを採用している場合、mode=maxにしないと、最も時間のかかる依存関係解決のステップがキャッシュされず、効果が半減してしまいます。
パフォーマンス検証:実測データ比較
この設定を適用する前後で、同じアプリケーション(Reactフロントエンド + Node.js API)のビルド時間を計測しました。CI/CD高速化の効果は劇的です。
| 条件 | 従来の設定 (No Cache) | Buildx + GHA Cache | 改善率 |
|---|---|---|---|
| 初回ビルド (Cold) | 14分 20秒 | 14分 45秒 | -3% (アップロード時間) |
| 2回目以降 (Warm) | 14分 10秒 | 6分 30秒 | 54% 短縮 |
| 依存関係のみ変更 | 14分 15秒 | 8分 10秒 | 42% 短縮 |
初回ビルド時はキャッシュを生成してアップロードするオーバーヘッドがあるため、若干時間が延びますが、2回目以降は劇的に速くなっています。特に、ソースコードのみを変更し、package.jsonに変更がない場合、npm installの工程が丸ごとスキップされ、即座にビルドフェーズへ移行できていることが確認できました。
これがDocker Layer Cachingの真価です。CPUリソースを無駄な再計算に使わず、必要な差分ビルドだけに集中させることができます。
GitHub: Docker Build Push Action 公式ドキュメント注意点とエッジケース
この手法は強力ですが、万能ではありません。いくつかの運用上の注意点(エッジケース)を共有します。
mode=maxを多用し、巨大なイメージを頻繁にビルドすると、古いキャッシュが積極的にEviction(追い出し)され、キャッシュヒット率が下がることがあります。
また、GitHub Actionsのキャッシュは「ブランチスコープ」の影響を受けます。デフォルトでは、子ブランチは親ブランチ(通常はmain)のキャッシュを読み取ることができますが、兄弟ブランチ間のキャッシュは共有されません。PR(プルリクエスト)ごとのビルドにおいて、ベースとなるmainブランチのキャッシュが最新でない場合、期待したほどの高速化が得られないことがあります。
その場合は、type=registryを使用して、コンテナレジストリ自体にキャッシュレイヤーをプッシュする方法(Inline Cacheなど)を検討する必要があります。ただし、これはレジストリからのダウンロード時間を伴うため、ネットワーク帯域とのトレードオフになります。
結論
GitHub ActionsにおけるDockerビルドの遅延は、開発サイクルの回転数を下げる大きな要因です。しかし、docker-containerドライバーとGitHub Actions Cache APIを適切に組み合わせることで、追加のインフラコストをかけることなく、ビルド時間を大幅に短縮可能です。
「たかが数分の短縮」と侮ってはいけません。1日10回のデプロイを行うチームであれば、年間で数百時間の待機時間を削減することになります。ぜひ、次回のプロジェクト設定で見直してみてください。
Post a Comment