CircleCI上のRSpecによるテスト実行時間を25min -> 12minに短縮する技術

株式会社アンドパッドのアカウント基盤チームでテックリードをしているid:shiba_yu36です。

最近自分のサイドプロジェクトとして、生産性を向上するために、CI実行時間の短縮化を行っていました。その結果、とくに時間のかかっていたCircleCI上のRSpecによるテスト実行時間を、25min -> 12minに改善できました。そこで今回はどのような流れでCIの実行時間を改善していったかについて、具体的に書いてみたいと思います。実行時間改善の勘所について参考になれば幸いです。

改善の流れ: CircleCIでボトルネック調査し、大きいボトルネックを解消する

速度改善を行うときは、基本的に以下の流れで行います。

  • CI実行時間を計測 & 観察し、ボトルネックを突き止める
  • 大きいボトルネックを解消する
  • 解消後に、さらにCI実行時間を計測し、ボトルネックがどこに移ったか観察する
  • 以下順次、ボトルネックを突き止める -> 大きいボトルネック解消を繰り返す

CircleCIを使っている場合、JobのTIMINGタブ、および、JobのSTEPSタブを観察することで、ボトルネックを見つけられるでしょう。

JobのTIMINGタブ
JobのSTEPSタブ

また速度改善するときは、効果が大きい順に解消していくことが基本です。たとえば次のようにしてしまうと、効果がない、もしくは、効果が薄くなってしまいます。

  • 5minのジョブと25minのジョブが並列で動いている時に、どれだけ5minの方を短縮しても効果はない
  • 25minの中で、実行時間5minのステップと、実行時間1minのステップがあった時、1minのステップを改善しても効果が薄い

この改善の流れを意識して、並列で動いているジョブの中で一番実行時間が遅いRSpecのテストに焦点を当て、CIの実行速度改善を行いました。

実行速度改善の前に: Flakyなテストを一斉に直す

さて実行時間の短縮に移りたいところですが、その時ちょうどRSpecのテストに「たまに落ちる不安定なテスト = Flakyなテスト」が大量に増えてしまい、テストの成功率が68%まで落ちていました。どれだけ速度を改善したとしてもテストの成功率が低いと効果が薄くなってしまいます。なぜなら

  • テストの成功率が68%の場合、3回に1回はテストが失敗し、テストの再実行が必要になってしまう
  • その場合、単純計算すると平均でテスト実行時間が33%伸びる
    • 3回CIを通すのに平均で4回はCIを実行しないといけないため
  • 20minのCIの場合、33%伸びると、それだけで平均27minになってしまう。また、平均で見れば27minだが、実際に落ちた時には最短でも40minかかってしまう

そこでまずはFlakyなテストがあるとまずいよということを周知し、いろんなチームに協力してもらって一気に直すことにしました。この時にCircleCIではテストインサイトのMost Failed TestsやFlaky testsを利用すれば不安定なテストが見つけやすいことを含めてお知らせしました。

協力の結果、テスト成功率をほぼ100%まで回復させることができました。*1

速度改善1: bundle installのキャッシュがうまく効いていなかった問題を修正 -> 4minの短縮

ここからが実行速度改善です。まずTimingタブを観察した結果、なぜかbundle installのステップで異常に時間がかかっていることが分かりました。RSpecのテストは、CircleCI上の並列実行の仕組みを利用し10並列で動かしていたのですが、すべてのコンテナでbundle installが4minほどかかっていました。

さらにSTEPSのログを観察すると、CircleCIのドキュメントを参考にbundle installのキャッシュの設定をしているはずなのに、なぜかキャッシュが効かず、毎回フルインストールが走っていることが判明しました。

調査をすると、以下がその原因でした。

  • RSpecのジョブにはCircleCIのworking_directoryが設定されていた
    • working_directory: ~/andpad のような設定が入っていた
  • working_directoryに~/andpadを入れているjobでは、bundlerは~/andpad/vendor/bundle にキャッシュを作る
  • restore_cache / save_cacheで相対パスを使ってキャッシュするディレクトリを指定する場合、基本的にworking_directoryを無視する。そのため、vendor/bundle ディレクトリをキャッシュする設定をすると、デフォルトの~/projectからのパスである~/project/vendor/bundleディレクトリ以下をキャッシュ保存するようになっていた
  • 結果、bundlerは ~/andpad/vendor/bundleをキャッシュとして使い、CircleCIは~/project/vendor/bundleをキャッシュとして保存しているため、うまくいかなかった

既存の設定を眺めるとworking_directoryを使いたい理由があまりなさそうだったため、解決策としてworking_directoryを使わないようにしました。これによりbundlerがキャッシュを作るディレクトリと、CircleCIがキャッシュするディレクトリが一致したため、キャッシュを使えるようになりました。

この改善の結果、大抵のケースでbundle installが10秒以内で終わるようになりました。これで4minほど削減できました。

速度改善2: developブランチ以外ではカバレッジを取らないように -> 5minの短縮

