ただの技術メモ

個人備忘録

Kubernetes × envoy × gRPC環境下でのGraceful Shutdownの流れ

はじめに

ABEMAの広告プロダクト開発チームで内定者バイトをしていた林です!

この記事はCyberAgentメディア事業部の広告横軸組織PTAのアドベントカレンダー18日目の記事です!

adventar.org

何について書くか・前提

Kubernetes × envoy × gRPC 環境下でのGraceful Shutdownに関して書いていきます。

Istioが導入されている環境でのGraceful Shutdownについてはいくつか記事が見つかったのですが、Headless Servicesを利用してenvoyをサイドカーにしている環境でのGraceful Shutdownについては記事が少なく?Istioがあるかどうかで内容も変わりそうなのでまとめてみたかったというようなモチベーションです。

以下のようなイメージで、Kubernetes上でenvoyプロキシを使ってgRPCのアプリケーションが動いているマイクロサービスを考えます。

KubenetesのHeadless Servicesを利用してServiceが持つPodのIPアドレス一覧を返して、envoyでバランシングしているような状況です。

kubernetes.io

Graceful Shutdownの実装

一般的な話として、サーバーを停止する際に停止が指示された時点で新たなリクエストは受け入れず既に受け付けていた処理中のリクエストは処理・レスポンスしてからサーバーを停止することをGraceful Shutdownと言います。

これによって処理中のリクエストを捌ききってから安全にサーバーを停止することができます。

Graceful Shutdownの具体的なユースケースは、運用中のサービスだと例えばリリース時にPodをrolloutするときなどが該当します。 Podで処理中のリクエストがある状態でGracefulにPodが終了されない場合、クライアント側で500系エラーが頻発してしまいます。

GoのGraceful Shutdownの実装

Goのnet/httpやnet/httpをラップしているechoではShutdownメソッドがGracefulになるように実装されています。

https://pkg.go.dev/net/http#Server.Shutdown

https://pkg.go.dev/github.com/labstack/echo/v4#Echo.Shutdown

一方でGoのgrpcパッケージはGracefulではないShutdownのメソッドも提供しています。

https://pkg.go.dev/google.golang.org/grpc@v1.45.0#Server.Stop

https://pkg.go.dev/google.golang.org/grpc@v1.45.0#Server.GracefulStop

引数にコンテキストを取るかどうか、返り値にエラーが返るかどうかなどシグネチャも若干異なります。 gRPCサーバーの場合、例えば以下のようにGraceful Shutodownを実装できそうです。

func main() {
         ...
         ...
         ...


    // サーバーの初期化
    server, err := newServer(cfg)
    if err != nil {
        logger.Fatalf("failed to setup server: %v", err)
    }

    // シグナルの受け取り
    stopChan := make(chan os.Signal, 1)
    signal.Notify(stopChan,
        os.Interrupt,
        syscall.SIGINT,
        syscall.SIGTERM,
        syscall.SIGTSTP,
    )

    go func() {
        <-stopChan
        logger.Infof("start server shutdown")
        server.shutdown(cfg.GracefulPeriod)
    }()

    // サーバーの起動
    if err := server.run(); err != nil {
        logger.Fatalf("failed to serve: %#v", err)
    }
}

func (s *server) shutdown(period time.Duration) {
    gracefulStopChan := make(chan bool, 1)

    go func() {
        logger.Infof("grpcserver:  graceful shutdown is running")
        s.grpcServer.GracefulStop()
        gracefulStopChan <- true
    }()

    t := time.NewTimer(period)
    select {
    case <-gracefulStopChan:
        logger.Infof("grpcserver: graceful shutdown completed before timing out")
    case <-t.C:
        logger.Infof("graceful shutdown failed timeout, closing pending RPCs.")
        s.grpcServer.Stop()
    }
}

ちなみにnet/httpやechoだと引数にContextを取るので以下リンク先のようにも実装できそうです。

echo.labstack.com

Kubernetesのライフサイクルについて

