Skip to content

ResourceQuotaとLimitRange

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

スケジューラは Pod ごとに置けるかを見た。だが1つずつ見ているかぎり、小さな Pod を1000個作ってクラスタを埋め尽くすことは止められない。1つ1つは正しく、合計だけが問題になる。ResourceQuota はその合計に上限を置く。ただし要求を書かない Pod は0として数えられるので、既定値を入れてから数える順序まで含めて初めて意味を持つ。

この章で作るもの

スケジューラは Pod ごとに「置けるか」を見た。空きに収まるかを確かめ、収まらなければ Pending にする。判断はいつも1つの Pod についてだった。

だが1つずつ見ているかぎり、誰かが小さな Pod を1000個作ってクラスタを埋め尽くすことは止められない。1つ1つは空きに収まっているので、どの判断も正しい。合計だけが問題になる。

ResourceQuota は、その合計に上限を置く。namespace ごとに「この区画で使ってよい CPU は合計これだけ」と宣言する。個々の判断でなく、総量の判断になっている。

そして、そこにもう1つ問題がある。要求を書かない Pod をどう数えるかだ。

  要求を書いていない Pod


  ┌ ① 既定値を入れる(LimitRange) ┐  200m/256Mi が入る
  └──────────┬───────────────────┘

  ┌ ② 1つあたりの上限 ───────────┐  総量に余裕があっても
  └──────────┬───────────────────┘  大きすぎるものは通さない

  ┌ ③ 個数の上限 ────────────────┐
  └──────────┬───────────────────┘

  ┌ ④ 総量の上限(ResourceQuota) ─┐  合計 + 200m が上限を超えないか
  └──────────┬───────────────────┘

        受け入れる

  ①が無いと: 書き忘れが 0 として④を素通りし、いくつでも入る
既定値を入れてから数える。順序が逆だと、書き忘れが0として通り、上限が意味を失う

順に見ていく。

  1. 判断の単位が総量になる: 個々でなく合計を見る。1つずつ正しくても合計で止まる
  2. 既定値を入れてから数える: 順序が逆だと、書き忘れが0として通る
  3. 3つの上限は別のものを守る: 総量、1つあたり、個数

① 総量、1つあたり、個数

まず、3種類の上限を作る:

go

// Resources は CPU(ミリコア)とメモリ(MiB)の量。
type Resources struct {
	CPU int
	Mem int
}

func (r Resources) add(o Resources) Resources {
	return Resources{CPU: r.CPU + o.CPU, Mem: r.Mem + o.Mem}
}

func (r Resources) sub(o Resources) Resources {
	return Resources{CPU: r.CPU - o.CPU, Mem: r.Mem - o.Mem}
}

// fitsIn は自分が cap に収まるかを返す。
func (r Resources) fitsIn(cap Resources) bool {
	return r.CPU <= cap.CPU && r.Mem <= cap.Mem
}

// IsZero は何も要求していないかを返す。
func (r Resources) IsZero() bool { return r.CPU == 0 && r.Mem == 0 }

// LimitRange は、要求を書いていない Pod に入れる既定値と、
// 1つあたりに許す上限を持つ。
type LimitRange struct {
	// Default は要求が書かれていないときに入れる値。
	Default Resources
	// Max は1つあたりの上限。これを超える要求は、総量に余裕があっても通さない。
	Max Resources
}

// ResourceQuota は namespace ごとの総量の上限。
type ResourceQuota struct {
	// Hard は合計の上限。
	Hard Resources
	// MaxPods は個数の上限(0 なら無制限)。
	MaxPods int
}

// Pod は要求を持つ1つの実体。
type Pod struct {
	Name string
	Req  Resources
	// Defaulted は既定値が入れられたかを示す(観測用)。
	Defaulted bool
}

ResourceQuota が総量と個数、LimitRange が既定値と1つあたりの上限を持つ。分かれているのは、守っているものが違うからだ。

総量は、区画が使える資源の割り当てになる。チームごとに namespace を分けているなら、これがチームの取り分になる。1つあたりの上限は別で、総量に余裕があっても、1つで大半を占めるような要求を止める。取り分の中でどう使おうと自由に見えるが、1つの Pod が取り分を丸ごと使うと、他の Pod が1つも入らなくなる。個数の上限は資源と関係なく、管理の手に負える数を保つためだ。

