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

# はじめに

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

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

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

<img width="600" alt="スクリーンショット 2020-07-08 午後4.17.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88231/b7e76455-1d59-8937-6596-0e7ab970f275.png">

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

# Service (LoadBalancer)

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

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

```yaml: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` の値を設定すれば問題ない。

https://docs.gunicorn.org/en/stable/settings.html

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

```
TERM, INT	fast shutdown
QUIT	graceful shutdown
```

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

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

```Dockerfile:Dockerfile
FROM nginx:<version>

...

STOPSIGNAL SIGQUIT
```

https://github.com/nginxinc/docker-nginx/issues/377

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

http://nginx.org/en/docs/ngx_core_module.html#worker_shutdown_timeout

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

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

```Dockerfile: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.
```

https://man7.org/linux/man-pages/man2/kill.2.html

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

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

Kubernetes 1.17以上の場合は、[ShareProcessNamespace](https://kubernetes.io/ja/docs/tasks/configure-pod-container/share-process-namespace/) を使ってPID 1を回避できる。

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

Kubernetes以外、またはKubernetesのバージョンが低い場合は、 [tini](https://github.com/krallin/tini)のような軽量initと呼ばれるライブラリを使えば解決できる。

```Dockerfile: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"]
```

# 参考

- https://www.amazon.co.jp/dp/4295004804/
- https://text.superbrothers.dev/200328-how-to-avoid-pid-1-problem-in-kubernetes/
- https://ngzm.hateblo.jp/entry/2017/08/22/185224
- https://man7.org/linux/man-pages/man2/kill.2.html
- https://github.com/krallin/tini
- https://docs.gunicorn.org/en/stable/settings.html
- https://stackoverflow.com/questions/58408087/is-there-a-easy-way-to-shut-down-python-grpc-server-gracefully
- https://docs.celeryproject.org/en/stable/userguide/workers.html
- https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/classic/config-conn-drain.html
- https://kubernetes.io/docs/concepts/cluster-administration/cloud-providers/
- http://nginx.org/en/docs/control.html
- https://docs.gunicorn.org/en/stable/settings.html
- https://kubernetes.io/ja/docs/tasks/configure-pod-container/share-process-namespace/
- http://nginx.org/en/docs/ngx_core_module.html#worker_shutdown_timeout
- https://github.com/nginxinc/docker-nginx/issues/377