アプリケーション側でSIGTERMなどのシグナルを受け取ってからGracefulにサーバーを停止する流れについて確認してきました。 次にシグナルの発行がどういう流れでされるかについて確認していきます。

kubectl rollout restart deployment名の実行などでPodが終了するイベントが起きた場合を考えます。 いくつかの処理が走るのですがGraceful Shutdownに関わる部分を掻い摘んで書くと、kubeletによるプロセスのシャットダウンとサービスアウトが独立して実行されます。

kubernetes.io

zenn.dev

kubeletによるプロセスのシャットダウンではpreStopフックを実行してからDockerデーモンにコンテナの終了(シグナルの発火)を依頼します。preStopフックはプロセス終了前に実行する事前処理のことでmanifestに記述します。

サービスアウトの処理ではServiceから終了対象のPodを除外してトラフィックの配送ルールを更新することによって、終了対象のPodに対して新規のTCPコネクションが貼られないようにします。

これらの処理は依存関係がなくそれぞれが独立して実行されるので、サービスアウトされる前にプロセスのシャットダウンがされてしまうと、リクエストがシャットダウン済みのプロセスにきてしまいエラーが発生してしまいます。 そこでpreStopフックには以下のようにsleepの処理を書くことで対処します。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: test-component
spec:
  ...
  ...
  ...
  template:
    metadata:
      labels:
        app: test-component
    spec:
      ...
      ...
      ...
      containers:
      - args:
        - --config-path /etc/envoy/envoy.json
        name: vega-linear-deliver-sidecar-envoy
        command:
        - /usr/local/bin/envoy
        image: envoyproxy/envoy:v1.7.0
        volumeMounts:
        - mountPath: /etc/envoy
          name: envoy
        ...
        ...
        ... 
        name: test-component
        image: asia.gcr.io/test-project/test-component:latest
        lifecycle:
          preStop:
            exec:
              command:
              - sh
              - -c
              - sleep 10
        ...
        ...
        ...
      volumes:
      - configMap:
          name: test-component-sidecar-envoy
        name: envoy

どれくらいの時間sleepする必要があるかは諸説ありそうですが下記記事によると10秒程度?で十分そうです。

It depends on the latency of your network and the nodes. You may need to perform some tests to find out this value. However, multiple sources suggest a value between 5-10 seconds should be enough for most cases.

engineering.rakuten.today

envoyサイドカー導入時の注意点

Kubernetesのライフサイクルとアプリケーション側でのGraceful Shutdownの流れを確認してきました。

一見するとこれでGracefulにサーバーを停止できそうな気がします。

が、envoyをサイドカーとして導入している場合にはもう1つ処理が必要です。というのもKubernetesのライフサイクルで述べたPodの終了処理の中のkubeletによるプロセスのシャットダウンはPod内のコンテナごとに行われます。

そのためenvoyコンテナの方にもpreStopでsleep処理を書かないと、アプリ本体のコンテナより先にenvoyコンテナが終了してしまいます。 サービスアウトが完了する前にenvoyコンテナが終了してしまうので、新規リクエストがアプリケーション側に到達せずにエラーが発生してしまいます。

この問題に対処するためには、manifestのenvoyコンテナにもpreStopフックでSleep処理を書くと良さそうです。

Readiness Probeについて

Kubernetesにはコンテナがトラフィックを受け入れられる状態かどうかを認識する仕組みとしてReadiness Probeがあります。

kubernetes.io

Readiess Probeによって一定間隔でコンテナのヘルスチェックを行って、ヘルスチェックが失敗したPodはServiceのロードバランシングから切り離されてトラフィックを受信しないようになります。

これは自分が勘違いしていたポイントなのですが、Graceful Shutdownに関して言えば、Kubernetesのライフサイクルとして先ほども説明したようなサービスアウトが実行されるので、アプリケーション側でGraceful Shutdownを実装する際にわざわざヘルスチェックを失敗させるようにするような処理は必要ありません。

おわりに

Kubernetesのライフサイクルとアプリケーション側での処理とサービスメッシュの理解が深まり良い経験になりました!