CircleCIの消費クレジットとRSpecの実行時間を半減させるために行った9の手順
概要
この記事はCircleCI Advent Calendar 2020の9日目の記事です。
スタディスト開発部の笹木です。今年に入ってからは開発基盤チームという位置づけで、開発環境の整備や、CI含むテスト自動化周りを担当しています。
本記事では、RSpecのテスト実行時間を半減させ、CircleCIの消費クレジットを大幅削減した取り組みについてご紹介します。(消費クレジットについては後述します)
改善の結果が以下のグラフで、定点観測しているジョブの消費クレジットが、ピーク時の半分にまで落とせていることがわかります。もちろんテストコードを減らすといった本末転倒なことはしていません。
実施した取り組みは以下の通りです。
- CircleCIの料金体系を知る
- 問題を認識する
- CircleCIの利用状況を可視化する
- RSpecの実行状況を可視化する
- 特に遅いテストをチューニングする
- FactoryBotの全体最適化
- CI上でのRSpec実行時に、Railsのログを吐かないようにする
- 並列テストをファイルサイズ別から実行タイミング別に変更
- CIでの実行マシンのスペックを下げ、並列数をあげる
- 可能な限りCircleCIのビルド済みイメージを使用する
0. CircleCIの料金体系を知る
本記事では、CircleCIにおける消費クレジット という概念が大きな役割を持っています。
はじめに消費クレジット含め、CircleCIの料金体系についておさらいします。
クレジットは、マシンのタイプとサイズ、Docker レイヤー キャッシュなどの有料機能に基づく使用料の支払いに充てられます。
CircleCIには、Free, Performance, Custom の3段階のプランが用意されており、弊社では Performance プランを利用しています。
Performanceプランは下記表のとおり、「ユーザ数に応じた定額課金」と「クレジットの消費量に応じた従量課金」の組み合わせで料金が確定します。
ユーザ数についてはCIを利用する開発者の数と同じになりますが、クレジットはCIの実行時間、実行マシンの性能、その他オプション機能の利用によって消費量が決定するため、CIの運用方法次第で大きく変わります。
本記事では、CIを1回まわすごとに消費したクレジット量を消費クレジットと表記し、そこに着目した改善を施します。
1. 問題を認識する
まず、なぜCIを改善しようと動き出したかのキッカケについてです。
それまでは漫然とCIを動くまま使い倒し、テストケースの増加に伴い、CI実行時間及び消費クレジットが増え続けていくことを受け入れていました。 正確に言えば受け入れていたのではなく、問題を認識できていないのでした。
そんなある日、CircleCIのプロジェクトページを眺めていると利用状況のページが目に留まりました。
「あれ、なんか今月ものすごい消費クレジットがハネてるな…。」
キッカケとしてはそれだけだったのですが、よく見てみると、今月たまたまハネていたのではなく、今年に入ってから急速に増えていき、半年のスパンで見ると大幅に予算を超えていたことがわかりました。
組織がスケールすればテストの総量及びCI実行回数が増えるため、消費クレジットが右肩上がりになるのは自然です。
しかし、それを考慮しても上がり幅が異常だと気が付きました。
2. CircleCIの利用状況を可視化する
アプリケーションのパフォーマンス改善の世界には、「推測するな、計測せよ」という名言があります。
これは、改善すべき対象を闇雲に改善するのではなく、正しく計測、評価し、目標に向かって最短経路での改善を実現しようという話で、アプリケーションに限らずCI周りの改善でも当てはまると思います。
そこで、まずは現状を可視化することがスタートだと考え、実行に移しました。
CircleCIのAPIでは、Insightsという、ワークフロー及びジョブの実行結果のサマリを取得するエンドポイントが提供されています。
例えば、ワークフロー “test” が定義されており、その中のジョブ “rspec” が、masterブランチで実行された場合のサマリは以下のURLで取得することができます。(リポジトリがGithubでホスティングされている場合)
APIを叩くと、以下のように該当するジョブの「ID」「開始日時」「終了日時」「実行時間」「ステータス」「消費クレジット」を取得することが出来ます。
{
"next_page_token" : null,
"items" : [ {
"id" : "30c1a7f0-1234-5678-9012-4dd8d44ef738",
"started_at" : "2020-08-11T05:52:04.126Z",
"stopped_at" : "2020-08-11T05:58:32.723Z",
"duration" : 388,
"status" : "success",
"credits_used" : 417
} ]
}上記で言う実行時間(duration)と、消費クレジット(credits_used)が評価の対象になります。
このAPIを定期的に叩くスクリプトをGAS(Google Apps Script)で作成、スプレッドシートで自動集計するようにし、現状の可視化を行えるようにしました。
3. RSpecの実行状況を可視化する
続けて、RSpec によるテストについても、どのテストが遅いのかを可視化します。
RSpec には、 — profile というオプションが用意されており、これを指定して実行することで、特に実行時間がかかっているテストケース及びファイルをプロファイリングすることが出来ます。
Top 100 slowest examples (192.87 seconds, 8.2% of total time):
HogeController ….
5.61 seconds ./spec/controllers/hoge/hoge_controller_spec.rb:29
Foo#Bar ….
3.32 seconds ./spec/models/foo/bar_spec.rb:48
Hige#Piyo ……
3.28 seconds ./spec/models/hige/piyo_spec.rb:53
(中略)Top 100 slowest example groups:
Hoge#show
2.59 seconds average (20.72 seconds / 8 examples) ./spec/requests/api/v2/hoge/show_spec.rb:8
Fuga#export
2.23 seconds average (17.86 seconds / 8 examples) ./spec/requests/api/v2/fuga/export_spec.rb:12
Foo
1.96 seconds average (13.75 seconds / 7 examples) ./spec/models/foo_spec.rb:5
Bar
1.8 seconds average (1.8 seconds / 1 example) ./spec/models/bar_spec.rb:6
contents#index
(以下略)
このプロファイリングは、1回限りではなく、定期実行して改善の進捗を確認したいので、CIで定期実行している、テストカバレッジ計測ジョブに仕込みます。
bundle exec rspec spec/ --format progress —-profile 100これでRSpec単体で見たときのパフォーマンスも監視できるようになりました。
4. 特に遅いテストをチューニングする
個々のテストコードをチューニングするよりも、実行基盤やテスト全体の最適化をしたほうが効果的。というのは頭では理解していましたが、前項で個々のテストのパフォーマンスを可視化してみたところ、思った以上に極端に遅いテストというのが多数あることが発覚しました。
一部のテストが極端に遅いという状況は、テストの総実行時間に影響を与えるのはもちろん、後述するタイミングデータに基づいたテストの並列化が正しく機能しない問題も抱えています。
極端に遅いテストには、以下のような特徴があったので、費用対効果を考慮しながら、テストの軽量化に取り組みました。
テスト観点において必要以上のデータが作られている
何をテストするのかを明らかにし、そのための必要十分な量のテストデータのみを作成するように修正します。
意図通りのテストが通ることにだけ安心してコミットしてしまいがちですが、テスト実行時のRailsのログを見ながら、不要なデータが生成されていないかをチェックします。
テスト観点が重複している
テスト観点の重複は、あとからジョインしたメンバーが、元々書かれていたテストコードをコピペして量産する場合などに起こりがちです。
多くの場合はコピペでも十分なテストコードでも、テスト観点を明らかにすることで、「このテストケースと、あそこのテストケースは重複しているな」といった判断が出来ます。
テスト観点が多すぎる
テストデータ生成などの前準備が高負荷なのに、テストの観点が多く、 it あるいは example が大量にあるようなテストの場合、その数だけ前準備処理が走ってしまい、テスト全体が非常に低速になってしまいます。
そういう場合は可読性、パフォーマンスとのトレードオフにはなりますが、 Aggregating Failures を用いて、一つのテストケースで複数の観点をチェックするようにすると良いでしょう。 (※これは比較的妥協案です)
5. FactoryBotの全体最適化
弊社では、RSpecによるRailsアプリケーションのテストのためのファクトリに、FactoryBot を使用しています。
これはActiveRecordによる実データの生成を、手軽かつ柔軟に行うことができるツールで、様々な状況のデータを必要とするリクエストスペックなどでは特に重宝します。
しかし、その柔軟さから、一度作られたファクトリは魔法のように動き、どんなデータが作成されるのかが使い手からは想像がつかなくなってしまう場合があります。
以下は実際にあったファクトリコードを模倣したコードになります。
FactoryBot.define do
factory :folder do
name { SecureRandom.hex[0..5] } trait :with_users do
after :create do |instance|
instance.users.create(FactoryBot.create_list(:user, 5))
end
end
end
end
FactoryBot.create(:folder, :with_users) を実行すると、フォルダ及びそのフォルダに所属しているユーザが5人生成されるというファクトリーです。
お気づきかと思いますが、with_users という trait は、「ユーザが5人参加しているフォルダを作りたい」という非常に限定的な条件下でしか活用できません。
しかし、後からジョインし、既存のテストコードをコピーして新しいテストを書くメンバがいた場合、 「ユーザが1人参加しているフォルダを作りたい」 場面でも、この with_users をつい使ってしまいます。
すると、余分な4人のユーザ(及び中間テーブルレコードなど)が作成されてしまい、冗長なテストデータの生成によってテストコードのパフォーマンスがダウンします。(そしてテストは構わずオールグリーンになるため、この事実に気づきません)
上記コードの場合、trait を以下のように改修することで改善できます。
transient { users_count { 1 } } # 指定できるようにするtrait :with_users do
after :create do |instance|
instance.users.create(
FactoryBot.create_list(:user, users_count)
)
end
end
transient を用いて、生成するユーザ数を指定できるようにします。さらに、そのデフォルトを1にすることで、意図せず with_users を使った場合も最小の1人のユーザ生成だけですみます。
また、 「ユーザが5人参加しているフォルダを作りたい」というケースでは、意図するユーザが不足するのでテストが失敗し、明示的にusers_count を指定するように促すことで、常に必要最小限のデータ生成で済ませることができます。(テストコードの書き方次第ではありますが)
上記の例はやや極端ではありましたが、このように意図せずにテストデータを大量生成してしまう罠の潜んだファクトリコードが多々あったため、それらを一掃することで大きく改善することができました。
6. CI上でのRSpec実行時に、Railsのログを吐かないようにする
シンプルながらこれが非常に強力でした。
RSpec では、各テストコードを実行するたびに、多量のデータ参照、作成、更新が行われるため、デフォルトの設定では大量のログがファイルに書き出されることになります。
しかし、CIコンテナ上で吐き出されたRailsログを見る機会というのは、非常に稀で、SSHでアクセスしたときぐらいでしょうか。
そんなエッジケース中のエッジケースのために常に大量のログを出力するというのは非効率だったため、config/environments/test.rb に以下の設定を追加しました。
config.log_level = ENV[‘CI’].present? ? :fatal : :debugこれによって、ローカルマシン上でのテスト時にはログを全て出力し、CI上ではエラー情報以外は出力しないという状況が作れます。
(CI は、CircleCI 側がCI上での実行時に注入してくれる環境変数です)
7. 並列テストをファイルサイズ別から実行タイミング別に変更
CircleCI では、一つのジョブに対して、複数コンテナで並列実行する仕組みが用意されています。(テストの並列実行)
これまでは、ファイルサイズによる分割を行っていましたが、 4. 特に遅いテストをチューニングする によって、ファイルごとの実行時間が均されるようになったため、満を持してタイミングデータに基づく分割 を活用できるようになりました。
— split-by=timings でタイミングデータに基づく分割を実施し、RSpec実行時にはRspecJunitFormatter を使ってテストレポートを生成します。
- run:
command: |
TESTFILES=$(circleci tests glob “spec/**/*_spec.rb” | circleci tests split — split-by=timings)
bundle exec rspec — format RspecJunitFormatter -o test-results/rspec.xml — format progress — $TESTFILES最後のステップにて、テストレポートをアップロードすることで、次回のテストではこのレポート内容に基づいて最適な分割を行ってくれます。
- store_test_results:
path: test-results8. CIでの実行マシンのスペックを下げ、並列数をあげる
CI実行時の消費クレジットは、「実行時間」と「実行マシンの種類、性能」によって決定します。
これまでのRSpecの改善によって、「実行時間」は大きく改善しました。続けて実行マシンの種類、性能について考えます。
弊社ではDockerコンテナを利用してCIを回しているので、Docker環境のマシンスペックと消費クレジットに関する一覧表を公式サイトから引用します。
さて、デフォルトでは Medium が自動的に選択されるため、クレジットは1分間に10消費されます。弊社ではこれまで10並列でRSpecを実行していたので、1コンテナあたり5分かかるとすると、単純計算で500クレジットを消費します。
しかし、現状のRSpecのボトルネックはあくまで DBなどのディスクI/Oのため、リソースクラスによる違い(CPU,memory) の影響を受けているとは考えにくいです。
そこで、以下のようにリソースクラスを Small に引き下げて、並列数を20に増やします。
parallelism: 20
resource_class: small実際に試してみると、コンテナの性能は半分にしたのに、RSpecのパフォーマンスは半分どころか、せいぜい20%減程度に収まりました。
並列数は倍にしたので、少ない消費クレジットで、テスト全体の実行時間を短くすることに成功しました。
9. 可能な限りCircleCIのビルド済みイメージを使用する
RSpec 自体のステップが高速化してくると、ボトルネックとなってくるのが最初の環境セットアップです。
特に、リクエストスペックを実行するには、各種DBなどのコンテナも別途用意する必要があり、コンテナイメージのプルに時間がかかるようになります。
並列数を20と言わず、30,40にしていきたいのですが、並列数を増やせば増やすほど、環境構築にかかるクレジットの総量は増していき、対して1コンテナあたりのRSpec 実行時間は限りなく0に近づいていくため、相対的に費用対効果が下がっていきます。(消費クレジット量を増やしてもCI実行時間が変わりにくい)そこで、 MySQL や Redis といった単純なDBコンテナイメージについては、CircleCI のビルド済み Docker イメージ を使用することによって、環境構築の一部の負荷を軽減することができました。
- image: circleci/mysql:5.7.17
- image: circleci/postgres:10.1
- image: circleci/redis:4.0.10とはいえ、アプリケーションを動かすために特化したコンテナイメージは、ホスティングしているDockerリポジトリサービスからプルする必要があるため、根本的な解決には到達していないのが現状です。
(DLC を使うのです…という声が聞こえてきそうですが、1ジョブあたり200クレジットを継続的に消耗するのは現在の規模からすると躊躇ってしまいます…)
まとめ
本記事では、CircleCIの消費クレジットとRSpecの実行時間を半減させるために行った9つの手順を紹介しました。
現状の可視化から始まり、テストコードレベル、実行環境レベル、CI設定レベルまで幅広く調べ、結果につなげることも出来たので、個人的には大変満足し、良い経験が出来たと思っています。
これもひとえに、開発基盤チーム という、「重要だが緊急でない仕事」に集中して取り組める環境があったからだと思います。
そんなスタディストですが、現在、マニュアル作成・共有プラットフォーム Teachme Biz を一緒に開発していくエンジニアの仲間を募集しています。

