Skip to content

Cluster Autoscaler

実装: orchestration/clusterautoscaler/ / 実行: go test ./orchestration/clusterautoscaler/

水平オートスケーラは Pod を増やすが、置き場所がなければ Pending のまま残る。足りないのがノードなら、ノードを増やすしかない。素朴に「Pending があれば増やす」では、置けない要求のために無限に増える。だから増やす前に、新しい1台に載るかをスケジューラで確かめる。減らすときも同じで、載っている Pod の行き先を確かめる。判断はすべてスケジューラに委ねられている。

この章で作るもの

この章で、スケジューラ水平オートスケールが合流する。

水平オートスケーラは、負荷に応じて Pod の数を決めた。だが Pod は置き場所がなければ動かない。全ノードが埋まっていれば、増やした Pod は Pending のまま残る。数を増やしただけでは、捌ける量は増えない。足りないのがノードそのものなら、ノードを増やすしかない。それをやるのが Cluster Autoscaler だ。

層が分かれていることに意味がある。水平オートスケーラは負荷を見て Pod の数を決め、置き場所のことは知らない。Cluster Autoscaler は Pending の Pod を見てノードの数を決め、なぜその Pod が要るのかは知らない。互いに相手の事情を知らないまま、それぞれの層で「足りないものを足す」を繰り返す。

  負荷が上がる


  HPA        使用率を見て Pod の数を決める
      │      「4 個では足りない、8 個にせよ」

  スケジューラ  Pod をノードに置く
      │      「置けない。Pending にする」

  Cluster    Pending を見てノードの数を決める
  Autoscaler 「ノードを1台足せば置けるか?」→ 足す


  置けるようになる(ただしノードの起動は遅い)
3つの層。それぞれが自分の層の不足だけを見ていて、下の層の不足が上の層の入力になる

先に押さえることが3つある。

  1. 増やす前に確かめる: 新しい1台に載るかをスケジューラで判定する。載らないなら足さない
  2. 減らす前に確かめる: 載っている Pod の行き先があるかを確かめる。使用率が低いだけでは消さない
  3. 判断はスケジューラのもの: この層に配置の知識はない。前章のスケジューラをそのまま部品として使う

① 増やす前に確かめる

素朴には「Pending の Pod があればノードを増やす」でよさそうに見える。だがそれでは足りない:

go

// scaleUp は Pending の Pod があるとき、ノードを1台足してよいかを判定する。
//
// 肝は、足す前に確かめることだ。新しいノードを仮に1台こしらえて、その Pod が
// そこに載るかをスケジューラの filter で見る。載らないなら足しても無駄なので
// 足さない。1台に収まらない要求をしている Pod のために、ノードを無限に
// 増やし続ける、という事故はこれで防げる。
func (a *Autoscaler) scaleUp() {
	if len(a.pending) == 0 || len(a.nodes)+len(a.booting) >= a.cfg.MaxNodes {
		return
	}
	// 仮のノードを1台こしらえて、Pending の Pod が載るかを見る。
	hypo := scheduler.NewNode("hypothetical", a.cfg.NodeCap)
	helped := false
	for _, p := range a.pending {
		if feasible, _ := scheduler.Filter(p, []*scheduler.Node{hypo}); len(feasible) > 0 {
			helped = true
			break
		}
	}
	if !helped {
		a.logf("ノードを足しても Pending は解消しない。増やさない")
		return
	}
	n := a.addNode(false)
	a.logf(n.Name + " を追加(起動に " + itoa(a.cfg.BootTicks) + " 周期かかる)")
}

scaleUp は、増やす前に仮のノードを1台こしらえる。何も載っていない新品のノードを作り、Pending の Pod がそこに載るかを scheduler.Filter で判定する。載るなら足す。載らないなら足さない。

これが要るのは、足しても解決しない Pending があるからだ。1台の容量を超える要求をしている Pod は、何台足しても置けない。設定を間違えて 100 コアを要求してしまった、というのはよくある。素朴な実装だと、この Pod のためにノードを上限まで増やし続ける。増えたノードは全部空のままで、費用だけがかかる。テストで、1台に収まらない要求では1台も増えないことを固定した。

この判定にスケジューラを使っているのが大事なところだ。「容量に収まるか」を自前で書き直すと、スケジューラ側の条件が増えたとき食い違う。汚れ(taint)を許容していない Pod のためにノードを足しても、やはり置けない。判定を一箇所に集めておけば、そういう食い違いが起きない。実物の Cluster Autoscaler も、内部でスケジューラのコードをそのまま呼んで判定している。

