CIに毎回15分待ってるの、人生の無駄 — ビルド時間を70%削った具体的な方法

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パイプラインは開発チームのインフラだ。そのインフラの性能を継続的に改善することが、チーム全体の生産性向上につながる。