bundle installの改善後、ほとんどの実行時間がRSpec本体の実行時間になっていました。そのため、次はここを改善したいと考えました。

調査した結果、すべての実行でカバレッジを取るようになってしまっており、それによって実行時間が5minほど増えてしまっていました。

カバレッジレポートは非常に有用ですが、最低限developブランチでさえ取れていれば用途としては十分と考え、そのような改善を行いました。

rails_helper.rb側で環境変数によってカバレッジの有効状態を切り替えられるようにし

if ENV.fetch('COVERAGE', 'false') == 'true'
  require 'simplecov'
  SimpleCov.start 'rails' do
    enable_coverage :branch
    # ...
  end
end

CircleCIの設定側で、developブランチでのみカバレッジを有効にしました。

command: |
    # coverage計測を有効にすると重いのでdevelopのみに限定
    if [ "$CIRCLE_BRANCH" = "develop" ]; then
      export COVERAGE=true
    fi
    # parallel_rspecによる実行
    circleci tests glob "spec/**/*_spec.rb" \
      | circleci tests split --split-by=timings --timings-type=filename \
      | tee -a /dev/stderr \
      | xargs bundle exec parallel_rspec -n $PARALLEL_TESTS_CONCURRENCY

これでdevelopブランチ以外のテスト実行では、カバレッジを取るための5minの実行時間がそのまま削減されたため、実行時間が5min短縮されました。

速度改善3: テスト実行の並列度を上げる -> 4minの短縮

これまでの改善で、むだに実行時間が伸びている部分をかなり無くせました。むだが無くなってきているということは、テストの並列数向上による実行時間削減の効果が上がりやすくなることに繋がります。そこで並列数の変更による実行時間短縮に取り組みました。

元々CircleCI上のRSpecのジョブでは、CircleCIの並列実行の仕組みを利用しlargeリソースクラスを使って10並列で動かしつつ、その中でparallel_tests gemを利用し3並列で動かしていました。つまり10 * 3 = 30並列で動いているということですね。

これを2倍にして60並列で動かすように変更しました。並列数を2倍に増やすパターンとしては、次の2つが存在します。

  • (1) CircleCIの並列数を2倍の20並列にするパターン
  • (2) CircleCIのリソースクラスをlargeの2倍のスペックのxlargeにスケールアップし、parallel_tests gem側を2倍の6並列にするパターン

それぞれを実際に検証してみると

  • (1)も(2)も実行時間やコストはほぼ同程度
  • キャッシュからのリストアが稀に失敗することがあり、CircleCI側の並列数を増やすほど可能性が上がるため、(2)の方が有利
  • CircleCIの並列数を増やすほど、キャッシュなどのネットワークトラフィックが増えてしまう。今後のCircleCIのネットワーク課金がどうなるか不透明だが、トラフィックを減らした方が有利ではあるので、(2)の方が有利
  • CircleCIの並列数を上げると、組織の上限にあたりジョブのキューイングが起こる可能性が上がるため、(2)の方が有利

この結果から、「(2) CircleCIのリソースクラスをlargeの2倍のスペックのxlargeにスケールアップし、parallel_tests gem側を2倍の6並列にするパターン」を実装し、テスト実行時間を4min短縮できました。

ちなみに並列度を上げるともちろんコストも上がります。調査の結果大体20~30%上がることが判明しました。しかし、以下の観点からコストを掛けたい理由を説明し、承認されました。

  • CIの高速化は、PullRequestのマージまでの時間、リリースにかかる時間、hotfixの時間などにすべて効くので、投資効果が高い
  • Flakyテストの削減、bundle installキャッシュ、カバレッジ計測数の減少により、すでに30~40%ほどコスト改善ができていた。これを再投資したい

改善のふりかえり

以上の改善により、開発の大きなボトルネックとなっていたCircleCI上のRSpecのジョブにおいて、元々25minの実行時間だったものを、11minまで短縮しました。

CI改善による生産性の改善の観点にも言及しておきます。今回の改善は3/20~4/1にかけて行いました。その後Findy Teamsを利用して生産性の指標を追いかけたところ、今回対象としたレポジトリの4月の平均プルリククローズ時間が大きく改善されました。4/1時点で87.9hだったものが、4/28では50.3hとなっています。

もちろん平均プルリククローズ時間は他の施策の影響も受けるので、今回のCI改善だけが理由でこのような成果につながったわけではありません。しかし多少なりとも生産性に影響を与えられたかなと思っています。

まとめとPR

今回はCircleCI上のRSpecによるテストの実行時間改善をどのように行ったかについて、具体的に紹介しました。今回の記事がCI改善の参考になれば幸いです。

最後にPRです。現在アンドパッドでは一緒に働く仲間を大募集しています。今回の記事のように、アンドパッドにはCI/CD周りの改善を通して全社の生産性を改善する機会がたくさんあります。興味がある人は一緒に働きましょう。

engineer.andpad.co.jp

参考文献

*1:Flakyなテストの直し方についてはこの記事の本筋からずれるため、詳細は省きます。また別の機会で書きます