Kubernetesでローリングアップデートをかけるたびに、一部のユーザーにエラーが返る。そんな経験はありませんか?

原因のほとんどは グレースフルシャットダウンの設定不足 です。Spring Boot側とKubernetes側の両方を正しく設定することで、処理中リクエストを取りこぼさずにデプロイできます。

なぜデプロイ時にリクエストがエラーになるのか

KubernetesがローリングアップデートでPodを終了させるとき、次の順序でシャットダウンシーケンスが進みます。

  1. PodがTerminating状態になり、Endpointsから削除される
  2. preStopフックが実行される(設定している場合)
  3. preStop完了後にSIGTERMがアプリに送信される
  4. terminationGracePeriodSeconds 経過後にSIGKILL

ここで注意したいのが、PodがEndpointsから削除されてもkube-proxyへの反映に数秒かかる 点です。preStopなしでは、SIGTERMが到達した時点でもトラフィックが届き続ける可能性があります。

さらにデフォルトのSpring BootはSIGTERMを受けてすぐにサーブを停止するため、処理中だったリクエストが強制切断されます。この2つのズレを解消するのが、graceful shutdownとpreStopフックの組み合わせです。

Spring Bootのgraceful shutdownを有効にする

Spring Boot 2.3以降 であれば、application.properties に1行追加するだけです。

server.shutdown=graceful

SIGTERMを受け取ったあとの挙動がこう変わります。

  1. 新規リクエストの受付を停止
  2. 処理中リクエストの完了を待機
  3. 完了したらシャットダウン

Spring Boot 2.2以前はこの設定が存在しないので、まずバージョンを確認しましょう。

タイムアウト時間を設定する

graceful shutdownには待機のタイムアウトがあります。デフォルトは 30秒 です。

spring.lifecycle.timeout-per-shutdown-phase=30s

この時間内に処理が完了しないリクエストは強制終了されます。APIのP99レスポンスタイムを参考に設定値を決めましょう。通常のREST APIなら30秒で十分ですが、重いバッチ処理があるなら延長を検討してください。

なお、これは シャットダウンフェーズごとのタイムアウト です。複数のBeanライフサイクルフェーズが存在するアプリでは、合計シャットダウン時間がこの値を超える場合があることを念頭においてください。

KubernetesのpreStopフックでSIGTERMを遅延させる

ここが見落としやすいポイントです。

KubernetesはPodをTerminatingにした時点でEndpointsから削除しますが、preStopフックはSIGTERM送信前に実行されます。sleepを挟むことで、kube-proxyの反映を待ってからSIGTERMがアプリに届くようにできます。

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

sleepは 5〜10秒 が一般的な推奨値です。クラスタの状況によりますが、10秒あれば余裕を持って対応できます。

terminationGracePeriodSecondsとの数値整合

preStopとgraceful shutdownを設定したら、terminationGracePeriodSeconds がそれらの合計より大きいことを確認してください。

terminationGracePeriodSeconds > preStop sleep秒 + timeout-per-shutdown-phase秒

sleep 10秒 + Spring Boot 30秒なら、60秒にしておくのが安全です。

terminationGracePeriodSeconds: 60

この値が不足していると、KubernetesがSIGKILLで強制終了してしまいます。

Readiness ProbeとActuatorの連携

Spring Boot Actuator を使っている場合、シャットダウン中の挙動をさらに活用できます。

graceful shutdownが始まると、Spring Bootは内部的に ReadinessState.REFUSING_TRAFFIC に遷移し、/actuator/health/readiness が自動的に OUT_OF_SERVICE を返すようになります。KubernetesのReadiness Probeをこのエンドポイントに向けておけば、トラフィックが自動的に遮断されます。

management.endpoint.health.probes.enabled=true
management.health.readinessstate.enabled=true
management.health.livenessstate.enabled=true
readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 5
livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  initialDelaySeconds: 30
  periodSeconds: 10

設定まとめ

コンテナイメージのビルド手順については Dockerコンテナ化ガイド を参照してください。

application.propertiesの最終形はこちらです。

# Graceful Shutdown
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s

# Actuator Probes
management.endpoint.health.probes.enabled=true
management.health.readinessstate.enabled=true
management.health.livenessstate.enabled=true

Kubernetes Deployment YAMLの該当部分はこうなります。

spec:
  template:
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: app
          image: your-app:latest
          lifecycle:
            preStop:
              exec:
                command: ["sh", "-c", "sleep 10"]
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10

KubernetesへのSpring Bootデプロイの基本設定については こちらの記事 も参考にしてください。

動作確認:ローリングアップデート中にリクエストを流す

設定が正しく機能しているか確認するには、ローリングアップデート中に 業務エンドポイントへ 連続リクエストを流し続けるのが一番わかりやすいです。/actuator/health はヘルス状態を返すだけなので、実際のAPIエンドポイントを叩いてHTTPステータスのエラー有無を確認しましょう。

# 別ターミナルで業務エンドポイントへリクエストを流し続ける
while true; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://your-service/api/hello)
  echo "$(date): $STATUS"
  if [ "$STATUS" != "200" ]; then
    echo "ERROR: Got $STATUS"
  fi
  sleep 0.5
done

このまま別ターミナルでアップデートを実行します。

kubectl rollout restart deployment/your-app
kubectl rollout status deployment/your-app

すべてのレスポンスが200なら設定成功です。502や503が混ざる場合は、preStopのsleep時間を延ばすか terminationGracePeriodSeconds を見直しましょう。

ハマりやすいポイント

preStopなしでgraceful shutdownだけ設定するケース がもっとも多いミスです。graceful shutdownはアプリ内の処理を保護しますが、kube-proxyのタイムラグで届くトラフィックは防げません。両方の設定がセットで機能します。

Spring Boot 2.2以前の環境 では server.shutdown=graceful が無効です。pom.xmlやbuild.gradleでバージョンを確認してください。

probes.enabled=true を忘れているケース も見落としやすいです。設定なしでは /actuator/health/readiness が404になります。

まとめ

各設定には明確な役割があり、1つでも欠けると別のところでリクエストロスが発生します。

  • server.shutdown=graceful がなければ、SIGTERMを受けた瞬間に処理中リクエストが強制切断される
  • preStopフックがなければ、kube-proxyのタイムラグ分だけトラフィックロスが残る
  • terminationGracePeriodSeconds が短すぎると、graceful shutdownが完了する前にSIGKILLで強制終了される
  • Actuatorのreadiness probeを設定しないと、シャットダウン開始後もKubernetesがトラフィックを送り続ける

Micrometer + Prometheusによる可観測性の設定 と組み合わせると、デプロイ中のエラーレートをリアルタイムに監視できてさらに安心です。