Oisix ra daichi Creator's Blog(オイシックス・ラ・大地クリエイターズブログ)

オイシックス・ラ・大地株式会社のエンジニア・デザイナーが執筆している公式ブログです。

ほぼゼロダウンタイムな Pod 再起動戦略(EKS + Dapr + AWS LB)

はじめに

こんにちは、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つでした;

  1. アプリケーションPodが削除されるタイミングで、一時的に500系エラー(内部エラー)が跳ね上がった。
  2. 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. コンテナ停止シーケンス

  1. (任意)preStop lifecycle hook実行
  2. 各コンテナに SIGTERM
  3. Podの terminationGracePeriodSeconds まで待機(デフォルト30s)
  4. 規定時間内に終了しなければ SIGKILL

B. ルーティング除外シーケンス

  1. EndpointSlice / Endpointsから当該Pod IPを削除(非同期)
  2. kube-proxy / CNIがiptables / eBPFルール更新
  3. CoreDNSでレコード更新、TTL(例: 30s)により古いキャッシュが残る

問題になる時間差

Pod終了シーケンス概要(Graceful Terminationフロー)。

コンテナ停止(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を落とさない)
  • terminationGracePeriodSecondspreStop 時間 + アプリの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.conditionstarget-health.alb.ingress.k8s.aws/... が追加されます。


おまけ:動作検証のステップ例

対策実施後、本当にうまくいっているかチェックする際、以下のステップを踏みます。

  1. 変更適用前にメトリクス収集: エラー率, p95レイテンシ, 再起動時のリクエスト失敗数
  2. preStop + Dapr annotation導入 → ローリング更新 → エラーログ観測
  3. ReadinessGate(ALB登録待ち)有効化 → ローリング更新 → ALBターゲット状態遷移をCloudWatch / consoleで確認
  4. Karpenterによるノード削除(drain)をシミュレーションし挙動確認
  5. 過剰な待機がないか(総デプロイ時間 / スループット低下)を評価し30sを最適化

k6のようなツールでリクエストを連続流せば、検証しやすいです。


まとめ

本記事では、EKS + Dapr + AWS LB構成でのPod再起動時に発生するダウンタイムと一時的なエラー増加について、Kubernetesマニフェスト設定での解決策を紹介しました。

ゼロダウンタイムに近づく上で特に効いたのは次の2点です。

  1. Pod終了シーケンスとネットワーク経路切り離しの非同期を吸収する余白を作ること
  2. 新Podを外部ロードバランサ(NLB)の登録完了と同期してトラフィックに載せること

まずは preStop とReadinessGateの2つから適用し、計測しながら待機時間をチューニングしていくと安全に改善できます。

以上のの改善により、開発チームはアプリケーションロジックに集中でき、より安全で迅速なデプロイサイクルを実現できるようなります。

Oisix ra daichi Creator's Blogはオイシックス・ラ・大地株式会社のエンジニア・デザイナーが執筆している公式ブログです。

オイシックス・ラ・大地株式会社では一緒に働く仲間を募集しています