B システム設計/インフラ — campaign-event-worker Pub/Subバックログ駆動オートスケーリング(KEDA gcp-pubsubスケーラー × PodDisruptionBudget × topologySpreadConstraintsによるゾーン分散 × graceful shutdown(SIGTERM+preStop+ack完了待ち) × VPA推奨値によるrequests適正化 × minReplicas冗長性 × liveness/readiness責務分離)(MOps キャンペーンイベントワーカー Bad→Good)

2026-08-11 (Day 123) 火曜 B: システム設計/インフラ ★★★★☆ GKE Autopilot / Kubernetes 1.30+ KEDA / Pub/Sub / VPA

概要

📈

KEDAによるバックログ駆動オートスケーリング

CPU使用率ではなくPub/Subの未配信メッセージ数を直接見るScaledObjectに置き換える。I/Oバウンドなワーカーの「本当の負荷」を捉えて初めてオートスケールが機能する。

🧭

PodDisruptionBudget × topologySpreadConstraints

最小レプリカ数を上げるだけでは不十分。ゾーン分散配置とメンテナンス時の最低稼働数保証を組み合わせて初めて「壊れる単位」が分散する。

🛑

graceful shutdown(SIGTERM+preStop+ack完了待ち)

SIGTERMはローリングアップデートやスケールインなど日常的な操作でも発生する。後処理設計が甘いと正常運用の中で継続的にメッセージ重複配信を招く。

🩺

liveness/readinessの責務分離

livenessはプロセス生存確認、readinessは依存先込みの準備確認。混在させるとPub/Subの一時的な不調がPod再起動の無意味な連鎖を招く。

問題

