概要
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-sub、campaign_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程度と判明しているlivenessProbeとreadinessProbeが同一エンドポイント(/healthz、Pub/Sub 接続確認込み)を参照しており、Pub/Sub側の一時的なレイテンシ増で liveness が失敗し Pod 再起動が連鎖したことがある
悪いコード (Before)
# 問題①: 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 }
ヒント(段階的開示)
ヒント1 — 方向性
ヒント2 — アプローチ
- 問題①: HPAがCPU使用率のみでI/OバウンドなPub/Subワーカーの負荷を捉えられない → KEDAの
gcp-pubsubスケーラーで subscription の未配信メッセージ数(SubscriptionSize)を外部メトリクスとして参照するScaledObjectに置き換える - 問題②:
minReplicas=1で単一障害点 →minReplicaCountを2以上に引き上げ、常時ベースラインの冗長性を確保する - 問題③: PodDisruptionBudgetが無い → ノードメンテナンス時に全レプリカが同時に退避されないよう
minAvailableを設定する - 問題④:
topologySpreadConstraintsが無くゾーン偏在のリスク →topology.kubernetes.io/zoneをtopologyKeyにして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点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | CPU使用率のみのHPA | スケーリング入力層 | KEDA gcp-pubsubでバックログを直接監視 |
| 2 | minReplicas=1で単一障害点 | 可用性層 | minReplicaCount 2以上 |
| 3 | PodDisruptionBudgetが無い | 可用性層 | minAvailable設定 |
| 4 | topologySpreadConstraintsが無い | 可用性層 | zone単位で分散配置 |
| 5 | SIGTERM即終了でメッセージ再配信 | シャットダウン層 | preStop+ack完了待ち |
| 6 | requestsが実測の6倍超 | コスト効率 | VPA推奨値で適正化 |
| 7 | liveness/readinessが同一判定 | 監視層 | 責務分離(生存確認 / 依存先確認) |
スケーリング構成図 — Bad vs Good の変換フロー
模範解答
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 }
# ── 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使用率のみのHPA | KEDA gcp-pubsubでバックログ直接監視 | I/Oバウンドな負荷の実態に即したスケール判断 |
| ② minReplicas=1 | minReplicaCount 2以上 | 単一障害点を解消しベースライン冗長性を確保 |
| ③ PDB無し | minAvailable設定 | ノードメンテナンス時も処理継続 |
| ④ topologySpread無し | zone単位で分散配置 | 単一ゾーン障害の影響を限定 |
| ⑤ SIGTERM即終了 | preStop + ack完了待ち | 正常運用でのメッセージ重複配信を抑制 |
| ⑥ requests過剰配分 | VPA実測値で適正化 | Autopilotのコスト無駄を排除 |
| ⑦ liveness=readiness | 責務分離 | 依存先の一時不調による無意味な再起動連鎖を防止 |
ポイント解説
minReplicasを上げるだけでは同一ゾーンに複製が集中し得る。topologySpreadConstraintsと組み合わせて初めて、単一ゾーン障害・単一ノード障害それぞれへの耐性が成立する。requestsは「念のため」で決めず実測に基づく — VPAのrecommenderをOffモード(推奨値の算出のみ行い実際の変更はしない)で動かして実測データを集め、それを根拠にrequestsを設定する。Autopilotではrequestsがそのまま課金対象になるため、過剰な値は継続的なコスト超過に直結する。実務への応用
同様のバックログ駆動オートスケーリングは、Pub/Subワーカーに限らずArgo WorkflowsのタスクキューやCloud Tasksを使った非同期処理全般に応用できる。「CPU/メモリベースのHPA」は多くのチュートリアルで最初に紹介される構成だが、実務のワークロードの多くはI/Oバウンドであり、CPUベースのHPAだけでは不十分なケースが多い。新しい非同期処理を設計する際は、まず「このワーカーの真のボトルネックは何か(CPU/メモリ/キュー長/コネクション数)」を特定してからスケーリング指標を選ぶ、という順序を徹底することが実務上のポイントになる。
今日のまとめ
次のステップ
- 発展問題:
campaign-event-subはordering keyでcampaign_id単位の順序を保証している。KEDAでスケールアウトした際、同一campaign_idのメッセージは同一Podに配送されるとは限らないため、複数Podが並行してpullする構成でも順序保証が崩れないか(Pub/Subのordering keyの仕組み上、同一keyのメッセージは常に単一の未ackメッセージとして直列に配送される)を検証し、その制約がスループットにどう影響するかを試算せよ - 参考: KEDA
gcp-pubsubscaler / GKE Autopilot リソース量子化 / PodDisruptionBudget / topologySpreadConstraints / Pub/Sub ordering keys / Vertical Pod Autoscaler(recommendation mode)