⛴️

Kubernetesのグレースフルシャットダウンやるよ

2022/10/18に公開

Kubernetesのグレースフルシャットダウンの話になります。GoogleのGKEを使用していますがAWS等でも同じかと思います。Node.js, Python, Go, Rustでの具体的なコードもご紹介します。

グレースフル・シャットダウンとは?

処理中のリクエストがある状態でWebサーバのプロセスを突然KILL(ハード・シャットダウン)するとHTTP接続がブチッとなり502などのエラーが発生してしまいます。それを極力避けるため、エレガント(graceful)に、段階を踏んでシャットダウンすることでエラーの発生を低減する手法です。

具体的には、Webアプリにシャットダウン用のエンドポイントを生やし、シャットダウンの手順を実装しておきます。KubernetesはPodをシャットダウンするときには必ずそのエンドポイントを叩くという仕組み(仕様)になっています。

仕掛かり中のリクエストの他に、データベースの接続やファイルシステムへのデータのフラッシュなどもケアする必要があります。

具体的なコード

Node.js(Express)でのサンプルコードになります。

KubernetesのPodの定義ファイルになります。
lifecycle > preStop > httpGetにシャットダウン用のエンドポイントを定義します。この例の場合は、http://example.com/prestopがGETで呼び出されます。そのハンドラーにリソース解放のロジックを実装することになります。
あと、基本的なことですがそもそもローリングアップデートの設定をしてなければ、ダウンタイムが発生してしまいます。

ハマりポイントですが、エンドポイントのパスの先頭にスラ(/)をつけると動きません。ex.) /prestop

my-app.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mls-tools-ts
spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1 # 余裕を持った数にしましょう
    replicas: 2
(略)
      containers:
        - name: mls-tools-ts
          lifecycle:
            preStop:
              httpGet:
                scheme: HTTP
                port: 3000
                path: prestop # シャットダウン用のエンドポイント。スラ(/)はつけちゃダメ

次にWebサーバのサンプルコードです。言語はなんでも良いですが、Node.js(Express)を例に説明します。先ほど説明した/prestopのハンドラーを実装します。

index.ts
app.get('/prestop', async (req: Request, res: Response) => {
    console.log('!!! 1. preStop called')
    await new Promise(resolve => setTimeout(resolve, 5_000)); // 30秒以内で指定
    console.log('!!! 2. preStop close DB connections...') // リソース解放のつもり
    console.log('!!! 3. preStop done')
    res.json({ok: true});
});

const server = app.listen(PORT, () => console.log(`Running on ${PORT}`));

process.on('SIGTERM', () => { // なくても良い。説明のため書いた。
    console.log('!!! 4. SIGTERM signal received: closing HTTP server')
    server.close(() => {
        console.log('!!! 5. HTTP server closed')
    })
})

まず始めにハマりポイントですがsetTimeout()でスリープ処理を入れる必要があります。
理由はアルパカさんの記事が詳しいですが、簡単に言えば、PodがLB(ロードバランサー)から切り離され完全にトラフィックが無くなるまで少しタイムラグがあります。従って、スリープ処理を入れて数秒待ってからリソースの解放処理を進めます。

憶測ですが、分散環境で動いていますので、全てのシステムの同期を取るのは難しく、また処理が複雑になるのかと思います。

https://zenn.dev/hhiroshell/articles/kubernetes-graceful-shutdown#fn-9702-2

このサンプルでは5秒のスリープを入れていますが、皆さんの環境やWebサーバの性能に応じて調整してください。注意点として、デフォルトで30秒後にプロセス強制終了のSIGKILLシグナルが飛んでくるため、それまでにリソースの解放処理を完了させる必要があります。
30秒以上必要な場合は、terminationGracePeriodSecondsパラメータで長くできます。

It’s important to note that this happens in parallel to the preStop hook and the SIGTERM signal. Kubernetes does not wait for the preStop hook to finish.
GCP公式ブログ

スリープした後は、SIGKILLまであと25秒ある訳ですが、その間にデータベース接続のクローズやファイルシステムへのデータのフラッシュなどを行います。もちろん、この時点では新規のリクエストはやってこず、仕掛かり中のリクエストも無いという想定(確証はない)であります。もしエラーが発生するようであれば、スリープ時間が足りない可能性がありますので、Webサーバの性能を上げる、terminationGracePeriodSecondsを長くするなどの調整が必要になります。

