Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: SidekiqからSolid Queueに移行したときの方法と注意点(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

rails/solid_queue - GitHub

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

ddollar/foreman - GitHub

🔗 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)、ActiveJobqueue_adapter設定がTestAdapterでオーバーライドされたりされなかったりしているのだそうです。この問題の修正プルリクは既にオープンされています(#485851

ありがたいことに、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. 監視

rails/mission_control-jobs - GitHub

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から完全に移行するつもりです。

関連記事

Solid Queue README -- DBベースのActive Jobバックエンド(翻訳)

Rails: Solid Queueで重要なUPDATE SKIP LOCKEDを理解する(翻訳)


  1. 訳注: #48585は現時点ではまだマージされていませんが、Rails 7.2のマイルストーンに追加されたので、Rails 7.2までにはマージされる見込みです。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。