② 順序が意味を決める

判定はこうなる:

go

// Result は1回の受け入れ判定の結果。
type Result struct {
	Admitted  bool
	Pod       *Pod
	Defaulted bool
	Reason    string
}

// Admit は Pod を受け入れるかを判定する。
//
// 順序が肝になる。まず既定値を入れ、次に1つあたりの上限を見て、最後に
// 総量を見る。既定値を先に入れるので、要求を書き忘れた Pod も総量に
// 正しく数えられる。逆順だと、書き忘れが 0 として通り、上限が意味を失う。
func (n *Namespace) Admit(name string, req Resources) Result {
	p := &Pod{Name: name, Req: req}

	// ① 要求が書かれていなければ、既定値を入れる。
	if p.Req.IsZero() && !n.limit.Default.IsZero() {
		p.Req = n.limit.Default
		p.Defaulted = true
		n.logf(name + " に既定値を入れた(" + res(p.Req) + ")")
	}

	// ② 1つあたりの上限。総量に余裕があっても、大きすぎるものは通さない。
	if !n.limit.Max.IsZero() && !p.Req.fitsIn(n.limit.Max) {
		n.Rejected++
		n.logf(name + " を拒否(1つあたりの上限 " + res(n.limit.Max) + " を超える)")
		return Result{Pod: p, Defaulted: p.Defaulted,
			Reason: "1つあたりの上限を超える要求"}
	}

	// ③ 個数の上限。
	if n.quota.MaxPods > 0 && len(n.pods) >= n.quota.MaxPods {
		n.Rejected++
		n.logf(name + " を拒否(個数の上限 " + itoa(n.quota.MaxPods) + " に達している)")
		return Result{Pod: p, Defaulted: p.Defaulted, Reason: "個数の上限に達している"}
	}

	// ④ 総量の上限。
	after := n.used.add(p.Req)
	if !after.fitsIn(n.quota.Hard) {
		n.Rejected++
		n.logf(name + " を拒否(総量が上限 " + res(n.quota.Hard) + " を超える)")
		return Result{Pod: p, Defaulted: p.Defaulted, Reason: "総量の上限を超える"}
	}

	n.pods = append(n.pods, p)
	n.used = after
	n.Admitted++
	return Result{Admitted: true, Pod: p, Defaulted: p.Defaulted,
		Reason: "受け入れた(合計 " + res(n.used) + " / " + res(n.quota.Hard) + ")"}
}

// Remove は Pod を消し、使っていた分を返す。
func (n *Namespace) Remove(name string) bool {
	for i, p := range n.pods {
		if p.Name != name {
			continue
		}
		n.used = n.used.sub(p.Req)
		n.pods = append(n.pods[:i], n.pods[i+1:]...)
		n.logf(name + " を削除(合計 " + res(n.used) + ")")
		return true
	}
	return false
}

Admit は4段を順に通す。既定値、1つあたりの上限、個数、総量。この順序が肝になる。

要求が0の Pod に、まず既定値を入れる。入れてから数えるので、書き忘れた Pod も200mとして総量に載る。もし数えてから入れる順序だと、0として総量を素通りし、いくつ作っても上限に当たらない。実際には資源を使うので、上限は宣言されているのに守られていない状態になる。テストで、既定値の無い区画では書き忘れが延々と通り、合計が0のままであることを固定した。

admissionの章で見た「書き換えが先、検証が後」と、まったく同じ形になっている。実際、実物でもこの処理は admission の段で走る。書き換えてから検証するという原則が、資源の勘定にもそのまま効いている。

既定値を入れた後で1つあたりの上限に照らすところも同じ理由だ。既定値そのものが上限を超えていたら、書き忘れた Pod は断られる。設定が矛盾していることに気づける。テストで、この場合に断られることを固定した。

断った分を数えないことも確かめておきたい。拒否した Pod が総量を消費してしまうと、断るたびに残りが減っていく。テストで、断った後も残りが変わらないことを固定した。

③ 上限が意味を持つ条件

この仕組みで注意が要るのは、上限を宣言しただけでは守られない、という点になる。

ResourceQuota を置いた namespace では、要求を書いていない Pod は作れなくなる。総量を数えられないからだ。だが、その挙動は「作れない」であって「0として通る」ではない。つまり実物では、上限を置いた時点で全員が要求を書く義務を負う。LimitRange はその義務を肩代わりして、書いていないものに既定値を入れる。

