Spring WebFlux + KuberntesでアプリのGraceful Shutdownを実装する
最近業務でお手伝いさせていただく案件のなかでも、Java on Kubernetesなパターンが増えてきています。Kubernetesに限らずクラウドのマネージドPaaSで動かすにはアプリケーション側でもクラウドネイティブを考慮した実装が必要になり、特に耐障害性や回復性などを意識した実装が大事になってきます。
クラウドネイティブな環境でアプリケーションを動かすときに、開発者の観点でどういうところに注意すればよいかは
- 通信先のサービス(DB/外部API)は常に動いていると思うべからず
- 死に際を美しく(Graceful Shutdown)
- 秘匿情報・環境依存値の取り扱い
- ステートレスにすべし
あたりが大事なポイントになってくるかなと思っています。
このエントリでは、2つ目の
死に際を美しく(Graceful Shutdown)
対策の一つであるアプリケーションのGraceful Shutdownの実装について、Spring Bootを使った実装の例を説明します。
そもそも、Graceful Shutdownとは?
従来のオンプレでのWeb3層システムの場合、システムメンテナンスなどでWebサーバを停止させる場合、システム運用者によって次の作業を行います。
- LBで該当Webサーバへの新規リクエストの受付を停止する
- 該当Webサーバ上で動くアプリケーションが処理し終わるのを待つ
- 処理中のプロセスが無いことを確認できたら、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で応答します。
では、具体的な実装例を見ていきましょう。
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: graceful | Graceful 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
インターフェースを実装したWebServerGracefulShutdownLifecycle
のstop
メソッドを呼び出しているのがわかります。
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になっています。
次のコマンドでコンテナイメージが作成できます。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で行われます。
KubernetesのMaster Node上の
api-server
がkubectl delete pod
コマンドを受け取ります。Endpoint Controller
によってインターセプトされます。Endpoint Controller
は、api-server
にコマンドを発行して、エンドポイントオブジェクトからIPアドレスとポートを削除します。Endpoint Controller
の変更情報をkube-proxy
/Ingress Controller
/CoreDNS
/kube-proxy
が受け取ります。
Webサーバの停止
KubernetesではオンプレWeb3層システムでいうところの「Webサーバの停止」に相当する、以下の処理がWorker Nodeで行われます。
KubernetesのWorker Node上の
kubelet
がkubectl delete pod
コマンドを受け取ります。kubelet
がAPI Server
をポーリングしてPodの削除情報を更新します。kubeletは、該当Podの破棄を
Container Runtime Interface(CRI)
/Container Network Interface(CNI)
/Container Storage Interface(CSI)
に委任します。
実はこの2つの処理は並行して行われます。そのため、Master Nodeでの処理が完全に終わる前にPodが削除された場合、とても悲しいことになります。
従来のオンプレでの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
は同期処理である必要があります。もしコマンドが非同期で終了処理を行ってしまうと、kubelet
はpreStop 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を意識して実装する必要があります。
信頼性大事。可用性大事。クラウドネイティブな環境においては非機能要件はインフラチームだけの話じゃないのが奥深いところです。
以上