Railsのテスト実行時間を1/3まで短縮した話 (Rspec + CircleCI)
背景
CIのビルド実行時間の長さは度々社内で問題になっており、「CIに時間かかるので業務効率が下がる」といった話が現場でも増加していました。
業務時間中は複数 Pull Request のビルドが発生するので、「CI順番待ちで次の自分のビルドは1時間後」のような現象は日常茶飯事でした。
おまけに「頻度は低いがランダムで失敗するテスト」といった false positive なテストの存在も、開発現場のフラストレーションは溜まる原因になっていました。単純に CI を再ビルドすれば直るのか、自分が追加した実装/rspecに問題があるのか一件判断がつかないケースだと、開発担当者も一旦 CI 上で再ビルドを行い、同じ箇所でコケるのか確認することになるので、これがさらなる CI 渋滞を引き起こしていました。
こうした背景もあり、丁度プロジェクトの隙間で工数もあったので「CI 改善をしっかり工数確保してやろう」ということで対応が始まりました。
問題解決のアプローチ方法
CI 速度改善のためには以下2種類のアプローチがありました。
- 既存インフラを前提にコードを改善 (CircleCI + Rspec)
- コードは変更せず、インフラ側のアプローチで解決 (CircleCI > Buildkite移行し分割実行)
AnyPay 社のテスト高速化自体はまったく未対応という訳でもなく、各エンジニアが1日くらいの工数の範囲内では定期的なリファクタ作業も行っておりました。そのため着手前の段階では個人的には劇的な速度改善できる余地があるのかな、という印象がありました。
Rails のテスト実行時間を60分から6分に短縮するまでhttp://tech.smarthr.jp/entry/2017/10/24/153000
SmartHR 社の上記の記事も見ていたので、Buildkite によるテストの並列分割実行もチャレンジしてみてもいいかなと感じましたが
- 現状の CircleCI 上の各フェーズの実行時間をしっかり見直すと、意外と現状にも改善余地もありそう
- 業務委託エンジニアなど権限的にインフラを触りにくいメンバも本 CI 改善プロジェクトに巻き込めるようにする
という制約条件もあったため、ひとまずSlack上にテスト高速化チャンネルを作成し、こういった改善が好きそうなエンジニアを招聘し、正攻法で対応することにしました。
- CircleCI 上の各フェーズごとの実行時間をみて、一番効きそうな施策を対応
- 遅いテストを優先的にテスト自体の書き方の改善
- testprofの指摘箇所を修正: https://github.com/palkan/test-prof
構成前提
Ruby
- rails (5.1.4)
- rspec (3.6.0)
- parallel_tests (2.19.0)
- テストケース: 約4,000 (request/model spec)
CircleCI
- version 2.0
- resource_class: xlarge (8CPU/16384MB)
- concurrency: 4
- parallel_tests: parallelism 4
実行時間 Before/After
「5分を切ったら最高」という目標を立て、最終的にmasterブランチは 4分36秒 まで高速化することに成功しました。
施策1: 簡単に導入出来て効果あったもの
Bootsnap 導入 (4分削減〜)
Rails 5.2 より Gemfile に標準導入された gem です。AnyPay 社のコードベースでは導入しただけで、なんと4分近い速度削減がありました。
Spring は Railsを事前ローディングしておくことで高速化しますが、Bootsnap はそもそものRailsの起動処理自体をキャッシュ化して高速化します。
CircleCI の config.yml
に Bootsnap より生成されたキャッシュファイルもキャッシュ化をさせます。
実際の Rspec が動きだす前にも DB Setup など色々な箇所で Rails の起動/停止が呼ばれており、相当な起動コストがかかっています。上記のようにキャッシュを設定することで、並列コンテナ間でも Rails 起動時間が短縮されるので結果として絶大な効果がありました。
parallel_tests の setup を一個にする (1分削減〜)
Circle CI の DB Setup フェーズに 5分かかっており、頭を悩ませていました。parallel_tests
を実行するためには、テスト用のDBを並列数分作成します。(AnyPay社は4つ)
当時は「db:seed
しないほうが早い」という理由でこのような実装になっていましたが、実際には2段階に分けてしまうと Rails 自体の起動回数が2倍になるため、setup
で一発だけ呼び出すほうが1分ほど早くなりました。(5分→4分)
しかし Bootsnap 導入後に、そもそもRailsの起動が超高速化し、 DB Setup が 9秒で完了するようになったので、この対応の最終的な影響度は低くなりました。
simplecov の結果を CircleCI では出力しない (1分削減〜)
昔に導入されていて当時は利用していたが、今は使っていなさそうなものを洗い出しました。rspec の実行内容を観察してみると、テスト実行後にこの処理に各コンテナがそれぞれ1分ほど時間を掛けて実行していました。
必要な時に起動する方針で削除。まるごと1分ほど時間短縮に成功しました。
こちらの Gem 以外は一通り活用されていたため、特に削除するものはありませんでした。
test-prof: let_it_be helper (1分削減〜)
通常の let
とほぼ同じ記法で、生成オブジェクトを before(:each)
から before(:all)
に書き換えてくれる syntax sugar です。
テスト毎に都度 DBインサート& ロールバック するよりは before(:all)
で一度だけ作成して、同じデータを作成して、各テストケースで使いまわしたほうが早いのは言うまでもありません。しかし各テストケースの独立性という観点では、あまり推奨もできません。
let_it_be
helper であれば、書き方を let
から let_it_be
にするくらいなので、非常にライトに書き換えられます。影響の少ない read only なテストケース周辺で利用を行い高速化を図りました。paymo のタイムライン周辺や、管理画面の admin user のテストケースなどに適応していきました。
施策2: 地道にやって効果あり
FactoryBot で DBインサートしすぎている (2分削減〜)
User
オブジェクトを FactoryBot
経由で作るだけでも複数の callback 処理が走り、とにかくコストがかかる状態でした。「User A
と User B
の paymo での割り勘が、User C
のタイムラインに見えないか確認する」といった複数ユーザが絡むテストケースも沢山に存在し、このコストは無視できません。
こちらに関しては地道に不要そうなものをピックアップして削除していきました。
FactoryBot
でbuild_stubbed
で利用できる箇所は利用するFactoryBot
で association 記述になっていない箇所を修正する- テストに不必要な trait は取り除く
- テスト時には
audit gem
を必要最低限のみ動作するようにする - テスト時には不要なオブジェクト作成の callback 処理は skip するようにする
51.times
といった信じられない量のオブジェクト作成をしているテストケースで量を減らす
CI高速化プロジェクトの枠内では、Users
クラスといった全テストに関連するものから対応し、徐々に範囲を広げていきました。
一気にソースコード全適応は工数的にも厳しいので、適度な所でこの対応は切り上げ、残りは関係者のRailsエンジニア全員に啓蒙活動を行い、日頃の開発/Pull Requestレビューの中で徐々に対応していくようにしました。
施策3: 少しだけ効果あり
Dockerイメージの最適化
テスト実行時にmysql など各種イメージを利用しますが、Circle CI がテスト用にチューニングしたベースイメージを用意しており、利用を推奨しています。例えば mysql:5.6
からcircleci/mysql:5.6
に書き換えるだけで CI 環境に最適な状態を手に入れることが出来ます。
しかし DB に関してはそこまで効果を体感できませんでした。理由としてそもそもDatabaseCleaner 経由でDBロールバック処理を行うため、実書き込みが殆ど発生せず、ここでオーバヘッドにはなっていませんでした。
bundle install を並列インストールにする
jobs オプションでインストール時の並列数を指定できます。j4
とすれば4並列です。
但し、基本的に vendor/bundle
ディレクトリ配下に関しては Gemfile.lock
のチェックサム値をキーとして、CircleCI で丸ごとキャッシュする設定を入れているため、新しい Gem を含んだ開発ブランチでない限り、問題は発生しませんが、数文字追加だけで損もないので、追記。
見送り案
KnapsackProの導入
全体の CI 実行時間=一番遅いコンテナが終わる時間です。すなわちテストを複数コンテナで実行している場合では、全コンテナが同じ時間でテストが終われば理想の状態になります。KnapsackPro はその名の通り、このナップサック問題をいい感じに解いてくれるサービスです。
現状ではテストファイルの分割にデフォルトで用意されているsplit-by=timing
を利用しています。その状態でもコンテナのテスト実行時間のズレは 1分 の枠内で収まっているので、一旦見送りになりました。
システムの導入は簡単なので、またテスト時間が伸びて来た時に検討しようと思います。
We’re hiring!
AnyPay では各種エンジニアを絶賛採用中です。皆様のご応募をお待ちしております。