Rails: SidekiqからSolid Queueに移行したときの方法と注意点(翻訳)
私たちBigBinaryは、neetoでさまざまなプロダクトを構築しています。現在22のプロダクトを開発中で、それらはいずれもSidekiqを利用しています。Solid Queueが公開された後で、私たちのneetoFormで使われているSidekiqをSolid Queueに移行する決定を下しました。
なお、現時点のSolid Queueはcronスタイルのジョブや定期的に繰り返されるジョブ実行をサポートしておらず、これに関するプルリク#155がオープンされています。そういうわけで、Solid Queueへの移行は部分的にとどめており、定期ジョブ実行については引き続きSidekiqを利用しています。このプルリクがマージされたら、Solid Queueに完全に移行する予定です。
訳注
その後#155はマージされ、現在はcronスタイルのジョブや定期実行ジョブも利用できるようになっています。また、元記事公開時点のSolid Queueはhttps://github.com/basecamp/に置かれていましたが、その後Railsのリポジトリに移動しました。
参考: 週刊Railsウォッチ20240402: solid_queueとmission_control-jobsがRailsのリポジトリに追加された
🔗 SidekiqからSolid Queueに移行する
追記2024/05/02
上見出しを修正しました。
以下は、RailsアプリケーションのSidekiqをSolid Queueに移行するときのステップを示した移行ガイドです。
🔗 1. Solid Queueのインストール
- RailsアプリケーションのGemfileに"solid_queue" gemを追加して、
bundle install
を実行します。 -
bin/rails generate solid_queue:install
を実行します。
これによりSolid Queueのコンフィグファイルと、必要なマイグレーションファイルがコピーされます。 -
bin/rails db:migrate
を実行してマイグレーションを実行します。
🔗 2. Solid Queueの設定
上のインストール手順によって、config/solid_queue.ymlファイルが作成されたはずです。必要に応じて、このymlファイルのコメントを解除して変更します。私たちの場合、ymlファイルは以下のようになっています。
default: &default
dispatchers:
- polling_interval: 1
batch_size: 500
workers:
- queues: "auth"
threads: 3
processes: 1
polling_interval: 0.1
- queues: "urgent"
threads: 3
processes: 1
polling_interval: 0.1
- queues: "low"
threads: 3
processes: 1
polling_interval: 2
- queues: "*"
threads: 3
processes: 1
polling_interval: 1
development:
<<: *default
staging:
<<: *default
heroku:
<<: *default
test:
<<: *default
production:
<<: *default
🔗 3. Solid Queueを起動する
開発用のコンピュータで以下のコマンドを実行することで、Solid Queueを起動できます。
bundle exec rake solid_queue:start
これにより、Solid Queueのスーパバイザ(supervisor)プロセスが起動して、エンキューされたジョブの処理が開始されます。
このスーパバイザプロセスは、config/solid_queue.ymlファイルで指定された設定に沿ってワーカーとディスパッチャをforkします。スーパバイザプロセスは、ワーカーやディスパッチャのハートビート制御も行っており、必要に応じて停止や開始のシグナルをワーカーやディスパッチャに送信します。
私たちはforemanを利用しているので、上述のコマンドを以下のようにProcfileに追加しました。
# Procfile
web: bundle exec puma -C config/puma.rb
worker: bundle exec sidekiq -C config/sidekiq.yml
solidqueueworker: bundle exec rake solid_queue:start
release: bundle exec rake db:migrate
🔗 4. Active Jobのキューアダプタを設定する
application.rbファイルで、以下のようにActive Jobキューアダプタにsolid_queue
を設定可能です。
# application.rb
config.active_job.queue_adapter = :solid_queue
上の変更を行うと、すべてのジョブのキューアダプタがアプリケーションレベルで設定されます。ただし私たちの場合は、通常のジョブにはSolid Queueを利用し、cronジョブには引き続きSidekiqを利用するというように使い分けたかったので、上のapplication.rbの変更は行いませんでした。
代わりに、ApplicationJob
を継承したベースクラスを新しく作成して、その中で以下のように:solid_queue
を設定しました。
# sq_base_job.rb
class SqBaseJob < ApplicationJob
self.queue_adapter = :solid_queue
end
次に、(cronジョブでない)通常のジョブを実装するすべてのクラスで、ApplicationJob
の継承をこの新しいSqBaseJob
の継承に置き換えました。
# send_email_job.rb
- class SendEmailJob < ApplicationJob
+ class SendEmailJob < SqBaseJob
# ...
end
上の変更によって、すべての通常ジョブがSidekiq経由ではなくSolid Queue経由でエンキューされるようになりました。
しかしその後、メール送信がまだSidekiq経由のままであることに気づきました。Rails内部をデバッグして調べたところ、Action Mailerではエンキューやメール送信にActionMailer::MailDeliveryJob
が使われていることが判明しました。
ActionMailer::MailDeliveryJob
は、アプリケーションのApplicationJob
を継承しているのではなく、ActiveJob::Base
を継承しています。これでは、application_job.rbファイルでqueue_adapter
を設定しても反映されません。ActionMailer::MailDeliveryJob
は、application.rbファイルまたは環境ごとの設定ファイル(production.rb / staging.rb / development.rb)で定義されたアダプタを利用する形でフォールバックします。しかし私たちの場合はcronジョブにはSidekiqを利用したいので、そうするわけにはいきません。
メーラーでSolid Queueを利用するには、メーラーについてはqueue_adapter
をオーバーライドする必要がありました。これは以下のようにapplication_mailer.rbファイルで行えます。
# application_mailer.rb
class ApplicationMailer < ActionMailer::Base
# ...
ActionMailer::MailDeliveryJob.queue_adapter = :solid_queue
end
この変更は、SidekiqとSolid Queueを両方使う期間中のみ利用することにしました。Solid Queueにcronスタイルのジョブ機能が導入されたら、このオーバーライドを削除して、application.rbファイルでqueue_adapter
を直接設定すれば、その設定がグローバルに適用されるようになります。
🔗 5. コードの変更
SidekiqからSolid Queueに移行するために、ジョブをエンキューする構文を以下のように変更する必要がありました。
.perform_async
を.perform_later
に置き換える.perform_at
を.set(...).perform_later(...)
に置き換える
- SendMailJob.perform_async
+ SendMailJob.perform_later
- SendMailJob.perform_at(1.minute.from_now)
+ SendMailJob.set(wait: 1.minute).perform_later
また、ジョブのステータス問い合わせやジョブのキャンセルを行うために、ジョブIDをレコードに保存している箇所もいくつかありました。そのような箇所は以下のように変更しました。
def disable_form_at_deadline
- job_id = DisableFormJob.perform_at(deadline, self.id)
- self.disable_job_id = job_id
+ job_id = DisableFormJob.set(wait_until: deadline).perform_later(self.id)
+ self.disable_job_id = job.job_id
end
def cancel_form_deadline
- Sidekiq::Status.cancel(self.disable_job_id)
+ SolidQueue::Job.find_by(active_job_id: self.disable_job_id).destroy!
self.disable_job_id = nil
end
🔗 6. エラー処理とリトライ
当初は、Solid Queueのその他の設定に記載されているon_thread_error
設定を利用すればエラーを処理できると思ったのですが、実際にはエラーをキャプチャできていなかったことが開発中に判明したので、これはバグではないかと問い合わせました(#120)。
Rosa Gutiérrezがこのissueについて以下のように明快に説明してくれました(#120コメント)。
on_thread_error
は、ジョブそのもののエラーを対象としたものではなく、ジョブを実行しているスレッド内で発生する、ジョブ自体の前後のエラーを対象としている。たとえば、Active Recordのスレッドプールがスレッド数に対して小さすぎるために、新しいコネクションをチェックアウトしようとしたときにエラーが発生すると、そのエラーについてon_thread_error
が呼び出される。
ジョブ自体のエラーを処理するのであれば、Active Job自体にフックをかけてみるのがよさそう。
上の情報を頼りに、SqBaseJob
クラスを以下のように変更して、例外処理とHoneybadgerへの通知を行うようにしました。
# sq_base_job.rb
class SqBaseJob < ApplicationJob
self.queue_adapter = :solid_queue
rescue_from(Exception) do |exception|
context = {
error_class: self.class.name,
args: self.arguments,
scheduled_at: self.scheduled_at,
job_id: self.job_id
}
Honeybadger.notify(exception, context:)
raise exception
end
end
先ほども申し上げたように、Action MailerはApplicationJob
を継承していないことを忘れてはいけません。つまり、メーラーの例外も以下のように個別に処理しなければならないということです。
# application_mailer.rb
class ApplicationMailer < ActionMailer::Base
# ...
ActionMailer::MailDeliveryJob.rescue_from(Exception) do |exception|
context = {
error_class: self.class.name,
args: self.arguments,
scheduled_at: self.scheduled_at,
job_id: self.job_id
}
Honeybadger.notify(exception, context:)
raise exception
end
end
Sidekiqと異なり、Solid Queue自体には自動リトライのメカニズムがなく、自動リトライについてはActive Jobに依存しています。私たちの場合、エラー発生時にアプリケーションからのメール送信をリトライしたかったので、ApplicationMailer
にリトライロジックを追加しました。
# application_mailer.rb
class ApplicationMailer < ActionMailer::Base
# ...
ActionMailer::MailDeliveryJob.retry_on StandardError, attempts: 3
end
注意: アプリケーション全体がSolid Queueに移行完了すれば、application_mailer.rbにあるキューアダプタ設定は削除可能になりますが、ActionMailer::MailDeliveryJob
が継承しているのはアプリケーションのApplicationJob
ではなくActiveJob::Base
なので、エラー処理とリトライのオーバーライドは削除できません。
🔗 7. テスト
以上の変更をすべて完了すると、多数のテストが失敗することが判明しました。構文変更に伴う失敗の修正とは別に、一部のテストが失敗したりしなかったりしていました。デバッグの結果、失敗したテストはすべてコントローラがらみであり、特にActionDispatch::IntegrationTest
を継承するテストであることがわかりました。
解決のためにデバッグを試みるうちに、Ben SheldonがGood Jobのissueに付けたコメントが目に止まりました(#846)。Benの指摘によれば、これは実はRails側の問題であり(#37270)、ActiveJob
のqueue_adapter
設定がTestAdapter
でオーバーライドされたりされなかったりしているのだそうです。この問題の修正プルリクは既にオープンされています(#48585)1。
ありがたいことに、Benはこの修正がRailsにマージされるまでの回避方法についても書いてくれていました。
私たちはその回避方法を以下のようにテストのhelper_methods.rbに追加し、コントローラのテストが失敗するたびにメソッドが呼び出されるようにしました。
# test/support/helper_methods.rb
def ensure_consistent_test_adapter_is_used
# This is a hack mentioned here: https://github.com/bensheldon/good_job/issues/846#issuecomment-1432375562
# The actual issue is in Rails for which a PR is pending merge
# https://github.com/rails/rails/pull/48585
(ActiveJob::Base.descendants + [ActiveJob::Base]).each(&:disable_test_adapter)
end
# test/controllers/exports_controller_test.rb
class ExportsControllerTest < ActionDispatch::IntegrationTest
def setup
ensure_consistent_test_adapter_is_used
# ...
end
# ...
end
🔗 8. 監視
37signals(旧Basecamp)はmission_control-jobsという、バックグラウンドジョブを監視できるツールをリリースしました。このツールは汎用なので、Active Jobアダプタと互換性があれば利用できます。
訳注
mission_control-jobsも、37signalsのBasecampリポジトリからRailsリポジトリに正式に移動しました。
参考: 週刊Railsウォッチ20240402: solid_queueとmission_control-jobsがRailsのリポジトリに追加された
アプリケーションのGemfileに"mission_control-jobs" gemを追加して、bundle install
を実行します。
続いて、アプリケーションのroutes.rbファイルにmission_control-jobs用のルーティングを以下のように追加します。
# routes.rb
Rails.application.routes.draw do
# ...
mount MissionControl::Jobs::Engine, at: "/jobs"
mission_control-jobsは、デフォルトではアプリケーションのapplication.rbまたは環境ごとのファイルで指定されたアダプタの読み込みを試みます。Sidekiqは現時点ではmission_control-jobsと互換性がないため、ブラウザで/jobs
を開いてダッシュボードを読み込むとエラーが発生します。これを修正するには、以下のようにmission_control-jobsアダプタのリストにsolid_queue
を明示的に追加します。
# application.rb
# ...
config.mission_control.jobs.adapters = [:solid_queue]
これで、ブラウザで/jobs
にアクセスすればSolid Queueのジョブを監視できるダッシュボードが読み込まれるはずです。
しかし、認証がまだ設定されていないので、これだけでは不十分です。development環境なら問題ありませんが、/jobs
ルーティングはproduction環境でも公開されます。mission_control-jobsは、デフォルトではホストアプリのApplicationController
を拡張するので、認証を設定しておかないと誰でも/jobs
にアクセスできてしまいます。
何らかの認証を実装するために、mission_control-jobs用のコントローラのベースクラスとして別のコントローラを指定して、そこに認証を追加することにしました。
# application.rb
# ...
MissionControl::Jobs.base_controller_class = "MissionControlController"
# app/controllers/mission_control_controller.rb
class MissionControlController < ApplicationController
before_action :authenticate!, if: :restricted_env?
private
def authenticate!
authenticate_or_request_with_http_basic do |username, password|
username == "solidqueue" && password == Rails.application.secrets.mission_control_password
end
end
def restricted_env?
Rails.env.staging? || Rails.env.production?
end
end
上では、mission_control-jobs用のコントローラのベースコントローラとしてMissionControlController
を指定しました。次に、このMissionControlController
でstaging環境とproduction環境用のBASIC認証を実装しました。
🔗 導入の結果
今のところクレームは何もありません。Solid Queueはインフラストラクチャの追加が不要なおかげでシンプルになります。ジョブがデータベースに保存されるので、ジョブ管理を可視化できるようになります。
近いうちに、neetoで提供している22のプロダクトをすべてSolid Queueに移行する予定です。そしてcronスタイルのジョブがSolid Queueにも導入されたら、Sidekiqから完全に移行するつもりです。
関連記事
- 訳注: #48585は現時点ではまだマージされていませんが、Rails 7.2のマイルストーンに追加されたので、Rails 7.2までにはマージされる見込みです。 ↩
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。