8,706回のINSERTがCIを殺していた — Rails CI 55%短縮の全手順

CIが8分を超えていました。1日に10回pushすれば、待ち時間だけで80分になります。開発のテンポが悪くなるし、プルリクエスト(PR)のフィードバックループも遅れます。

結果から書くと、CI実行時間を8m08s → 3m37sまで短縮できました。55%の削減です。しかも最初のPRではプロダクションコードの変更は一切なく、CI設定とDB設定だけで47%削減しています。

この改善はRailsアプリのCI(GitHub Actions、matrix 5分割、MySQL使用)で行ったもので、似た構成のプロジェクトならそのまま適用できる部分が多いはずです。この記事では、インフラ層の最適化(第1弾)とアプリ層の最適化(第2弾)の2段階に分けて、やったことを順に書いていきます。

改善の全体像 — なぜインフラ層から着手したか

graph LR
    A["改善前<br/>8m08s"] -->|"第1弾: インフラ最適化<br/>-47%"| B["4m20s"]
    B -->|"第2弾: アプリ最適化<br/>-17%"| C["3m37s<br/>トータル -55%"]
    style A fill:#ff6b6b,color:#fff
    style B fill:#feca57,color:#333
    style C fill:#48dbfb,color:#333

改善前のCI構成

対象はRailsアプリのCIで、GitHub Actionsのmatrixで5グループに分割してテストを実行していました。改善前の各ジョブの実行時間は以下のとおりです。

ジョブ 実行時間
models 8m08s
http 5m44s
rubocop 5m04s
tasks 4m02s
lib 2m22s(揺らぎあり、最大5m59s)

全体のCI時間は最も遅いジョブに引きずられるため、modelsの8m08sがボトルネックでした。

「コードを変えずに効く施策」から始める判断基準

最初にインフラ層から手をつけたのは、3つの理由があります。

  • プロダクションコードに触らないので、デグレの心配がない
  • CI設定やDB設定の変更はレビューの負担が小さい
  • 変数が少なく、何が効いたかを切り分けやすい

第1弾 — インフラ層の最適化(8m08s → 4m20s)

MySQLの設定変更で得た最大の効果

テスト実行中のMySQLに対して、以下の設定を適用しました。

SET GLOBAL innodb_flush_log_at_trx_commit = 0;
SET GLOBAL sync_binlog = 0;

innodb_flush_log_at_trx_commit = 0 はトランザクションログのディスク書き込みを遅延させる設定です。sync_binlog = 0 はバイナリログの同期を無効化します。どちらもクラッシュ時のデータ損失リスクがありますが、テスト環境では問題ありません。

`innodb_flush_log_at_trx_commit = 0` と `sync_binlog = 0` はテスト環境専用の設定です。本番環境に適用するとクラッシュ時にデータを失う可能性があるため、絶対に本番では使わないでください。

この変更だけでCI全体のI/O待ちが目に見えて減りました。テストはDBへの書き込みが多いため、ここが一番効きました。

ランナーサイズの適正化

rubocopや通知等の軽量ジョブに大きめのランナーを割り当てていたのを、小さいランナーに変更しました。CPU負荷が低いジョブに高スペックなランナーは過剰で、リソースの無駄になっていました。ランナーのキュー待ちが減る副次的な効果もあります。

flaky test修正 — DateTime.nowの罠

CI上で断続的に失敗するテストがありました。原因は DateTime.now を使った時刻比較で、メソッド実行とアサーションの間に秒が変わると比較が失敗するパターンです。

# 失敗するケース
result = subject.execute
expect(result.created_at).to eq(DateTime.now)

# 修正: Timecop.freezeで時刻を固定
Timecop.freeze(Time.current) do
  result = subject.execute
  expect(result.created_at).to eq(Time.current)
end

`Timecop.freeze` で時刻を固定する際は `Time.current`(ActiveSupport)を使います。`DateTime.now` と `Time.current` を混在させるとタイムゾーンの違いで別の失敗パターンを生むことがあります。

flaky testそのものはCI時間に直接効かないように見えますが、失敗→リトライのコストが「CIが遅い」という体感に上乗せされていました。リトライが減ったことで体感の改善にもつながっています。

第1弾の成果

このPRでは プロダクションコードを1行も変えずにCI実行時間を47%削減 できました。8m08s → 4m20sです。

ただし、ここからさらに削れる余地がアプリ層に残っていました。FactoryProfで計測したら、想像以上の数字が出ました。

第2弾 — アプリ層の最適化(4m20s → 3m37s)

test-profで可視化する — 8,706回INSERTの発見

ボトルネックの特定にはtest-profのFactoryProfを使いました。

FPROF=1 bundle exec rspec spec/facades

この結果を見て驚きました。あるファクトリが8,706回も個別にINSERTされていたのです。テストごとに300件 x 4グループ = 1,200件を個別INSERTしており、それが7テスト分ありました。

バルクINSERT化(8,706回 → 56回)

activerecord-importを使って、個別INSERTをバルクINSERTに置き換えました。

