その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ジョブを実行しなくてよくなります。
仕組みとしては以下のようになっています。
- ロックファイルのハッシュ計算
- Actionsのキャッシュ検索
- ヒットすればそれを使用
※ npm, yarn, pnpmでサポートされています
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とは同じジョブを異なる設定で複数回並列実行する機能です。
並列実行という点では上記のやり方と同じですが、異なる設定という点でユースケースが異なります。
具体例を挙げるとテストのシャーディングや複数の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ワークフローを一度見直してみるのもいいかもしれません。
参考
NCDC株式会社( ncdc.co.jp/ )のテックブログです。 主にエンジニアチームのメンバーが投稿します。 募集中のエンジニアのポジションや、採用している技術スタックの紹介などはこちら( github.com/ncdcdev/recruitment )をご覧ください!
Discussion