負荷が落ち着いたのに、なぜ HPA はしばらく Pod を減らしてくれないのか?
深夜バッチや朝のアクセス急増で CPU 使用率が跳ね上がり、HorizontalPodAutoscaler(HPA)が Pod を増やしてくれた場面を見たことがあるはずだ。では、アクセスが落ち着いた直後、Pod 数はどれくらいで元に戻るだろうか。「静かになった数秒後」と思うかもしれないが、実際には数分経っても Pod 数はそのままということが多い。これは不具合ではなく、HPA に最初から埋め込まれた既定の設計である。
- HPA は既定 15 秒間隔の制御ループにすぎず、metrics-server が集めた使用率から
desiredReplicas = ceil(現在数 × 実測値/目標値)を計算するだけの単純な仕組みである。 - 実測値と目標値の比が既定 10%(トレランス)以内なら「変化なし」とみなされ、境界付近のブレでスケールが暴発しない。
- スケールアップは stabilizationWindowSeconds の既定が 0 秒で即断する一方、スケールダウンは既定 300 秒(5 分)の間の最大推奨値を使い続けるため、負荷が落ち着いてもすぐには縮まない。
HPA は 15 秒ごとにしか世界を見ていない
HPA は Pod の中で常時待機しているプロセスではなく、kube-controller-manager の中で動く 1 つのコントロールループにすぎない。既定では --horizontal-pod-autoscaler-sync-period の値である 15 秒に 1 回だけ起動し、その瞬間の使用率を見て判断を下す。次の判断はまた 15 秒後まで来ない。
使用率そのものは HPA が直接計測するわけではない。各ノードの kubelet(内部の cAdvisor)がコンテナの CPU/メモリ使用量を計測し、metrics-server がそれをクラスタ全体で集約して API として公開する。HPA はこの API に問い合わせるだけなので、kubelet 自身の計測周期より高い頻度で「本当の今」を知ることはできない。
判断のロジック自体は単純な比例計算だ。desiredReplicas = ceil(currentReplicas × currentMetricValue / desiredMetricValue) という式で、現在の Pod 数に「今の使用率 ÷ 目標使用率」を掛けて切り上げる。目標 50% に対し実測が 100% なら 2 倍、25% なら半分というように、目標に対する超過分をそのまま Pod 数の倍率に置き換えているだけである。
制御ループ(既定 15 秒間隔)
┌────────┐ ┌─────────────┐
│kubelet │─────▶│metrics-server│
└────────┘ └──────┬──────┘
│ 使用率
▼
┌─────────────┐
│HPA controller│
└──────┬──────┘
│desiredReplicas
▼
┌─────────────┐
│ Deployment │
└─────────────┘
境界のブレを暴発させない 10% のトレランス
単純な比例計算だけでは、使用率が目標値のちょうど際でわずかに上下するたびにスケールが発生し、Pod 数が細かく揺れ動く「フラッピング」が起きてしまう。これを防ぐため HPA には既定 10% のトレランス(許容誤差)があり、実測値と目標値の比が 1.0 から ±10% の範囲に収まっている間は「変化なし」として何もしない。目標 CPU 使用率が 50% なら、実質 45%〜55% の間はスケールが起こらないということになる。
このトレランスはクラスタ全体の既定値として --horizontal-pod-autoscaler-tolerance フラグで決まり、長らく個々の HPA ごとに変えることはできなかった。Kubernetes v1.33 で HPAConfigurableTolerance というフィーチャーゲートが導入され、behavior フィールド経由で HPA ごとに上書きできるようになったが、フィーチャーゲートを有効にしない限り既定の 10% がすべての HPA に適用される。
スケールアップは即断、スケールダウンは 5 分待つ
リードの問いに戻ろう。負荷が落ち着いても Pod 数がすぐに減らないのは、スケールアップとスケールダウンで既定の反応速度が非対称に設計されているからだ。behavior.scaleUp.stabilizationWindowSeconds の既定値は 0 秒で、直近の計算結果をそのまま即座に反映する。一方 behavior.scaleDown.stabilizationWindowSeconds の既定値は 300 秒(5 分)で、過去 5 分間に計算された desiredReplicas の中から最大値を採用し続ける。
「最大値を採用し続ける」という部分が肝心だ。5 分前に負荷が高く Pod 数 10 が推奨された後、直近では負荷が下がり Pod 数 3 で足りると計算されても、その 5 分間の窓の中に「10」という推奨値が含まれている限り HPA は 10 を選び続ける。5 分間ずっと 3 以下の推奨が続いて、ようやく 3 まで縮む。
spec:
behavior:
scaleDown:
stabilizationWindowSeconds: 60
policies:
- type: Pods
value: 2
periodSeconds: 60
この stabilizationWindowSeconds: 60 のように短くすれば、負荷低下後 1 分ほどでスケールダウンが始まるようになるが、その分「一時的な落ち込みで縮めてしまい、すぐ次のスパイクでまた慌てて増やす」というフラッピングのリスクも上がる。既定の 300 秒は、コスト最適化より安全側(縮めすぎない)に振った値だと理解しておくとよい。policies の value: 2 / periodSeconds: 60 は「60 秒あたり最大 2 Pod ずつしか減らさない」という速度制限で、大量の Pod を一気に落として後段(DB のコネクション数など)に負荷を掛けないためのものである。
スーパーのレジ運用に近い。レジ待ちの列が伸び始めたら、店長はほぼ即座に空いているレジを開けて対応する — お客を待たせるコストの方が、レジ担当者を 1 人動かすコストより大きいからだ。しかし列が短くなったからといって、店長はその場でレジを閉めない。数分後にまた列が伸びるかもしれないので、閉めるかどうかはしばらく列の状態を見てから決める。開けるときは即断、閉めるときは様子見という非対称な判断基準は、HPA の stabilizationWindowSeconds がスケールアップで 0 秒、スケールダウンで既定 300 秒という非対称さとまったく同じ発想である。
使用率は何に対する割合として計算されるか
ここまでの計算はすべて「使用率」を前提にしてきたが、CPU 使用率ベースの HPA は Pod 内の全コンテナに CPU の requests が設定されていない限り動作しない。使用率(utilization)は requests に対する実測値の比率として定義されるため、requests がなければ比較対象そのものが存在せず、HPA はそのメトリクスについて判断不能として扱う。
もう一つの前提は、使用率が Pod 単位の平均で計算されることだ。3 つの Pod のうち 1 つだけが CPU 100% に張り付いていても、残り 2 つが 0% なら平均は 33% 程度に収まり、目標 50% を下回ってスケールアップが起きないことがある。全体の平均は健全に見えても、一部の Pod だけが偏って詰まっている状況は、HPA の平均計算だけでは見えてこない。
HPA を CPU 使用率で運用していると、起動直後にウォームアップで CPU が一時的に跳ねるアプリケーション(JVM 系など)では、起動直後だけ余分に Pod が増えてすぐ元に戻るという挙動が観測される。これは startupProbe で起動完了までコンテナの準備状況を分けて扱うことである程度緩和できる。ノードレベルでは Cluster Autoscaler や Karpenter が HPA の決定を受けてノードを増減させるが、ノードの起立には数十秒〜数分かかるため、HPA が 15 秒で出した結論を実際に Pod が動き出すまでにはさらに遅延が積み重なる。キューの深さや RPS などのカスタムメトリクスで HPA を組む場合は、Prometheus Adapter のようにそのメトリクスを HPA が読める API として公開する層を別途用意する必要がある。
kind クラスタに metrics-server を入れ、CPU を使い切るだけの Pod を HPA でスケールさせてみる。目標を低くしてわざと閾値を超えさせ、REPLICAS が上限まで増える様子を観察する。
kind create cluster --name hpa-demo
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
kubectl patch deployment metrics-server -n kube-system --type=json \
-p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'
kubectl wait --for=condition=available --timeout=120s deployment/metrics-server -n kube-system
kubectl create deployment cpu-hog --image=busybox -- sh -c "while true; do :; done"
kubectl set resources deployment cpu-hog --requests=cpu=50m --limits=cpu=200m
kubectl autoscale deployment cpu-hog --cpu-percent=20 --min=1 --max=4
sleep 60
kubectl get hpa cpu-hog
kind delete cluster --name hpa-demo
kubectl get hpa の TARGETS 列は「実測/目標」の形式で、20% に対して数百% という値が出ているはずだ。REPLICAS は 1 から始まり、最終的に --max=4 で指定した上限まで増える。busybox の Pod を止めれば使用率はすぐ 0% に落ちるが、REPLICAS は既定 300 秒(5 分)の間は 4 のまま留まり続ける — これがまさに本文で説明した scaleDown の stabilizationWindowSeconds である。
- CPU 使用率が目標を超えた瞬間に即座に Pod が増える — HPA は既定 15 秒間隔のポーリングでしか判断せず、さらに目標値との差が既定 10% 以内なら何もしない。
- 負荷が下がればすぐ Pod 数も同じ速さで減る — スケールダウンは既定 300 秒(5 分)のスタビライゼーションウィンドウの間、直近の推奨値の最大値を使い続けるため、負荷低下から数分は Pod 数が減らない。
- HPA を設定すればコンテナの resources.requests は省略できる — CPU/メモリ使用率ベースの HPA は requests に対する比率で使用率を計算するため、全コンテナに requests が設定されていないとそのメトリクスについて動作しない。
- HPA(Horizontal Pod Autoscaler)
- 使用率などのメトリクスに応じて Pod 数を自動で増減させる Kubernetes のコントローラー。
- metrics-server
- 各ノードの kubelet が計測した CPU/メモリ使用量を集約し、API として公開するクラスタ内コンポーネント。
- トレランス
- 実測値と目標値の比がこの範囲内なら「変化なし」とみなす許容誤差。既定は 10%。
- スタビライゼーションウィンドウ
- 直近の一定時間内の推奨値から最大値を選び続けることで、スケールの揺れ戻りを防ぐ仕組み。
- リソースリクエスト
- コンテナが最低限確保する CPU/メモリ量の指定。使用率(utilization)はこの値に対する比率で計算される。
- Horizontal Pod Autoscaling | Kubernetes 公式ドキュメント — 制御ループの同期間隔・desiredReplicas の計算式・トレランス・stabilizationWindowSeconds の既定値を明記した一次情報。
- Kubernetes v1.33: Configurable tolerance for Horizontal Pod Autoscaling | Kubernetes 公式ブログ — 既定 10% のトレランスがなぜクラスタ全体で固定されていたのか、v1.33 で HPA ごとに上書き可能になった経緯を解説。
- metrics-server FAQ | kubernetes-sigs/metrics-server — metrics-server の計測解像度が kubelet 側の計測周期に制約される理由を説明した一次情報。