🚅

既にあるRailsプロジェクトにBulletを導入してN+1を検知する

2024/02/13に公開

こんにちは。
PharmaX でエンジニアをしている諸岡(@hakoten)です。

この記事の概要

Ruby on Railsのアプリケーションで「N+1」というパフォーマンス問題を自動で検知する方法として、「Bullet」という大変便利なツールがあります。

https://github.com/flyerhzm/bullet

この記事では、開発初期の段階でBulletを導入していなかったRailsアプリケーションに、後からBulletを導入した際の運用事例をご紹介します。

※ この記事では、「N+1問題」の詳しい内容については、触れていませんのでご了承ください。

Bulletで検知できることの簡単な説明

Bulletを使うと主に次の3つの問題を検知することができます。

問題の種類 説明
N+1クエリ問題 N+1クエリが発生している箇所を警告する。
Eager Loading 必要のない場所でEager Loadingが使用されている場合に警告します。また、Eager Loadingが必要だが、実際には使用されていない箇所についても警告します。
counter_cache countクエリを使用している箇所で、counter_cacheを使っていない箇所を警告します。

これらの検知については、次の設定で個別に有効/無効を切り替えられます。

# N+1クエリ
Bullet.n_plus_one_query_enable     = false
# Eager Loading
Bullet.unused_eager_loading_enable = false
# counter_cache
Bullet.counter_cache_enable        = false

PharmaXでのBulletの運用方法

具体的にPharmaXでどのように運用を始めたかをご紹介します。

Bulletの実行環境

Bulletは通常、development(開発時)test(CIなどのテスト)で利用されますが、PharmaXではテスト(RSpec)時のみ有効化しています。

主な理由は次のとおりです。

  • テストカバレッジは既に80%程度あるため、テストだけでもある程度の動作担保ができる
  • APIサーバーとしてRailsを使っているため、開発時もテスト実行で検知したほうが使いやすい

段階的な運用

今回は、既にあるプロジェクトに対してBulletを導入するため、「現時点で発生している警告をどうするか?」という問題を検討する必要がありました。

理想としては、CIを含めたテスト実行時にエラーでテストを失敗させたいのですが、既存のエラーを完全に解消するには少し時間がかかるため、最終的に以下の運用フローを取っています。

段階①: テストは失敗させず、Sentryへエラーを送る

PharmaXでは、エラートラッキングツールとして Sentry を使用していて、既存のエラーが解消できるまでは、Sentryへエラーを通知し、CIのテストは失敗させない運用に決めました。

https://sentry.io/welcome/

当面は、定期的にSentryをチェックし、既存のエラーを全て解消することを目指します。

  • environmentは他エラーと分けるために「develop」に設定しています。
  • Bulletからは「UniformNotifier::Exception」という例外が送られます。

段階②: テストを失敗させる

段階①で発生したエラーを修正し、既存のエラー数を0にできたら、Sentryへの通知を停止し、テスト失敗時にエラーを出す運用に移行する予定です。

テストでBulletを使うときの設定

テスト実行時にVulletの検知を有効にするには、次のような設定をしています。

config/environments/test.rb
  config.after_initialize do
    Bullet.enable = true
    # ① まずはsentryにエラーを送信する
    Bullet.sentry = true
    # ② 全ての既存エラーがなくなったら Bullet.raise に切り替えテストを失敗させる
    # Bullet.raise  = true
    # rspecのファイルは除外する
    Bullet.stacktrace_excludes = ["_spec.rb"]
  end
spec/rails_helper.rb
  if Bullet.enable?
    config.before(:each) do
      Bullet.start_request
    end

    config.after(:each) do
      Bullet.perform_out_of_channel_notifications if Bullet.notification?
      Bullet.end_request
    end
  end

Sentryへ警告を送信するには Bullet.sentry = true の設定を行うだけで可能です。

Bullet.raiseをtrueにすることで、警告時にエラーを発生させることができます。(段階①の現時点では設定していません。)

eager_loading の検知は、RSpecファイル内でも行われてしまうようなので、_spec.rbファイルは対象から除外しています。

[余談]Bullet.sentryとBullet.raiseは併用できない

余談ではありますが、少しハマった箇所があったため記載しておきます。

Bulletには、非常多くの通知チャンネルがあり大変便利ですが、「Sentryへの通知(Bullet.sentry)」「エラーのスロー(Bullet.raise)」という2つの設定は同時に有効にならないという事象がありました。

具体的には、次のように2つの設定をtrueにすると、Bullet.raiseのみ有効になります。

Bullet.sentry = true
Bullet.raise  = true

挙動が気になり、少しコードをみてみたのですが、Bulletでは通知のシステムをuniform_notifierという別Gemに切り出していて、このgemの挙動のようでした。

uniform_notifireでは次の箇所で、通知可能なnotifierを定義しています。

https://github.com/flyerhzm/uniform_notifier/blob/f2ec44f4156f84344eeb9f6801364aa876fee0b5/lib/uniform_notifier.rb#L21-L36

一方Bulletでは、このAVAILABLE_NOTIFIERSを設定どおりに順に有効にし、有効なnotifierから通知を投げるという仕組みになっているようです。

正確ではないかもしれませんが、AVAILABLE_NOTIFIERSの順番として、sentryよりもraiseの方が先になっているため、エラーが先にスローされてしまい、後ろのsentryの通知は動かないという挙動なのかなと思いました。

(Bulletでnotifierをactiveにする処理)

https://github.com/flyerhzm/bullet/blob/6abe9a5ace702d8590800f4cbe63e5918a72e385/lib/bullet.rb#L49-L51

(Bulletでactiveなnotifierを発火させる処理)

https://github.com/flyerhzm/bullet/blob/6abe9a5ace702d8590800f4cbe63e5918a72e385/lib/bullet.rb#L264-L271

現時点ではエラーを発生させつつSentryにも通知する必要はないですが、将来的にそのような要件が出てきた場合は、issueやPRを送ってみようと思っています。

終わりに

以上、既存のRailsプロジェクトにBulletを導入した事例について書かせていただきました。

以前(4~5年前)に使っていた頃よりも、通知方法が拡充され非常に便利になったなと感じました。とりあえず既存のエラーを解消して、CI時のチェックでエラーにできるように対応を進めていこうと思っています!

PharmaX では、様々なバックグラウンドを持つエンジニアの採用をお待ちしております。
もし、興味をお持ちの場合は、私の X アカウント(@hakoten)や記事のコメントにお気軽にメッセージいただけますと幸いです。まずはカジュアルにお話できれば嬉しいです!

PharmaXテックブログ

Discussion