
こんにちは、Plex Job開発チームの種井です。
Plex Jobでは、バックエンドシステム(Railsアプリケーション)での非同期処理にdelayed_jobを使用しています。
運用する中で、とあるジョブが数時間遅れて実行されてしまう問題が発生しました。調査したところdelayed_jobのraise_signal_exceptionsの設定値に原因がありました。
今回は、delayed_jobのraise_signal_exceptionsパラメーターの設定が非同期処理の遅延につながってしまう事象と、対応した内容について紹介します。
前提および今回の問題について
前提
問題発生時のPlex Jobのバックエンドシステムの構成は以下のような形となります。

また、ジョブについては再試行されても問題ないように冪等性が担保されていることを前提とします。
今回の問題について
delayed_jobが受け取った一部のジョブについて、処理が完了するまでに数時間かかっていました。

時間をかけても処理さえ完了すればよいものについては問題ないかもしれません。しかし、アプリケーションで発生したイベントに応じて通知を送信するような処理など、ユーザー体験やサービス上の機会損失を生むような処理の遅延は避けたいです。
調査と仮説
今回、登録されたジョブの大半が遅延しているわけではなく、ごく一部について遅延することが厄介な点でした。
まずは、ログからわかることがないか調査してみることにしました。
数時間遅延したジョブの実行ログを確認したところ、ジョブの実行中にWorkerプロセスがSIGTERMシグナルを受け取った後に終了していることが分かりました。
また、そのちょうど4時間後の再実行により成功しているようです。
※ 該当ログのローデータが残っていなかったので、以下はイメージとなります。
Oct 27 10:01:02.643 Performing SampleJob ... Oct 27 10:02:02.643 SampleJob SignalException: SIGTERM (SignalException) ... Oct 27 14:02:02.643 Performing SampleJob ... Oct 27 14:03:02.643 Performed SampleJob
同じ現象が発生した他のログを確認してみたところ、いずれも同じ挙動でジョブが実行されていました。

どうやらWorkerプロセスがジョブの処理中に終了すると、ジョブの遅延が発生するようです。
なぜWorkerプロセスにSIGTERMシグナルが送信されているのか?
Plex Jobでは、Railsアプリケーションのインフラ環境にk8s (GKE)を使用しています。 k8sは、Nodeに配置されるリソースの最適化や可用性を担保する機能が組み込まれている関係で、Podを強制的に終了させることがあります。 詳しい仕組みについては、以下の記事を参照いただくことにして、ここでは割愛します。
Podの終了にあたっては、アプリケーションに停止までの猶予を与えるGraceful Shutdownという仕組みが使われており、Podを終了する際は以下のような順番でシグナルを送信するような仕組みになっています。

参考: Pod終了時のライフサイクル
今回はこのGraceful Shutdownの停止サイクルにおいて、タイムアウト判定となった場合の挙動であると仮定し、実際に再現してみることにしました。
挙動の検証
Workerプロセスの終了時の挙動について、実際に再現してみます。
適当なジョブを定義しましょう。app/jobs/sample_job.rbを作成します。
class SampleJob < ApplicationJob def perform sleep 300 # ジョブをしばらく動かしておく end end
delayed_jobの設定値は、今回問題が発生したアプリケーションの設定と同じにします。
config/initializers/delayed_job.rb
Delayed::Worker.destroy_failed_jobs = false Delayed::Worker.max_attempts = 3
Workerプロセスを起動します。
$ rails jobs:work
ジョブを作成します。
$ rails c > SampleJob.perform_later
しばらくジョブは内部でSleep処理しているため、その間にk8sのPod終了時の挙動と同じく、WorkerプロセスにSIGTERMを送信した後で、SIGKILLシグナルを送信してみます。
$ kill -15 WorkerのプロセスID $ kill -9 WorkerのプロセスID
Workerプロセスはkillされました。ジョブキューがどのような状態になっているかを確認しましょう。
delayed_jobsテーブルの中身を確認します。
| id | priority | attempts | handler | last_error | run_at | locked_at | failed_at | locked_by | queue | created_at | updated_at | |----|-----------|-----------|----------|-------------|---------------------|---------------------|-----------|---------------------------|---------|---------------------|---------------------| | 6 | 0 | 0 | `--- !ruby/object:ActiveJob::QueueAdapters::DelayedJobAdapter::JobWrapper¶job_data:¶ job_class: SampleJob¶ job_id: 0f01af35-1341-4165-b100-b37ed777e397¶ provider_job_id:¶ queue_name: default¶ priority:¶ arguments: []¶ executions: 0¶ exception_execu` | | 2025-10-27 05:32:06.656 | 2025-10-27 05:32:08.964 | | host:b4b6d783ad99 pid:29768 | default | 2025-10-27 05:32:06.656 | 2025-10-27 05:32:06.656 |
すると、locked_atとlocked_byにロック日時とロックしたプロセス情報が入ったまま、キューに残り続けています。
再度Workerプロセスを起動してみます。
$ rails jobs:work
別のWorkerプロセスを起動しても、キューの中のジョブは実行されません。
今回のようにWorkerプロセスが強制終了されると、locked_atとlocked_byが残り、その他のWorkerプロセスはロックが解除されるまで対象のジョブを取得できません。ロックはDelayed::Worker.max_run_time(デフォルト: 4時間)を過ぎると無効化され、別のWorkerがジョブを取得して実行する可能性があります。
delayed_jobのREADME.mdより
Otherwise the lock on the job would expire and another worker would start the working on the in progress job.
ロック中のジョブは他のWorkerから実行されない。
The default Worker.max_run_time is 4.hours. If your job takes longer than that, another computer could pick it up. It's up to you to make sure your job doesn't exceed this time. You should set this to the longest time you think the job could take.
ジョブの最大実行時間は4時間で、それを超える場合は他のWorkerプロセスからジョブが実行される。
上記をまとめると以下のような流れとなります。

