ECSでGraceful Shutdownを正しく実現する - Nginx/PHP-FPM編
はじめに
こんにちは。都内でソフトウェアエンジニアをしている tomori です。
先日、Nginx と PHP-FPM を主要コンテナとして持つ ECS on Fargate タスクに対して、Graceful Shutdown 対応を行いました。
本記事では、その際に得られた知見や対応方法を紹介します。
「そもそも Graceful Shutdown とは何なのか」「この対応を行わないとどういう問題が起きるのか」という点は過去に公開した記事でも言及しているので、併せてご覧ください。
本記事で扱う内容は Nginx や PHP-FPM に限らず、ECS や Docker 上でプロセスを扱うすべてのケースに共通する考え方です。
シグナルの伝搬やルートプロセス(PID=1
)の挙動など、コンテナを安定運用するうえで押さえておきたい基礎知識としても役立ててもらえると思います。
先に要点まとめ
まずはこの記事の要点をまとめます。
- Nginx と PHP-FPM はどちらも
SIGQUIT
シグナルを受信すると Graceful Shutdown する- 補足: PHP-FPM は
process_control_timeout
を 0 より大きい値に設定しておかないと、SIGQUIT
シグナルを受信しても即座に停止する(バグなのか仕様なのかは不明)
- 補足: PHP-FPM は
- ECS ではタスク停止時にコンテナに
SIGTERM
シグナルが送信されるため、SIGTERM
シグナルをSIGQUIT
シグナルに変換してコンテナ内プロセスに伝搬する 必要がある - シグナルを変換して伝搬する方法は主に次の3つがある
- カスタムエントリポイントスクリプトで
trap
を用いてシグナルハンドラを実装する方法
Pros: シグナルハンドラを柔軟にカスタマイズできる、外部ツールに依存しない
Cons: 実装の難易度が上がる、メンテナンスコストが増える -
dumb-init
をPID=1
のプロセス(最上位プロセス)として起動し、オプションでシグナルを変換・伝搬する方法
Pros: シンプルかつ実績のある方法
Cons: 外部ツールに依存する、複雑なシグナルハンドリングはできない -
STOPSIGNAL
インストラクションを設定し、コンテナ停止時に送信するシグナルを変更する方法
Pros:Dockerfile
で完結できる最もシンプルな方法
Cons: 記事執筆時点では ECS ではまだサポートされていない
- カスタムエントリポイントスクリプトで
- Fluent Bit や Datadog エージェントなどのサイドカーコンテナを利用している場合は、メインコンテナの終了を待ってからサイドカーコンテナが終了するように依存関係を定義する など考慮が必要
構成
今回対応を行ったプロダクトは以下のような構成になっています。
- 実行環境: ECS on Fargate ( Fargate Spot も部分的に利用 )
- タスクのコンテナ構成
- Nginx
- PHP-FPM
- FireLens
- 監視や補助用途のサイドカーコンテナ
- デプロイ: ブルーグリーン方式
従来の問題点
ECS on Fargate の構成で Fargate Spot を利用している都合上、デプロイ時に限らず、運用中にもタスクが強制終了されることがあります。
ECS はタスクを停止する際にコンテナに SIGTERM
シグナルを送信し、30秒経過してもコンテナが残っている場合は、強制終了のために SIGKILL
シグナルを送信します。
When a task is stopped, a SIGTERM signal is sent to each container’s entry process, usually PID 1. After a timeout has lapsed, the process will be sent a SIGKILL signal.
今回対応を行ったプロダクトではシグナルに対する適切なハンドリングが行われていなかったため、Fargate Spot が強制終了された際にアクティブコネクションが切断され、ユーザーに 5xx
系のエラーを返してしまう問題がありました。
Nginx と PHP-FPM のどちらも SIGTERM
シグナルを受信すると即座に終了してしまうため、Graceful Shutdown が実現されていなかったのです。
ボットからの不正アクセス等によって Datadog 上では常に一定割合の 5xx
系のエラーが発生しており、発見が遅れる原因となっていました。しかし、Fargate Spot の枯渇が顕著になったタイミングでサーバーエラーの発生率が急増したことで問題が顕在化し、対応を行うことになりました。
Graceful Shutdown 対応
Nginx と PHP-FPM はいずれも自分でソースコードを変更できる性質のものではないため、Graceful Shutdown を実現するにはそれぞれの実装仕様に従う必要があります。
ドキュメントを確認すると、Nginx は SIGTERM
シグナルを受信すると fast shutdown
を実行し、SIGQUIT
シグナルを受信すると graceful shutdown
を実行すると記載されています。
The master process supports the following signals:
TERM, INT fast shutdown
QUIT graceful shutdown
同様に PHP-FPM もドキュメントを確認すると、SIGTERM
シグナルを受け取ると即座に終了し、SIGQUIT
シグナルを受け取ると graceful stop
すると記載されています。
Once started, php-fpm then responds to several POSIX signals:
SIGINT,SIGTERM
immediate termination
SIGQUIT
graceful stop
これらの仕様から、Nginx と PHP-FPM のどちらもSIGQUIT
シグナルを受信すると Graceful Shutdown することがわかります。
前述したように ECS では、タスク停止時にコンテナに SIGTERM
シグナルが送信されるため、Graceful Shutdown を行うためには SIGTERM
シグナルを SIGQUIT
シグナルに変換して各種プロセスに伝搬する必要があります。
本記事では、そのための方法として次の3つを紹介します。
- カスタムエントリポイントスクリプトを用いた対応
-
dumb-init
を用いた対応 -
STOPSIGNAL
インストラクションを用いた対応 (将来的に可能になるであろう選択肢)
1. カスタムエントリポイントスクリプトを用いた対応
コンテナ起動時のエントリポイントスクリプトを自前で用意し、SIGTERM
シグナルを SIGQUIT
シグナルに変換して伝搬する方法です。
こちらは既存のエントリポイントスクリプトをベースに一部改変を加える形で実装します。
サンプル実装を以下に示します。
Nginx 用 Dockerfile, エントリポイントスクリプト
FROM nginx:1.29.0
...
# カスタムエントリポイントスクリプトを追加する
COPY ./docker/nginx/docker-entrypoint.sh /tmp/docker-entrypoint.sh
RUN chmod +x /tmp/docker-entrypoint.sh
# 追加したエントリポイントスクリプトを使用するように変更する
ENTRYPOINT ["/tmp/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
#!/bin/sh
# vim:sw=4:ts=4:et
set -e
entrypoint_log() {
if [ -z "${NGINX_ENTRYPOINT_QUIET_LOGS:-}" ]; then
echo "$@"
fi
}
if [ "$1" = "nginx" ] || [ "$1" = "nginx-debug" ]; then
if /usr/bin/find "/docker-entrypoint.d/" -mindepth 1 -maxdepth 1 -type f -print -quit 2>/dev/null | read v; then
entrypoint_log "$0: /docker-entrypoint.d/ is not empty, will attempt to perform configuration"
entrypoint_log "$0: Looking for shell scripts in /docker-entrypoint.d/"
find "/docker-entrypoint.d/" -follow -type f -print | sort -V | while read -r f; do
case "$f" in
*.envsh)
if [ -x "$f" ]; then
entrypoint_log "$0: Sourcing $f";
. "$f"
else
# warn on shell scripts without exec bit
entrypoint_log "$0: Ignoring $f, not executable";
fi
;;
*.sh)
if [ -x "$f" ]; then
entrypoint_log "$0: Launching $f";
"$f"
else
# warn on shell scripts without exec bit
entrypoint_log "$0: Ignoring $f, not executable";
fi
;;
*) entrypoint_log "$0: Ignoring $f";;
esac
done
entrypoint_log "$0: Configuration complete; ready for start up"
else
entrypoint_log "$0: No files found in /docker-entrypoint.d/, skipping configuration"
fi
fi
"$@" &
pid="$!"
sigterm_handler() {
echo "SIGTERM received, sending SIGQUIT to nginx"
kill -s QUIT "$pid"
wait "$pid"
}
trap 'sigterm_handler' TERM
wait "$pid"
参考: Nginx の公式実装はこちら
PHP-FPM 用 Dockerfile, エントリポイントスクリプト
FROM php:8.2-fpm
...
# カスタムエントリポイントスクリプトを追加する
COPY ./docker/php/docker-entrypoint.sh /tmp/docker-entrypoint.sh
RUN chmod +x /tmp/docker-entrypoint.sh
# 追加したエントリポイントスクリプトを使用するように変更する
ENTRYPOINT ["/tmp/docker-entrypoint.sh"]
CMD ["php-fpm", "-F"]
#!/bin/sh
set -e
# first arg is `-f` or `--some-option`
if [ "${1#-}" != "$1" ]; then
set -- php-fpm "$@"
fi
"$@" &
pid="$!"
sigterm_handler() {
echo "SIGTERM received, sending SIGQUIT to php-fpm"
kill -s QUIT "$pid"
wait "$pid"
}
trap 'sigterm_handler' TERM
wait "$pid"
参考: PHP-FPM の公式実装はこちら
どちらも肝となるのは以下の部分です。
"$@" &
pid="$!"
sigterm_handler() {
kill -s QUIT "$pid"
wait "$pid"
}
trap 'sigterm_handler' TERM
wait "$pid"
既存のエントリポイントスクリプトは通常 exec "$@"
で直接プロセスを起動するため、PID=1
のシェルが存在せず trap
が効きません。
そのため "$@" &
としてバックグラウンド実行し、trap
と wait
を組み合わせることでシグナルハンドリングが可能になります。
サンプル実装では SIGTERM
シグナルに対するシグナルハンドラ内で nginx
や php-fpm
のマスタープロセスに SIGQUIT
シグナルを送信するようにカスタムしています。
カスタムエントリポイントスクリプトを用いた対応は、外部ツールに頼らずシグナルハンドラを柔軟にカスタマイズできる点がメリットです。
シグナルの変換、伝搬以外にも前後で処理を挟みたい場合に有効ですが、シグナルの取り扱いを自前で実装する必要があるため、実装の難易度が上がる点やメンテナンスコストが増える点には注意が必要です。
単にシグナル変換や伝搬のみを行いたい場合は、次に紹介する dumb-init
のほうがよりシンプルに実現できます。
2. dumb-init を用いた対応
dumb-init
というツールを利用してシグナルの変換と伝搬を行う方法です。
カスタムエントリポイントスクリプトよりもシンプルに実装できる方法で、シグナル変換や伝搬のみにフォーカスしたい場合に適しています。
dumb-init
は PID=1
のプロセスとして動作し、子プロセスにシグナルを伝搬することができるシンプルな init システムです。
SIGTERM
シグナルを受信するとそのまま子プロセスに SIGTERM
シグナルを伝搬しますが、--rewrite
オプションを利用することで伝搬時のシグナルを変更できます。
SIGTERM
シグナルを SIGQUIT
シグナルに変換して伝搬する場合は、以下のようにオプションでシグナルナンバーを指定してコマンドを実行します。
$ dumb-init --rewrite 15:3 -- <COMMAND>
Dockerfile の場合は以下のように設定します。
Nginx 用 Dockerfile
FROM nginx:1.29.0
# dumb-init をインストールする
RUN apt-get update && apt-get install -y dumb-init
...
# dumb-init を PID=1 のプロセスとして起動し、その子プロセスとして nginx を起動する
ENTRYPOINT ["dumb-init", "--rewrite", "15:3", "--"]
CMD ["nginx", "-g", "daemon off;"]
PHP-FPM 用 Dockerfile
FROM php:8.2-fpm
# dumb-init をインストールする
RUN apt-get update && apt-get install -y dumb-init
...
# dumb-init を PID=1 のプロセスとして起動し、その子プロセスとして php-fpm を起動する
ENTRYPOINT ["dumb-init", "--rewrite", "15:3", "--"]
CMD ["php-fpm", "-F"]
以上のように dumb-init
を PID=1
のプロセスとして起動することで、SIGTERM
シグナルを受信した際に自動的に SIGQUIT
シグナルに変換して子プロセスに伝搬できます。
シグナルハンドラを自前で実装する必要がなく、最も手軽に Graceful Shutdown を実現できる方法です。
3. STOPSIGNAL インストラクションを用いた対応(将来的に可能になるであろう選択肢)
Dockerfile
で STOPSIGNAL
インストラクションを設定する方法です。
Dockerfile では STOPSIGNAL
インストラクションを利用して、コンテナ停止時に送信されるシグナルを指定できます。
The STOPSIGNAL instruction sets the system call signal that will be sent to the container to exit.
これを利用することでコンテナ終了時に適切なシグナルを送信できるようになります。
SIGQUIT
シグナルを指定する場合は以下のように記述します。
STOPSIGNAL SIGQUIT
実際は Nginx と PHP-FPM の公式 Dockerfile ではすでに STOPSIGNAL
が SIGQUIT
に設定されているため、追記の必要はありません。
今後 ECS 側で STOPSIGNAL
がサポートされれば、よりシンプルかつ標準的な方法で Graceful Shutdown を実現できるようになるでしょう。
サイドカーコンテナの考慮
ECS タスクでは FireLens (Fluent Bit) や Datadog エージェントなどのサイドカーコンテナを利用しているケースが多いと思います。
そういった場合、Nginx や PHP-FPM の主要コンテナより先にサイドカーコンテナが終了してしまうと、ログの送信漏れなどの問題が発生する可能性があります。
そのため、サイドカーコンテナは主要コンテナの終了を待ってから停止するように設計する必要があります。
ECS では container dependency
を使ってコンテナ間の依存関係を定義でき、起動だけでなく停止順序もここで定義した内容に基づいて制御されます。
When a dependency is defined for container startup, for container shutdown it is reversed.
停止順序の実装仕様はドキュメント上で明記されていないようですが、実際に検証したところ、「A depends on B」(A が B に依存している)関係のタスクでは、まず A に SIGTERM
シグナルが送信され、A の終了を待機してから B に SIGTERM
シグナルが送信されることが確認できました。
サイドカーコンテナを使用している場合は依存関係を定義し、ログやメトリクスの欠損を防ぎましょう。
あとがき
本記事では、Nginx と PHP-FPM コンテナで構成された ECS タスクにおいて、Graceful Shutdown を実現するための取り組みを紹介しました。
(以前対応した)Go の API サーバーとは異なり、Nginx や PHP-FPM はコードを変更できないという制約がありましたが、最終的にはシンプルな改修で対応できたので良かったです。
今回の対応を通して Nginx や PHP-FPM の実装仕様や実行モデル、ECS の挙動についても理解を深めることができ、非常に有意義な経験となりました。
本記事が同様の課題に直面している方の参考になれば幸いです。
ではでは〜👋
Discussion