ノードの起動が遅いことも、この層の性質を決めている。Pod は秒で立ち上がるが、ノードは分単位でかかる。足すと決めてから使えるようになるまで、Pending の Pod はただ待つ。だから、負荷が来てから足すのでは間に合わない場面がある。あらかじめ余らせておく(そのぶん費用を払う)か、遅れを受け入れるかの選択になる。

② 減らす前に確かめる

縮小も同じ形をとる:

go

// scaleDown は使用率の低いノードを1台探し、その Pod が他へ収まるなら消す。
//
// ここでもスケジューラに判定を委ねる。そのノードを取り除いた世界を作り、
// 全 Pod を置き直せるかを試す。1つでも置けなければ消さない。使用率が低い
// だけでは消してよい理由にならず、行き先があることまで確かめて初めて消せる。
func (a *Autoscaler) scaleDown() {
	if len(a.nodes) <= a.cfg.MinNodes || len(a.pending) > 0 {
		return // 下限、または置けていない Pod があるうちは減らさない
	}
	for _, victim := range a.nodes {
		if util(victim) >= a.cfg.ScaleDownUtil {
			continue
		}
		if nodes, place, ok := a.simulateWithout(victim); ok {
			a.nodes, a.place = nodes, place
			a.logf(victim.Name + " は使用率 " + itoa(util(victim)) + "% で、載っている Pod も他へ移せる。削除")
			return
		}
		a.logf(victim.Name + " は使用率が低いが、載っている Pod の行き先がない。残す")
	}
}

// simulateWithout は victim を除いた世界を作り、全 Pod を置き直せるかを試す。
// 成功したときだけ、新しいノード一式と配置を返す。
func (a *Autoscaler) simulateWithout(victim *scheduler.Node) ([]*scheduler.Node, map[string]string, bool) {
	fresh := a.freshNodes(victim)
	if len(fresh) == 0 {
		return nil, nil, false
	}
	place, ok := a.packInto(fresh)
	if !ok {
		return nil, nil, false // 1つでも置けなければ、この縮小は諦める
	}
	return fresh, place, true
}

// freshNodes は今のノードを、何も載っていない状態で作り直す(except は除く)。
// 置き直しの試算は、この空のノードの上で行う。
func (a *Autoscaler) freshNodes(except *scheduler.Node) []*scheduler.Node {
	var out []*scheduler.Node
	for _, n := range a.nodes {
		if n != except {
			out = append(out, scheduler.NewNode(n.Name, n.Cap))
		}
	}
	return out
}

// packInto は今ある Pod を nodes へ置き直す。1つでも置けなければ false。
func (a *Autoscaler) packInto(nodes []*scheduler.Node) (map[string]string, bool) {
	place := map[string]string{}
	for _, name := range a.podOrder() {
		r := a.sched.Schedule(a.specs[name], nodes)
		if !r.Scheduled() {
			return nil, false
		}
		place[name] = r.Node
	}
	return place, true
}

// podOrder は置き直す順序を返す。要求の大きい順に置くほうが収まりやすく、
// 何より順序が固定なので結果が再現する。
func (a *Autoscaler) podOrder() []string {
	names := make([]string, 0, len(a.place))
	for name := range a.place {
		names = append(names, name)
	}
	sort.Slice(names, func(i, j int) bool {
		pi, pj := a.specs[names[i]], a.specs[names[j]]
		if pi.Req.CPU != pj.Req.CPU {
			return pi.Req.CPU > pj.Req.CPU
		}
		return names[i] < names[j]
	})
	return names
}

// Remove は Pod を消す。空いた資源を反映するため、残りを置き直す。
func (a *Autoscaler) Remove(name string) {
	if _, ok := a.place[name]; !ok {
		return
	}
	delete(a.place, name)
	delete(a.specs, name)
	fresh := a.freshNodes(nil)
	if place, ok := a.packInto(fresh); ok {
		a.nodes, a.place = fresh, place
	}
}

scaleDown は、使用率の低いノードを候補として選ぶ。だが選んだだけでは消さない。simulateWithout が、そのノードを除いた世界を作り、全 Pod を置き直せるかを試す。1つでも置けなければ、この縮小は諦める。

使用率が低いことは、消してよい理由にならない。20% しか埋まっていないノードでも、そこに載っている Pod が他のどこにも入らないなら、消せば行き場を失う。空きは総量としては足りていても、連続した空きとして足りないことがある。これはメモリアロケータの断片化とまったく同じ形の問題で、総量が足りることと1つ分の場所があることは別だ。テストで、使用率が低くても集約できない配置では台数が減らないことを固定した。

