🚀

「Sidekiq、君に決めた!」 - Railsアプリ非同期処理の夢を叶える、最強の相棒

2024/12/06に公開

はじめに

皆さんこんにちは。バックエンド基盤チームの山本です。私が携わっているRailsプロジェクトでは長年にわたり非同期処理のバックエンドとしてDelayedJobを利用してきました。
今回のストーリーはDelayedJobからSidekiqに移行した話を書こうと思います。同じように移行を検討している方のお役にたてば良いなという気持ちです。

モチベーション

移行を検討し始めた2024年5月頃の状況を思い返すと、DelayedJobの最終更新日は2022年9月が最終の状態でした。現在は程よい頻度で更新されているようですが、長期間メンテナンスされていないライブラリを使い続けるのはプロジェクトの継続性を考えると心配な状況でした。また、重めのJobを実行するとメモリリークが発生するのも悩みのタネでした。
他にもデプロイ時にJobが中断されないことや、管理画面がほしいなどいくつかの要望が重なり、モダンなバックエンドワーカーへの移行の検討を開始しました。

移行先の検討

Resque

https://github.com/resque/resque

  • Pros.
    • 新たなJobはForkしてプロセスで実行されるためメモリリークに強い。
    • Jobの状況を可視化するGUIが付いている。
  • Cons.
    • Forkするコストが大きく、細かなJobを大量にさばく用途としては遅い。
    • JSONable Rubyオブジェクトを引数としてキューにのみ配置できる。DelayedJobからの乗り換えではオブジェクトをシリアライズする箇所で互換性に問題が発生する可能性がある。

Sidekiq

https://github.com/sidekiq/sidekiq

  • Pros.
    • 新たなJobはスレッドで実行されるためJob実行のスピンアップが高速である。
    • Jobの状況を可視化するGUIが付いている。
    • ActiveJobのバックエンドワーカーとしてもっともRubyGemsのダウンロード数が多い。
  • Cons.
    • スレッドで実行されるためメモリリークに弱い。
    • Resqueと同じくJSONable Rubyオブジェクトを引数とする。
    • redisが消えるとJobが消える。

GoodJob

https://github.com/bensheldon/good_job

  • Pros.
    • DealyedJobと同じくRDBMSをストレージとして利用するためredisやAmazon SQSなど追加のミドルウェアが不要。
    • Jobの状況を可視化するGUIが付いている。
  • Cons.
    • 少なからずRDBMSに負担がかかる。
    • ユーザー数が他と比較して少ないためWeb上の情報が少ない。

aws-sdk-rails

https://rubygems.org/gems/aws-sdk-rails

  • Pros.
    • バックエンドにAmazon SQSを利用するため既存のミドルウェアと分離できる。
  • Cons.
    • ローカルにSQSのエミュレータを導入する必要があるため、開発環境の構築が若干面倒。
    • おそらくRails上の管理画面が存在しない。

検討結果

以上のように比較検討した結果、Sidekiqに決めました。採用理由や検討内容は次のとおりです。

  • Web上の情報が多いこと。
  • Jobの実行開始が高速であること。
  • すでにredisを本番環境で利用しており、新たにミドルウェアの導入が不要であること。
  • メモリリークに対してはSidekiqWorkerKillerというソリューションで対応できること。
  • 長年にわたりAmazon ElastiCache(redis互換)を利用してきたが、トラブルが発生することが非常に稀であったこと。また仮にプライマリノードに障害が発生してもレプリカがプライマリに昇格し、可用性が担保されていることからJobが消失する可能性は極めて稀であると判断したため。

移行方法の検討

移行を決定した段階で25個のJobがプロジェクト内に存在することがわかりました。当初は同じ書き方で移行できるため、ActiveJobのバックエンドをそのままSidekiqに置き換えようと考えていましたが、一気に移行すると機能退行が怖いのとSidekiq自体の豊富な機能が利用できないため、段階的な移行を決定しました。
app/jobs に存在するJobを app/sidekiq に徐々に移行することでビッグリライトを避けることができます。
具体的には以下の手順で徐々に移行します。

  1. app/jobs に定義されているJobを app/sidekiq に持っていき、素のSidekiqの書き方に変更する。
  2. delayed_job_active_record.gem によって実現されている Model#delay をSidekiqのJobとして定義し直す。
  3. ActiveJobのバックエンドをDelayedJobからSidekiqに切り替える。
    このタイミングでメールの非同期送信がActiveJob経由のSidekiq実行に切り替わる。

