はじめに
こんにちは、Platformセクションのミカエルです。
今回扱うテーマは、デプロイ速度の遅さと、それによる不安を無くす方法についてです。
現代のマイクロサービスアーキテクチャにおいて、サービス間の通信の信頼性とダウンタイムの最小化は重要な課題です。特に、デプロイやインフラの運用変更時に発生する一時的なエラーやダウンタイムは、ユーザー体験に直接影響を与えるため、インフラ側で適切な「ガードレール」を整えておくことが必要です。
私たちは現在、基盤刷新プロジェクトにおいてDapr(Distributed Application Runtime)を活用しています。DaprはOSSで、分散アプリケーション開発を簡素化する優れたランタイムです。サービス間通信、状態管理、パブ・サブメッセージングなどの共通機能を提供し、アプリケーションコードから複雑なインフラストラクチャの詳細を抽象化してくれます。
我々の構成では、EKS上でDaprを活用し、AWS Load Balancer Controller(ALB/NLB)経由で外部トラフィックを受ける形を採用しています。

しかし、この構成でPodの削除・再起動・ノードDrainingが発生すると、ダウンタイムや瞬間的なエラー率上昇が起こる課題に直面しました。今回は、Kubernetesマニフェスト/コントローラ設定に焦点を当てて、この問題をどのように解決できるのかをご紹介します。
起きていた現象
デプロイ時やノード入れ替え(Karpenterによるノード削除)のタイミングでアプリケーションログに次のようなエラーが発生していました。また、同じタイミングでクライアント側では500(Server Error)と503(Service Unavailable)のレスポンス比率が跳ね上がっていました。
{"errorCode":"ERR_DIRECT_INVOKE","message":"failed to invoke, id: ..."}
観測された主なパターンは次の2つでした;
- アプリケーションPodが削除されるタイミングで、一時的に500系エラー(内部エラー)が跳ね上がった。
- Ingress ControllerのPodが削除されるタイミングで、一時的に503(Service Unavailable)が増加した。
「見える症状が別なので、理由ももしかしたら別かも?」と思いましたが、まずはPodが削除されると内部で何が起きるのかを押さえていきます。
Pod が削除されると何が起きるか(Graceful Termination)
Podの削除 / ローリング更新 / ノードのdrainなどでPod終了が始まると、Kubernetesはほぼ同時に2つの処理フローを走らせます。
| フロー | 役割 | 主な構成要素 |
|---|---|---|
| A. コンテナ停止シーケンス | アプリ処理の停止 | preStop Hook -> SIGTERM ->(待機)-> SIGKILL |
| B. ルーティング除外シーケンス | トラフィック経路から外す | Endpoints 更新 -> kube-proxy / CNI 反映 -> DNS TTL 消化 |
A. コンテナ停止シーケンス
- (任意)
preStoplifecycle hook実行 - 各コンテナに
SIGTERM - Podの
terminationGracePeriodSecondsまで待機(デフォルト30s) - 規定時間内に終了しなければ
SIGKILL
B. ルーティング除外シーケンス
- EndpointSlice / Endpointsから当該Pod IPを削除(非同期)
- kube-proxy / CNIがiptables / eBPFルール更新
- CoreDNSでレコード更新、TTL(例: 30s)により古いキャッシュが残る
問題になる時間差