置き直しの試算は、空のノードを作り直したうえで全 Pod を置き直す形にした。実際に動かすのでなく、頭の中でやってみて、うまくいったときだけ採用する。順序は要求の大きい順に固定してあるので、何度走らせても同じ結果になる。テストで、同じ入力を繰り返しても同じ台数に落ち着くことを固定した。

scaleDown の冒頭にある、Pending があるうちは減らさないという条件も要る。増やす側と減らす側が同時に動くと、片方が足したものを片方が消し、足しては消しての繰り返しになる。水平オートスケールで見た flapping と同じ形の振動で、対処も似ている。上げと下げを同時に走らせない。

動かす

下のデモは、Pod を投入しながらノードの増減を見る。小さい Pod を 5 つ以上足すと空きが尽きて Pending が出て、周期を進めるとノードが増える。起動には時間がかかるので、その間 Pending は待つ。「1台に収まらないPod」を投入すると、増やす判断そのものが見送られる。Pod を消していくと、行き先を確かめたうえで台数が減る。

デモCluster Autoscalerノード 1 + 起動中 0 / Pending 0
t=0

1台 2000m / 2048Mi ・ 1〜4台 ・ 起動に 3 周期 ・ 使用率 40% 未満が縮小の候補

node-10%
(空)
Pending(置き場所がない)(なし)
ノード 1 台。Pod を追加すると、空きが尽きたところで増える
起きたこと
(まだ何も起きていない)

小さい Pod を 5 つ以上足すと空きが尽き、Pending が出る。周期を進めると、新しい1台に載るかを確かめてから ノードが増える。起動には時間がかかるので、その間 Pending は待つ。「1台に収まらないPod」は何台足しても 置けないので、増やす判断そのものが見送られる。Pod を消して空きが増えると、載っている Pod の行き先を 確かめたうえでノードが減る。増やすときも減らすときも、判断はスケジューラに委ねられている。

設計の観点

  • 層を分ける: HPA は Pod の数、Cluster Autoscaler はノードの数。互いに相手の事情を知らないまま、それぞれの不足を埋める。下の層の不足が、上の層の入力になる
  • 判定を一箇所に集める: 増やすかどうかも、減らせるかどうかも、スケジューラに聞く。自前で条件を書き直すと、スケジューラ側の条件が増えたときに食い違う
  • 起動の遅さが設計を決める: ノードは分単位でかかる。足りなくなってから足すのでは間に合わないなら、あらかじめ余らせるしかない。速さを金で買う選択になる
  • 断片化としての縮小: 総量として空いていても、1つ分の連続した場所がなければ Pod は動かせない。使用率だけを見た縮小は、この違いを見落とす
  • 上げと下げを同時に走らせない: Pending があるうちは減らさない。両方が同時に動くと振動する
  • 消すときは安全に落とす: 縮小は Pod を移すことであり、終了処理を通る必要がある。台数の判断と、落とし方の作法は別の層

対照と実例

水平オートスケール(HPA)Cluster Autoscaler
増やすものPod の数ノードの数
引き金使用率が目標を超えたPod が置けず Pending になった
判断のもと指標の値スケジューラの判定
反映の速さ
上限の意味増やしすぎの歯止め費用の歯止め

裏どり:

  • Cluster Autoscaler: Pending Pod を引き金にノードグループを増やす。内部でスケジューラの判定を再利用する設計
  • scale-down の条件: 使用率が閾値未満の状態が一定時間続き、かつ載っている Pod が他ノードに収まることを確かめてから消す
  • Karpenter: ノードグループを前提とせず、Pending Pod の要求に合う形のノードを直接選ぶ方式。判定にスケジューラを使う点は同じ
  • overprovisioning: 優先度の低いダミー Pod を先に置いておき、本番の Pod が来たら追い出して即座に場所を空ける手法。ノードの起動の遅さへの対処

簡略化したこと

  • ノードは全部同じ: 実物はノードグループごとに種類が違い、どの種類を足すかも判断の対象になる
  • 退避なし: 縮小のとき Pod を移すが、実際には終了処理を通して安全に落とす必要がある
  • PodDisruptionBudget なし: 自発的な退避で最低何個を保つかの宣言は別章(PodDisruptionBudget)
  • 待ち時間なし: 実物は使用率が低い状態が一定時間続いてから縮小する。ここは即座に判断する
  • 論理時刻: 実時間でなく周期で数える

参考資料