Skip to content

水平オートスケール(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 へ
  └─────────────────┘  縮小 → 直近の提案の最大値まで(待つ)
1 回の判断が通る道。観測から始まり、許容誤差・式・上下限・安定化の4つを順に通る。以降の節はこの4つを、式から順に見ていく

4 つのうち、判断の中身を持っているのは式だけで、残りの 3 つはすべて「式に従いすぎない」ためにある。順に見ていく。

  1. 式は比例: 使用率が目標の 2 倍なら、レプリカも 2 倍にすれば戻るはず、と当てる
  2. 許容誤差で揺れを無視する: 目標付近の小さな上下には反応しない
  3. 上下限で暴走を止める: 式が何を出しても、決めた範囲から出さない
  4. 安定化で縮小だけ遅らせる: 拡大は即座に、縮小は待つ

① 式: 比例で当てる

中心の式はこれだけだ:

go

// 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 個だと足りず、目標を超えたままになる。足りないとサービスが落ち、多いぶんは金がかかるだけで済むので、多い側へ倒している。

② 許容誤差: 揺れを無視する

式に素直に従うと、実は動かなくなる。測った使用率は常に揺れているからだ。設定にはその揺れを吸収するための値が並んでいる:

go

// Config はオートスケーラの設定。式の周りを固めるための値が並ぶ。
type Config struct {
	Target    int // 目標使用率(百分率)
	Min       int // 下限レプリカ数
	Max       int // 上限レプリカ数
	Tolerance int // 許容誤差(百分率)。この幅の中の揺れは無視する
	// StabilizeDown は縮小を決めるまでに待つ観測回数。
	// 直近この回数ぶんの提案の最大値を採るので、一瞬の谷では縮まない。
	StabilizeDown int
}

目標が 50% で、実測が 51%、49%、52% と揺れているとする。式は毎回わずかに違う数を出す。それに従えば、レプリカ数は増えては減り、減っては増える。この振動を flapping と呼ぶ。Pod の起動には時間がかかるので、増やした Pod が効き始める前に「まだ高い」と判断してさらに増やし、効き始めた頃には「低すぎる」と減らす。負荷は何も変わっていないのに、クラスタだけが揺れ続ける。

withinTolerance がこれを止める:

go

// 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% の許容誤差を持つ。制御の話に置き換えれば、これはヒステリシスになる。基準ぴったりで切り替えるのでなく、幅を持たせて、その中では判断を変えない。同じ発想はサーキットブレーカーの状態遷移にもある。

③ 上下限: 暴走を止める

許容誤差が無視するのは目標付近の小さな揺れだけで、大きく外れた値には従ってしまう。指標そのものが壊れている場合はこれが効かない。だから式の結果を、設定した範囲へ最後に押し込める:

go

// 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 個が起動するまで待たせる」ことを受け入れる判断が別に要る。

④ 安定化: 縮小だけを遅らせる

拡大と縮小を、同じ速さで扱ってはいけない:

go

// 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 周期で跳び、深夜に戻すと数周期待ってから縮む。

デモ水平オートスケール(HPA)2 レプリカ / 使用率 0%
深夜(負荷小)平常バースト
許容誤差と安定化あり式そのまま
周期を進めると、使用率(棒)とレプリカ数(下の数字)が並ぶ
周期を進めると、判断とその理由が出る

負荷には固定の揺らぎが乗っている。「式そのまま」に切り替えて平常負荷で 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 などの領域

参考資料