Delayed Job から Solid Queue へ移行する

1 本目に書いたとおり、この移行は 2 つのフェーズに分けました。 この記事はその前半にあたる、旧プラットフォームである Heroku の上でジョブ基盤だけを Delayed Job から Solid Queue へ入れ替える話です。

ジョブ基盤を先に入れ替えた理由

どうせ移行するなら Fly.io の上で一度に Solid Queue 化すればよいのではないか、という考えは当然出てきます。 そうしなかったのは、障害が起きたときに原因を切り分けられなくなるからです。 ジョブが動かないときに、それが Solid Queue の設定によるものか Fly.io の machine 構成によるものか判断できない状態は避けたいところでした。

旧環境の上でジョブ基盤だけを先に入れ替え、安定を確認してからプラットフォームを移しています。 実際この後にジョブが滞留する問題を踏みますが、Solid Queue の設定によるものだと即座に切り分けられました。 同時に進めていたら、machine 構成を疑うところから始めることになったはずです。

メモリ不足で一度 revert していた

このプロジェクトでは、以前に Solid Queue を本番投入して revert した経緯がありました。 原因は worker dyno のメモリ不足です。

Solid Queue の worker は Delayed Job よりも明確にメモリを使います。 supervisor / worker / dispatcher / scheduler という複数のサブプロセスで構成されるためで、threads: 1batch_size: 100 まで絞っても 1GB に収まりませんでした。

メモリが厳しいと分かっている環境へ、あえて再挑戦する形になりました。

キューごとの並列化の手段

最終的に落ち着いた config/queue.yml です。

default: &default
  dispatchers:
    - polling_interval: 10
      batch_size: 100
  workers:
    - queues: [ "default", "solid_queue_recurring" ]
      # IO バウンド中心なのでスレッドで並列化し、プロセス数を減らしてメモリを節約
      threads: 3
      processes: 1
      polling_interval: 5
    - queues: file_generation
      # Excel と CSV の生成は CPU バウンドで GVL の影響を受けるため、プロセス分離で並列度を確保
      threads: 1
      processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
      polling_interval: 5

キューの性質に応じて並列化の手段を変えています。

  • IO バウンド(メール送信や通知):スレッドで並列化する。1 プロセスあたり数百 MB を使うので、プロセスを増やさずに済むのはメモリ面で大きい
  • CPU バウンド(Excel や CSV の生成):CRuby には GVL があるためスレッドを増やしても伸びない。プロセスを分ける

すべてをプロセスで並列化するとメモリが足りなくなり、すべてをスレッドにすると重いファイル生成が他のジョブを詰まらせます。

processesJOB_CONCURRENCY という環境変数で外に出しているのは、ステージングと本番で並列度を変えるためです。 設定ファイルを環境ごとに分岐させるより、差分が見えやすくなります。

solid_queue_recurring キューの引き手

移行後にはまった問題です。

Solid Queue の定期実行タスクを command: 形式(ジョブクラス名ではなく Ruby の式を直接書く形式)で定義すると、SolidQueue::RecurringJob にラップされ、solid_queue_recurring という別のキューへ入ります。

# この書き方だと solid_queue_recurring キューに入る
clear_solid_queue_finished_jobs:
  command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
  schedule: every hour at minute 12

queues: default しか処理しない worker しかいないと、このジョブは誰にも拾われず滞留し続けます。

ログの上では正常に見えます。 scheduler はタスクを起動したと記録し、ジョブもテーブルに積まれ、エラーも出ません。 実行されないまま滞留していることに気づいたときには、大量に溜まっていました。

対処は前掲の queue.yml のとおりで、default の worker に solid_queue_recurring も処理させます。 定期タスクを追加したときは、発火したかではなく完了したかを確認するのが正しい検証手順でした。

Heroku Scheduler の置き換え

Heroku Scheduler の 3 ジョブは config/recurring.yml に移しました。

production:
  clear_solid_queue_finished_jobs:
    command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
    schedule: every hour at minute 12

  daily_aggregation:
    class: DailyAggregationJob
    schedule: every day at 5am
    timezone: Asia/Tokyo

  reminder_notice:
    class: ReminderNoticeJob
    schedule: every day at 8am
    timezone: Asia/Tokyo

  db_sessions_trim:
    class: DbSessionsTrimJob
    schedule: every day at 3am
    timezone: Asia/Tokyo

