Spring WebFlux + KuberntesでアプリのGraceful Shutdownを実装する


最近業務でお手伝いさせていただく案件のなかでも、Java on Kubernetesなパターンが増えてきています。Kubernetesに限らずクラウドのマネージドPaaSで動かすにはアプリケーション側でもクラウドネイティブを考慮した実装が必要になり、特に耐障害性や回復性などを意識した実装が大事になってきます。


クラウドネイティブな環境でアプリケーションを動かすときに、開発者の観点でどういうところに注意すればよいかは

あたりが大事なポイントになってくるかなと思っています。

このエントリでは、2つ目の

死に際を美しく(Graceful Shutdown)

対策の一つであるアプリケーションのGraceful Shutdownの実装について、Spring Bootを使った実装の例を説明します。

そもそも、Graceful Shutdownとは?

従来のオンプレでのWeb3層システムの場合、システムメンテナンスなどでWebサーバを停止させる場合、システム運用者によって次の作業を行います。

  1. LBで該当Webサーバへの新規リクエストの受付を停止する
  2. 該当Webサーバ上で動くアプリケーションが処理し終わるのを待つ
  3. 処理中のプロセスが無いことを確認できたら、Webサーバを停止する

クラウドネイティブな構成の場合、これらの処理をソフトウエアで自動化しています。たとえば、Kubernetesの場合、LBに相当するService/IngressからWebアプリケーションに相当するPodへのルーティングを宣言ベースで行い、クラスタの状態を維持します。またプラットフォームのメンテナンス等でPodが動いているWorker Nodeが移動するケースも普通にあります。また、Deploymentの更新などの理由でPodの再作成やPodの終了処理がいたるところで行われます。


これらは基盤メンテチーム側からみると「Kubernetesのしくみをつかって工数のかかるWebサーバのメンテナンスを自動化した!生産性向上!ゆっくり眠れる!!最高!!!!」となります。

一方のアプリ開発チーム側から見ると「Kubernetesが勝手にWebサーバ再起動した!!!こっちはサービス提供時間中だぞ!!!ひどい!!!今までは1台ずつ丁寧に夜間手作業でやってくれてたじゃないか!!!」

そしてここではじめて、システム運用者のありがたみを心から知ることになります。

つまり、、、、、このような悲惨なすれ違いをなくすには 「今までシステム運用者がやっていた1-3の作業はどうなる?」 を深堀りしたアプリケーション方式設計/基盤方式設計が必要だということがわかります。


Graceful Shutdownとは、アプリケーションをシャットダウンする前に、まだ進行中の要求を完了するためのタイムアウト期間を設け、その間に安全にアプリケーションを終了させることです。タイムアウト期間中に、すでに動いているアプリの処理の終了やDBなど外部サービスへの接続を停止する処理を行い、それが完了したらシャットダウンを行うことでデータのロストやコネクションのクローズ処理、アプリ不整合やエラーを防ぐことができます。

Spring boot 2.3.0.RELEASE以降では、フレームワーク自体にGraceful Shutdownの機能が追加されました。この機能は現在Tomcat/Undertow/Netty/JettyのWebサーバで提供されており、SmartLifecycleBeanを停止する最初のフェーズで実行されます。

なおアプリケーションが新しい要求を停止する方法は、Webサーバによって異なりますが公式ドキュメントによると、Tomcat/Jetty/Nettyはネットワーク層でのリクエストの受け入れを停止し、Undertowはリクエストを受け入れるもののHTTP503で応答します。

Graceful Shutdown

では、具体的な実装例を見ていきましょう。

Spring WebFlux/NettyでのGraceful Shutdown

Spring WebFluxは、NettyなどのノンブロッキングI/Oベースのアプリケーションサーバ上で動作します。そして、ネットワークアクセスやDBアクセスなどのI/O処理の呼び出しに対して、1つのスレッドで複数のリクエストを処理でき少ないスレッドで実行できるため、メモリサイズやCPUの使用を減らすことが可能です。

