ECS Fargate上でNginxをサイドカーとして持つWebサービスで、Service Connectを有効化してからデプロイやスケールインのたびに503エラー発生問題がありました。
最終的な対策は「Nginxのentrypointをラップして、SIGQUITを送るタイミングを少し遅らせてからgraceful shutdownする」というものです。
最初は「ECSの設定とService Connectのリトライだけで十分」と考えていましたが、実際には通用しなかったので、その調査の流れを記録しておきます。
背景
デプロイ時に決まって503が十数個ぐらい発生するようになりました。
ただし常時ではなく「デプロイのたびに必ず出る」という再現性があったので、無視できない状態でした。
ALBのヘルスチェックは正常で、アプリ自体もエラーを吐いていません。
503を返しているのはアプリではなく手前のNginx/Envoy層でした。
原因調査
まず503が発生するタイミングを整理しました。
整理すると、クライアント側のEnvoyが持っている情報が古いために停止し始めているタスクにもリクエストを送ってしまい、
そのタスクではNginxの方が先に落ちてしまっているので、アクセス先のEnvoyが503を返す、という構図です。
このとき実際に返っていたのは、アプリ(Nginx)が明示的に返した503ではなく、アクセス先のEnvoy(停止中タスク自身のサイドカー)が生成したデフォルトのエラーレスポンスでした。
HTTP/1.1 503 Service Unavailable
upstream connect error or disconnect/reset before headers. reset reason: connection failure, transport failure reason: delayed connect error: 111
delayed connect error: 111はLinuxのerrno ECONNREFUSED(接続拒否)です。
アプリのログにエラーが残っていなかったのは、リクエストがアプリまで届いていなかったからです。
Nginxには、デフォルトの停止シグナルであるSIGTERM(即座にワーカープロセスを落とす)とは別に、SIGQUITというシグナルがあります。
SIGQUITを受け取ると、新規の接続は受け付けずに処理中の接続だけを完了させてから停止する、いわゆる「graceful shutdown」の動作になります。
また、Service Connectには標準で自動リトライの機構が組み込まれています。
接続失敗時に別ホストへ自動で2回リトライする、カスタマイズ不可の組み込み機能です。
参考: Amazon ECS Service Connect components(Retriesの節)
このリトライがあれば、1タスクが停止中でも健全なタスクに振り直される可能性があります。
ここまでの調査で、最初は「graceful shutdownとこのリトライを組み合わせれば実用上十分で、独自のスクリプトは不要」と考えていました。
このリトライは当たる確率を上げるだけで、確実に防げるわけではありません。
リトライ先が空いていない、リトライ回数を使い切ってしまう、といったケースは普通にあります。
それだけでは防げなかった
実際にデプロイ時の503を再現させて検証したところ、graceful shutdownとリトライの組み合わせでは503を防ぎきれないことがわかりました。
SIGQUIT(graceful shutdown)が守ってくれるのは処理中の既存リクエストだけです。
Cloud Mapの登録解除がクライアント側のEnvoyに伝搬し終わるまでのラグの間に届く新規リクエストは、SIGQUITでは守れません。
すでに新規接続を拒否しているタスクに、古い情報のままのEnvoyがリクエストを送り続けてしまうためです。
つまり、SIGQUITを送るタイミング自体をCloud Mapの伝搬が終わるまで遅らせて、その間はNginxに新規リクエストを普通に受け付け続けさせる必要がありました。
これが次のentrypointラッパーで15秒スリープを挟んでいる理由です。
最終的な対策
ECSのタスク定義とNginx側のシグナルハンドリングを組み合わせて対応しました。
| 設定 | 値 | 効果 |
|---|---|---|
| コンテナ停止シグナルをSIGQUITで受ける | entrypointラッパー経由 | 処理中のリクエストを完了してから停止する |
stopTimeout |
45秒(sleep 15秒 + graceful shutdown 15秒 + バッファ15秒) | graceful shutdownの猶予を確保する |
initProcessEnabled |
true |
Dockerの--init相当。initプロセス(tini等)をコンテナのPID 1として起動し、シグナルの転送とゾンビプロセスの回収(reap)を行う |
nginx公式イメージが2020年11月(1.19.5以降)からDockerfileに持っているSTOPSIGNAL SIGQUITを、Fargateが2025年12月のアップデートで尊重するようになったため、コンテナは自動的にSIGQUITを受け取ります。
このSIGQUITは、initProcessEnabledで起動したinitプロセス(PID 1)がまず受け取り、その子プロセスであるentrypointラッパーへ転送します。
参考: nginx/docker-nginx#377、Amazon ECS custom container stop signals on Fargate
entrypointラッパーの実装は以下の通りです。
#!/bin/sh
# entrypoint-wrapper.sh
# ECSタスク終了時の503エラー対策
# Cloud Map登録解除の伝搬を待ってからNginxをgraceful shutdownする
# 元のdocker-entrypoint.shを経由してNginxを起動
# → /docker-entrypoint.d/ のテンプレート処理(envsubst等)もそのまま動作
/docker-entrypoint.sh "$@" &
MAIN_PID=$!
handle_quit() {
echo "[entrypoint-wrapper] SIGQUIT received, waiting 15s for Cloud Map deregistration propagation..."
sleep 15
echo "[entrypoint-wrapper] Sending QUIT to nginx (PID: $MAIN_PID)..."
kill -QUIT "$MAIN_PID"
wait "$MAIN_PID"
exit 0
}
trap 'handle_quit' QUIT
wait "$MAIN_PID"
このスクリプトをDockerfileでENTRYPOINTに設定し、CMDに元のnginx -g "daemon off;"相当を渡します。
停止時の動作を時系列にすると次の流れになります。
検証・結果
対策前は、デプロイ時に503が発生することを再現確認しました。
最初はsleepを10秒で試したのですが、8秒程度503が出続けるケースが観測されたため、余裕を持って15秒に調整しました。
15秒に調整した後は、何度もデプロイを繰り返してもService ConnectおよびNginxのログの両方で503が発生しないことを確認しました。
ブラウザでの動作確認でも問題ありませんでした。
余談: Service Connectのログを有効にする
Service Connectのログやアクセスログ機能を有効化できます。
serviceConnectConfigurationにlogConfigurationとaccessLogConfigurationを追加するだけです。
{
"serviceConnectConfiguration": {
"logConfiguration": {
"logDriver": "awsfirelens",
"options": null
},
"accessLogConfiguration": {
"format": "TEXT",
"includeQueryParameters": "ENABLED"
}
}
}
既存のenabled・namespace・servicesはそのままで、この2つを追加するだけです。
HTTPアクセスログにはデフォルトでresponse_code・upstream_host・durationなどが出力されます