🪦

ECSFargateにおけるLaravelキューワーカーのGracefulShutdown

2024/02/28に公開

※リンクや表現はLaravel10.xに準拠する。

Laravelではキュー機能を使うことで、Jobという処理単位を非同期処理できる。
これをAWSのECS、特にFargateを使って動かすときの安全な停止手段について考える。

前提

考える構成

Laravelのドキュメントでは、supervisordを経由でqueue:workコマンドを実行するように紹介されている。

キューより引用
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /home/forge/app.com/artisan queue:work sqs --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=forge
numprocs=8
redirect_stderr=true
stdout_logfile=/home/forge/app.com/worker.log
stopwaitsecs=3600

直接queue:workコマンドを呼ぶ場合と異なり、numprocsの指定によって並列実行数をシンプルに制御できる。

※今回は取り上ないが、LaravelファミリーのLaravel Horizonを使ってキューシステムを組む方法も存在する。

supervisorのconfファイルが然るべき場所に配置されていれば、コンテナでは実行コマンドとしてsupervisordを指定すればよい。

Fargateの停止時の仕様

スケールインやローリングアップデートなどの原因でタスクが停止されるとき、コンテナの実行コマンドに対して停止シグナル、通常SIGTERMが送信される。
SIGTERMの後タスク定義のstopTimeoutだけ待機し、
それでも実行コマンドが終了しない場合は、SIGKILLを送信しタスクが終了する。

参考:
https://aws.amazon.com/jp/blogs/news/graceful-shutdowns-with-ecs/

実行コマンドがsupervisordの場合、stopsignalで指定したシグナル(デフォルトSIGTERM)が各プロセスに送信され、プロセスの終了を待つ。
stopwaitsecs秒(デフォルト10秒)待機した後も残っていた場合、SIGKILLを送信しsupervisorが終了する。

起こりうる問題点

プロセスの処理時間が短すぎる場合、supervisorで指定したプロセスが起動しなくなる

プロセスがsupervisorのstartsecs(デフォルト1秒)以内に終了する場合、終了コードに関わらず起動失敗と見なされタスクが維持されない。
queue:workの場合該当する場合はほぼ無いと考えられるが、--stop-when-emptyを指定する場合や、キュー以外のワーカーを作成する場合は注意する必要がある。

シグナル受信により終了するはずのプロセスが、まれに生き残りSIGKILLされる

現状のsupervisor4.2.5では、頻繁に再起動するプロセスの場合、SIGTERMの受信と同時にspawnする場合がある。

2024-01-01 12:00:00,000 INFO spawned: 'laravel-worker__00' with pid ------
2024-01-01 12:00:00,001 WARN received SIGTERM indicating exit request
...	
2024-01-01 12:00:08,000 INFO waiting for laravel-worker_00 to die

この場合spawnしたプロセスへシグナルが送られないため終了されず、stopwaitsecs後SIGKILLされるだろう。

対策としては、以下のようなプロセスを並走させて、同時にspawnしたプロセスにも確実にシグナルが送られるようにするなどが考えられる。

#!/bin/sh

# supervisorから受け取るシグナルの捕捉とハンドラの定義
trap 'handler' TERM

handler() {
    sleep 5
    pkill -f "queue:work"
    exit 0
}
while true
do
    sleep 1
done

Laravel8.xからsupervisorのconfigに追加されているstopasgroup=true, killasgroup=trueでも解決できるかもしれない。

Jobのシグナル処理が記述てきない

Workerコマンドでは、停止・一時停止用のシグナルとしてSIGQUIT,SIGTERM,SIGUSR2,SIGCONTが、タイムアウト処理用のシグナルとしてSIGALRMが使用されている。
そのため、Jobではこれらのシグナルハンドラを使用するべきではない。どうしても必要な場合は、

  • これらのシグナルを避ける
  • Workerの元の挙動を維持するように、シグナルハンドラを上書きする。
    • SIGQUIT,SIGTERMであれば、queue:restartコマンド相当のことを行えばほぼ同じ効果が得られるでしょう
  • Workerの処理に加えて、Job側に処理を渡せるようにWorkerコマンドを改造する

などの対応が必要である。

シグナルを受信しても2分以内に終了できない

ECSタスク定義の[stopTimeout]はFargateの場合、120秒が最大となっているため。
本来はFargate上で動かすプロセスはシグナル受信後2分以内に終了できなければならない。
Laravelキューで考えると、WorkerコマンドではSIGTERMを受け取った場合、次のJobの処理に行くタイミングでの終了となるので、1つのJobは120秒以内に終了するべきといえる。

それが難しい場合、ECS側から止めるのではなく、OS側から実行コマンドを止めるという手段が使える。
ecs-execで、supervisorへ停止コマンドを送れば、FargateのstopTimeoutの制限は受けないので、supervisor側のstopwaitsecs分だけフルに待つことができる。
(デプロイのserviceUpdateの代わりには使えそうですが、スケールインで使うのは難しいだろう。)

補足: Jobでのエラーハンドリング

LaravelのJobでは例外は握りつぶさずthrowするべきである。
Workerコマンドの、maxExceptionsでリトライ回数を指定できたり、
DetectsLostConnectionsにより、DB接続エラーの場合はプロセスを終了する機能が正しく動作しない。

まとめ

Laravelのキューのドキュメントを5.110.xで比べると、日本語文字数にして13843文字→116540文字というように約10倍の増量している。
このようにLaravelのJob機能はバージョンの進歩と共に高機能化が進んでいる機能なので、是非その恩恵を受けられるような使い方をするべきである。

ソーシャルデータバンク テックブログ

Discussion