最後のSIGTERMのところは書かなくても問題ありません。
一応説明しますと、Kubernetesはprestopの呼び出しの実行完了後(HTTPレスポンスがリターンした後)に、SIGTERMシグナルを飛ばします。Node.jsなどの多くのWebサーバではSIGTERMシグナルを受けると一応グレースフルシャットダウンをするようです。つまり、ソケットのリッスンをクローズし、仕掛かり中のリクエストの終了を待ったあとでプロセスを終了します。
しかしながら、prestopフックでそれは対応していますので特にここではやることはありません。

prestopではなくSIGTERMのハンドラでリソース解放処理を実装しても良いでしょう。実際、Googleの公式によるとprestopはSIGTERMをハンドルできない場合のワークアラウンドであると説明しています。ただ、シグナルのハンドリングはOSの種類やKubernetes以外の実行環境で変わります。またローカル開発環境では即終了して欲しい場合もあります。

次にNode.jsを起動するDockerファイルになります。
ハマりポイントですが、nodeを直で起動しないとSIGTERMシグナルを受け取れない罠があります。そのためSIGKILLを受けてPod終了となります。PID 1問題として知られています。

Dockerfile
CMD [ "node", "dist/index.js" ] # npmやyarn、shell経由はダメ。直で起動する。

実行してみます。Podをリスタートします。

restart
kubectl rollout restart deployment mls-tools-ts

GCPのコンソールログで出力を確認しましょう。

めでたく番号順に出力されました。5秒のスリープ、prestopとSIGTERMが逐次的、同期的に実行されたことがわかります。

AWSの事例ですが、株式会社ヌーラボさんのこちらのブログもとても参考になります。

https://nulab.com/ja/blog/backlog/graceful-shutdown-of-kubernetes-application/

言語別の補足説明

Python, Go, Rustのコード例です。

Python(FastAPI)編

一番簡単なPythonのFastAPIでの方法です。厳密に言えばuvicornでの方法になります。
通常、FastAPIをKubernetes(Docker)で動かす場合は、uvicorn経由で動かします。
したがってシグナルのハンドリングもuvicornがやってくれます。つまり、特に何もしなくても良いです。

Dockerfile
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "4000", "--workers", "1", "--log-config", "uvicorn-log.yml"]

Go(Gin)編

そのままですと、仕掛かり中のリクエストがあってもプロセスは終了してしまいます。
そこでendlessというライブラリを使用します。

app.go
import "github.com/fvbock/endless"

r := gin.New()

err := endless.ListenAndServe(":7000", r)
if err != nil {
	log.Fatal("listen and serve error:", err.Error())
}
Dockerfile
CMD ["./app"]

Ginの場合、コンテキストからデータベースなどハンドラー間で持ち回す変数を取り出すかと思います。例えばprestopハンドラーでMongoDBの接続を閉じます。

handler.go
func prestop(c *gin.Context) {
	log.Println("!!! prestop invoked")
	time.Sleep(5 * time.Second)

	log.Println("!!! MongoDB connections closing...")
	mongodb, _ := c.Get("MongoDB")
	_ = mongodb.Disconnect(c) // DB接続のクローズ
	
	log.Println("!!! prestop done")
	c.String(http.StatusOK, "ok")
}

prestopハンドラーの実行時間がスリープを含めた5秒ちょいになっているのがよくわかります。その後でSIGTERMシグナルも飛んできましたね。

余談ですが、prestopの後に、ヘルスチェックのreadinessが叩かれたのが気持ち悪ですね。

Rust(Actix)編

Rust(Actix)は特に何もする必要はありません。ただし、shutdown_timeout()でActix自身のグレース期間を設定出来ますので、Kubernetesのグレース期間に合わせておきます。デフォルト値は30(秒)なので指定しなくても良いです。

main.rs
    HttpServer::new(move || {
        App::new()
            .service(prestop)
    })
        .bind(("0.0.0.0", 8000))?
        .shutdown_timeout(30) // デフォで30なので書かなくても良い。
        .workers(1)
        .run()
        .await
Dockerfile
CMD ["./main"]

Discussion