TL;DR
- CIの遅延原因は**4レイヤー(依存管理・ビルドキャッシュ・テスト分割・ジョブ並列化)**に分解できる
- 「まず計測、次に分解、最後に優先順位」の順で取り組む。闇雲なチューニングはROIが低い
- 各レイヤーの改善で合計50〜70%の時間短縮が現実的に狙える
- この記事のコード例はすべてGitHub Actions。コピペして今日から試せる
CIの「見えないコスト」を計算してみる
1日10回ビルド × 20分待ち = 年間833時間の損失
CIが遅いことの最大の問題は、開発者の集中力が途切れることだ。
具体的に計算してみよう。5人のチームで、1人あたり1日平均2回PRを出すとする。CIの平均実行時間が20分なら、
- 1日あたり: 5人 × 2回 × 20分 = 200分(3.3時間)
- 年間(250営業日): 200分 × 250日 = 50,000分 ≒ 833時間
833時間。フルタイムのエンジニア約0.5人分の年間稼働に相当する。しかもこれは純粋な待ち時間であり、コンテキストスイッチによる生産性低下は含まれていない。
CI待ちが開発フローに与える3つの悪影響
- コンテキストスイッチの増加: CI待ちの間に別タスクに着手し、結果が返ってきたら元のタスクに戻る。この切り替えコストは1回あたり15〜25分と言われている
- PRの巨大化: 「どうせ待つなら」とPRに変更を詰め込み、レビュー負荷が上がる悪循環
- フィードバックループの鈍化: DORA MetricsのLead Time for Changesが悪化し、開発生産性指標全体に波及する
ボトルネック特定の3ステップ ― 計測・分解・優先順位
改善に着手する前に、どこが遅いかを正確に把握する。これを飛ばすと、効果の薄い箇所に時間を投じてしまう。
CIログから各ステップの実行時間を抽出する
GitHub Actionsの場合、各ステップの実行時間はログに表示される。まずはこれを手動で記録するだけでいい。
Step Duration
─────────────────────────────────
Checkout 5s
Setup Node 12s
Install Dependencies 3m20s ← ★ここが長い
Build 4m15s ← ★ここも長い
Unit Tests 2m30s
Integration Tests 5m10s ← ★ここも長い
E2E Tests 6m45s ← ★ここも長い
Deploy 30s
─────────────────────────────────
Total 22m47s
4レイヤーに分類して最大のボトルネックを特定する
抽出した時間を以下の4レイヤーにマッピングする。
| レイヤー | 該当ステップ | 時間 | 改善余地 |
|---|---|---|---|
| L1: 依存管理 | Install Dependencies | 3m20s | キャッシュで90%短縮可能 |
| L2: ビルド/キャッシュ | Build | 4m15s | 増分ビルドで60%短縮可能 |
| L3: テスト分割 | Unit + Integration + E2E | 14m25s | 並列化で50〜75%短縮可能 |
| L4: ジョブ並列化 | パイプライン全体の直列構造 | ― | DAG設計で30〜50%短縮可能 |
最も時間を食っているレイヤーから着手する。上の例ではL3(テスト)が全体の63%を占めているので、ここが最優先だ。
レイヤー1 ― 依存管理の最適化
lockfileキャッシュで毎回のインストールを省く
依存パッケージのインストールは、CIで最も「無駄」になりやすい工程だ。lockfileが変わっていないなら、前回のインストール結果をそのまま再利用すればいい。
Before(キャッシュなし): 3分20秒
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: pnpm install --frozen-lockfile # 毎回フルインストール
After(キャッシュあり): 15秒
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm" # pnpm-lock.yamlベースでキャッシュ
- run: pnpm install --frozen-lockfile # キャッシュヒット時は数秒で完了
cache: "pnpm" の1行を追加するだけで、3分20秒が15秒に短縮される。最もROIの高い改善だ。
pnpm / Bun でinstall自体を速くする
パッケージマネージャの選択自体もインストール速度に大きく影響する。
| パッケージマネージャ | キャッシュなし | キャッシュあり |
|---|---|---|
| npm | 45s | 12s |
| yarn v4 | 30s | 8s |
| pnpm | 20s | 5s |
| Bun | 10s | 3s |
※ 中規模プロジェクト(依存500パッケージ程度)での目安。環境・プロジェクトにより変動する。
pnpmはハードリンクベースの依存管理により、npm比で2〜3倍高速。移行コストが低いので、まだnpmを使っているなら検討する価値がある。
レイヤー2 ― ビルドキャッシュの活用
Dockerレイヤーキャッシュで差分ビルドに切り替える
Dockerイメージのビルドをしているなら、レイヤーキャッシュの活用が必須だ。
Before(キャッシュなし): 4分15秒
- name: Build Docker image
run: docker build -t myapp:${{ github.sha }} .
After(BuildKitキャッシュ): 45秒
- name: Build Docker image with cache
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha でGitHub Actionsのキャッシュストレージをビルドキャッシュとして利用する。変更のないレイヤーはスキップされるため、差分が小さいPRほど効果が大きい。
Turborepo / Nxのリモートキャッシュでモノレポを加速する
モノレポ構成のプロジェクトでは、Turborepo(またはNx)のリモートキャッシュが劇的に効く。
仕組み: ビルドタスクの入力(ソースコード + 依存関係 + 設定)のハッシュをキーとして、ビルド成果物をリモートキャッシュに保存する。同じ入力なら、ビルドを実行せずにキャッシュから成果物を復元する。
# turbo.jsonの設定例
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": []
}
}
}
# GitHub Actionsでのリモートキャッシュ利用
- name: Build with Turborepo
run: pnpm turbo run build --filter=...[HEAD^1]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
--filter=...[HEAD^1] で前回コミットからの変更に影響するパッケージだけをビルドする。10パッケージのモノレポで2パッケージだけ変更した場合、ビルド時間は理論上1/5になる。
注意点: リモートキャッシュのストレージにはVercel(Turborepo)やNx Cloudを利用する。セルフホストも可能だが、運用コストを考慮すると公式サービスの利用が現実的だ。
キャッシュ破損のリスクと対策: ビルドキャッシュは万能ではない。キャッシュキーの設計が不適切だと、古い成果物が使われて「ローカルでは再現しないがCIでだけ落ちる」現象が起きる。対策として、キャッシュキーにOSバージョン・Node.jsバージョン・lockfileのハッシュを含めること。また、週に一度はキャッシュをパージしてフルビルドが通ることを確認するのが安全だ。
レイヤー3 ― テスト分割と並列実行
テストはCIの中で最も時間を食うことが多い。ここを並列化するだけで、全体の実行時間が半分以下になるケースも珍しくない。
テストシャーディングで実行時間を1/Nにする
テストファイルをN個のランナーに分割して並列実行する。Jest・Vitest・Playwrightはいずれもシャーディングをネイティブサポートしている。
Before(直列実行): 14分
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile
- run: pnpm test # 全テストを1ランナーで実行
After(4分割シャーディング): 4分
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm vitest --shard ${{ matrix.shard }}
strategy.matrix.shard でランナーを4つに分割し、各ランナーが全テストの1/4を実行する。14分のテストが約4分で完了する。
注意: 各シャードの実行時間が均等になるよう、テストファイルの分割を調整する必要がある。極端に重いテストファイルがあると、そのシャードがボトルネックになる。
変更影響範囲テストで不要なテストをスキップする
すべてのPRで全テストを実行する必要はない。変更されたファイルに関連するテストだけを実行する「影響範囲テスト」を導入すると、平均実行時間を大幅に短縮できる。
- name: Run affected tests only
run: |
CHANGED=$(git diff --name-only origin/main...HEAD)
if echo "$CHANGED" | grep -q "^src/api/"; then
pnpm vitest run src/api/
elif echo "$CHANGED" | grep -q "^src/components/"; then
pnpm vitest run src/components/
else
echo "No relevant changes, skipping tests"
fi
ただし、影響範囲の判定精度には注意が必要だ。依存関係が複雑なプロジェクトでは、変更の影響が想定外のモジュールに波及することがある。CIの高速化と品質のトレードオフを意識し、Change Failure Rateの変動も合わせてモニタリングしよう。
レイヤー4 ― ジョブDAG設計とパイプライン構造
直列ジョブを並列化してクリティカルパスを短くする
多くのCIパイプラインは、依存関係がないジョブまで直列に実行している。ジョブ間の依存関係を正しく定義し、DAG(有向非巡回グラフ)として設計すると、クリティカルパスが短くなる。
以下はBefore/Afterのパイプライン構造を示すMermaid図だ。
Before: 直列パイプライン(合計22分)
graph LR
A["Checkout5s"] --> B["Install3m20s"]
B --> C["Lint1m"]
C --> D["Build4m15s"]
D --> E["Unit Test2m30s"]
E --> F["Integration Test5m10s"]
F --> G["E2E Test6m45s"]
G --> H["Deploy30s"]
After: 並列DAG(合計12分)
graph LR
A["Checkout + Install20s (cached)"] --> B["Lint1m"]
A --> C["Unit Test ×440s (sharded)"]
A --> D["Build45s (cached)"]
D --> E["Integration Test ×22m30s (sharded)"]
D --> F["E2E Test ×32m15s (sharded)"]
B --> G["Deploy"]
C --> G
E --> G
F --> G
G["Deploy30s"]
ジョブを並列化した上で、キャッシュとシャーディングを組み合わせると、22分が約8分に短縮できる。
条件付き実行とpath filterで不要ジョブを削る
すべてのPRですべてのジョブを実行する必要はない。paths フィルタを使って、変更のあったディレクトリに関連するジョブだけを実行する。
on:
pull_request:
paths:
- "src/**"
- "tests/**"
- "package.json"
- "pnpm-lock.yaml"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm lint
test-unit:
if: |
contains(github.event.pull_request.changed_files, 'src/') ||
contains(github.event.pull_request.changed_files, 'tests/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm test:unit
test-e2e:
if: |
contains(github.event.pull_request.changed_files, 'src/pages/') ||
contains(github.event.pull_request.changed_files, 'src/api/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm test:e2e
ドキュメントだけの変更(docs/**)でE2Eテストが走る無駄をなくすだけで、CI全体の平均実行時間を20〜30%短縮できることが多い。GitHub Actionsのワークフロー設計と組み合わせて最適化しよう。
AI生成コード時代にCIが重くなる理由
AIコーディングツール(GitHub Copilot、Cursor、Claude Code等)の普及で、コード生成の速度が上がった。その結果、CIパイプラインへの負荷が増大している。
PR数の増加がCIキューを詰まらせる
AIエージェントが1日に5〜10本のPRを生成する環境では、CIキューが詰まりやすくなる。従来は1日2〜3本だったPRが倍以上になれば、CIの実行回数も比例して増える。
GitHub Actionsの同時実行数には上限があるため(Freeプランで20、Teamプランで40)、キューの待ち時間が実行時間に上乗せされる。
生成コードの特性に合わせたCI最適化
AI生成コードには「変更範囲が広いが、構造的にはパターンの繰り返し」という特徴がある。この特性を活かしたCI最適化のポイントは以下の通り。
- 差分検知の精度向上: AI生成PRは変更行数が多くなりがちだが、影響範囲は限定的なことが多い。
pathsフィルタとモジュール単位の依存グラフを組み合わせて、不要なテスト実行を減らす - リスクベーステストとの連携: PRのリスクレベルに応じてテスト深度を動的に変える設計は、AI生成PRの品質ゲート自動化で詳しく解説している。パイプライン高速化とリスクベーステストは補完関係にある
- キャッシュヒット率の監視: AI生成コードが依存関係を頻繁に変更する場合、キャッシュヒット率が下がる。週次でヒット率を確認し、キャッシュキーの設計を調整する
OSSに学ぶCI設定の実例
実際に大規模OSSプロジェクトがどのようにCI/CDを高速化しているかを見てみよう。
Next.jsのCI構成 ― Turborepoキャッシュ+テスト分割
Next.jsリポジトリ(vercel/next.js)は、モノレポ構成でTurborepoを活用している。
- Turborepoリモートキャッシュ: 変更のないパッケージのビルドをスキップ
- テストシャーディング: 数千のテストファイルを複数ランナーに分割
- 条件付き実行:
pathsフィルタでドキュメント変更時はテストをスキップ - カスタムランナー: 大規模テスト用にセルフホストランナーを運用
PrismaのCI構成 ― マトリクスビルド+条件付き実行
Prismaリポジトリ(prisma/prisma)は、複数のデータベースとOSをマトリクスでテストしている。
- マトリクス戦略: PostgreSQL / MySQL / SQLite × Ubuntu / macOS / Windows の組み合わせ
- 条件付きマトリクス: PRの変更内容に応じて、テスト対象のDBを動的に選択
- 並列ジョブ: エンジンビルドとテストを並列実行し、依存関係をDAGで管理
これらのプロジェクトに共通するのは、「全部を毎回やらない」設計思想だ。変更に関係ないジョブをスキップし、キャッシュを最大限活用している。
4レイヤーチェックリスト ― まず1つ選んで今週やる
自チーム診断チェックリスト
以下のチェックリストで、自チームのCIパイプラインを診断しよう。チェックが付かない項目が改善ポイントだ。
L1: 依存管理
- lockfileベースのキャッシュを設定している
- キャッシュヒット時のインストールが30秒以内で完了する
- パッケージマネージャはpnpm / yarn v4 / Bun のいずれかを使っている
L2: ビルド/キャッシュ
- Dockerビルドでレイヤーキャッシュを利用している
- モノレポの場合、Turborepo / Nx のリモートキャッシュを設定している
- ビルド成果物をジョブ間でアーティファクト共有している
L3: テスト分割
- テストを2つ以上のシャードに分割している
- 各シャードの実行時間が均等(最大/最小の差が2倍以内)
- 変更影響範囲に基づくテスト選択を導入している
L4: ジョブ並列化
- 独立したジョブ(lint, test, build)を並列実行している
-
pathsフィルタで不要ジョブをスキップしている - パイプラインのクリティカルパスを把握し、最適化している
改善効果の早見表と着手の優先順位
| レイヤー | 代表施策 | 改善効果(目安) | 導入難易度 | 推奨優先度 |
|---|---|---|---|---|
| L1: 依存管理 | lockfileキャッシュ | 3分 → 15秒 | 低(1行追加) | ★★★(最優先) |
| L2: ビルド/キャッシュ | Docker BuildKit + GHAキャッシュ | 4分 → 45秒 | 中 | ★★☆ |
| L2: ビルド/キャッシュ | Turborepo リモートキャッシュ | ビルド時間 1/3〜1/5 | 中 | ★★☆(モノレポの場合) |
| L3: テスト分割 | 4シャード並列化 | 14分 → 4分 | 中 | ★★★(テストが重い場合) |
| L4: ジョブ並列化 | DAG設計 + pathフィルタ | 全体 30〜50%短縮 | 中〜高 | ★★☆ |
着手の原則: 導入難易度が低く、改善効果が高いものから始める。ほとんどのプロジェクトでL1(lockfileキャッシュ)が最優先になる。設定1行で3分短縮できるなら、今日中にやらない理由がない。
まとめ
CIが遅い原因は「なんとなく全体が遅い」ではなく、4つのレイヤーに分解できる。
- 計測する: CIログから各ステップの実行時間を抽出する
- 分解する: 4レイヤー(依存管理・ビルドキャッシュ・テスト分割・ジョブ並列化)にマッピングする
- 優先順位をつける: 最も時間を食っているレイヤーから着手する
- 効果を検証する: 改善後のCI時間を計測し、期待通りの短縮が得られたか確認する
CIの高速化は、単に「待ち時間が減る」だけではない。フィードバックループが速くなることで、PRが小さくなり、レビューが早くなり、デプロイ頻度が上がる。開発生産性指標の改善が連鎖的に起こる。
改善後の運用モニタリング: CI高速化は一度やって終わりではない。以下の指標を週次で追跡し、改善効果の持続を確認しよう。
- CI平均実行時間: 改善前と比較して短縮が維持されているか
- キャッシュヒット率: 80%以上を目標。下がってきたらキャッシュキーの見直しが必要
- Flaky Test率: テスト分割後に不安定なテストが増えていないか
- DORA Lead Time for Changes: CI時間の短縮がデプロイ頻度の改善に波及しているか
今日のアクション: 上のチェックリストで自チームのCIを診断し、最も効果の高い改善ポイントを1つ特定しよう。そして今週中に、その改善のPRを1本出してほしい。
