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で設定されていた name や status の値をそのままバルク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はランナーサイズの適正化が効いています。

コメント