GitHub ActionsでESLintのShardingを実装して、CIの実行時間を51%削減しました – newmo 技術ブログ


newmoでは、pnpm workspaceで管理している複数のアプリケーションやライブラリに対してESLintを実行しています。
プロジェクトの成長とともにLint対象のファイル数が増加し、CI実行時間とメモリ使用量が増加していました。GitHub Actionsのmatrixオプションを使用した動的なShardingを実装し、これらの問題に対応しました。

newmoフロントエンドの開発原則

newmoのフロントエンド開発では、次の原則を重視しています。

1つ目は、「同じ目的を達成するための手段を統一する」という原則です。同じ目的に対する複数の手段が混在するよりも、統一した手段を使うことで学習コストや保守コストを削減できます。例えば、npmパッケージのバージョン管理では、One Version Ruleに基づき、同じ目的を持つライブラリは1つに絞っています。これは「同じコードを書かない」ではなく「同じ課題に対して同じアプローチを使う」という意味です。過度なコードの共通化や抽象化は複雑性を増してメンテナンスを困難にする可能性があるため、重複削除だけを目的にはしていません。

2つ目は、「書きやすさよりも読みやすさを優先する」という原則です。暗黙的な書き方を減らし、処理内容を明確にすることを重視しています。例えば、型推論で戻り値の型を作るよりも、明示的に型を定義してそれを使います。コード量は増えますが宣言的になり、別の人が読んだ時に理解しやすくなります。

Lint設定の一元管理

これらの原則に基づき、ESLintの設定ファイルはリポジトリのルートに1つだけ配置し、全パッケージで同じルールを適用する設計にしています。
各パッケージ個別にLint設定を持たせない理由は、新しく追加されたパッケージでLintがかからないのを防ぐためです。

各パッケージではGitHub Actionsのworkflowでテストを実行していますが、Lintは意図的に全体で統一しています。これにより、新しくパッケージが追加されても自動的にLintルールが適用され、コード品質の一貫性が保たれます。開発者は新しいパッケージを作成する際にLint設定を考える必要がなく、既存のルールセットが自動的に適用されるため、認知負荷が軽減されます。

この設計により、コードベース全体で一貫したコーディングスタイルが維持されています。一方で、プロジェクト規模の拡大とともにCI実行時間が増加していました。

課題:モノレポでのESLint実行の限界

newmo-appリポジトリは、pnpm workspaceを使用したモノレポ構成で、複数のWebアプリケーション、ライブラリ、E2Eテスト、サーバーコンポーネントを含んでいます。
ここでの「パッケージ」とは、package.jsonを持つ独立した単位(1つのWebアプリケーションや共通ライブラリなど)を指します。
従来は単一のジョブですべてのパッケージに対してESLintを実行していましたが、次の問題が発生していました。

  • メモリ使用量が線形に増加し、Node.jsのメモリ上限を8GBに設定する必要があった
  • 実行時間が長くなり、PRのフィードバックループが遅延していた
  • 変更していないパッケージに対してもLintが実行されるため、無駄なCI実行コストが発生していた

解決策:動的matrix生成によるSharding

高速でスケーラブルなE2E実行基盤を目指してを参考に、GitHub Actionsのmatrixオプションを使用した動的なShardingを実装しました。

重要なのは、この実装が「全体で同じルールを適用する」という原則を維持しながら、実行を効率化している点です。Shardingはあくまで実行の並列化であり、適用されるルールや対象範囲は変わりません。新しいパッケージが追加されても、自動的にmatrixに含まれ、同じLintルールが適用される仕組みを保持します。

matrixを事前に定義するのではなく、実行時に動的に生成します。GitHub Actionsでは、jobの出力を別のjobのmatrixとして使用できます。この仕組みを利用して、pnpm workspaceから自動的にLint対象を検出し、それぞれを独立したジョブとして並列実行します。

jobs:
  generate-lint-matrix:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
    steps:
      - name: Generate lint matrix
        id: set-matrix
        run: |
          MATRIX=$(node script/web/eslint/get-lint-matrix.ts)
          echo "matrix=${MATRIX}" >> "$GITHUB_OUTPUT"

  lint:
    needs: [generate-lint-matrix]
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.generate-lint-matrix.outputs.matrix) }}

