👾

ECSでGraceful Shutdownを正しく実現する - Nginx/PHP-FPM編

に公開

はじめに

こんにちは。都内でソフトウェアエンジニアをしている tomori です。

先日、Nginx と PHP-FPM を主要コンテナとして持つ ECS on Fargate タスクに対して、Graceful Shutdown 対応を行いました。

本記事では、その際に得られた知見や対応方法を紹介します。

「そもそも Graceful Shutdown とは何なのか」「この対応を行わないとどういう問題が起きるのか」という点は過去に公開した記事でも言及しているので、併せてご覧ください。

https://zenn.dev/tksx1227/articles/5ab5b3c99336c3#graceful-shutdownとは

本記事で扱う内容は Nginx や PHP-FPM に限らず、ECS や Docker 上でプロセスを扱うすべてのケースに共通する考え方です。

シグナルの伝搬やルートプロセス(PID=1)の挙動など、コンテナを安定運用するうえで押さえておきたい基礎知識としても役立ててもらえると思います。

先に要点まとめ

まずはこの記事の要点をまとめます。

  • Nginx と PHP-FPM はどちらも SIGQUIT シグナルを受信すると Graceful Shutdown する
    • 補足: PHP-FPM は process_control_timeout を 0 より大きい値に設定しておかないと、SIGQUIT シグナルを受信しても即座に停止する(バグなのか仕様なのかは不明)
  • ECS ではタスク停止時にコンテナに SIGTERM シグナルが送信されるため、SIGTERM シグナルを SIGQUIT シグナルに変換してコンテナ内プロセスに伝搬する 必要がある
  • シグナルを変換して伝搬する方法は主に次の3つがある
    1. カスタムエントリポイントスクリプトで trap を用いてシグナルハンドラを実装する方法
      Pros: シグナルハンドラを柔軟にカスタマイズできる、外部ツールに依存しない
      Cons: 実装の難易度が上がる、メンテナンスコストが増える
    2. dumb-initPID=1 のプロセス(最上位プロセス)として起動し、オプションでシグナルを変換・伝搬する方法
      Pros: シンプルかつ実績のある方法
      Cons: 外部ツールに依存する、複雑なシグナルハンドリングはできない
    3. 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.

https://aws.amazon.com/jp/blogs/containers/graceful-shutdowns-with-ecs/

今回対応を行ったプロダクトではシグナルに対する適切なハンドリングが行われていなかったため、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

https://nginx.org/en/docs/control.html

同様に PHP-FPM もドキュメントを確認すると、SIGTERM シグナルを受け取ると即座に終了し、SIGQUIT シグナルを受け取ると graceful stop すると記載されています。

Once started, php-fpm then responds to several POSIX signals:
SIGINT,SIGTERM
immediate termination
SIGQUIT
graceful stop

https://linux.die.net/man/8/php-fpm

これらの仕様から、Nginx と PHP-FPM のどちらもSIGQUIT シグナルを受信すると Graceful Shutdown することがわかります。

前述したように ECS では、タスク停止時にコンテナに SIGTERM シグナルが送信されるため、Graceful Shutdown を行うためには SIGTERM シグナルを SIGQUIT シグナルに変換して各種プロセスに伝搬する必要があります。

本記事では、そのための方法として次の3つを紹介します。

  1. カスタムエントリポイントスクリプトを用いた対応
  2. dumb-init を用いた対応
  3. STOPSIGNAL インストラクションを用いた対応 (将来的に可能になるであろう選択肢)

1. カスタムエントリポイントスクリプトを用いた対応

コンテナ起動時のエントリポイントスクリプトを自前で用意し、SIGTERM シグナルを SIGQUIT シグナルに変換して伝搬する方法です。

こちらは既存のエントリポイントスクリプトをベースに一部改変を加える形で実装します。

サンプル実装を以下に示します。

Nginx 用 Dockerfile, エントリポイントスクリプト
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;"]
docker-entrypoint.sh
#!/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 の公式実装はこちら
https://github.com/nginx/docker-nginx/blob/7895505c41013f66d3841cd2613b436229c1fe0e/entrypoint/docker-entrypoint.sh