タイムゾーンの指定

Heroku Scheduler では UTC で設定し、JST を暗算する必要がありました。 管理画面には「毎日 20:00」としか表示されず、それが JST の翌 05:00 にあたることはどこにも書かれていません。 そのためドキュメントに対応表を作って運用していました。

タスク UTC JST
日次集計 毎日 20:00 翌 05:00
リマインド通知 毎日 23:00 翌 08:00
古いセッション削除 毎日 18:00 翌 03:00

Recurring Tasks では timezone: Asia/Tokyo をタスク単位で指定できるので、この暗算がなくなりました。 対応表を保守する必要もなくなっています。

Heroku Scheduler との違い

  • Fugit ベース:cron 式に加えて every day at 5amevery hour at minute 12 のような書き方ができる
  • 重複実行の防止(task_key, run_at) のユニークインデックスにより、worker が複数台になっても二重に発火しない
  • Active Job ベース:既存のジョブクラスをそのまま class: に指定できる
  • 専用プロセスが不要:スケジューラは worker プロセスの中に同居する

Heroku Scheduler は add-on の画面で設定する形だったため、定期実行の内容がコードレビューを通りませんでした。 recurring.yml に移したことで、変更が PR に乗るようになっています。

切り替えで消えるジョブ

デプロイした時点から、delayed_jobs テーブルに残っているジョブは誰も処理しません。 Solid Queue は別のテーブルを見ているためです。

影響が大きかったのは予約送信メッセージでした。 利用者が翌日の 10:00 に送信と設定したメッセージは、再エンキューしなければ予約時刻が来ても送信されません。 しかもこれは、利用者からは送ったはずのメッセージが届いていないという形で見えます。 エラー画面も出ないので、誰も気づかないまま数日が経つ恐れがありました。

そこでデプロイの直前に、本番のコンソールで棚卸しをしました。

# 1. 未処理ジョブの総数
Delayed::Job.where(failed_at: nil).count

# 2. 未送信の予約メッセージ
ScheduledMessage.not_sent.count

# 3. 未来時刻の予約ぶん(これが再エンキュー対象)
ScheduledMessage.not_sent.where(send_reserved_at: Time.current..).pluck(:id, :send_reserved_at)

そのうえで予約メッセージが少ない深夜帯にデプロイし、直後に再エンキュー用の rake タスクを流しています。

bundle exec rake solid_queue_migration:reenqueue_reserved_messages

このタスクは対象メッセージがすべて送信済みかをガードに持つので、二重送信は起きません。 移行用の再エンキュータスクは冪等でなければ流せません。 送られないより二重送信のほうがましだと考えがちですが、業務システムでは二重送信のほうが事故として重い場合があります。

想定した損失の一覧

当日の判断がぶれないよう、事前に表を作りました。

種別 影響 対応
予約送信メッセージ rake タスクで再エンキュー
deliver_later 系のメーラー デプロイ中の数秒から数分ぶんは消失。再送は手動
CSV と Excel の生成ジョブ 利用者が画面から再実行できる
分析用のトラッキング 一部の欠損を許容
失敗ジョブのリトライ待ち 旧基盤のリトライ待ちは再実行されない。件数だけ事前に把握

何が消えるかを先に表にしておくと、当日に迷いません。 逆にいえば、この表が書けないうちはデプロイできない、ということでもあります。

メモリの制約から生まれた設計

このフェーズの時点では worker が旧プラットフォームの 1GB のままだったので、余裕はほとんどありませんでした。 前掲の「default はスレッドで並列化し、file_generation はプロセスを分ける」という設計は、このメモリ制約の中で最大の並列度を出すための妥協点として生まれています。

Fly.io へ移行して worker を 2GB にしたあとも、この設定はそのまま使っています。 制約から生まれた設計が、制約が消えたあとも合理的だった形です。

3 本目の内容

3 本目は Fly.io への移行そのものを書きます。 Procfilefly.toml にどう対応するか、DB をどう運んだか、CI/CD をどう組んだかが中心です。

参考リンク

CONTACT

フレイズに相談してみる

開発からデザインまで、構想段階からでも大丈夫です。
まずはお気軽にご相談ください。

[email protected]