RailsアプリのCI高速化

参加しているプロジェクトで、RailsアプリのCIの高速化を行った。

まだ進行中の部分も幾つかあるが、結果から言うと、元々8分前後だったテストが3分半程度に短縮された。行った作業を幾つかの観点に分け、どのように高速化を行ったか、どの程度高速化されたか等を記述する。

プロセス数とマシン性能の調整

元々は2コア1プロセス4マシンで8分程度掛かっていたが、8コア8プロセス1マシンに変更することで5分程度に短縮された。

このプロジェクトではCIにGitHub Actionsを利用している。GitHub Actionsではデフォルトで2コアのマシンが利用されるが、Large runnerを利用して8コアに変更した。コア数を2倍にした代わりにマシン数も半分に減らしたので、結果的に費用は変わっていない。

また同時に、8プロセスで並列実行するためにparallel_testsを導入した。このプロジェクトではMySQLとElasticsearchを利用しており、またファイルアップロード機能等もあったので、プロセス間で処理が競合しないように配慮し、flakyなテストも発生しないようにする等の細々とした調整を行った。

並列実行の導入によるflakyなテストの発生は確率的には避けられないだろうと思ったので、flakyなテストが発生したときの報告手順を整理した。具体的には、Issueテンプレートを用意し、発生時にはまずテストファイル名でIssueを検索してもらい、見当たらなければテストファイル名とURL等を記述してもらうという素朴な運用で始めた。

テストケースの配分の調整

過去のテストファイルごとの実行時間を元に、各プロセスに均一にテストファイルを配分することで、5分程度から4分半程度に短縮された。各マシンでの合計実行時間は変わらないが、CI開始から完了までの時間は短縮される。

当初は1プロセス4マシンの時代にこの調整を行っていたので、GitHub Actionsでこの機能を実現するために、r7kamura/split-tests-by-timingsというGitHub Actionを用意して利用していた。今は8プロセス1マシンで実行しているので、parallel_testsの機能でこの調整を行うようにした。もっとテストが大規模化してきて、例えば8プロセス2マシンとかになれば、これらの両方を用いることになると思う。

デフォルトブランチでのCI実行時、各テストファイルの実行時間を記録したXMLファイルを最新の1回分だけ保存しておいて、テスト実行時にこれを参照しているような形。

Dockerイメージの利用

カスタムのDockerイメージを用意することで、テストの起動時間が30秒程度短縮された。

それまでは、CIの実行のたびに都度aptのパッケージをインストールしたり、細かい設定をしたりといったことをやっていたので、それが無くなった。CIにGitHub Actionsを利用している場合、GitHub Container Registry (以下GHCR) との転送量が無料なので、これが非常に低コストに実現できる。CIにGitHub Actionsを利用する強みの一つ。

これに伴い、開発環境でもCI環境でも同じDockerイメージを使うように統一した。GHCRとの認証を済ませていれば、開発環境構築時や更新時に開発者各位の手元でDockerイメージをビルドしてもらう必要はなく、GHCRからイメージがダウンロードされる。認証を済ませていない場合は、フォールバックして手元でビルドされる。

この手のDockerイメージの利用はまだまだ珍しい方だと思うので、CI高速化の文脈からは少し脱線するが、以下で運用における細かいあれこれにも一応言及しておく。

Dockerイメージのバージョン管理等について、現在のところはこれで必要十分なので、ruby-3-0ruby-3-1 のように、Rubyのマイナーバージョンごとにタグを発行して運用している。

アプリの規模や管理コストを鑑みて、MacでもLinuxでもWindowsでも、開発にはDockerを利用する運用で統一している。MacのDocker用ファイルシステムの仕組みはまだまだ遅く使い物にならないレベルだと思われがちだが、この問題はDocker Desktop for MacにMutagen extensionを入れることで大きく改善されるので、この運用で上手く収まっている。

Rubyのバージョン変更

Rubyのバージョン変更により、テストの時間が数秒短縮された。

Ruby 2.4から3.2まで試したが、2.7から3.0にかけての変更が大きかった。

Railsのバージョン変更

Railsのバージョン変更により、テストの時間が40秒程度短縮された。

Rails 5.0から7.1まで試したが、Rails 6.0から6.1にかけての変更が劇的だった。

gem groupの整理

gem groupを整理し、そのジョブで必要なgemだけをインストールさせることで、RuboCop等のジョブの実行時間が10秒程度短縮された。

これはCI全体の実行時間が短くなったというより、速めのジョブがより速く終わってすぐ報告されるようになったという形。

例えばよくあるのが、RuboCop実行用のジョブでdefault gem groupのgemもインストールしてしまっているようなパターンや、test gem groupにrubocopを入れていて、test gem groupのgemを全部インストールしてしまう以下のようなパターン。

# bad
group :test do
  # ...
  gem "rubocop", require: false
  gem "rubocop-rails", require: false
end

好ましいのは以下のようなパターン。

# good
group :rubocop do
  gem "rubocop", require: false
  gem "rubocop-rails", require: false
end

group :test do
  # ...
end

このように分けて BUNDLE_ONLY=rubocop を指定すると、rubocop gem groupのものだけ入るようになる。BUNDLE_ONLYが未実装な古いバージョンのBundlerでは、BUNDLE_WITHOUT=default:development:production:test のようにも書ける。BUNDLE_ONLYはこういう用途のために実装したので、活用してほしい。

eager_load_pathsの整理

Rails.configuration.eager_load_paths を整理した結果、RSpecの起動時間が1秒弱程度短縮された。これはどちらかと言うと、高速化というより別の観点での改善だったが、念のため記述しておく。

具体的には、eager-loadingを行う必要のないディレクトリがここに幾つか含まれていたので、これを適切に整理した。Railsアプリではよく、libをautoload_pathsやeager_load_pathsに登録するような横着を見かけることがある。挙動も予測しづらくなるし、処理効率も悪い。

現在の個人的な好みは、以下のディレクトリを設け、それぞれをautoload_pathsとeager_load_pathsに登録すること。

  • lib/autoload
  • lib/eager_load

但し、Railsアプリではデフォルトでlibが $LOAD_PATHS に登録されるので、そのlibの中にこれらのディレクトリを設けるのは避けるべきという見方もあるかもしれない。それで言うと、以下のようなディレクトリを設けるのが良いのかもしれない。

  • autoload
  • eager_load

Gemfileの整理

Railsアプリ起動時に読み込む必要がないgemに require: false を付けることで、テスト起動までの時間が数百ms程度短縮された。

spec_helper / rails_helperの整理

spec/spec_helper.rb や spec/rails_helper.rb の整理を加えたことで、数秒程度高速化された。

不要な処理が各テストケースごとに実行されていたので、それを取り除いた形。

テストの記述方法の改善

地道な改善だが、最終的にはこれを泥臭く進めていく必要がある。

各テストレイヤーの役割をはっきりさせつつ、ある程度既存のテストコードを模範的な形に書き換えていくような形。改善の最初の方の段階では、静的解析器で検知したり書き換えを進めていったりするのが非常に有効で、具体的にはそのプロジェクトに適したカスタムCopを書きながら進めた。

また、良くないコードは主にコピペで指数的に増えていくので、ある程度手作業になってしまっても、コピーされやすい代表的なテストファイルの内容を早い段階で改善しておくのも効率的。