Spring WebFluxでは、Reactorによるリアクティブプログラミングを採用しています。リアクティブプログラミングとは、データに着目したイベント駆動型のプログラミングの一種で、通知されるデータを受け取って処理を行うハンドラを実装することによって連続的なデータを処理する手法です。Reactorはリアクティブプログラミングを実現するためのライブラリの一つであり、ノンブロッキングで非同期なリアクティブプログラミングの仕組みを提供しています。

Spring WebFluxによるWebアプリケーションの作成

Spring WebFluxを使ったWebアプリケーションは「Spring Initializr」を使ってプロジェクトのひな型を作成できます。その際、[ADD DEPENDENCIES]で[Spring Reactive Web]と[Spring Boot Actuator]を追加します。

ダウンロードしたZIPのpom.xmlを確認すると以下のdependencyが登録されています。

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

Graceful Shutdownの設定

作成したアプリケーションのGraceful Shutdownの機能を有効にします。Mevenを使用している場合、application.propertiesに以下の設定を追加します。

server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=60s

関連する設定値の意味は以下のとおりです。

設定値意味
server.shutdown: immediateサーバの即時シャットダウン
server.shutdown: gracefulGraceful Shutdownの有効化

Graceful Shutdownが有効化されると、シャットダウンまでの時間をspring.lifecycle.timeout-per-shutdown-phaseで指定できます。これはjava.time.Durationの値をとります。ここでは60sを指定しました。

Controllerの実装

リクエストを受けるためのシンプルなControllerを書きます。'/sleep'にリクエストが来たら60s待って画面を返すだけです。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@ComponentScan("com.example.front.service")
@Controller
public class FrontController {

    @GetMapping("/")
    public String index(Model model) {
        log.info("Mapping: Index");
        model.addAttribute("host", getHost());
        return "index";
    }

    @GetMapping("/sleep")
    public String sleep(Model model) throws Exception {
        log.info("Sleep: 60s");
        Thread.sleep(60_000L); 
        model.addAttribute("message", " Graceful shutdown complete");
        return "sleep";
    }

次のコマンドでWebサーバを起動します。

./mvnw spring-boot:run
[INFO] --- spring-boot-maven-plugin:2.5.4:run (default-cli) @ demo ---
[INFO] Attaching agents: []

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.5.4)

2021-09-30 13:15:06.043  INFO 5095 --- [           main] c.e.front.controller.FrontApplication    : Starting FrontApplication using Java 11.0.9.1 on xxx with PID 5095 ()
2021-09-30 13:15:06.060  INFO 5095 --- [           main] c.e.front.controller.FrontApplication    : No active profile set, falling back to default profiles: default
2021-09-30 13:15:14.498  INFO 5095 --- [           main] o.s.b.a.e.web.EndpointLinksResolver      : Exposing 14 endpoint(s) beneath base path '/actuator'
2021-09-30 13:15:15.627  INFO 5095 --- [           main] o.s.b.web.embedded.netty.NettyWebServer  : Netty started on port 8080
2021-09-30 13:15:15.673  INFO 5095 --- [           main] c.e.front.controller.FrontApplication    : Started FrontApplication in 11.977 seconds (JVM running for 13.529)

これでテストを行います。

アプリのリクエストが無い場合

なにもリクエストが無い状態で、Webサーバを停止します。すると、Nettyはgraceful shutdownを行いますが、即時にサーバが停止します。

2021-09-30 13:19:21.418  INFO 5095 --- [ionShutdownHook] o.s.b.w.embedded.netty.GracefulShutdown  : Commencing graceful shutdown. Waiting for active requests to complete
2021-09-30 13:19:21.429  INFO 5095 --- [ netty-shutdown] o.s.b.w.embedded.netty.GracefulShutdown  : Graceful shutdown complete

終了待ちアプリのリクエストがある場合

次は/sleepにアクセスした状態でWebサーバを停止します。すると、NettyはGraceful Shutdownを行いますが、spring.lifecycle.timeout-per-shutdown-phaseで指定した時間待機してシャットダウンを行っています。