# Before: ループで個別INSERT
group_ids.each do |group_id|
  300.times do
    create(:record, group_id: group_id,
                    owner_id: owner.id)
  end
end

# After: バルクINSERT
records = group_ids.flat_map do |group_id|
  300.times.map do |i|
    Record.new(group_id: group_id,
               owner_id: owner.id,
               name: "record_#{i}")
  end
end
Record.import(records, validate: false)
  • Before: 8,400回の個別INSERT(7テスト x 1,200件)
  • After: 56回のバルクINSERT(7テスト x 4グループ x 2回)

バルクINSERT化ではバリデーション対象カラムの値を元のテストと揃えてください。`validate: false` でスキップしていても、データの整合性が崩れるとテストの意味が失われます。

実際に、元のfactoryで設定されていた namestatus の値をそのままバルクINSERT用の配列に反映させました。ここを手抜きするとテストがパスしても検証の意味がなくなります。

テストの重複集約 — 300行→30行のパラメタライズ

同じ構造で値だけが違うテストが20個並んでいました。これを shared_examples とパラメタライズで集約し、300行→30行に圧縮しました。HTTPモックの重複も共通ヘルパーに抽出しています。

テストコードの行数が減ればメンテナンスコストも下がりますし、新しいパターンを追加するときのハードルも下がります。

CI Matrixの再バランス

第1弾の時点でボトルネックだったグループからfacadesを独立させ、4グループ→5グループに再分割しました。各グループのテスト行数のバランスを見直し、最遅ジョブの実行時間が突出しないように調整しています。

結果のまとめ

gantt
    title CIジョブ実行時間の比較
    dateFormat mm:ss
    axisFormat %M:%S
    section Before
    models (8m08s)         :a1, 00:00, 8m
    http (5m44s)           :a2, 00:00, 5m44s
    rubocop (5m04s)        :a3, 00:00, 5m4s
    lib (5m59s)            :a4, 00:00, 5m59s
    tasks (4m02s)          :a5, 00:00, 4m2s
    section After
    tasks (3m37s)          :b1, 00:00, 3m37s
    facades (3m32s)        :b2, 00:00, 3m32s
    http (2m27s)           :b3, 00:00, 2m27s
    models (2m06s)         :b4, 00:00, 2m6s
    lib (1m42s)            :b5, 00:00, 1m42s
    rubocop (1m40s)        :b6, 00:00, 1m40s
施策 Before After 改善率
第1弾: インフラ最適化 8m08s 4m20s -47%
第2弾: アプリ最適化 4m20s 3m37s -17%
トータル 8m08s 3m37s -55%

:::details ジョブ別の詳細

ジョブ Before After 改善率
全体(最遅) 8m08s 3m37s -55%
models 8m08s 2m06s -74%
facades(新設) (旧libに含む) 3m32s
lib 5m59s 1m42s -72%
tasks 4m02s 3m37s -10%
http 5m44s 2m27s -57%
rubocop 5m04s 1m40s -67%

modelsは74%の削減で、MySQL設定変更の恩恵を最も受けたジョブでした。rubocopはランナーサイズの適正化が効いています。

改善プロセスから得た判断基準

「インフラ層 → アプリ層」の順番が有効な理由

インフラ層の施策は全ジョブに横断的に効きます。MySQL設定の変更はmodelsだけでなく、DBを使うすべてのテストグループの時間を短縮しました。

もう1つの利点は、インフラ最適化後の数値をベースラインにすると、アプリ層の改善効果を正確に測れることです。変数を減らしてから次の改善に進む方が、何が効いたかを見失わずに済みます。

投資対効果の見極め方

FactoryProfの出力を見れば、「どのファクトリが何回呼ばれているか」が一目でわかります。呼び出し回数が多い箇所ほど、バルクINSERT化やテスト集約の効果が大きいです。

自分の場合は8,706回という数字を見た時点で、ここを直せば確実に効くと判断できました。勘ではなく数値で優先順位をつけられるのがFactoryProfの強みです。

:::details やらなかったこと

  • CIのコンテナ化(container directive)はOS互換性の問題で見送りました
  • テスト並列数の追加はコスト増とのバランスで保留しました

「やらない判断」もCI改善の一部だと考えています。すべてを一度にやろうとすると、どの施策が効いたか切り分けられなくなります。段階的に進める方が、各施策の効果を正しく評価できます。

CI高速化チェックリスト

自分のプロジェクトで確認すべきポイントをまとめました。

おわりに — 明日からやれること

CI改善をこれから始めるなら、以下の順番で試してみてください。

  1. MySQL設定変更(5分で完了): innodb_flush_log_at_trx_commit = 0sync_binlog = 0 をCI環境に適用する
  2. FactoryProfを実行(10分で完了): FPROF=1 bundle exec rspec で呼び出し回数の多いファクトリを特定する
  3. 呼び出し回数が1,000回を超えるファクトリがあれば、バルクINSERT化を検討する

今回の改善で一番よかったのは、PRを2つに分けたことで「何が効いたか」を数字で説明できたことです。チームへの説得材料にもなりましたし、次に何をやるべきかの判断も楽になりました。

コメント

タイトルとURLをコピーしました