get-lint-matrix.tsスクリプトが生成するマトリックスは次のような形式です。

{
  "target": [
    { "name": "lib", "displayName": "lib" },
    { "name": "web/app/app-a", "displayName": "web-app-app-a" },
    { "name": "web/app/app-b", "displayName": "web-app-app-b" },
    { "name": "e2etest/web", "displayName": "e2etest-web" }
  ]
}

get-lint-matrix.tsスクリプトの処理の流れは次のようになります。

  1. git diffでベースブランチとの差分ファイルを取得
  2. ESLint設定ファイル(eslint.config.ts、ESLintプラグイン、pnpm-lock.yaml)が変更されているかチェック
  3. ESLint関連ファイルに変更がある場合は全パッケージを対象とし、それ以外は変更されたパッケージのみを対象とする
  4. pnpm list --filter "[ベースブランチ]"で変更されたパッケージを検出
  5. 検出されたパッケージをターゲットに振り分け

検出されたパッケージは次のようにターゲットに振り分けられます。

  • lib/**配下のパッケージ → libとして統合(複数の共通ライブラリをまとめて実行)
  • web/app/app-a配下のパッケージ → web/app/app-aとして独立したターゲット
  • web/app/app-b配下のパッケージ → web/app/app-bとして独立したターゲット
  • e2etest/web配下のパッケージ → e2etest/webとして独立したターゲット

このように、共通ライブラリは統合し、アプリケーションごとに独立したターゲットとして扱うことで、並列実行によるCI時間の短縮を実現しています。
libは小さなパッケージが多いため、パッケージごとに分けると細かすぎてセットアップコストが実行時間を上回ってしまいます。そのため、lib配下のパッケージは1つのジョブにまとめています。

変更検出による最適化

Sharding実装後、さらなる最適化として、変更されたパッケージのみをLint対象とする仕組みを追加しました。これはPull Request時のCI実行時間を80-90%短縮します。

pnpmの--filterオプションは、Gitの差分を基にパッケージをフィルタリングする機能を提供しています。pnpm list --filter "[origin/main]"のような形式で、指定したGitリファレンスから変更があったパッケージのみを抽出できます。

const filterArg = `[${refTarget}]`;
const output = execFileSync(
  "pnpm",
  ["list", "--filter", filterArg, "--recursive", "--json", "--depth=0"],
  {
    cwd: rootDir,
    encoding: "utf-8",
    stdio: ["pipe", "pipe", "ignore"],
  },
);

ただし、ESLint設定ファイル(eslint.config.ts)やESLintプラグイン、pnpm-lock.yamlが変更された場合は、すべてのパッケージに影響する可能性があります。
そのため、これらのファイルが変更された場合は全パッケージをLint対象とします。
この判定ロジックにより、安全性を保ちながら、変更されたパッケージのみに絞ってLintを実行できます。

PR時の典型的なケースでは、1-2個のパッケージのみが変更されることが多く、その場合は80-90%のCI実行時間を削減できます。mainブランチへのpush時も同様に、直前のコミットとの差分のみを対象とすることで、不要な実行を避けています。

実行環境と考慮事項

従来はNODE_OPTIONS="--max-old-space-size=8192"でNode.jsのメモリ上限を8GBに設定していました。
Sharding後は、各ジョブで処理するパッケージ数が減ったため、メモリ使用量が分散されました。その結果、この設定なしでも動作するようになりました。

実行環境として、GitHub Actionsの1 vCPU Linuxランナー(1コア、5GBメモリ)でも動作するようになりました。1 vCPU Linuxランナーは従来のランナーと比較してコストが低く、Shardingによりコア数が少ないマシンでも実行できるようになっています。

ESLintの並列実行とShardingの使い分け

ESLint v9.34.0で追加された--concurrency=autoオプションにより、ESLintはnode:worker_threadsを使って単一プロセス内でファイルを並列処理できます。
newmoでもこのオプションを有効にしています。
しかし、--concurrency=autoだけでは次の課題を解決できませんでした。

1つ目は、変更検出ができないため、すべてのパッケージを毎回Lintする必要があり、変更のないパッケージも処理していました。
2つ目は、GitHub Actionsのデフォルトランナーは2コアに限られているため、--concurrency=autoによる並列化を有効にしても実行時間はほとんど変わりませんでした。
3つ目は、全パッケージを単一ジョブで処理するため、メモリ使用量が1つのランナーに集中していました。

そのため、ESLintの--concurrency=autoによる単一プロセス内の並列化に加えて、GitHub ActionsのmatrixによるジョブレベルのShardingを実装しました。
これにより、次のメリットが得られます。

  • 各ジョブのメモリ使用量が分散され、NODE_OPTIONS="--max-old-space-size=8192"の指定が不要になった
  • 変更されたパッケージのみを並列実行することで、無駄な処理を削減
  • 複数のランナーを同時に活用し、合計の実行時間を短縮

一方で、Shardingにはトレードオフも存在します。
各ジョブごとに依存関係のインストールやセットアップが必要になるため、小さなパッケージを個別にジョブ化するとセットアップコストが実行時間を上回る可能性があります。セットアップコストと並列化のメリットのバランスを取る必要があります。
ただし、Shardingによって分割して実行できるようになったため、1つの強いマシンではなくコストが低い弱いマシンでも実行できるようになるため、全体の柔軟性は向上しています。

--concurrency=autoは各ジョブ内での処理を高速化し、Shardingは複数ジョブに分散することで全体を最適化する、という補完的な関係になっています。

実際の削減効果

実装を段階的に改善した結果、次の削減効果が得られました。

Pull Request時の削減効果

Pull Requestで変更されるパッケージ数に応じて、実行時間が短縮されています。

シナリオ 変更パッケージ数 実行対象 削減率
特定のアプリケーションの修正 1個 変更されたパッケージのみ 約90%削減
共通ライブラリの変更 1個(lib) 変更されたパッケージのみ 約90%削減
複数アプリケーションの変更 3-4個 変更されたパッケージのみ 50-70%削減
ESLint設定の変更 全11パッケージ 削減なし(安全性優先)
依存関係の更新(pnpm-lock.yaml) 全11パッケージ 削減なし(安全性優先)

典型的なPRは1-2個のパッケージ変更であることが多く、実質的に約90%のCI実行時間を削減できています。
mainブランチへのマージ後も同様に差分検出を適用しており、同様の削減効果が得られています。

実行時間の削減結果

gh workflow-statsを使用して実際のワークフロー実行時間を分析した結果を示します。

項目 実装前 実装後(Sharding + 変更検出)
ワークフロー実行時間(平均) 404.8秒(約6分45秒) 196.8秒(約3分17秒)
ワークフロー実行時間(中央値) 390.0秒 209.5秒

実装後のワークフロー実行時間が約51%短縮されました。並列実行により最も遅いジョブが完了するまでの時間が短縮され、変更検出により不要なジョブを削減できています。パッケージ数が増加しても並列実行で対応できるため、スケーラブルな仕組みになっています。

なお、平均削減率が51%に留まっている要因として、Renovateによる依存関係の自動更新PRを挙げられます。
これらのPRではpnpm-lock.yamlが変更されるため、全パッケージがLint対象となります。
この点については、さらなる最適化の余地があると考えています。

結果と今後の展望

この実装により、メモリ使用量が削減され、並列実行で全体の実行時間が短縮されました。
新しいパッケージが追加されても自動的にLint対象となるため、メンテナンス性が向上しました。
PRのフィードバックループも短縮されています。

このGitHub ActionsのmatrixによるShardingの仕組みは、Playwrightを使用したIntegration testでも利用しています。

GitHub Actionsのmatrix機能を使った動的なShardingにより、「全体で同じLintルールを適用する」という原則を維持したまま、実行時間を約51%削減できました。変更検出による最適化と組み合わせることで、コードベースの成長に対してスケーラブルなCI基盤を構築できています。




Source link

関連記事

コメント

この記事へのコメントはありません。