2021-09-30 13:23:42.867  INFO 5662 --- [or-http-epoll-3] c.e.front.controller.FrontController     : Sleep: 30s
2021-09-30 13:23:48.593  INFO 5662 --- [ionShutdownHook] o.s.b.w.embedded.netty.GracefulShutdown  : Commencing graceful shutdown. Waiting for active requests to complete
2021-09-30 13:24:48.594  INFO 5662 --- [ionShutdownHook] o.s.c.support.DefaultLifecycleProcessor  : Failed to shut down 1 bean with phase value 2147483647 within timeout of 60000ms: [webServerGracefulShutdown]
2021-09-30 13:24:48.627  INFO 5662 --- [ netty-shutdown] o.s.b.w.embedded.netty.GracefulShutdown  : Graceful shutdown aborted with one or more requests still active

Sping BootではSmartLifecycleを実装したクラスはApplicationContextの起動時やシャットダウン時にstart/stopメソッドが呼ばれます。ソースをみてみると、Graceful ShutdownはこのSmartLifecycleインターフェースを実装したWebServerGracefulShutdownLifecyclestopメソッドを呼び出しているのがわかります。

WebServerGracefulShutdownLifecycle.java

	@Override
	public void stop(Runnable callback) {
		this.running = false;
		this.webServer.shutDownGracefully((result) -> callback.run());
	}

このようにSpringではプロパティで設定できるようになっています。

KubernetesでのGraceful Shutdown

では、作成したアプリをKuberntesで動かしてみます。

KubbernetesでのPod作成

まず作成したSpringアプリをKubernetesで動かすためには、アプリをコンテナ化する必要があります。自前でDockerfileを作成しても良いですが、Cloud Native Buildpacksを使うと便利です。Cloud Native Buildpacksとは、コンテナイメージを作るツールで、CNCFのincubating projectになっています。

Cloud Native Buildpacks

次のコマンドでコンテナイメージが作成できます。Kubernetesで利用するには、でき上がったDocker imageを任意のコンテナレジストリにPushしておきます。

./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=xxx/frontend:v1.0.0

docker push xxx/frontend:v1.0.0

Kubernetesクラスタをデプロイします。ローカルで確認するときはminikubeでもいいですしクラウドのマネージドを使うのも便利です。 Azureの場合、Azure Kubernetes Serviceだと数分でクラスタをデプロイできます。

クラスタができたら、Kuberntes Manifestを作成します。

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: front
  name: front
spec:
  replicas: 1
  selector:
    matchLabels:
      app: front
  template:
    metadata:
      labels:
        app: front
    spec:
      containers:
        - image: xxx.azurecr.io/frontend:v1.0.0
          name: front
apiVersion: v1
kind: Service
metadata:
  labels:
    app: front
  name: frontend
spec:
  ports:
  - name: front-port
    port: 80
    protocol: TCP
    targetPort: 8080
  selector:
    app: front
  type: LoadBalancer

作成したManifestをクラスタに反映します。

kubectl apply -f deployment.yaml
kubectl apply -f service.yaml

Podの削除処理を行うとどうなるか?

KubernetesでPodをアプリを提供するのですが、改めてPodの削除処理について詳細を見ていきます。

次のコマンドでPod frontendを削除します。

kubectl delete pod frontend-xxx

このコマンドを実行すると、Kuberntesクラスタでは以下の処理が行われます。

LBからの切り離し

KubernetesではオンプレWeb3層システムでいうところの「LBの切り離し」に相当する、以下の処理がMaster Nodeで行われます。

  1. KubernetesのMaster Node上のapi-serverkubectl delete pod コマンドを受け取ります。

  2. Endpoint Controllerによってインターセプトされます。

  3. Endpoint Controllerは、api-serverにコマンドを発行して、エンドポイントオブジェクトからIPアドレスとポートを削除します。

  4. Endpoint Controllerの変更情報をkube-proxy/Ingress Controller/CoreDNS/kube-proxyが受け取ります。

delete pod master

Webサーバの停止

KubernetesではオンプレWeb3層システムでいうところの「Webサーバの停止」に相当する、以下の処理がWorker Nodeで行われます。

  1. KubernetesのWorker Node上のkubeletkubectl delete pod コマンドを受け取ります。

  2. kubeletAPI ServerをポーリングしてPodの削除情報を更新します。

  3. kubeletは、該当Podの破棄をContainer Runtime Interface(CRI)/Container Network Interface(CNI)/Container Storage Interface(CSI)に委任します。