また、1のタイミングでJob内にそのまま書かれているビジネスロジックをパッケージ化し、サービスクラスに外出しにしてテスト容易性を上げる事も実施しました。

メモリリーク対策

DelayedJobで悩まされていた問題のひとつにメモリリークがあります。Rubyで書かれた長期に動作するサービスは少なからずメモリリークが発生します。

# app/sidekiq/work_job.rb
class WorkJob
  include Sidekiq::Job
  sidekiq_options retry: 3

  # https://zenn.dev/lovegraph/articles/09dec0f2727f50
  sidekiq_retry_in do |_count, exception, _jobhash|
    case exception
    when StandardError
      60
    end
  end

  def perform(arg)
    Rails.logger.info 'テスト: WorkJobを実行しました。'

    raise 'テスト: 引数のエラーです' if arg < 0

    huge_string = 'a' * arg #メモリ大量消費実験
    Tracker::Event.notify('テスト:WorkJob実行しました。')
  end
end

上記のようなJobを定義して大量のStringを内部生成すると、Sidekiq管理画面のRSSが増加していくことが観察できました。

WorkJob.perform_async(10_000_000)

alt text

この問題に対してはSidekiqWorkerKillerを導入して一定のRSSの値になるとワーカーを削除することで対応しました。

# config/initializers/sidekiq.rb
# 設定例
require 'sidekiq/worker_killer'

Sidekiq.configure_server do |config|
  config.server_middleware do |chain|
    chain.add Sidekiq::WorkerKiller, max_rss: 480
  end
end

SidekiqWorkerKillerはワーカーの停止しか実施してくれません。再起動は社内のインフラチームに協力をお願いし、ECSのタスク定義で担保しました。ちなみにワーカーを停止して問題ないのかという点ですが、SidekiqWorkerKillerは現在実行中のJobが完了してから安全に停止するため問題ありません。また一定時間Jobが実行されなくなることを防止するため、2ワーカー立ち上げるようにしました。

デプロイ時にJobが停止しないための工夫

プロダクト開発チームが開発した価値を高頻度でユーザーに届けるため、デプロイ時にJobが異常停止しないことは必須要件でした。
ECSはコンテナを差し替える際に、Sidekiqワーカーに対してSIGTERMを送信します。デフォルトでは25秒待って実行中のJobはすべて強制終了されるようでした。この機能をうまく利用することでワーカーを安全に停止させることができます。
そこで、既存の25個のDelayedJobの実行時間を集計してみました。その結果、最大81.3秒かかっているJobが存在していることがわかりました。この値をベースにSIGTERMを受け取ってからの終了までの時間を100秒と決定しました。

# ECSタスクの設定の一部
command: [
  "bin/sidekiq -C config/sidekiq/default.yml -t 100",
],

移行期間中のトラブル

Sidekiq化することで5スレッド ✕ 2ワーカーで合計10並列のJobが実行できる環境が構築できました。しかし並列化によって発生してしまったトラブルがあります。
クリニック内で実施できない病原体検査は検体を外部の検査機関に送っているのですが、同時に検査依頼データをオンラインで連携するため依頼ファイルを作成しAmazon S3にアップロードしています。
このファイルは以下のように 年月日時分秒ミリ秒 で構成していました。

def file_name(datetime)
  datetime.strftime('%Y%m%d%H%M%S%L') + '.txt'
end

今まではシリアルで実行されるためファイルが被ることは無かったのですが、並列度合いが増したためミリ秒を指定していてもファイル名が被ることがありました。DelayedJobでも並列実行はできるためSidekiq化に直接起因する問題ではありませんが、ユニークであることが重要なファイルやDB項目名は注意する必要があります。

まとめ

今回の移行作業を通して、多くのActiveJobバックエンドの設計思想に触れることができました。Redisを必要とせずDelayedJobを進化させたGoodJobのような新しいタイプのワーカーも登場してきており、プロジェクトの状態に合ったバックエンドワーカーを吟味して選択する必要を感じました。
そんな中選択したSidekiqは昨今のコンテナ化が進んだ本番環境にマッチしており大変気に入っています。
来年はSidekiq Proを契約しより複雑なワークフローを作成することで、現在バッチで行っている処理の非同期化にチャレンジしていきたいと思っています。

参考資料

Linc'well, inc.

Discussion