📧

ActionMailerでのメール送信をDelayedJobからSidekiqに置き換えた

2024/05/27に公開

現在オンラインコミュニティプラットフォームOSIROを運営している弊社ですが、
弊社で技術的負債の解消、パフォーマンス改善を目的として、メール配信で利用しているDelayedJobをSidekiqに置き換えが完了しました。それらの施策について共有いたします。

なぜ複数種類のキューワーカーがあるのか

OSIROというオンラインコミュニティプラットフォームの開発は2015年からスタートしており、
リリースから8年以上が経過しているそれなりに長年動いているRailsアプリケーションです。
当時のRailsは4.2が最新で、ActionCableがありませんでした(!)

SidekiqにはRedisが必要でしたが、当時のプロダクトは現在のように一つの大きなRailsアプリケーションではなく、1コミュニティにつき、1Railsアプリケーション、1RDBMSが存在したシングルテナントだったために、新しくミドルウェアを増やすにしても費用の捻出が難しく、当時はエンジニア1名〜2名と少なくインフラの管理コストも無視できなかったのが実情でした。
従ってキューワーカーで処理を非同期にしたい場合は、RDBMSベースで動き新しいミドルウェアを入れる必要がないDelayedJobを使うしか選択肢がありませんでした。

今では複数のコミュニティを1つのRailsアプリケーションで管理するマルチテナント型のシステムなので、Redisを利用することになっても、インフラ費用や管理コストも現実的な状況になり、新しくキューワーカーを使う実装箇所についてはSidekiqを積極的に利用していました。

しかしメール送信、Action Mailerだけはスレッドセーフでないオプションを利用していたため置き換えが難しい状況でした。

Action Mailerでスレッドセーフではない設定の例

先ほども書いたようにマルチテナント型のアプリケーションなので、1つのRailsアプリケーションに対して複数のコミュニティ、複数のドメインのサイトが混在しているものになります。
メール送信元(from)にコミュニティの名前、送信元アドレス、返信先アドレスであったりを、
送信時に動的に設定されるようにする必要があるため設定を行なっていました。
またActionMailerでレンダリングするテンプレートでのリンク先のホスト名も動的に設定する必要があり、これらの設定を下記のようにしていました。(下記はサンプルコードです)

# メール送信元と返信先メールの設定
ActionMailer::Base.default(from: "#{@site.site_name} <#{@site.from_address}>", reply_to: @site.address)
# ActionMailerで利用するリンクにホスト名を設定する
default_url_options[:host] = @site.domain

これらの設定についてはRails側の実装上スレッドセーフではないため、
この設定を使わずAction Mailerを使いながら動的にメールの設定をスレッドセーフにしなければなりませんでした。

なぜDelayedJobをやめたいのか?

パフォーマンス・メンテナンスコストの改善

メール送信においてスレッドセーフではない設定があり、DelayedJobは仕方なく存在していますが、
それ以外にDelayedJobがある理由は弊プロダクトにおいては特にありません。
従って、Action MailerをSidekiqから送信するようにできれば併用する理由ほとんどなくなり、
複数のキューライブラリを利用することがなくなり、認知的負荷の削減によりメンテナンスコストが改善されると思いました。

スレッドであれば1CPUで複数のスレッドで並列処理ができるので、Sidekiqの方がより多くのメールを配信できるため、コストパフォーマンスもかなり良いと言えるでしょう。

今後選択肢に増えるSolidQueueも視野に入れてマルチスレッドに対応しておく

37Signalsで開発しているSolidQueueはキューのバックエンドにRDBMSを使っています。

https://techracho.bpsinc.jp/hachi8833/2024_04_08/140501

同じくRDBMSをキューバックエンドに使っているDelayedJobと異なるのは、古いRDBMSのサポートを切ることにより効率的なSQLを発行するため、RDBMSであっても及第点のパフォーマンスが出せ、
Redisよりもシンプルであることが理由に、37Signalsではこのキューバックエンドに置き換えられています。

https://dev.37signals.com/introducing-solid-queue/

そして今後の(おそらくRails 8)ActiveJobのキューバックエンドのデフォルトに選ばれるということから、SolidQueueへの移行も視野に入れて考えてもよいのかなと思っています。

https://techracho.bpsinc.jp/hachi8833/2024_01_17/138406#2-4

Sidekiqがすぐに開発が止まるということはないと思いますし、
すぐの移行ということは考えていませんが、SolidQueueもスレッドで動いているので、
スレッドセーフにしておくことで今後の選択肢を増やすということは良いことであると思った次第です。

置き換えに向けて取り組んだこと

