ECSとALBの組み合わせでWebSocketサーバーを動かしています。このサーバーには「SIGTERMを受け取ったら、接続中の全クライアントに再接続を促すメッセージを送る」という処理を入れていました。デプロイでタスクが入れ替わるときに、クライアントが気づかないまま切れた接続を握り続けないようにするための、いわば親切設計のつもりでした。
ところが、本番で古いタスク3台を同時に止めたところ、APIのp95応答時間が最大48秒まで跳ね上がりました。原因を追ったところ、SIGTERMの通知はほとんど誰にも届いておらず、ALBに切られたクライアントがそれぞれ勝手に再接続し、そのままAPIにリクエストが殺到していたことが分かりました。ALBのderegistration_delayとSIGTERMの順番を取り違えていた、という話なので、調査の過程と対策の候補をまとめておきます。
状況:SIGTERMで通知すれば穏やかに移行できると思っていた
想定していた流れは次のようなものでした。
- タスクが停止対象になり、SIGTERMが飛んでくる
- サーバーが全クライアントに「再接続してください」と通知する
- クライアントが少し間を置いて、生きている別のタスクにつなぎ直す
クライアント側で再接続のタイミングをずらせば、再接続が一斉に起きないようにできるはず、という考えでした。
症状:タスクを止めた直後にAPIが重くなった
古いタスク3台を同時に止めたタイミングで、APIの応答時間が急激に悪化しました。p95で最大48秒です。WebSocketサーバー自体は落ちていないのに、その先のAPIが詰まっている状態でした。
クライアントが再接続すると、APIへのリクエストも一緒に増える構成だったため、接続がまとめて切れると、リクエストもまとめて押し寄せます。ここまでは想像がつきました。分からなかったのは、再接続を分散させるはずの通知が、なぜ効いていなかったのかという点です。
原因:SIGTERMが来る頃には、接続がほとんど残っていなかった
タスクのログを確認すると、次のことが分かりました。
- SIGTERMを受け取った時刻が、ALBの
deregistered targetsイベントのちょうどderegistration_delay後になっていた - SIGTERM時点のログ(
Connected count is N)を見ると、接続数がほぼ0になっていた
つまり、今回の環境ではタスク停止の流れが次の順番になっていました。
- ECSがタスクの停止を決め、ALBのターゲットから外す(draining開始)
- deregistration_delayが過ぎると、ALBが残っている接続を切る
- そのあとでコンテナにSIGTERMが送られる
SIGTERMが届いた時点で、接続はすでにALBに切られています。通知を送ろうにも相手がいないので、クライアントは切断を検知して、それぞれのタイミングで再接続するしかありませんでした。
deregistration_delayの既定値は300秒です。AWSのドキュメントにも、既定では300秒待つと書かれています。WebSocketのような長く張りっぱなしの接続は、この待ち時間のあいだ自然には終わらないため、基本的に最後まで残り続けます。
なお、ECSのドキュメントを読んだ限りでは、「draining、SIGTERM、SIGKILLの順序」を明示した記述は見つけられませんでした。上の順番は、あくまで今回のログから読み取れた挙動です。ドキュメントで保証されている仕様かどうかまでは確認できていないので、同じ構成で試す場合は、ご自身の環境のログで時刻を突き合わせるのが確実だと思います。
対策の候補
現時点で考えている候補は、次の3つです。
deregistration_delayを短くする
待ち時間を短くすれば、接続が切られるのは早くなりますが、「切られる」という事実は変わりません。接続を延命するための対策ではなく、切られるタイミングを前倒しするだけなので、再接続の殺到を防ぐ決め手にはなりません。ECSのドキュメントには、応答が1秒未満のサービスなら5秒程度にする例が載っていますが、同時に、長く続くリクエストがあるサービスでは5秒にしないよう注意書きもあります。WebSocketはまさに後者なので、安易に短くするのは危なそうです。
draining開始の時点で、アプリから再接続を促す
SIGTERMを待たずに、draining開始を検知した時点で通知を送り、クライアント側でランダムに遅らせて再接続させる案です。通知がALBの切断より先に届けば、再接続の山を時間方向に散らせます。ただし、draining開始をアプリ側で検知する手段が必要になる点が課題です。
タスクを1台ずつ入れ替える
そもそも3台を同時に止めなければ、再接続の波も小さくなります。ECSのmaximumPercentとminimumHealthyPercentで入れ替えの粒度を調整するか、手動で1分に1台ずつ止める運用も考えられます。いちばん手軽で、効果も確実に出そうです。
まとめ
今回学んだことは、次のとおりです。
- ALB配下のWebSocketサーバーでは、SIGTERMを受けた時点で接続がほぼ残っていないことがある。SIGTERMをきっかけにした通知処理は、期待どおり届かない前提で考える
- SIGTERMの時刻とALBの
deregistered targetsイベントの時刻、それとSIGTERM時点の接続数をログで突き合わせると、順番がすぐ確認できる - 再接続がAPIへのリクエスト増加につながる構成だと、接続切断がそのままAPIへの負荷になる。通知の工夫に加えて、クライアント側の再接続の分散も必要
「優雅にシャットダウンする処理を入れたから大丈夫」と思い込んでいたので、通知が空振りしていたことに気づくまで少し時間がかかりました。対策はまだ候補を挙げた段階ですが、同じようにWebSocketをECSとALBで動かしている方の参考になれば嬉しいです。