エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

GitLab CI での RSpec 実行時間を半分に短縮する

この記事はエムスリー Advent Calendar 2022 の11日目の記事です。

エムスリーエンジニアリングG コンシューマチームの松原(@ma2ge)です。 今回は現在のプロジェクトで動かしている CI の RSpec 実行時間を約半分に改善したことについて書きます。 きっかけは現在扱っているメインプロダクトの CI 実行時間が当時 20 分弱かかっており、レビュー依頼やリリース作業などの運用作業をするにあたり待ちが長く不便だったためです。 コンシューマチームでは毎週技術的負債や改善のための時間を設けており*1、その時間を使って待ち時間を解消するための改善をすることにしました。

2022/11/08 20:37 の皆既月食。天王星食も合わせて起こるという貴重な現象でした。
2022/11/08 20:37 の皆既月食。天王星食も合わせて起こるという貴重な現象でした。

ボトルネックの特定

まずはボトルネックとなっている箇所の特定です。 今回対象のプロダクトで動いている CI のジョブは、RSpec, Jest の自動テストから、RuboCop, ESLint の Linter まで様々なものが動いています。 これらの中で一番遅かったのは RSpec で、他のジョブが 10 分以内に終わっている中、このジョブだけは 20 分ほどと2倍以上の時間がかかっていました。 一方で RSpec の実行時間さえ改善できれば CI 実行時間を半減させることも可能と見込みが立ったので、RSpec を対象にして改善検討することにしました。

ただ RSpec が遅いと行っても、何が遅いのでしょうか? もしかしたら何かの間違いで異常に遅いテストがあるだけかもしれません。 念のためプロファイラで取得して*2、そういったテストがないかも調べましたが、一部遅いテストはありつつも全体的には高速なテストが多い状態で、個別の遅いテストを全て修正するだけでは実行時間を半減させるほどの効果は見込めませんでした。

parallel_tests で並列化

せっかく改善対応をするならそれに見合ったインパクトのある内容を実施したいです。ただ個別のテストはある程度適切な時間で実行されていて、それ以上の改善は簡単ではなさそうだったので処理自体の並列化を検討しました。並列化させることができれば、実行時間を半減させるようなインパクトも出せそうです。 調べたところ RSpec を並列化するための仕組みには parallel_tests gem がよく使われてそうでしたので、こちらを使用することを検討しました。 設定もシンプルで、コード中で影響を与えるのは parallel_tests が使う環境変数の TEST_ENV_NUMBER くらいでした。開発時は TEST_ENV_NUMBER は使わないため nil となり、下記の設定例の通りローカルでの実行には特に影響を与えないので導入しやすかったです。

# rails の config/database.yml より抜粋
# parallel_tests ではジョブを並列化させる際に、
# DB も同じ数だけ作るため `TEST_ENV_NUMBER` を使ってジョブに対応する DB を識別する
test:
  <<: *default
  database: test_db_name<%= ENV['TEST_ENV_NUMBER'] %>

実行も容易で rake parallel:setup, rake parallel:spec を実行することで、それぞれ DB のセットアップ、rspec の実行を、マシンのコア数に応じたプロセスを立ち上げて処理します。

早速 parallel_tests を取り込み、CI ジョブ 1 マシンで 3 並列での実行をしてみたところ、逆に実行時間が 1.5 倍と伸びてしまいました。 詳しく調べてはいませんが、弊社の GitLab CI で使われているマシンのコア数が多くないため、1つのジョブ中で並列実行をしても性能があまりでないことに原因がありそうでした。

parallel_tests と GitLab CI の parallel で並列実行

parallel_tests 単体では結果が振るわなかったため、次に CI ジョブを複数のマシンで実行することを検討しました。 parallel_tests ではジョブを複数マシンで独立しての実行もできます*3。 この場合は rake parallel:spec の代わりに、parallel_rspec-n および --only-group とともに使うことで、1プロセスでの実行に切り替えます。 また、幸い GitLab CI にも parallel という仕組みがあり*4、下記のように parallel でジョブ数を指定することで並列数を指定しての実行ができます。これを行うと GitLab CI 側で事前定義した環境変数、CI_NODE_TOTAL (並列数をいくつに指定したか) および CI_NODE_INDEX (並列化したうち実行されるジョブのインデックスがいくつなのか) が使えるようになり、並列化されたそれぞれのジョブに指定できます。

# .gitlab-ci.yml での parallel 設定例の抜粋
rspec:
  parallel: 3 # いくつのマシンで並列実行するか
  script:
    # 並列化したマシン内では常に 1 プロセスでしか実行されないため
    # `parallel:setup` を使わず `db:schema:load` を使って 1 つの DB だけセットアップする
    - bundle exec rake db:schema:load
    # `-n` および `--only-group` によって index で割り振られたテストだけ実行
    - bundle exec parallel_rspec -n $CI_NODE_TOTAL --only-group $CI_NODE_INDEX
  artifacts:
    expire_in: 4 week
    when: always
    reports:
      junit:
        - junit.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage/coverage.xml

こちらを試したところ 3 並列で約半分の 10 分弱で RSpec の実行が完了するようになりました。 また上記 GitLab CI の設定ファイル記述例を見ると分かるのですが、parallel に指定した数値を変えるだけで並列数の調整が可能なので、今後の状況に応じて増減が用意なのも嬉しいポイントです。

一方でジョブを並列化した副作用によってカバレッジレポートも複数ジョブで分散して出力され、カバレッジを正確に出せなくなっており課題感が残っています。一応定期スケジュールで実行しているジョブが別途あり、そちらは並列化せずに処理しているため見ることが可能な状態とはなっています。 同じようにテスト結果のレポートも JUnit XML 形式で分散して出力されるのですが、こちらについては GitLab が集計してくれるため問題なく見れています*5

まとめ

毎週継続的に行っている改善活動で取り組んだ一事例として CI の実行時間改善を紹介しました。 RSpec の実行時間を減らすことで、CI の待ち時間が減り日々の運用が快適になりました。 また並列化の対応でみたように、今後時間が増えた場合にもちょっとした対応ですぐにスケールできる仕組みとなったのもよかったです。

一方でまだまだ改善できる点はあるので、プロダクトを改善していってくれる仲間を募集中です。ご興味があればお気軽にお問い合わせください。

jobs.m3.com

参考リンク

*1:チームの紹介スライドにある「割れ窓直しタイム」のことです。スライドはこちら https://speakerdeck.com/m3_engineering/introduction-of-askdoctors-engineering-team?slide=16

*2:RSpec の --profile オプションで調べられます https://relishapp.com/rspec/rspec-core/docs/configuration/profile-examples

*3:Wiki に設定例の記載があります https://github.com/grosser/parallel_tests/wiki/Distributed-Parallel-Tests-on-CI-systems

*4:https://docs.gitlab.com/ee/ci/yaml/#parallel

*5:GitLab 15.1 以降で正常に表示されるようになったようで、継続的に GitLab を最新版にしてくれている SRE チームに感謝しています https://docs.gitlab.com/ee/ci/testing/unit_test_reports.html