対応
問題となっている現象を再現できたので、対応していきます。
ジョブの処理途中でWorkerプロセスが終了した場合はDelayed::Worker.max_run_timeを待たずに、再実行されてほしいです。
delayed_jobには、raise_signal_exceptionsというパラメーターが用意されています。SIGKILLは捕捉できないため、終了までの猶予を示すSIGTERMイベントを捕捉するようになっています。
こちらを有効化することでSIGTERMを受け取った場合に例外を発生させることができます。
- false: デフォルト。SIGTERMを受け取っても例外を発生させない
- term: SIGTERMを受け取ると例外を発生させる
- true: SIGTERM, SIGINTを受け取った場合に例外を発生させる
Delayed::Worker.raise_signal_exceptions = :term
例外が発生すると、ジョブは失敗扱いとなり再試行の対象となります。
さきほどの挙動の検証のコードに設定を加えて確認してみましょう。
config/initializers/delayed_job.rb
Delayed::Worker.destroy_failed_jobs = false Delayed::Worker.max_attempts = 3 Delayed::Worker.raise_signal_exceptions = :term
同じ手順で、SIGTERM→SIGKILLシグナルを送信してプロセスを終了させ、Workerプロセスを再度立ち上げます。
しばらく待つと、ジョブが処理されていました。
| id | priority | attempts | handler | last_error | run_at | locked_at | failed_at | locked_by | queue | created_at | updated_at | |----|-----------|-----------|----------|-------------|---------------------|---------------------|-----------|---------------------------|---------|---------------------|---------------------|
補足
delayed_jobにはいくつかパラメーターの設定方法があり、config/initializers/delayed_job.rbイニシャライザへ定義もその一つとして、アプリケーション全体で共通のパラメーターを指定できます。
README.mdではそれ以外にも
- 環境変数の指定
- ジョブオブジェクトでの属性のoverride
が紹介されています。
例: 起動コマンド(rails jobs:work)の引数
対象のWorkerプロセス毎にパラメーターを指定できる
$ QUEUE=tracking rails jobs:work
例: ジョブオブジェクトでの属性のoverride
ジョブ毎にパラメーターを指定できる
NewsletterJob = Struct.new(:text, :emails) do def perform emails.each { |e| NewsletterMailer.deliver_text_to_email(text, e) } end def max_attempts 3 end end
当初は設定の変更による影響範囲を絞るため「ジョブオブジェクトでの属性のoverride」する方法を検討しましたが、挙動についても検証済であったため、config/initializers/delayed_job.rbに設定を追加する方法を採用しました。
ということで、Delayed::Worker.raise_signal_exceptions = :termを設定した結果、今回の非同期処理が数時間遅延する問題を解消することができました。
まとめ | アプリケーションの終了フローを制御するということ
ここまで、delayed_jobを使った非同期処理が遅延する問題について、調査・対応までの流れを紹介してきました。
途中k8sやdelayed_jobの機能について触れましたが、今回の問題はGraceful Shutdownという仕組みを理解せずに、Workerプロセスを扱っていたことに根本的な原因がありました。
delayed_jobにはraise_signal_exceptionsというオプションが用意されています。前提知識があれば適切に設定を行い、今回の問題に至ることもありませんでした。
k8sやPaaSなどのプラットフォームでは、リソースの最適化や可用性の担保を目的としたプロセスの再起動が実施されることが多いため、アプリケーションはプロセスの終了に対しての振る舞いについて予め想定しておく必要があります。
今回、delayed_jobのパラメーターの設定を一つ見直すだけではありましたが、調査や検証を通じてGraceful Shutdownをはじめとしたプロセスのライフサイクルを理解した上で、システム設計を行うことの重要性に気付かされる良い機会でした。
エンジニア募集
100兆円規模のインフラ産業の課題解決に挑戦|業務支援SaaSのテックリード - 株式会社プレックス
急成長する業務支援SaaSのソフトウェアエンジニア・リードエンジニア - 株式会社プレックス
インフラ領域で日本を動かす仕組みを作るスタートアップのエンジニア - 株式会社プレックス
オペレーションの効率化によって事業成長に貢献するコーポレートエンジニア - 株式会社プレックス
インフラ産業の人材課題を解決 | フロントエンドの技術を牽引するテックリード - 株式会社プレックス
弊社について