水平オートスケール(HPA)
実装:
orchestration/autoscaler// 実行:go test ./orchestration/autoscaler/
Pod を何個にするかを、負荷の測定値から決める。式は比例で、使用率が目標の2倍なら台数も2倍にすればいい。ところが実測は常に揺れていて、式に素直に従うと台数が増減を繰り返し、負荷が変わっていないのに止まらなくなる。この章の大半は、その揺れをどう無視するかに使われる。許容誤差を外すと何が起きるかは、デモで見比べられる。
この章で作るもの
調整ループは「Pod を 3 個に保て」という宣言を守る。スケジューラは作られた Pod をどのノードに置くかを決める。だが、その「3 個」という数字を誰が決めるのか。ここまでは人が書いていた。
負荷は時間で変わる。深夜は誰も来ないのに、昼にキャンペーンが走れば 10 倍が来る。人が張り付いて数字を書き換えるのは無理だし、常に最大に合わせておけば夜通し無駄な金を払う。だから負荷から自動で決める。これが水平オートスケーラで、Kubernetes では HorizontalPodAutoscaler(HPA)がやる。この章ではそれを作る。
「水平」というのは、1 台を大きくする(垂直)のでなく、同じものを増やす方向を指す。増やせば効くという前提が要るので、ロードバランサで分散でき、レプリカ間に状態を持たないことが条件になる。そこは前提として、ここでは「何個にするか」を決める部分だけを作る。
観測(4 レプリカ、使用率 80%、目標 50%)
│
▼
┌── ② 許容誤差 ──┐ |80-50| は目標の10%を超える → 先へ
└────────┬────────┘ 内側ならここで止まる(揺れを無視)
▼
┌── ① 式 ────────┐ ceil(4 × 80 ÷ 50) = 7
└────────┬────────┘
▼
┌── ③ 上下限 ────┐ Min 1 / Max 10 に収める → 7
└────────┬────────┘
▼
┌── ④ 安定化 ────┐ 拡大 → すぐ 7 へ
└─────────────────┘ 縮小 → 直近の提案の最大値まで(待つ)4 つのうち、判断の中身を持っているのは式だけで、残りの 3 つはすべて「式に従いすぎない」ためにある。順に見ていく。
- 式は比例: 使用率が目標の 2 倍なら、レプリカも 2 倍にすれば戻るはず、と当てる
- 許容誤差で揺れを無視する: 目標付近の小さな上下には反応しない
- 上下限で暴走を止める: 式が何を出しても、決めた範囲から出さない
- 安定化で縮小だけ遅らせる: 拡大は即座に、縮小は待つ
① 式: 比例で当てる
中心の式はこれだけだ:
// ceilDiv は a/b を切り上げた整数を返す(b > 0 を前提)。
// 切り上げるのは、足りないより多いほうが安全だから。3.2 個必要なら 4 個要る。
func ceilDiv(a, b int) int { return (a + b - 1) / b }
// Ratio は「今の使用率 / 目標の使用率」に基づく必要レプリカ数を返す。
// HPA の中心の式そのもので、負荷とレプリカ数が比例するという仮定に立つ。
// 使用率が目標の 2 倍なら、レプリカを 2 倍にすれば目標に戻る、と読む。
func Ratio(s Sample, target int) int {
if target <= 0 || s.Replicas <= 0 {
return s.Replicas
}
return ceilDiv(s.Replicas*s.Utilization, target)
}4 個で使用率 80%、目標が 50% なら、ceil(4 × 80 ÷ 50) = 7 個。7 個に増やせば 1 個あたりの使用率が 50% 付近に落ちるはず、と読む。負荷とレプリカ数が比例するという仮定を、そのまま計算にしている。
この仮定は厳密には正しくない。共有のデータベースが詰まっていれば、レプリカを増やしても使用率は下がらない。むしろ接続が増えて悪化することもある。それでも比例で当てるのは、単純で、外れても次の周期でまた測り直すからだ。1 回で正解を出す必要はなく、調整ループと同じで、繰り返すうちに近づけばよい。
切り上げているのは非対称な選択になる。3.2 個必要なら 4 個にする。3 個だと足りず、目標を超えたままになる。足りないとサービスが落ち、多いぶんは金がかかるだけで済むので、多い側へ倒している。
② 許容誤差: 揺れを無視する
式に素直に従うと、実は動かなくなる。測った使用率は常に揺れているからだ。設定にはその揺れを吸収するための値が並んでいる:
// Config はオートスケーラの設定。式の周りを固めるための値が並ぶ。
type Config struct {
Target int // 目標使用率(百分率)
Min int // 下限レプリカ数
Max int // 上限レプリカ数
Tolerance int // 許容誤差(百分率)。この幅の中の揺れは無視する
// StabilizeDown は縮小を決めるまでに待つ観測回数。
// 直近この回数ぶんの提案の最大値を採るので、一瞬の谷では縮まない。
StabilizeDown int
}目標が 50% で、実測が 51%、49%、52% と揺れているとする。式は毎回わずかに違う数を出す。それに従えば、レプリカ数は増えては減り、減っては増える。この振動を flapping と呼ぶ。Pod の起動には時間がかかるので、増やした Pod が効き始める前に「まだ高い」と判断してさらに増やし、効き始めた頃には「低すぎる」と減らす。負荷は何も変わっていないのに、クラスタだけが揺れ続ける。
withinTolerance がこれを止める:
// withinTolerance は使用率が目標の許容誤差の内側かを返す。
// 目標 50%・許容誤差 10% なら、45% から 55% までは目標どおりとみなす。
func (c Config) withinTolerance(u int) bool {
d := u - c.Target
if d < 0 {
d = -d
}
return d*100 <= c.Target*c.Tolerance
}目標の ±10% の中にいる限り、目標どおりとみなして何もしない。50% 目標なら 45% から 55% までは動かない。実物の HPA も既定で 10% の許容誤差を持つ。制御の話に置き換えれば、これはヒステリシスになる。基準ぴったりで切り替えるのでなく、幅を持たせて、その中では判断を変えない。同じ発想はサーキットブレーカーの状態遷移にもある。
③ 上下限: 暴走を止める
許容誤差が無視するのは目標付近の小さな揺れだけで、大きく外れた値には従ってしまう。指標そのものが壊れている場合はこれが効かない。だから式の結果を、設定した範囲へ最後に押し込める:
// clamp は n を [Min, Max] に収める。式が何を出しても、ここを超えることはない。
func (c Config) clamp(n int) int {
if n < c.Min {
return c.Min
}
if n > c.Max {
return c.Max
}
return n
}式が 100 個必要だと言っても、Max が 10 なら 10 で止まる。使用率は目標を超えたままだが、それが正しい。指標の欠損や設定ミスで無限に増え続ければ、クラスタの資源も課金も破綻する。オートスケーラは「うまく動くこと」より「暴走しないこと」を優先する。テストで、式が 16 を出しても上限 5 で止まることを固定した。
下限にも別の役目がある。負荷がゼロでも 1 個は残すことで、次に来た要求を受ける先が消えない。0 まで縮める設計もあるが、そのときは「最初の 1 個が起動するまで待たせる」ことを受け入れる判断が別に要る。
④ 安定化: 縮小だけを遅らせる
拡大と縮小を、同じ速さで扱ってはいけない:
// Decision は 1 回の判断の結果と、その理由。
type Decision struct {
From int // 判断前のレプリカ数
To int // 判断後のレプリカ数
Raw int // 式が出した生の値(clamp も安定化も通す前)
Reason string
}
// Changed はレプリカ数が変わったかを返す。
func (d Decision) Changed() bool { return d.From != d.To }
// Autoscaler は観測から目標レプリカ数を決める。直近の提案を覚えていて、
// 縮小のときだけその履歴を使う(急に縮めないため)。
type Autoscaler struct {
cfg Config
history []int // 直近の提案(古い順)
}
// New は設定 cfg のオートスケーラを作る。値が壊れていれば最小限に補正する。
func New(cfg Config) *Autoscaler {
if cfg.Min < 1 {
cfg.Min = 1
}
if cfg.Max < cfg.Min {
cfg.Max = cfg.Min
}
if cfg.StabilizeDown < 1 {
cfg.StabilizeDown = 1
}
return &Autoscaler{cfg: cfg}
}
// Decide は 1 回の観測から次のレプリカ数を決める。
// 式を当て、許容誤差の内側なら動かさず、上下限に収め、縮小なら安定化を通す。
func (a *Autoscaler) Decide(s Sample) Decision {
raw := Ratio(s, a.cfg.Target)
a.remember(raw)
d := Decision{From: s.Replicas, To: s.Replicas, Raw: raw}
// 許容誤差の内側の揺れは無視する。これがないと、目標付近の小さな
// 上下でレプリカ数が動き続ける(flapping)。
if a.cfg.withinTolerance(s.Utilization) {
d.Reason = "目標の許容誤差の内側。動かさない"
return d
}
want := a.cfg.clamp(raw)
switch {
case want > s.Replicas:
// 拡大は即座に行う。負荷は待ってくれない。
d.To = want
d.Reason = "使用率が目標を超えた。すぐ拡大する"
case want < s.Replicas:
// 縮小は直近の提案の最大値まで。一瞬の谷では縮まない。
stable := a.cfg.clamp(a.maxRecent(s.Replicas))
if stable >= s.Replicas {
d.Reason = "縮小の提案が安定していない。据え置く"
return d
}
d.To = stable
d.Reason = "使用率が目標を下回り続けた。縮小する"
default:
d.Reason = "式の結果が現在と同じ。動かさない"
}
return d
}
// remember は提案を履歴に積み、安定化ウィンドウのぶんだけ残す。
func (a *Autoscaler) remember(raw int) {
a.history = append(a.history, raw)
if len(a.history) > a.cfg.StabilizeDown {
a.history = a.history[len(a.history)-a.cfg.StabilizeDown:]
}
}
// maxRecent は安定化ウィンドウ内の提案の最大値を返す。最大を採るから、
// ウィンドウ内に 1 度でも高い提案があれば縮小しない。
//
// ウィンドウがまだ埋まっていない間は、現在のレプリカ数 now も候補に含める。
// 観測が足りない段階では「今の数が必要だった」と見なす、という意味で、
// 起動直後の 1 回の低い観測で縮めてしまうのを防ぐ。
func (a *Autoscaler) maxRecent(now int) int {
m := 0
if len(a.history) < a.cfg.StabilizeDown {
m = now
}
for _, v := range a.history {
if v > m {
m = v
}
}
return m
}Decide を読むと、拡大側は式の結果をそのまま採っている。バーストが来たら 1 周期で必要数まで跳ぶ。ここで待つと、その間も負荷は積もり続けて応答が遅れる。
縮小側は違う。直近の提案を覚えておき、その最大値までしか縮めない。1 回だけ低い値を観測しても、直前に高い提案があれば縮まらない。低い状態が安定化ウィンドウのぶん続いて、はじめて縮む。なぜ最大値かというと、ウィンドウ内に 1 度でも「多く要る」という提案があれば、それを信じるほうが安全だからだ。テストで、低い観測が続いても途中に 1 度バーストが挟まれば縮まないことを固定した。
この非対称は、失敗したときの重さが違うことから来ている。増やし損ねればサービスが落ち、減らし損ねれば余分な金がかかる。前者のほうが取り返しがつかないので、増やす側だけを速くしてある。実物の HPA も、既定で縮小に 300 秒の安定化ウィンドウを置く一方、拡大は即座に行う。
動かす
下のデモは、揺らぎのある負荷に対してレプリカ数がどう動くかを見る。「式そのまま」に切り替えて平常負荷で 10 周期進めると、わずかな揺れでレプリカ数が増減を繰り返す。「許容誤差と安定化あり」に戻すと、同じ揺れでもまったく動かない。バーストに切り替えると 1 周期で跳び、深夜に戻すと数周期待ってから縮む。
負荷には固定の揺らぎが乗っている。「式そのまま」に切り替えて平常負荷で 10 周期進めると、 わずかな揺れにレプリカ数が反応して増減を繰り返す(flapping)。「許容誤差と安定化あり」に戻すと、 目標 ±10% の揺れを無視するので動かない。バーストに切り替えると 1 周期で必要数まで跳び、 深夜に戻しても 3 周期待ってから縮む。上がるのは速く、下がるのは遅い。
設計の観点
- 式は当てずっぽうでよい: 比例の仮定は厳密には外れる。それでも次の周期で測り直すので、繰り返すうちに近づく。1 回の判断で正解を出す設計にしないことが、この単純さを許している
- 上下限は式より強い: 指標が壊れたときの防波堤。指標の欠損や異常値で無限に増えることを、上限だけが止める
- 揺れは必ずある前提で作る: 測定値が安定しているという前提でシステムを組むと、必ず振動する。許容誤差と安定化は、後付けの回避策でなく最初から要る部品
- 拡大と縮小を分けて考える: 失敗の重さが違うものを、同じ速度で扱わない。上げは速く、下げは遅くという非対称は、リトライやサーキットブレーカーの復帰にも共通する
- Pod が増えても置けるとは限らない: オートスケーラは数を決めるだけ。置く先がなければ Pod は Pending のまま残る(スケジューラ)。ノード自体を増やすのはCluster Autoscalerという別の層の仕事
- 指標の選び方が効く: CPU 使用率は測りやすいが、実際のボトルネックとは限らない。キューの長さや待ち行列で測るほうが素直な場合も多い
対照と実例
| 水平(HPA) | 垂直(VPA) | |
|---|---|---|
| 変えるもの | レプリカ数 | 1 つあたりの要求 |
| 反映 | 新しい Pod を足す | Pod を作り直す |
| 上限 | ノードの総量まで | 1 ノードの容量まで |
| 前提 | 状態を持たない | 何でもよい |
| 効き方 | 台数で捌く | 1 台を大きくする |
裏どり:
- HorizontalPodAutoscaler:
desiredReplicas = ceil(currentReplicas × currentMetric / desiredMetric)という式そのもの - HPA の許容誤差: 既定 10%。目標との差がこの幅の内側なら動かさない。公式の説明では「設定できる許容誤差、既定は 0.1」と書かれていて、HPA ごとに変えられる
- behavior / stabilizationWindowSeconds: 縮小の既定は 300 秒、拡大は 0 秒。上下を別々に設定できる
- Cluster Autoscaler: Pod が置けずに Pending のままならノードを増やす。HPA とは別の層で、役割が分かれている
- KEDA: キューの長さなど外部指標で駆動する拡張。指標をどこから取るかを差し替える発想
簡略化したこと
- 指標は 1 つだけ: 実物は CPU・メモリ・カスタム指標・外部指標を同時に見て、最も多い提案を採る
- 実時間なし: ウィンドウを観測回数で数える。実物は秒(既定 300 秒)
- 拡大側の制限なし: 実物は拡大にも速度制限(1 周期で何個まで、何倍まで)をかけられる
- Pod の起動遅れを無視: 増やした Pod が効き始めるまでの遅れは模していない。実物はこの遅れが flapping の主因になる
- 0 へは縮まない: レプリカ 0 からの起動(scale to zero)は扱わない。KEDA などの領域
参考資料
- Horizontal Pod Autoscaling — 式と許容誤差の定義
- HPA behavior — 拡大と縮小を別々に設定する
- Cluster Autoscaler — ノード側を増やす層
- 実装: orchestration/autoscaler