- バックエンド
- PdM
- 急成長中の福利厚生SaaS
- Other occupations (24)
- Development
- Business
- Other
Rails アプリケーションの CI を2.5倍高速化した話
Photo by Rowan Freeman on Unsplash
ウォンテッドリーでバックエンドエンジニアをしている江草です。
今回は、私たちのプロダクトである Wantedly Hire の Rails アプリケーションにおいて、CI の実行を約2.5倍高速化しコストを大幅に削減した取り組みについてご紹介します。
抱えていた課題
高速化に取り組む前、このリポジトリの CI には大きく2つの問題がありました。
1. CircleCI の料金が高額となっている
対象のリポジトリでは CircleCI を利用していますが、十数年以上前から存在し、ウォンテッドリーにおいて最も巨大な Rails アプリケーションよりも CircleCI の利用クレジットが高額となっていました。Wantedly Hire がリリースから1年未満であることなどアプリケーションの規模を考えると、異常にコストが高い状態でした。
2. テストファイルの実行時間が異常に長い
個々のテストファイルの実行時間が非常に長く、最悪のケースでは1つの spec ファイルの実行に10分以上かかっていました。
このリポジトリでは CircleCI の機能を利用してファイルごとに分割して RSpec の実行が並列化されていましたが、特定の spec ファイルの実行に時間がかかることにより、CI 全体のボトルネックとなっている上にローカルでの作業にも支障が出ている状態でした。
原因の調査
Datadog Test Optimization
CI の中でも特に実行時間が長く料金のかかっている RSpec について、原因を特定するために、Datadog Test Optimization を導入しました。このツールは、Datadog APM と同様の Flame Graph 形式でテストの処理時間を詳細に分析できます。
pg gem などには自動計装が存在しますが、対象のリポジトリでテストデータの作成に利用している factory_bot の計装はデフォルトでは存在しません。そのため、次のように計装を追加しました。
module FactoryBotInstrumentation
module FactoryRunner
def run(runner_strategy = @strategy, **)
Datadog::Tracing.trace(
"run.#{runner_strategy}",
resource: @name,
service: "factory_bot",
) do
super
end
end
end
end
FactoryBot::FactoryRunner.prepend(FactoryBotInstrumentation::FactoryRunner)これにより、個別のテストについて Flame Graph を分析できるようになります。次の画像は実際にあるテストについて Datadog Test Optimization で Flame Graph を表示した画面です。青色の company や company_user などと書かれている部分が factory_bot の実行にかかっている span です。
判明した 2 つの原因
原因 1: Company モデルの作成が極めて遅い
Datadog の分析により、Company というモデルのテスト用レコードの作成に Company 1つあたり約0.8秒かかっていることが判明しました。
この遅さの理由は、対象のリポジトリの特性にありました。
- Wantedly Hire では顧客の会社(テナント)ごとにカスタマイズ可能な項目が多数存在する
- これらの項目にはデフォルト値が設定されており、通常のテーブルのレコードとして表現されている
- このデフォルト値作成ロジックがテスト実行時にも毎回実行されている
テストで Company を1つ作成するたびに、数百のレコードが作成されます。これが 0.8 秒という作成時間の正体でした。
原因 2: factory_bot によって意図せず Company が大量作成される
Rails のテストで factory_bot を使用している場合、関連モデルのレコードが暗黙的・再帰的に作成されることがあります。
例えば、factory_bot で User というモデルに company というアソシエーションを定義していると、create(:user) としたときに暗黙的に Company のレコードも作成されます。これは非常に単純な例ですが、対象のリポジトリでは多くのモデルが非正規化されており、さまざまなモデルから Company への関連が存在していました。その結果、本来意図していない箇所で Company が大量作成される状態となっていました。
この 2 つの原因が組み合わさり、テスト実行時間の異常な長さを引き起こしていました。
対策
Company の作成を減らす
対策として、ボトルネックとなっている Company の作成回数を減らすことを考えました。なお、bulk insert などで作成処理自体を高速化する案も検討しましたが、Active Record の callback がスキップされ既存のロジックへの影響が大きいため採用しませんでした。
テストのほとんどの場所では、本来大量の Company を作成する必要はありません。特に実行時間の長いいくつかの spec ファイルでは個別に対応しましたが、全体として CI の実行時間や料金を削減するには対応箇所が多すぎる状態でした。
テスト間で Company を使い回す
既存のテストの互換性を保ちつつ Company の作成回数を減らす方法として、一度作成した Company を複数のテスト example 間で使い回す仕組みを導入しました。
着眼点
ほとんどのテスト example では、factory_bot で attribute や trait を指定せずにデフォルトの Company を作成しているだけでした。それであれば、初回のテスト example 実行時に作成した Company を、異なる example でも再利用できるのではないかと考えました。
これに近いことを行う factory_bot_cache という gem も存在します。これは同一のテスト example 内で同じモデルのレコードを再利用しやすくするというものでした。今回は既存のテストを修正する手間をできるだけ減らすために、複数のテスト example 間で一度作成したレコードを使い回すという方法を採用しました。
実装
私たちの Rails アプリケーションのテストでは、database_cleaner gem を利用してテストデータの削除などを行っています。
database_cleaner には truncation、deletion、transaction の strategy があります。truncation や deletion はテスト終了時に TRUNCATE または DELETE する strategy、transaction はテストをトランザクション内で実行しテスト終了時に ROLLBACK する strategy です。
今回は transaction strategy を利用することを前提としてキャッシュを実装しました。
最初のテスト実行時(キャッシュなし):
-
Companyが必要になった際、メインのテストトランザクションとは独立したトランザクションでCompanyを作成 - その独立トランザクションを COMMIT して、DB に永続化
- メインのテストトランザクションは通常通り ROLLBACK
次のテスト example 実行時:
-
Companyが必要になった際、キャッシュが存在していれば作成処理をスキップ - 以前のテスト example で COMMIT されたレコード(キャッシュ)を返す
- メインのテストトランザクションは通常通り ROLLBACK
これにより、0.8秒かかる重い作成処理を2つ目以降の example でスキップできます。テスト内で Company や関連モデルのレコードを更新したとしても、その example 終了時に ROLLBACK され作成直後の状態に戻るため、他の example に影響を与えることはありません。
前述の通り、example 終了時にトランザクションが ROLLBACK されてしまうため、キャッシュの作成のためには独立したトランザクションを開始して COMMIT する必要があります。今回は新しいスレッドを作成して、その中でレコードを作成することにしました。Active Record では複数のスレッドが同時に同じ DB connection を利用することはないため、結果として独立したトランザクションを作成できます。
実際のコードは以下の通りです。複数の Company を作成することを意図したテストも存在するため、同じ example の中で暗黙的に同じ Company レコードが再利用されることはない実装になっています。
module FactoryBotCache
module FactoryRunner
def run(runner_strategy = @strategy, **)
return super if runner_strategy != :create
return super if @overrides.present? || @traits.present?
FactoryBotCache.fetch(@name) do
super
end
end
end
class << self
def fetch(name, &block)
cache = try_to_consume_cache(name)
return cache if cache.present?
# DatabaseCleaner の transaction 外でレコードを作成して
# 次回のテストと共有するために、新しい DB connection を作る
record = Thread.new do
yield
end.value
append_cache(name, record)
record
end
private
def try_to_consume_cache(name)
@cache ||= {}
records = @cache[name]
return nil if records.nil?
@next_cache_indexes ||= {}
next_cache_index = @next_cache_indexes[name] || 0
return nil if records.size <= next_cache_index
@next_cache_indexes[name] = next_cache_index + 1
record = records[next_cache_index]
record.class.find(record.id)
end
def append_cache(name, record)
@cache[name] ||= []
@cache[name] << record
end
end
end
FactoryBot::FactoryRunner.prepend(FactoryBotCache::FactoryRunner)結果
これらの取り組みにより、CI の平均実行時間を11分22秒から4分33秒へと、約2.5倍高速化することができました。RSpec の並列実行数は変えていないため、CircleCI の利用クレジットも約50%削減され、大幅なコスト削減を実現できました。
おわりに
今回は、Datadog Test Optimization による分析を活用し、ボトルネックを特定した上で factory_bot にキャッシュを導入することで、CI の実行時間を大幅に削減することができました。1つのモデルの factory_bot がボトルネックになっているケースはあまりないかもしれませんが、参考になれば幸いです。