CI/CDパイプラインの実行時間は、開発チームの生産性に直結する。PRを出してからCIが完了するまで20分待つのと、6分で完了するのとでは、開発者体験が全く違う。この記事では、GitHub Actionsのビルド時間を70%短縮した具体的な手法を、実際のYAML設定例とともに解説する。
最適化前の状態: 平均18分のビルド
筆者のチームが管理するNext.jsアプリケーションのCIパイプラインは、以下のステップで構成されていた。
- 依存関係のインストール: 3分
- リント: 2分
- 型チェック: 3分
- ユニットテスト: 4分
- E2Eテスト: 4分
- ビルド: 3分
- Dockerイメージのビルドとプッシュ: 2分
合計で約18分。これが1日に30〜50回のPRで実行されるため、CIの待ち時間だけで相当な開発時間が失われていた。さらに、GitHub Actionsの実行時間に応じた課金もコスト要因になっていた。
最適化1: 依存関係キャッシュの改善
最初に取り組んだのは、依存関係のキャッシュ最適化だ。デフォルトのnpmキャッシュでは不十分で、node_modulesそのものをキャッシュする方が高速だ。
# 最適化前(非効率)
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
# 最適化後(node_modulesを直接キャッシュ)
- name: Cache node_modules
id: cache-deps
uses: actions/cache@v4
with:
path: node_modules
key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-deps.outputs.cache-hit != 'true'
run: npm ci
この変更だけで、キャッシュヒット時の依存関係インストールが3分からほぼ0秒になった。ただし注意点がある。package-lock.jsonが変わっていなくてもOSやNode.jsバージョンが変わるとキャッシュが無効になるよう、キーの設計は慎重に行う必要がある。
最適化2: ジョブの並列化
リント、型チェック、テストを直列で実行していたが、これらは互いに独立しているため並列化できる。
name: CI
on:
pull_request:
branches: [main, develop]
jobs:
# 依存関係のインストール(共有ジョブ)
setup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Cache node_modules
id: cache-deps
uses: actions/cache@v4
with:
path: node_modules
key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install
if: steps.cache-deps.outputs.cache-hit != 'true'
run: npm ci
# リント(並列実行)
lint:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npm run lint
# 型チェック(並列実行)
typecheck:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npx tsc --noEmit
# ユニットテスト(並列実行)
test:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npm test -- --reporter=verbose
# E2Eテスト(並列実行)
e2e:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install Playwright
run: npx playwright install --with-deps chromium
- run: npm run e2e
# ビルド(全チェック通過後)
build:
needs: [lint, typecheck, test, e2e]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npm run build
この並列化により、リント(2分) + 型チェック(3分) + テスト(4分) + E2E(4分) = 直列13分だったものが、最も遅いジョブの4分で完了するようになった。
最適化3: テストのシャーディング
テスト数が増えてくると、テスト自体の実行時間がボトルネックになる。GitHub Actionsのmatrix strategyを使って、テストを複数のランナーに分散実行する。
test:
needs: setup
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Run tests (shard ${{ matrix.shard }}/4)
run: |
npx vitest run --reporter=verbose
--shard=${{ matrix.shard }}/4
4分かかっていたテストが、4シャードに分割することで約1分に短縮された。シャード数はテスト数とランナーの並列数に応じて調整する。
最適化4: Next.jsビルドキャッシュ
Next.jsのビルドは、.nextディレクトリのキャッシュを利用することで大幅に高速化できる。
build:
needs: [lint, typecheck, test, e2e]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
restore-keys: |
nextjs-${{ runner.os }}-
- run: npm run build
変更されたページのみ再ビルドされるため、ビルド時間が3分から30秒〜1分に短縮された。ただしキャッシュキーの設計には注意が必要で、設定ファイルの変更時にはキャッシュを無効化する必要がある。
最適化5: Dockerビルドの多段キャッシュ
Dockerイメージのビルドでは、BuildKitのキャッシュ機能とGitHub Actions Cacheを組み合わせる。
docker:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
BuildKitのGitHub Actions Cache(type=gha)を使うことで、変更のないレイヤーはキャッシュから取得される。Dockerfileのレイヤー順序を最適化し、変更頻度の低いレイヤー(OS、依存関係)を上に、頻度の高いレイヤー(アプリコード)を下に配置することが重要だ。
最適化6: パス指定による不要な実行の回避
ドキュメントだけの変更でCIを走らせる必要はない。パスフィルタを使って、関連ファイルが変更された場合のみCIを実行する。
on:
pull_request:
branches: [main]
paths:
- 'src/**'
- 'package.json'
- 'package-lock.json'
- 'tsconfig.json'
- 'next.config.ts'
- '.github/workflows/**'
paths-ignore:
- '**.md'
- 'docs/**'
- '.vscode/**'
最適化7: Larger Runnerの活用
GitHub-hostedランナーのデフォルト(2コア、7GBメモリ)では、大きなプロジェクトのビルドやテストに時間がかかる。GitHub Team以上のプランでは、Larger Runnerを利用できる。
# Larger Runnerを使用(4コア、16GB)
jobs:
build:
runs-on: ubuntu-latest-4-cores
steps:
# ...
コストは上がるが、ビルド時間の短縮とチームの生産性向上を考えると、費用対効果は高い。特にE2Eテストはメモリとコア数の恩恵が大きい。
最適化結果: 18分 → 5.5分
すべての最適化を適用した結果を整理する。
- 依存関係のインストール: 3分 → 約0秒(キャッシュヒット時)
- リント + 型チェック + テスト: 直列13分 → 並列で約2分(テストはシャーディング)
- ビルド: 3分 → 1分(Next.jsキャッシュ)
- Dockerビルド: 2分 → 30秒(BuildKitキャッシュ)
- セットアップジョブ + オーバーヘッド: 約2分
合計約5.5分。18分からの70%短縮を達成した。
CI/CDの最適化は、一度やって終わりではない。プロジェクトの成長とともにテスト数もビルド時間も増えていく。定期的にパイプラインの実行時間を計測し、ボトルネックを特定して改善し続ける姿勢が重要だ。
実運用でのTips
Concurrency設定で無駄な実行を止める
同じブランチで連続してプッシュした場合、古い実行をキャンセルして最新のもののみ実行する設定が有効だ。
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
ワークフローの実行時間を可視化する
GitHub Actionsの実行時間を定期的にモニタリングし、劣化を検知する仕組みを作ろう。GitHub APIを使って実行時間を取得し、Grafanaなどで可視化するのが効果的だ。
# GitHub CLIで直近のワークフロー実行時間を取得
gh run list --workflow=ci.yml --limit=20 --json conclusion,updatedAt,createdAt
--jq '.[] | select(.conclusion=="success") |
{duration: (((.updatedAt | fromdate) - (.createdAt | fromdate)) / 60 | floor | tostring) + "min"}'
CI/CDパイプラインは開発チームのインフラだ。そのインフラの性能を継続的に改善することが、チーム全体の生産性向上につながる。









# comment