PHP-FPM 用 Dockerfile, エントリポイントスクリプト
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"]
docker-entrypoint.sh
#!/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 の公式実装はこちら
https://github.com/docker-library/php/blob/686f9529f8659f929509a2c3ec2df34a14a4594a/8.4/alpine3.22/fpm/docker-php-entrypoint

どちらも肝となるのは以下の部分です。

"$@" &
pid="$!"

sigterm_handler() {
    kill -s QUIT "$pid"
    wait "$pid"
}

trap 'sigterm_handler' TERM

wait "$pid"

既存のエントリポイントスクリプトは通常 exec "$@" で直接プロセスを起動するため、PID=1 のシェルが存在せず trap が効きません。

そのため "$@" & としてバックグラウンド実行し、trapwait を組み合わせることでシグナルハンドリングが可能になります。

サンプル実装では SIGTERM シグナルに対するシグナルハンドラ内で nginxphp-fpm のマスタープロセスに SIGQUIT シグナルを送信するようにカスタムしています。

カスタムエントリポイントスクリプトを用いた対応は、外部ツールに頼らずシグナルハンドラを柔軟にカスタマイズできる点がメリットです。

シグナルの変換、伝搬以外にも前後で処理を挟みたい場合に有効ですが、シグナルの取り扱いを自前で実装する必要があるため、実装の難易度が上がる点やメンテナンスコストが増える点には注意が必要です。

単にシグナル変換や伝搬のみを行いたい場合は、次に紹介する dumb-init のほうがよりシンプルに実現できます。

2. dumb-init を用いた対応

dumb-init というツールを利用してシグナルの変換と伝搬を行う方法です。

カスタムエントリポイントスクリプトよりもシンプルに実装できる方法で、シグナル変換や伝搬のみにフォーカスしたい場合に適しています。

https://github.com/Yelp/dumb-init

dumb-initPID=1 のプロセスとして動作し、子プロセスにシグナルを伝搬することができるシンプルな init システムです。

SIGTERM シグナルを受信するとそのまま子プロセスに SIGTERM シグナルを伝搬しますが、--rewrite オプションを利用することで伝搬時のシグナルを変更できます。

SIGTERM シグナルを SIGQUIT シグナルに変換して伝搬する場合は、以下のようにオプションでシグナルナンバーを指定してコマンドを実行します。

$ dumb-init --rewrite 15:3 -- <COMMAND>

Dockerfile の場合は以下のように設定します。

Nginx 用 Dockerfile
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
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-initPID=1 のプロセスとして起動することで、SIGTERM シグナルを受信した際に自動的に SIGQUIT シグナルに変換して子プロセスに伝搬できます。

シグナルハンドラを自前で実装する必要がなく、最も手軽に Graceful Shutdown を実現できる方法です。

3. STOPSIGNAL インストラクションを用いた対応(将来的に可能になるであろう選択肢)

DockerfileSTOPSIGNAL インストラクションを設定する方法です。

Dockerfile では STOPSIGNAL インストラクションを利用して、コンテナ停止時に送信されるシグナルを指定できます。

The STOPSIGNAL instruction sets the system call signal that will be sent to the container to exit.

https://docs.docker.com/reference/dockerfile/#stopsignal

これを利用することでコンテナ終了時に適切なシグナルを送信できるようになります。

SIGQUIT シグナルを指定する場合は以下のように記述します。

Dockerfile
STOPSIGNAL SIGQUIT

実際は Nginx と PHP-FPM の公式 Dockerfile ではすでに STOPSIGNALSIGQUIT に設定されているため、追記の必要はありません。

https://github.com/nginx/docker-nginx/blob/7895505c41013f66d3841cd2613b436229c1fe0e/Dockerfile-alpine-slim.template#L100
https://github.com/docker-library/php/blob/686f9529f8659f929509a2c3ec2df34a14a4594a/8.4/alpine3.22/fpm/Dockerfile#L257

今後 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.

https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html#container_definitions

停止順序の実装仕様はドキュメント上で明記されていないようですが、実際に検証したところ、「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