1. リンクにhostオプションを明示的に設定する

これまではApplicationMailerで渡されたパラメーターから引数になるオブジェクトを使い、
host名の動的な指定を行っていました。

# RailsのMonckPatch サイトオブジェクトをアサインするために引数のx番目の要素から取得する。
  # もしサイトオブジェクトが存在しない場合、例外を出す。
  def send_action(method_name, *args)
    @action_args = args
    args.each do |arg|
      if arg.respond_to?(:site)
        set_site(site: arg.site)
        break
      elsif arg.is_a?(Site)
        set_site(site: arg)
        break
      elsif arg.is_a?(Hash)
        arg.values.each do |model|
          if model.respond_to?(:site)
            set_site(site: model.site)
            break
          end
          break if @site.present?
        end
      end
    end
    # ...中略
    raise SiteNotAssignError if @site.nil?
    # Not Thread Safe !!!
    default_url_options[:host] = @site.domain
    super
  end

  def set_site(site:)
    @site = site
  end

Railsのデフォルト設定、ホスト名についてはスレッドセーフではないので、スレッド間で共有するようで、他の方法を考えました。
一番面倒ですが、スレッド関係の不具合が少ないのは_urlのHelperに対してhostオプションをホスト名を明示的に設定することでした。

before

<%= link_to, root_url, root_url %>

after

<%= link_to, root_url(host: @site.domain), root_url(host: @site.domain) %>

2. fromをスレッドセーフな方法で設定する

送信元メールアドレスである、fromについても、
@siteオブジェクトから、host名の動的な指定を行っていました。
こちらもスレッドセーフではない設定値です。

ActionMailer::Base.default(from: "#{@site.name} <#{@site.from_address}>", reply_to: @site.reply_address)

これもかなり簡単で、Mailerクラスでmailメソッドでfromを明示的に設定するだけでした。

before

class UserMailer < ApplicationMailer
    def payment
        mail(to: @user.email)
    end
end

after

class UserMailer < ApplicationMailer
    def payment
        mail(from: @site.email, from: "#{@site.site_name} <#{@site.from_address}>", reply_to: @site.address, to: @user.email)
    end
end

3. そのためにとにかくテスト追加

対応方法としてはめちゃくちゃ簡単なものでしたがひたすらに面倒なものでした。
なので、単純な間違いがないか?ということを網羅的に継続的にチェックしたかったので、とにかくテスト、MailerのSpecを追加しています。
書いたのは一番最後だったのですが、この施策を一番最初に行なっています。

元々RequestSpecであったりでメールに対してのSpecも実行してて、
メールのテストカバレッジ自体はそこまで低くなかったですが、fromやリンクのドメインについての細かいチェックは特にない状態でした。
fromとreply_toが正しく設定されているかのSpecを一旦全てのMailerに対して書いた上で行なっています。お陰で弊社のMailerSpecのテストカバレッジが75%から96%になり、全体のカバレッジも2%程度向上しました。

マイクロサービスにはしませんでした

メール配信用のマイクロサービス作成・導入も検討したのですが、
具体的にはテストの再実装とbefore_actionを剥がすのが大変で一旦断念しています。
特にテストの恩恵を受けながら実装したいですし、マイクロサービスにするならActionMailerのテストの利便性は失いたくないので、RSpecでどうやってマイクロサービスを使ったメール配信サービスのテストを行うのか?という部分でも戦略を立てて実装しなければいけなかったので、
マイクロサービスに向けてActionMailerやテストから再実装するのはシンプルな解決策ではないと判断したため一旦見送りました。

もしメール送信のマイクロサービスの導入をする場合は、ActionMailerは一切利用しないで、
自前でクラスを作って対応していくのが楽でいいかなーと個人的には考えています。

置き換えた結果

置き換えた結果、メールの配信処理が早くなりました。
DelayedJobを利用していたときと比べてWorkerコンテナの数は多くないのですが、
実際にメール配信をする秒間の通数が2倍になっていました。
またDelayedJobを利用している機能はメール以外では少なくなってきたので、本格的にDelayedJobの廃止に向けて対応が進められそうです。

まとめ

DelayedJobとSidekiqが混在する環境でどのようにしてDelayedJobからメール送信を置き換えていくかについて書いていきました。
結果としてかなりシンプルで泥臭い解決策になりましたが、やることはシンプルで方針が決まってから1ヶ月程度で完了しました。
これらのように弊社ではプロダクトの内部品質を高めていく施策を色々やっているので、他にも色々ご紹介できればと思っています。

OSIRO テックブログ

Discussion