Kubernetesにおける無停止デプロイメント


はじめに

新しいバージョンのアプリケーションをデプロイする時に、アプリケーションをコンテナで運用している場合は、コンテナを作り替える必要がある。デプロイ時に適切な手順を踏まないと、リクエストを正しく捌けずに、クライアントにエラーを返すことになる。これは本番環境で動いているアプリケーションに取ってはクリティカルな問題である。
Kubernetesは RollingUpdate をデフォルトで対応していため、デプロイは適切にManifestを設定をしていればダウンタイムなくデプロイしてくれる。新しいPodの生成からServiceへの追加はヘルスチェック(readnessProbe)を適切に設定するだけで良い。しかし、古いPodを停止する時には、色々考慮する問題が出てくる。適切に設定を行わないと、まだアプリケーションがリクエストを処理中にもかかわらず、Podを停止してしまうということが起こり得る。今回はこの問題を解決するために考察したことをまとめた。

KubernetesのPodを停止するまでの挙動

Kubernetesの挙動は以下のようになっている。

Kubernetesでは「 preStop 処理 + SIGTERM 処理」と、「ServiceからのPodの除外処理」が非同期で行われる。コンテナは SIGTERM のシグナルが送られた場合に、適切に処理中のリクエストを捌き切ってから終了するようにする必要がある。そうでなければ、 SIGKILL シグナルがコンテナに送られて強制終了してしまう。したがって、適切な terminationGracePeriodSeconds を設定する必要がる。
また、 Service は、該当のコンテナに新しいリクエストを送らないように切り離すが、コンテナが処理を行っている途中である場合は、その接続を切断しないようにしなければいけない。

Service (LoadBalancer)

LoadBalancerがコンテナに新しいリクエストを送らないようにするとともに、現在行っている処理の接続を切断しないようにするために、Connection Drainingの設定をする必要がある。

例えば、EKSでは、 Connection Draining の有効化や、タイムアウトの設定を以下のように設定する。

manifest
apiVersion: v1
kind: Service
metadata:
    name: test
    annotations:
        service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout: "10"
        service.beta.kubernetes.io/aws-load-balancer-connection-draining-enabled: "true"
        service.beta.kubernetes.io/aws-load-balancer-connection-draining-timeout: "10"
spec:
...

アプリケーションのGraceful Shutdown

コンテナが安全に終了するためには、それぞれのコンテナがGraceful Shutdownをサポートしている必要がある。

例えば、 Gunicorn はGraceful Shutdownを対応している。適切に graceful_timeout の値を設定すれば問題ない。

また、アプリケーションコンテナの前に Nginx のようなリバースプロキシを立てるのはよくある構成だが、その場合、 Nginx も同様に対応していないと、 Nginx が先に終了し、クライアントに 504(Gateway Timeout)が返ってしまう。
Nginx 自体はGraceful Shutdownに対応しているが、Graceful Shutdownを行うためのシグナルが、 SIGTERM ではなく、 SIGQUIT である。

TERM, INT   fast shutdown
QUIT    graceful shutdown

そこで、終了時のシグナルを SIGTERM から SIGQUIT に変更してやる必要がある。

Dockerfile
FROM nginx:<version>

...

STOPSIGNAL SIGQUIT

また、タイムアウトは worker_shutdown_timeout の値を設定する。

コンテナ実行時の複数コマンド実行

SIGTERM シグナルはコンテナ内の PID 1 のプロセスに送られる。コンテナ起動時に複数コマンドを実行したい場合は、Startup Scriptを使うことはあるが、通常のスクリプトを用いると、そのスクリプトが PID 1 を持つことになり、アプリケーションにシグナルが送られない。
そこで、 exec を用いて、スクリプトのプロセスをアプリケーションのプロセスに変更することで対処できる。

Dockerfile
...

ENTRYPOINT ["./startup.sh"]
CMD ["runserver"]
startup.sh
#!/bin/sh
set -e

# コマンド

exec "$@"

PID 1問題

LinuxにおけるPID 1は通常 init である。 init は全てのプロセスの親であるため、このプロセスが殺されると、システムがダウンしてしまうため、特別扱いされている。具体的には、明示的にハンドラを設定していないシグナルは無視される。

NAME
        kill - send signal to a process

NOTES
       The only signals that can be sent to process ID 1, the init process,
       are those for which init has explicitly installed signal handlers.
       This is done to assure the system is not brought down accidentally.

したがって、アプリケーションサーバが明示的に SIGTERM のハンドラを設定していない場合は、プロセスが SIGTERM を無視してしまい、終了処理が適切に行われない。 Node.js でこれが起きるらしい。

この問題の回避方法は「明示的にシグナルをハンドリングする」または、「アプリケーションのプロセスをPID 1以外で立ち上げる」の2つが考えられる。前者はそれぞれで対応すれば良い。前者が難しい場合は、後者を使う必要がある。

Kubernetes 1.17以上の場合は、ShareProcessNamespace を使ってPID 1を回避できる。

manifest
apiVersion: v1
kind: Pod
metadata:
    name: test
spec:
    shareProcessNamespace: true
...

Kubernetes以外、またはKubernetesのバージョンが低い場合は、 tiniのような軽量initと呼ばれるライブラリを使えば解決できる。

Dockerfile
...

ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini
ENTRYPOINT ["/tini", "--"]

CMD ["runserver"]

参考