そのCI、並列にしたらもっと速くなります

に公開

CIの並列実行できていますか?

CI(継続的インテグレーション)におけるインテグレーションとは

複数人によって変更したコードを組み合わせて、そのコードが意図したとおりに動くかどうかを検証する行為

引用『入門 継続的デリバリー』

CIでの継続的はそれを 「可能な限り早く」 行うこととされています。

しかしCIタスクはそもそも大きな処理ではないことが多く、実行時間が気にならないまま直列で実行されているケースはよくあるのではないでしょうか。

※ 本記事のCIパイプライン例および設定例は、GitHub Actions をCI/CDプラットフォームとして使用します。

CIはどんなタスクを担うのか

基本的なCI/CDパイプライン

CI/CDパイプラインは以下のようなタスクで構成されます。

  • リント
    • ソースコードの構文エラーやスタイルエラーを検出します
  • テスト
    • 単体テストや統合テストを実行します
  • ビルド
    • イメージとはソフトウェアが実行するために必要なものが全て含まれた実行可能なパッケージです
  • パブリッシュ
    • イメージレジストリにコンテナイメージをアップロードします
  • デプロイ
    • ソフトウェアが新しいバージョンで新しいイメージを使用するようになります

この中でCIが担うタスクは リントテスト になります。リントやテストはコードの品質を保証するためのゲートであり、変更の加わったソースコードを入力として受け取り、それらの品質を保証するための検証を行います。

今回は例として以下のタスクをCIパイプラインとして定義します。

  • フォーマット
  • リント
  • TypeScriptの型チェック
  • 単体テストとカバレッジ計測

CIタスクが直列実行になっていませんか?

PR作成時のワークフローなどでCIパイプラインを実行することはよくあることだと思いますが、以下のように愚直にstepsに各タスクを並べてしまうと直列で実行されてしまいます。

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm format # 20秒
      - run: pnpm lint # 20秒
      - run: pnpm typecheck # 20秒
      - run: pnpm test:coverage # 2分

実行フロー

独立しているタスクは並列で実行できる

CIパイプラインで実行されるリント、テストのようなタスクは基本的にはそれぞれ独立していて順次実行でなくても良いことがほとんどかと思います。

以下のようにjob単位でタスクを定義すると各タスクが並列で実行され、実行時間はタスクの合計ではなくjobs内タスクの最大値となり、トータルの実行時間を削減できます。

jobs:
  # 並列実行: Format check
  format:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm format

  # 並列実行: Lint
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint

  # 並列実行: Type check
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm tc

  # 並列実行: Test with Coverage
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm test:coverage

  # すべてのチェックが成功したことを確認
  ci-success:
    needs: [format, lint, typecheck, test]
    runs-on: ubuntu-latest
    if: always()
    steps:
      - run: |
          if [[ "${{ needs.format.result }}" != "success" ]] || \
             [[ "${{ needs.lint.result }}" != "success" ]] || \
             [[ "${{ needs.typecheck.result }}" != "success" ]] || \
             [[ "${{ needs.test.result }}" != "success" ]]; then
            echo "❌ One or more checks failed"
            exit 1
          fi
          echo "✅ All checks passed"

実行フロー

max(20s, 20s, 20s, 2m) = 2m

この例では最後にci-successで全てのチェックが成功したかどうかを確認しています。Branch Protection RulesやマージキューでCIの結果を使用するときに便利です。

さらなる最適化

setupジョブは本当に必要?

よくあるパイプラインのセットアップとして依存パッケージのインストールがあると思いますが、これはスキップできる可能性があります。

jobs:
  # ❌ 本当に必要?
  setup:
    name: Setup Dependencies
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
      - name: Install dependencies
        run: pnpm install --frozen-lockfile

  # setupの完了を待つ
  format:
    needs: setup # ⚠️ ここで待機時間が発生
    runs-on: ubuntu-latest
    steps:
      ...

Github Actionsには依存パッケージのキャッシュ機能があります。これを使うことで毎回ワークフローの先頭でsetupジョブを実行しなくてよくなります。

仕組みとしては以下のようになっています。

  1. ロックファイルのハッシュ計算
  2. Actionsのキャッシュ検索
  3. ヒットすればそれを使用

※ npm, yarn, pnpmでサポートされています
https://github.com/actions/setup-node#caching-global-packages-data

lint:
  name: Lint
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - name: Install pnpm
      uses: pnpm/action-setup@v4
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: "20"
        cache: "pnpm" # 👈 ココ
    - run: pnpm install --frozen-lockfile
    - name: Lint
      run: pnpm lint

各ジョブでキャッシュ機能を使用することでsetupジョブが不要になり、CIタスクの並列実行をブロックするものがなくなります。

Matrixの使い所

Matrixとは同じジョブを異なる設定で複数回並列実行する機能です。

https://docs.github.com/ja/actions/how-tos/write-workflows/choose-what-workflows-do/run-job-variations

並列実行という点では上記のやり方と同じですが、異なる設定という点でユースケースが異なります。

具体例を挙げるとテストのシャーディングや複数のNode.jsバージョンでのテスト、複数OSでのテストなどです。

テストのシャーディングとは複数のインスタンスに跨ってテストを並列化することです。例えばE2Eテストの完了に30分かかるケースでは、テストケースを3分割して3つのインスタンスでテストを並列実行することで、実行時間を単純計算で10分に短縮することができます。

test:
  name: Test (Shard ${{ matrix.shard }})
  runs-on: ubuntu-latest
  strategy:
    fail-fast: false
    matrix:
      shard: [1, 2, 3] # 👈 3つのインスタンス
  steps:
    - uses: actions/checkout@v4
    - name: Install pnpm
      uses: pnpm/action-setup@v4
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: "20"
        cache: "pnpm"
    - name: Install dependencies
      run: pnpm install --frozen-lockfile
    - name: Run tests (Shard ${{ matrix.shard }}/3) # 👈 3つのインスタンスでテスト実行
      run: pnpm test --shard=${{ matrix.shard }}/3

終わりに

本記事では、CIパイプラインの並列実行による最適化について解説しました。

主なポイントをまとめると以下のようになります。

並列実行の基本:

  • 独立したタスクはjob単位で定義することで並列実行可能
  • 実行時間は各タスクの合計ではなく、最も遅いタスクの実行時間になる

さらなる最適化:

  • setupジョブは不要な場合が多い(GitHub Actionsのキャッシュ機能を活用)
  • 各ジョブで直接キャッシュを使用することで並列実行をブロックしない
  • Matrixを使用したシャーディングで大規模テストの実行時間を短縮

CIタスクはそもそも大きな処理ではないことが多く、実行時間が気にならないまま直列で実行されがちです。ですが、独立したタスクの並列実行に加え、キャッシュの活用やテストのシャーディングを組み合わせることで、CI全体の実行時間を意外と削減できることがあります。

新年を迎えたこの機会に、CIワークフローを一度見直してみるのもいいかもしれません。

参考

https://www.oreilly.co.jp//books/9784814400690

NCDC テックブログ

Discussion