delete pod worker

実はこの2つの処理は並行して行われます。そのため、Master Nodeでの処理が完全に終わる前にPodが削除された場合、とても悲しいことになります。

delete pod master

従来のオンプレでのWeb3層システムの場合、システムメンテナンスなどでWebサーバを停止させる場合、システム運用者によってLBからの切り離しが終わってからWebサーバのシャットダウンをかけるといいましたが

「LBからの切り離しとWebサーバの切り離しを同時かつ別々に始める!LBから切り離されたかどうかは知らんが、Webサーバは停止するぞ!」

という動きになります。

しょぼん。

しかも、インフラ担当とアプリ担当が異なるプロジェクトの場合、「そんなはずじゃなかった」「そっちがよしなにやってくれると思っていた」に陥りがちなポイントです。

Kubernetes でのGraceful Shutdown

このように、Endpointがkube-proxyまたはIngress Controllerから削除される前にPodが終了すると、ダウンタイムが発生する可能性があります。

なぜならKubernetesは引き続きトラフィックをPodのIPアドレス宛にルーティングしていますが、Podは削除されていてすでに存在しない状態だからです。

ではどうすればよいかというとIngress Controller/kube-proxy/CoreDNSなどが該当PodのIPアドレスを削除しおわったのを待ってから、実際にPodを削除すればよい!となります。

Kuberntesでは通常SIGTERMシグナルを送信し、30秒待ってからプロセスを強制終了します。この時間を調整できるオプションが、terminationGracePeriodSecondsです。

terminationGracePeriodSecondsの指定はPodのマニフェストに記述します。これを指定することで、API ServerはPodの削除のリクエストを受け取ると、削除対象のPodリソースのMetadataにdeletionTimestamp (削除されるであろう日時) と deletionGracePeriodSeconds (削除猶予時間) を設定し、etcd に書き込みます。

次のマニフェストは180s待って削除する例です。

    spec:
      terminationGracePeriodSeconds: 180
      containers:
        - image: xxx.azurecr.io/frontend:v1.0.0
          name: front

またPodにpreStop hookを設定できます。preStop hookは Podのシャットダウンプロセスの最初に実行され、コンテナを正常終了させるために任意のコマンド (exec)や HTTP GET (httpGet)、TCP Socket (tcpSocket)のアクションを選択できます。

なおこのpreStop hookは同期処理である必要があります。もしコマンドが非同期で終了処理を行ってしまうと、kubeletpreStop hookが終了したと判断し、次の処理に遷移するので注意です。

preStop hookを設定するには、Podのマニフェストを次のように指定します。次の例では180秒間sleepを実行します。

          lifecycle:
            preStop:
              exec:
                command: ["sh", "-c", "sleep 180"]

というわけで、全体をまとめたDeploymentは以下のようになります。

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: front
  name: front
spec:
  replicas: 1
  selector:
    matchLabels:
      app: front
  template:
    metadata:
      labels:
        app: front
    spec:
      terminationGracePeriodSeconds: 180
      containers:
        - image: xxx.azurecr.io/frontend:v1.0.0
          name: front
          resources: {}
          lifecycle:
            preStop:
              exec:
                command: ["sh", "-c", "sleep 180"]

めでたしめでたし。

このあたりのパラメータをどう設定すればよいかについては、アプリケーションによりけりなので、開発チームとインフラチームで密連携して設計・テストが必要になってきます。

また、モニタリングのしくみも整備し、Podの生き死にでエンドユーザに影響が出ていないかや、Worker Nodeへのカオス注入などでプラットフォームのメンテナンスを想定した総合テストなども必要になってきます。

Graceful Node Shutdown

Kubernetes 1.21 betaよりGraceful Node Shutdownがサポートされ、–configフラグを介してkubeletに渡されるkubelet configによって構成を変更できます。クラウドのマネージドサービスでの対応など今後も注目です。

まとめ

このように、クラウドネイティブな環境でアプリケーションを動かすためには、GracefulなShutdownを意識して実装する必要があります。

信頼性大事。可用性大事。クラウドネイティブな環境においては非機能要件はインフラチームだけの話じゃないのが奥深いところです。

以上