
こんにちは、kickflow QAチームの川村です。
今回は、PlaywrightのE2Eテストを12並列で実行しているにもかかわらず、特定のシャードだけが80分かかってボトルネックになっていた問題を、実行時間ベースのバランスドシャーディングを自作して解消した話をします。
以前の記事「ローカル環境で動くCypressテストの並列実行を自作して高速化した話」で「今後の展望」として触れた「実行時間に基づいたインテリジェントなテスト分割」を、今回CIのPlaywright環境で実現しました。
テストの並列実行はCI高速化の定石ですが、「均等に分割しているはずなのに、なぜか1つだけ異常に遅い」という経験をお持ちの方も多いのではないでしょうか。
この記事では、Playwrightの標準シャーディングの限界と、実行時間データに基づくインテリジェントなシャード配分の実装について解説します。
問題:12シャードなのに実行時間が8倍違う
kickflowでは約900のE2Eテストを12シャードに分割してGitHub Actionsで並列実行しています。
Playwrightの --shard=X/Y オプションを使えば、テストファイルを均等に分割してくれます。
しかし、CIの実行結果を確認すると、シャード間で実行時間に大きな偏りがありました。
| シャード | テスト数 | 実行時間 |
|---|---|---|
| Shard 4/12 | 75 | 80分 |
| Shard 5/12 | 75 | 61分 |
| Shard 1/12 | 75 | 54分 |
| Shard 6/12 | 75 | 10分 |
| Shard 7/12 | 75 | 13分 |
| Shard 10/12 | 74 | 12分 |
テスト数はほぼ均等(74〜75)なのに、実行時間は10分〜80分と最大8倍の差が出ています。
全シャードの完了を待つため、CI全体の所要時間は最も遅いShard 4の80分に引きずられます。
原因:アルファベット順分割の落とし穴
Playwrightの --shard は、テストファイルをファイル名のアルファベット順にソートしてから均等に分割します。
テスト数は均等になりますが、テストの実行時間は考慮されません。
Shard 4に割り当てられたテストの内訳を調べたところ、原因が明確になりました。
| カテゴリ | ファイル数 | 特徴 |
|---|---|---|
organization/ |
29 | 組織図操作、CSVインポート、バージョン管理 |
route/ |
30 | 経路作成・再計算・シミュレーション |
pipeline/ |
6 | パイプライン実行(チケット申請フロー全体) |
plan/ |
9 | プラン変更テスト |
organization/ と route/ はアルファベット順で隣接しており、かつどちらも複雑なデータ操作を伴う重いテスト群です。
一方、10分で終わるShard 6は ticket/(表示確認系)や ui/(UI表示テスト)など軽いテストばかりでした。
つまり、テスト数の均等分割は、実行時間の均等分割を意味しないのです。
解決策:実行時間ベースのバランスドシャーディング
この問題を解決するために、以下の3つのコンポーネントを実装しました。
- duration-reporter: テストファイルごとの実行時間を記録するPlaywrightカスタムレポーター
- compute-shards.js: 実行時間データに基づいてシャードを均等配分するスクリプト
- merge-durations.js: 各シャードの実行時間データを統合・平滑化するスクリプト
アーキテクチャ
全体の処理フローは以下のとおりです。
sequenceDiagram
participant GCS as GCS (test-durations.json)
participant Setup as setup job
participant Shard as e2e-parallel (x12)
participant Merge as report-merge job
GCS->>Setup: 前回の実行時間データをダウンロード
Setup->>Setup: compute-shards.js でLPTビンパッキング
Setup->>Shard: シャード配分ファイルをアーティファクトで配布
par 12シャード並列実行
Shard->>Shard: 割り当てられたテストファイルのみ実行
Shard->>Shard: duration-reporter が実行時間を記録
end
Shard->>Merge: shard-durations.json をアップロード
Merge->>Merge: merge-durations.js で指数移動平均マージ
Merge->>GCS: 更新された test-durations.json をアップロード
ポイントは、実行するたびに実行時間データが更新され、次回の配分がさらに最適化されるフィードバックループになっていることです。
LPTビンパッキングアルゴリズム
シャード配分の核となるのは、LPT(Longest Processing Time First)アルゴリズムです。
考え方はシンプルで、「最も重いタスクから順に、最も空いているシャードに割り当てる」というものです。
ビンパッキング(bin packing) とは、サイズの異なる複数のアイテムを、限られた数の容器(ビン)にできるだけ効率よく詰める組合せ最適化問題です。
ここでは「テストファイル=アイテム」「シャード=ビン」と見立て、各シャードの合計実行時間が均等になるよう配分しています。
// scripts/compute-shards.js の主要部分 // ファイルに実行時間を付与 const filesWithDuration = testFiles.map((file) => ({ file, durationMs: durations[file]?.durationMs || defaultDuration, })) // LPT: 実行時間の長い順にソート filesWithDuration.sort((a, b) => b.durationMs - a.durationMs) // シャード初期化 const shards = Array.from({ length: shardCount }, () => ({ files: [], estimatedMs: 0, })) // Greedy bin-packing: 各ファイルを最も軽いシャードに割り当て for (const item of filesWithDuration) { let minIndex = 0 let minDuration = shards[0].estimatedMs for (let i = 1; i < shards.length; i++) { if (shards[i].estimatedMs < minDuration) { minDuration = shards[i].estimatedMs minIndex = i } } shards[minIndex].files.push(item.file) shards[minIndex].estimatedMs += item.durationMs }
約330ファイルを12シャードに配分する規模であれば、グリーディ法でほぼ最適な結果が得られます。
グリーディ法(貪欲法) とは、各ステップで「その時点で最も良い選択」を繰り返すアルゴリズム設計手法です。
最適解を保証しないケースもありますが、マルチプロセッサスケジューリングにおいてはLPT(大きい順にソート)と組み合わせることで、理論的に最適解の 4/3 - 1/(3m) 倍以内(mはマシン数)に収まることが証明されています。
実行時間の平滑化
テストの実行時間はCI環境の負荷状況やネットワーク遅延によって毎回変動します。
1回の測定値だけで配分を決めると、偶然遅かったテストに過剰に時間を割り当ててしまいます。
そこで、指数移動平均(EMA)を使って実行時間データを平滑化しています。
// scripts/merge-durations.js の主要部分 const ALPHA = 0.3 for (const [file, current] of Object.entries(currentRun)) { if (previous[file]) { // 既知のファイル: EMA で平滑化 merged[file] = { durationMs: Math.round( ALPHA * current.durationMs + (1 - ALPHA) * previous[file].durationMs, ), testCount: current.testCount, lastRun: current.lastRun, } } else { // 新規ファイル: そのまま採用 merged[file] = current } }
α=0.3に設定しているため、最新の実行結果が30%、過去の蓄積データが70%の重みで反映されます。
これにより、一時的なスパイクに振り回されることなく、テストの実行時間傾向を正確に捉えることができます。
カスタムレポーター
Playwrightのレポーターインタフェースを実装し、テストファイルごとの実行時間を記録します。
// playwright/reporters/duration-reporter.ts class DurationReporter implements Reporter { private fileDurations: Map<string, FileDuration> = new Map() onTestEnd(test: TestCase, result: TestResult): void { const relativePath = test.location.file.replace(/.*scenarios\//, '') const existing = this.fileDurations.get(relativePath) if (existing) { existing.durationMs += result.duration existing.testCount += 1 } else { this.fileDurations.set(relativePath, { durationMs: result.duration, testCount: 1, lastRun: new Date().toISOString(), }) } } onEnd(_result: FullResult): void { // shard-durations.json として出力 } }
CIの各シャードで生成された shard-durations.json は、report-mergeジョブで統合されてGCSに保存されます。
GitHub Actionsワークフローの変更
従来のワークフローでは --shard=X/Y を使っていましたが、新しいワークフローではsetupジョブが配分を計算し、アーティファクト経由で各シャードにファイルリストを配布します。
# setup job でシャード配分を計算 - name: Compute balanced shard assignments run: | node scripts/compute-shards.js \ --durations test-durations.json \ --shard-count "$SHARD_COUNT" \ --test-dir playwright/scenarios \ --output-dir shard-assignments # 各シャードで割り当てられたファイルのみ実行 - name: Run Playwright tests run: | cd playwright && npx playwright test \ $(cat "../shard-assignments/shard-${{ matrix.shard-index }}.txt")
日本語のファイル名を含むパスをシェル変数経由で渡す際のエンコーディング問題を避けるため、ファイルリストはテキストファイルとしてアーティファクトで受け渡しています。
未知のテストファイルのデフォルト値
実行時間データに含まれない新規テストファイルには、既知ファイルの実行時間の中央値をデフォルト値として割り当てます。
// 既知のファイルの中央値を計算(未知ファイルのデフォルト値に使用) const knownDurations = testFiles .map((f) => durations[f]?.durationMs) .filter((d) => d != null) .sort((a, b) => a - b) const defaultDuration = knownDurations.length > 0 ? knownDurations[Math.floor(knownDurations.length / 2)] : 60000 // データがない場合は60秒
平均値ではなく中央値を使うことで、極端に重いテストファイルの影響を受けにくくしています。
また、実行時間データが一切存在しない初回実行時は、すべてのファイルに60秒を割り当ててラウンドロビンで分散します。
ラウンドロビン(round-robin) とは、対象を順番に1つずつ各グループへ配り分ける方式です。
トランプのカードを配るように「1番目のファイルはシャード0、2番目はシャード1、…、13番目はまたシャード0」と巡回的に割り当てます。
アルファベット順にまとめて分割する--shardと異なり、隣接するディレクトリのファイルが異なるシャードに分散されるため、偏りが生じにくくなります。
実測結果
実際にバランスドシャーディングを導入してCIを繰り返し実行し、効果を計測しました。
LPTアルゴリズムは実行時間データのフィードバックを受けて回を重ねるごとに配分が最適化されるため、複数回の推移を記録しています。
| 改善前 | 1回目 | 2回目 | 3回目 | 4回目 | 5回目 | |
|---|---|---|---|---|---|---|
| 最速シャード | 10分 | 24分 | 33分 | 33分 | 36分 | 28分 |
| 最遅シャード | 80分 | 66分 | 63分 | 60分 | 52分 | 35分 |
| 実行時間の幅 | 70分 | 42分 | 30分 | 27分 | 16分 | 7分 |
| バランス比(最速/最遅) | 0.13 | 0.36 | 0.52 | 0.55 | 0.70 | 0.80 |
1回目はまだ実行時間データがなくラウンドロビンでの配分ですが、それだけでも最遅シャードが80分→66分に改善しています。
回を重ねるごとにEMAで実行時間データが平滑化され、3回目でバランス比0.55、4回目で0.70と着実に改善。
5回目では全12シャードが28〜35分のわずか7分幅に収まり、最遅シャードの実行時間が80分→35分と56%短縮されました。
改善前のバランス比0.13(最速シャードが最遅の13%の時間で終わってしまう偏り)が、0.80まで改善されたことが示すとおり、シャード間の負荷がほぼ均等に配分されています。
現在のテスト規模における最適シャード数
シャード数は多ければ多いほど速くなるわけではありません。
現在のテストスイート(約330ファイル)に対して、シャード数ごとの理論的な効果を整理すると以下のようになります。
| シャード数 | 1シャードあたりのファイル数 | 期待される最遅シャード | 備考 |
|---|---|---|---|
| 4 | 約83 | 約100分 | 偏りの影響が大きい |
| 8 | 約41 | 約50分 | 実用的だがまだ余裕あり |
| 12 | 約28 | 約35分 | 現在の設定。バランスと効率の両立 |
| 16 | 約21 | 約28分 | 改善幅が小さくなる |
| 24 | 約14 | 約22分 | セットアップコストの比重が増大 |
12並列を選択した理由は次のとおりです。
- セットアップコストとのバランス: 各シャードはブラウザインストール・依存パッケージ取得などに3〜5分のセットアップ時間がかかります。シャード数を増やすほどこの固定コストの比重が増え、並列化の効果が逓減します
- ファイル粒度の限界: 1シャードあたり28ファイル程度であれば、LPTアルゴリズムが十分な粒度で負荷を均等配分できます。これが10ファイル以下になると、1つの重いテストファイルの影響を吸収しきれなくなります
- GitHub Actionsの同時実行制限: 並列ジョブ数が多すぎるとキューイング待ちが発生し、実際の待ち時間の短縮につながらないことがあります
テストファイル数が倍増するなどスイートの規模が大きく変わった場合は、シャード数の見直しが必要です。
今後の展望で触れる「動的シャード数の決定」により、この判断を自動化することも可能です。
report-mergeジョブの高速化
バランスドシャーディングの実装と合わせて、report-mergeジョブのGCS転送も最適化しました。
gsutilをgcloud storageに全置換(JSON APIベースで大量ファイルの転送が高速)- 3つのGCSアップロード処理をバックグラウンドで並列実行
これにより、report-mergeジョブの所要時間が23分→5分30秒と76%短縮されました。
今後の展望
この仕組みは、テストファイル単位での配分最適化です。
さらなる改善として、以下を検討しています。
- テストケース単位での配分:
fullyParallelモードと組み合わせることで、ファイル内のテストケース単位での分散も可能になる - リトライ時間の考慮: 失敗してリトライするテストは実質3倍の時間がかかるため、不安定なテストの重みを大きくする
- 動的シャード数の決定: 合計実行時間に基づいて、最適なシャード数を自動で決定する
まとめ
Playwrightの標準シャーディングは、テスト数を均等に分割してくれますが、実行時間の均等化は保証しません。
テストスイートの規模が大きくなると、ディレクトリ構成やテスト内容の偏りによって、特定のシャードがボトルネックになりがちです。
今回実装したバランスドシャーディングは、LPTビンパッキングと指数移動平均という比較的シンプルなアルゴリズムの組み合わせで、この問題を解決します。
実行時間データが自動的にフィードバックされる仕組みにより、テストの追加や変更にも自律的に適応します。
同様の課題を抱えているプロジェクトの参考になれば幸いです。
kickflowのQAチームでは、テスト自動化の効率化だけでなく、品質保証プロセス全体の改善に取り組んでいます。
テストの高速化・安定化を通じてプロダクト開発の生産性向上に貢献し、品質と開発速度の両立を実現することがQAチームの重要なミッションだと考えています。
品質保証の技術的な課題解決に興味がある方、一緒にkickflowの品質を支えていただける方を募集しています。
ぜひ採用サイトをご覧ください!