コンテナ停止(A)が速く終わる一方で、ルーティング除外(B)は非同期 + DNSキャッシュにより最大で数十秒遅延することがあります。
上図のパターン1は理想的なケースで、すべてが正常に動作してエラーが発生しない状況を表しています。一方、パターン2ではA(コンテナ停止シーケンス)とB(ルーティング除外シーケンス)が並列で進み、A完了後もしばらくBのルーティングが残る「ギャップ領域」が発生することを示しています。
このギャップ中に、既にアプリは終了手続きに入りつつあるのに、リクエストが飛び、ERR_DIRECT_INVOKE のような一時エラーが出ます。
対策: 意図的な「余白」を作る
preStop で短いスリープを入れて Endpoint の更新が終わってそれに気づくまで(プロパゲーション)を待つ余白 を確保します。
Sidecar(本例ではDapr)を経由してネットワーク呼び出しする場合、SIGTERMはPod内の全コンテナへ同時に送られるため、Sidecarが先に終了すると途中で接続が失われます。 Endpoint伝播が終わるまでSidecarも存続させるよう、後述のdapr.io/block-shutdown-durationで待機時間を揃えます。
推奨初期値: 35秒(CoreDNSのデフォルトTTLが30秒であることと、Endpoint伝播速度を考慮。実際のクラスタ環境で観測して調整可)。
Manifestの例
apiVersion: apps/v1 kind: Deployment metadata: name: sample-api spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 # 常に全レプリカ稼働を維持 maxSurge: 1 # 余剰 1 Pod を許可 selector: matchLabels: app: sample-api template: metadata: labels: app: sample-api annotations: dapr.io/enabled: "true" dapr.io/app-id: "sample-api" # Dapr が内部シャットダウン動作をブロックする時間(秒) dapr.io/block-shutdown-duration: "30s" spec: terminationGracePeriodSeconds: 60 # preStop + アプリ shutdown の余白を十分に containers: - name: app image: ghcr.io/example/sample-api:1.2.3 lifecycle: preStop: exec: command: ["/bin/sh", "-c", "echo 'preStop: wait for LB & DNS'; sleep 30"] ports: - containerPort: 8080 readinessProbe: httpGet: path: /healthz/ready port: 8080 periodSeconds: 5 failureThreshold: 3 livenessProbe: httpGet: path: /healthz/live port: 8080 periodSeconds: 10
ポイント:
preStopのsleep中はまだEndpoint削除されますが、コンテナはSIGTERM受信前なのでアプリは稼働継続(※ 実装によってはreadinessを落とさない)terminationGracePeriodSecondsはpreStop時間 + アプリのshutdown所要時間より長く- Dapr:
dapr.io/block-shutdown-durationを同値に設定し、Dapr runtimeが早期にサービス呼び出しを閉じないようにする - アプリ側でもSIGTERMを受けたら新規受付を止め、進行中リクエスト/コネクション完了を待って正常終了する(graceful shutdown)
これらを組み合わせることにより、通常のPod終了(ローリング更新/drainなど)に伴う500エラーの発生頻度は大幅に減りました。
一方でOOMKillやノード障害など、コンテナが突然死するケースではpreStop/graceful shutdownが発動できず、残りの一時エラーが発生し得る点には注意が必要です。
NLBへの登録タイムラグによるダウンタイム
前の対策で1つの問題を解決できました。
次にIngress ControllerのPod削除時に観測された一時的な503(Service Unavailable)増加の原因を探ります。
調べた結果、新しいPodが「Kubernetes的にはReady」になっても AWS Load Balancer 上ではまだ Target Health = initial のままで、古いPodが先に削除されるとリクエストを受け止める対象がしばらく0になることがわかりました。
Kubernetes対AWS LB:ステータスのズレ
| 観点 | Kubernetes | AWS LB |
|---|---|---|
| Pod Ready 判定 | readinessProbe 成功 | TargetGroup ヘルスチェック成功 |
| 削除トリガー | 新 Pod Ready ⇒ 古い Pod Termination 開始 | DeregistrationDelay(デフォルト 300s など) |
KubernetesとAWS LBの間では、ステータスの解釈が微妙に異なります。例えば「Pod Readyの判定をして」という指示をすると、KubernetesはReadiness Probeの成功を判定しますが、一方でAWS LBはTarget Groupのヘルスチェックが成功しているかどうかを判定します。このズレが問題を引き起こす原因になります。
そこで AWS Load Balancer Controller が提供する Pod Readiness Gate を活用することでKubernetesとAWS LBの認識を合わせることができます。
Pod Readiness Gate とは
Podの status.conditions にコントローラ独自のConditionを追加し、それがTrueになるまでPodをReadyとみなさない仕組みです。AWS Load Balancer ControllerはALB / NLBのターゲット登録とヘルスチェック成功を待つCondition(target-health.alb.ingress.k8s.aws/...)を自動注入でき、これにより「アプリ側readinessProbeは通ったがLBではまだ未登録」という空白時間を解消します。
ReadinessGate 自動注入を有効化する Namespace ラベル
AWS Load Balancer Controllerは、コントローラを動かしているNamespaceに特定のラベルを付与すると、PodへReadinessGate Conditionを自動注入します。 クラスタ種別でキーが異なる点に注意が必要です。
| クラスタ | 付与するラベル(key=value) |
|---|---|
| 通常(EKS Auto Mode ではない) | elbv2.k8s.aws/pod-readiness-gate-inject=enabled |
| EKS Auto Mode 利用クラスタ | eks.amazonaws.com/pod-readiness-gate-inject=enabled |
自動注入が有効な場合、新規に作成されたPodの status.conditions に target-health.alb.ingress.k8s.aws/... が追加されます。
おまけ:動作検証のステップ例
対策実施後、本当にうまくいっているかチェックする際、以下のステップを踏みます。
- 変更適用前にメトリクス収集: エラー率, p95レイテンシ, 再起動時のリクエスト失敗数
- preStop + Dapr annotation導入 → ローリング更新 → エラーログ観測
- ReadinessGate(ALB登録待ち)有効化 → ローリング更新 → ALBターゲット状態遷移をCloudWatch / consoleで確認
- Karpenterによるノード削除(drain)をシミュレーションし挙動確認
- 過剰な待機がないか(総デプロイ時間 / スループット低下)を評価し30sを最適化
k6のようなツールでリクエストを連続流せば、検証しやすいです。
まとめ
本記事では、EKS + Dapr + AWS LB構成でのPod再起動時に発生するダウンタイムと一時的なエラー増加について、Kubernetesマニフェスト設定での解決策を紹介しました。
ゼロダウンタイムに近づく上で特に効いたのは次の2点です。
- Pod終了シーケンスとネットワーク経路切り離しの非同期を吸収する余白を作ること
- 新Podを外部ロードバランサ(NLB)の登録完了と同期してトラフィックに載せること
まずは preStop とReadinessGateの2つから適用し、計測しながら待機時間をチューニングしていくと安全に改善できます。
以上のの改善により、開発チームはアプリケーションロジックに集中でき、より安全で迅速なデプロイサイクルを実現できるようなります。