ECサイト MOps チームのキャンペーンイベントワーカー(campaign-event-workerは、Pub/Sub の Pull 型サブスクリプション(campaign-event-subcampaign_id を ordering key として順序保証)から注文完了イベントを受け取り、クーポンを発行する GKE Autopilot 上のワーカーである。直近のセール開催時、Pub/Subの未配信メッセージ数(バックログ)が数万件まで積み上がり、クーポン発行が数時間遅延する障害が発生した。調査すると、以下のマニフェスト/Terraformには7つの設計上の問題が潜んでいる。

問題点を全て洗い出しKEDA ScaledObject による Pub/Sub バックログ駆動オートスケーリング × PodDisruptionBudget × topologySpreadConstraints によるゾーン分散 × graceful shutdown(SIGTERMハンドリング+preStop+ack完了待ち)× VPA推奨値に基づく requests 適正化 × minReplicas引き上げによる冗長性確保 × liveness/readiness probe の責務分離 を考慮した Bad→Good リファクタリングを行ってください。

制約・前提条件

  • GKE Autopilot、Kubernetes 1.30+
  • Pub/Sub subscription: campaign-event-sub(Pull型、campaign_id を ordering key として同一キャンペーン内の順序を保証)
  • 現状の HPA は CPU 使用率(平均80%)のみをトリガーにしているが、このワーカーは Pub/Sub pull 待ちが大半(I/Oバウンド)で CPU 使用率がほとんど上がらないため、バックログが積み上がってもスケールしない
  • Deployment は minReplicas=1 で稼働しており、ローリングアップデートやノードメンテナンス時に処理が完全停止する瞬間がある
  • terminationGracePeriodSeconds はデフォルト30秒のままで、SIGTERM受信時にアプリが Pub/Sub メッセージの ack 処理を中断してすぐ終了するため、処理中だったメッセージが再配信され重複クーポン発行のリスクがある(冪等性キー自体は実装済みだが、re-delivery の発生自体を減らしたい)
  • resources.requests は「念のため」2vCPU/4Giと大きめに設定されており、VPA の recommender を Off モードで動かした結果、実際の使用量は平均0.3vCPU/512MiB程度と判明している
  • livenessProbereadinessProbe が同一エンドポイント(/healthz、Pub/Sub 接続確認込み)を参照しており、Pub/Sub側の一時的なレイテンシ増で liveness が失敗し Pod 再起動が連鎖したことがある
期待する回答形式: 問題点の列挙(番号付き)+ 改善後 Kubernetes マニフェスト(KEDA ScaledObject含む)+ 動作確認手順(設計意図の説明)

悪いコード (Before)

このマニフェストには 7つの設計上の問題 が隠れています。見つけてみてください。
bad-campaign-event-worker.yaml — CPUベースHPA・minReplicas=1・PDB無し・SIGTERM即終了
# 問題①: CPU使用率のみのHPA。I/OバウンドなPub/Subワーカーの負荷を捉えられない
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: campaign-event-worker
spec:
  minReplicas: 1              # 問題②: 冗長性が無い
  maxReplicas: 30
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 80

---
# 問題③: PodDisruptionBudgetが無い
# 問題④: topologySpreadConstraintsが無い
apiVersion: apps/v1
kind: Deployment
metadata:
  name: campaign-event-worker
spec:
  replicas: 1
  template:
    spec:
      # 問題⑤: デフォルト30秒のまま。preStopフックも無い
      containers:
        - name: worker
          image: .../campaign-event-worker:latest
          resources:
            requests:            # 問題⑥: 実測(0.3vCPU/512MiB)の6倍超
              cpu: "2000m"
              memory: "4Gi"
          # 問題⑦: liveness/readinessが同一エンドポイント・同一判定基準
          livenessProbe:
            httpGet: { path: /healthz, port: 8080 }
          readinessProbe:
            httpGet: { path: /healthz, port: 8080 }
問題点サマリー(7点)
1CPU使用率のみのHPA — バックログが積み上がってもスケールしない
2minReplicas=1で単一障害点 — 冗長性の引き上げが必要
3PodDisruptionBudgetが無い — ノードメンテナンス時に全滅しうる
4topologySpreadConstraintsが無い — ゾーン偏在で単一ゾーン障害に弱い
5SIGTERM即終了 — ack未完了メッセージが再配信され重複処理
6requestsが実測の6倍超 — Autopilotのコスト過剰配分
7liveness/readinessが同一判定 — Pub/Sub一時不調でPod再起動連鎖

ヒント(段階的開示)

ヒント1 — 方向性
問題は4層に分類できる。(1) スケーリングの入力層 — CPU使用率という「このワーカーの負荷を正しく表さない指標」でオートスケールを判断している、(2) 可用性層 — 最小レプリカ数・ゾーン分散・破壊予算(PDB)が無く、単一障害点になっている、(3) シャットダウン層 — SIGTERM受信後の猶予・後処理設計が無く、正常系の終了(ローリングアップデート等)ですらメッセージ重複配信を誘発している、(4) 監視層 — liveness/readinessという役割の異なる2つのprobeが同じ判定基準を使っており、一時的な依存先の不調が不要な再起動を招く。オートスケーリングは「CPU/メモリを見る」のではなく「何が処理待ちで詰まっているか」を見るべきで、Pub/Subワーカーの場合はバックログ(未配信メッセージ数)こそが真の負荷指標になる。
ヒント2 — アプローチ
  • 問題①: HPAがCPU使用率のみでI/OバウンドなPub/Subワーカーの負荷を捉えられない → KEDAの gcp-pubsub スケーラーで subscription の未配信メッセージ数(SubscriptionSize)を外部メトリクスとして参照する ScaledObject に置き換える
  • 問題②: minReplicas=1 で単一障害点 → minReplicaCount を2以上に引き上げ、常時ベースラインの冗長性を確保する
  • 問題③: PodDisruptionBudgetが無い → ノードメンテナンス時に全レプリカが同時に退避されないよう minAvailable を設定する
  • 問題④: topologySpreadConstraints が無くゾーン偏在のリスク → topology.kubernetes.io/zonetopologyKey にしてPodを分散配置する
  • 問題⑤: SIGTERM後すぐ終了しメッセージ再配信を誘発 → terminationGracePeriodSeconds を延長し、preStop フックで新規pull停止 → 処理中メッセージのack完了を待つgraceful shutdownを実装する
  • 問題⑥: requests が実測の6倍以上で過剰配分 → VPA推奨値(0.3vCPU/512MiB)を踏まえて適正化する(Autopilotは0.25vCPU刻みで量子化される点も考慮)
  • 問題⑦: liveness/readinessが同一判定 → livenessはプロセス生存確認のみ、readinessはPub/Sub接続込みの依存先確認、と責務を分離する
ヒント3 — コードの骨格
# KEDA ScaledObject スケルトン
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: campaign-event-worker
spec:
  scaleTargetRef:
    name: campaign-event-worker
  minReplicaCount: 2
  maxReplicaCount: 30
  triggers:
    - type: gcp-pubsub
      authenticationRef:
        name: keda-pubsub-auth   # Workload Identity経由の認証
      metadata:
        subscriptionName: campaign-event-sub
        mode: SubscriptionSize   # 未配信メッセージ数を見る
        value: "100"             # Pod1台あたりの目標バックログ件数

問題点分析(7点)

#問題点分類改善方法
1CPU使用率のみのHPAスケーリング入力層KEDA gcp-pubsubでバックログを直接監視
2minReplicas=1で単一障害点可用性層minReplicaCount 2以上
3PodDisruptionBudgetが無い可用性層minAvailable設定
4topologySpreadConstraintsが無い可用性層zone単位で分散配置
5SIGTERM即終了でメッセージ再配信シャットダウン層preStop+ack完了待ち
6requestsが実測の6倍超コスト効率VPA推奨値で適正化
7liveness/readinessが同一判定監視層責務分離(生存確認 / 依存先確認)

スケーリング構成図 — Bad vs Good の変換フロー

Bad(変更前)— CPUベースHPA・minReplicas=1・PDB無し HorizontalPodAutoscaler(CPU平均80%のみ) 問題①: I/OバウンドなワーカーはCPUがほぼ上がらずスケールしない バックログが数万件になっても指標に反映されない Deployment(replicas=1・PDB無し・分散配置無し) 問題②③④: 単一障害点・ノードメンテナンス耐性無し・ゾーン偏在 全Podが同一ゾーンに集中し得る 終了処理(terminationGracePeriodSeconds=30秒・preStop無し) 問題⑤: SIGTERM即終了でack未完了メッセージが再配信 ローリングアップデートのたびに重複クーポン発行リスク requests=2vCPU/4Gi・liveness=readiness=/healthz 問題⑥⑦: 実測の6倍超の過剰配分・Pub/Sub不調でPod再起動連鎖 セール時の障害シナリオ バックログが数万件に積み上がってもスケールせず クーポン発行が数時間遅延 デプロイのたびに重複配信・不要なPod再起動も併発 × CPU指標だけでは真のボトルネックが見えない × 単一障害点・単一ゾーン偏在で可用性が低い × 正常運用(デプロイ・スケールイン)が事故要因になる × コスト過剰配分と不要な再起動が常態化 Good(変更後)— KEDAバックログ駆動・冗長構成・graceful shutdown KEDA ScaledObject(gcp-pubsub / SubscriptionSize) 修正①: 未配信メッセージ数を直接監視しHPAを自動生成 minReplicaCount=2 / maxReplicaCount=30 / cooldown=120秒 Deployment(PDB minAvailable=1 + topologySpreadConstraints) 修正②③④: 冗長性・メンテナンス耐性・ゾーン分散を同時に確保 終了処理(terminationGracePeriodSeconds=90秒 + preStop) 修正⑤: 新規pull停止 → ack完了待ち → 正常終了 デプロイ・スケールインでも重複配信を最小化 requests=0.5vCPU/512Mi・liveness=/livez readiness=/readyz 修正⑥⑦: VPA実測値で適正化 + 責務分離で不要な再起動を排除 セール時の挙動 バックログ増加 → KEDAが即座にPodを増やす クールダウン後は自動でスケールイン(ack完了を待って終了) Pub/Subの一時的な不調ではPodは再起動されない ✓ バックログに応じて即座にスケールし遅延を防止 ✓ ゾーン障害・ノードメンテナンスでも処理継続 ✓ デプロイ・スケールインが事故要因にならない ✓ コストは実測ベース、不要な再起動も無い 修正

模範解答

Before — CPUベースHPA・minReplicas=1・PDB/分散配置無し
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
  minReplicas: 1              # 問題②
  metrics:
    - type: Resource
      resource:
        name: cpu             # 問題①
        target: { averageUtilization: 80 }

---
apiVersion: apps/v1
kind: Deployment
spec:
  replicas: 1
  template:
    spec:
      # 問題③④⑤: PDB/分散配置/graceful shutdownすべて無し
      containers:
        - name: worker
          resources:
            requests: { cpu: "2000m", memory: "4Gi" }  # 問題⑥
          livenessProbe:
            httpGet: { path: /healthz, port: 8080 }     # 問題⑦
          readinessProbe:
            httpGet: { path: /healthz, port: 8080 }
After — KEDA + PDB + topologySpread + graceful shutdown
# ── Deployment ──────────────────────────────────────────────
apiVersion: apps/v1
kind: Deployment
metadata:
  name: campaign-event-worker
spec:
  replicas: 2   # 修正②: KEDAが上書き管理するが初期値として冗長性を持たせる
  selector:
    matchLabels: { app: campaign-event-worker }
  template:
    metadata:
      labels: { app: campaign-event-worker }
    spec:
      # 修正⑤: ack完了を待つための猶予を確保
      terminationGracePeriodSeconds: 90
      # 修正④: ゾーンを跨いでPodを分散配置
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels: { app: campaign-event-worker }
      containers:
        - name: worker
          image: asia-northeast1-docker.pkg.dev/PROJECT_ID/mops/campaign-event-worker:latest
          resources:
            # 修正⑥: VPA推奨値(0.3vCPU/512MiB)を踏まえ適正化
            # Autopilotは0.25vCPU刻みで量子化されるため0.5vCPUに切り上げ
            requests: { cpu: "500m", memory: "512Mi" }
            limits:   { cpu: "1000m", memory: "1Gi" }
          # 修正⑦: livenessはプロセス生存確認のみ
          livenessProbe:
            httpGet: { path: /livez, port: 8080 }
            periodSeconds: 15
            failureThreshold: 3
          # 修正⑦: readinessはPub/Sub接続込みの依存先確認
          readinessProbe:
            httpGet: { path: /readyz, port: 8080 }
            periodSeconds: 10
            failureThreshold: 2
          lifecycle:
            # 修正⑤: 新規pull停止 → ack完了待ち → 正常終了
            preStop:
              exec:
                command: ["/bin/sh", "-c", "kill -SIGUSR1 1 && sleep 60"]

---
# ── PodDisruptionBudget(修正③)───────────────────────────────
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: campaign-event-worker-pdb
spec:
  minAvailable: 1
  selector:
    matchLabels: { app: campaign-event-worker }

---
# ── KEDA ScaledObject(修正①②)────────────────────────────────
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: campaign-event-worker
spec:
  scaleTargetRef: { name: campaign-event-worker }
  minReplicaCount: 2
  maxReplicaCount: 30
  cooldownPeriod: 120
  triggers:
    - type: gcp-pubsub
      authenticationRef: { name: keda-pubsub-auth }
      metadata:
        subscriptionName: campaign-event-sub
        mode: SubscriptionSize
        value: "100"

---
# ── TriggerAuthentication(Workload Identity経由)───────────────
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
  name: keda-pubsub-auth
spec:
  podIdentity:
    provider: gcp
# ① KEDAがHPAを生成していることを確認(gcp-pubsubトリガーが登録されているか)
kubectl get scaledobject campaign-event-worker -o yaml
kubectl get hpa   # KEDA-hpa-campaign-event-worker が自動生成されているはず

# ② バックログを人工的に発生させ、スケールアウトを確認(修正①の検証)
for i in $(seq 1 5000); do
  gcloud pubsub topics publish campaign-event-topic --message="{\"campaign_id\":\"test\"}"
done
watch -n5 kubectl get pods -l app=campaign-event-worker
# バックログが増えるにつれレプリカ数が増加していくことを確認する

# ③ ゾーン分散を確認(修正④の検証)
kubectl get pods -l app=campaign-event-worker -o wide | awk '{print $7}' | sort | uniq -c
# 各ノードが異なるゾーンに属しており、Podが偏っていないことを確認する

# ④ ノードドレインでPDBが機能することを確認(修正③の検証)
kubectl drain  --ignore-daemonsets
kubectl get pods -l app=campaign-event-worker -w
# minAvailable=1により、全Podが同時に退避されないことを確認する

# ⑤ graceful shutdownのログを確認(修正⑤の検証)
kubectl rollout restart deployment campaign-event-worker
kubectl logs -l app=campaign-event-worker --previous | grep -E "new pull stopped|ack completed"
# preStopフック発火後、新規pull停止 → 処理中メッセージのack完了、の順にログが出ることを確認する

# ⑥ liveness/readinessの分離を確認(修正⑦の検証)
kubectl describe pod  | grep -A3 "Liveness\|Readiness"
# Pub/Sub側の一時的なレイテンシを模擬してreadyzのみが失敗し、
# livezは失敗せずPodが再起動されないことを確認する
問題修正内容効果
① CPU使用率のみのHPAKEDA gcp-pubsubでバックログ直接監視I/Oバウンドな負荷の実態に即したスケール判断
② minReplicas=1minReplicaCount 2以上単一障害点を解消しベースライン冗長性を確保
③ PDB無しminAvailable設定ノードメンテナンス時も処理継続
④ topologySpread無しzone単位で分散配置単一ゾーン障害の影響を限定
⑤ SIGTERM即終了preStop + ack完了待ち正常運用でのメッセージ重複配信を抑制
⑥ requests過剰配分VPA実測値で適正化Autopilotのコスト無駄を排除
⑦ liveness=readiness責務分離依存先の一時不調による無意味な再起動連鎖を防止

ポイント解説

1オートスケーリングの入力指標は「実際に詰まっているもの」を見る — CPU使用率は汎用的な指標だが、I/Oバウンドなワーカーの負荷を代表しない。Pub/Subワーカーであれば未配信メッセージ数、DBコネクションプールのワーカーであれば待機キュー長など、ワークロードの性質に応じた「本当のボトルネック」をスケーリングトリガーに選ぶ必要がある。
2可用性は「複製すること」と「壊れる単位を分散すること」の両方で作るminReplicasを上げるだけでは同一ゾーンに複製が集中し得る。topologySpreadConstraintsと組み合わせて初めて、単一ゾーン障害・単一ノード障害それぞれへの耐性が成立する。
3graceful shutdownは「正常系の運用」を守るための設計 — SIGTERMはPodの異常時だけでなく、ローリングアップデートやHPA/KEDAによるスケールインといった日常的な操作でも発生する。ここでの後処理設計が甘いと、通常運用の中で継続的にメッセージ重複配信・処理のやり直しコストが発生し続ける。
4リソースのrequestsは「念のため」で決めず実測に基づく — VPAのrecommenderをOffモード(推奨値の算出のみ行い実際の変更はしない)で動かして実測データを集め、それを根拠にrequestsを設定する。Autopilotではrequestsがそのまま課金対象になるため、過剰な値は継続的なコスト超過に直結する。
5liveness/readinessは目的が異なる別々の判定基準を持つべき — livenessは「プロセスを再起動すべきか」、readinessは「今このPodにトラフィックを送ってよいか」を判定する。依存先(Pub/Sub等)の一時的な不調をlivenessに混ぜると、再起動しても直らない問題に対してPodを再起動し続ける無意味な連鎖を招く。

実務への応用

同様のバックログ駆動オートスケーリングは、Pub/Subワーカーに限らずArgo WorkflowsのタスクキューやCloud Tasksを使った非同期処理全般に応用できる。「CPU/メモリベースのHPA」は多くのチュートリアルで最初に紹介される構成だが、実務のワークロードの多くはI/Oバウンドであり、CPUベースのHPAだけでは不十分なケースが多い。新しい非同期処理を設計する際は、まず「このワーカーの真のボトルネックは何か(CPU/メモリ/キュー長/コネクション数)」を特定してからスケーリング指標を選ぶ、という順序を徹底することが実務上のポイントになる。

graceful shutdownはオートスケーリング導入とセットで見直す: graceful shutdownの設計は、KEDAやHPAでスケールインが自動化されるほど重要性が増す。手動でしかスケールしない構成ではSIGTERMの発生頻度が低く見過ごされがちだが、オートスケーリングを導入した瞬間から「Podの生成・破棄が日常的に起きる」前提でアプリケーションを設計し直す必要がある。

今日のまとめ

Pub/Subワーカーのオートスケーリング設計チェックリスト: ① 負荷の実態に即したスケーリング指標(CPU使用率ではなくバックログ件数) ② minReplicasによるベースライン冗長性 ③ PodDisruptionBudgetによるメンテナンス耐性 ④ topologySpreadConstraintsによるゾーン分散 ⑤ SIGTERM後の後処理設計(preStop+ack完了待ち) ⑥ VPA実測値に基づくrequests適正化 ⑦ liveness/readinessの責務分離。 オートスケーリングは「レプリカ数を自動調整する仕組み」を入れて終わりではなく、スケールイン・アウトが頻繁に起きることを前提に、可用性・整合性・コストの三点を設計し直す作業である。

次のステップ

  • 発展問題: campaign-event-subはordering keyでcampaign_id単位の順序を保証している。KEDAでスケールアウトした際、同一campaign_idのメッセージは同一Podに配送されるとは限らないため、複数Podが並行してpullする構成でも順序保証が崩れないか(Pub/Subのordering keyの仕組み上、同一keyのメッセージは常に単一の未ackメッセージとして直列に配送される)を検証し、その制約がスループットにどう影響するかを試算せよ
  • 参考: KEDA gcp-pubsub scaler / GKE Autopilot リソース量子化 / PodDisruptionBudget / topologySpreadConstraints / Pub/Sub ordering keys / Vertical Pod Autoscaler(recommendation mode)

自己評価(あとで記入)