この2つはセットで使って初めて機能する。ResourceQuota だけを置くと、要求を書いていない既存の Pod が作れなくなって壊れる。LimitRange だけを置くと、既定値は入るが総量は無制限のままになる。片方だけ置いて期待した効果が出ない、というのがよくある形になる。

そして、既定値の決め方が難しい。大きすぎると、小さな Pod まで大きく数えられて、取り分をすぐ使い切る。小さすぎると、実際の使用量より少なく数えられて、詰め込みすぎになる。スケジューラの章で「要求は予約であり実測ではない」と書いたが、その申告値を機械が決めているのがこの場面になる。決めた値が実態と合っているかは、誰も見ていない。

動かす

下のデモは、3つの上限がそれぞれ別に効く様子を見る。「要求を書かずに作る」を既定値ありで押すと200mとして数えられ、無しで押すと0のまま数えられる。後者を繰り返すと、いくつ作っても合計が増えない。大きすぎる Pod は総量に余裕があっても止まる。

デモResourceQuotaとLimitRange0m / 2000m ・ 0 / 6 個
要求を書いていない Pod既定値を入れるそのまま(0として数える)

総量 2000m / 2048Mi ・ 個数 6 ・ 1つあたり 1000m / 1024Mi ・ 既定値 200m / 256Mi

CPU 合計0 / 2000m
メモリ合計0 / 2048Mi
個数0 / 6
(Pod なし)
Pod を作ると、総量・個数・1つあたりの3つの上限に照らされる
起きたこと
(まだ何も起きていない)

1つ1つは正しくても、合計で止まる。「要求を書かずに作る」を既定値ありで押すと 200m として数えられ、 無しで押すと 0 のまま数えられる。後者を繰り返すと、いくつ作っても合計が増えないまま資源だけが消費される。 既定値を入れてから数えるという順序が、書き忘れを勘定に載せている。大きすぎる Pod は、総量に余裕があっても 1つあたりの上限で止まる。3つの上限がそれぞれ別のものを守っている。

設計の観点

  • 1つずつの正しさは合計を保証しない: 個別の判断がすべて正しくても、総量は破綻しうる。層を分けて、別の単位で見る
  • 書き換えと検証の順序は資源の勘定にも効く: 既定値を入れてから数える。admissionと同じ原則が、別の場面で同じ形で出てくる
  • 2つを組で使う: ResourceQuota だけでは要求を書いていない Pod が作れなくなり、LimitRange だけでは総量が無制限のまま。片方だけでは期待した効果にならない
  • 既定値は実態と合っているか誰も見ていない: 機械が決めた申告値がそのまま予約になる。大きすぎれば取り分を無駄にし、小さすぎれば詰め込みすぎになる
  • 上限は公平の道具でもある: チームごとに取り分を決めると、誰か1人が使い切ることを防げる。技術というより組織の取り決めを、仕組みで表している
  • Cluster Autoscalerとの関係: 総量の上限で断られた Pod は Pending にすらならない。ノードを増やしても解決しないので、あちらは動かない

対照と実例

ResourceQuotaLimitRange
見る単位区画の合計1つの Pod
決めるもの総量と個数の上限既定値と1つあたりの上限
無いと合計が青天井書き忘れが0として通る
走る順
守るもの区画の取り分勘定の正しさと、極端な要求

裏どり:

  • ResourceQuota: namespace ごとに合計の上限を置く。CPU やメモリのほか、オブジェクトの個数にも置ける
  • 要求の必須化: ResourceQuota で CPU の上限を置くと、その namespace では要求を書いていない Pod が作れなくなる
  • LimitRange: 既定値、1つあたりの上限と下限を指定する。既定値は admission の段で挿入される
  • 走る順序: LimitRange の挿入は mutating、ResourceQuota の判定は validating の段で走る

簡略化したこと

  • 資源は CPU とメモリのみ: 実物はストレージや、Service や Secret の個数にも上限を置ける
  • requests と limits を分けない: 実物は両方に別々の上限を置ける
  • scope なし: 優先度クラスごとに別の上限を置く仕組みは扱わない
  • 下限なし: 実物の LimitRange は最小値も指定できる
  • 判定のみ: 実際の適用は admission の段で行われる

参考資料