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 として④を素通りし、いくつでも入る順に見ていく。
- 判断の単位が総量になる: 個々でなく合計を見る。1つずつ正しくても合計で止まる
- 既定値を入れてから数える: 順序が逆だと、書き忘れが0として通る
- 3つの上限は別のものを守る: 総量、1つあたり、個数
① 総量、1つあたり、個数
まず、3種類の上限を作る:
// 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つも入らなくなる。個数の上限は資源と関係なく、管理の手に負える数を保つためだ。
② 順序が意味を決める
判定はこうなる:
// 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 は総量に余裕があっても止まる。
総量 2000m / 2048Mi ・ 個数 6 ・ 1つあたり 1000m / 1024Mi ・ 既定値 200m / 256Mi
1つ1つは正しくても、合計で止まる。「要求を書かずに作る」を既定値ありで押すと 200m として数えられ、 無しで押すと 0 のまま数えられる。後者を繰り返すと、いくつ作っても合計が増えないまま資源だけが消費される。 既定値を入れてから数えるという順序が、書き忘れを勘定に載せている。大きすぎる Pod は、総量に余裕があっても 1つあたりの上限で止まる。3つの上限がそれぞれ別のものを守っている。
設計の観点
- 1つずつの正しさは合計を保証しない: 個別の判断がすべて正しくても、総量は破綻しうる。層を分けて、別の単位で見る
- 書き換えと検証の順序は資源の勘定にも効く: 既定値を入れてから数える。admissionと同じ原則が、別の場面で同じ形で出てくる
- 2つを組で使う: ResourceQuota だけでは要求を書いていない Pod が作れなくなり、LimitRange だけでは総量が無制限のまま。片方だけでは期待した効果にならない
- 既定値は実態と合っているか誰も見ていない: 機械が決めた申告値がそのまま予約になる。大きすぎれば取り分を無駄にし、小さすぎれば詰め込みすぎになる
- 上限は公平の道具でもある: チームごとに取り分を決めると、誰か1人が使い切ることを防げる。技術というより組織の取り決めを、仕組みで表している
- Cluster Autoscalerとの関係: 総量の上限で断られた Pod は Pending にすらならない。ノードを増やしても解決しないので、あちらは動かない
対照と実例
| ResourceQuota | LimitRange | |
|---|---|---|
| 見る単位 | 区画の合計 | 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 の段で行われる
参考資料
- Resource Quotas — 総量の上限と、要求が必須になる条件
- Limit Ranges — 既定値の挿入と、1つあたりの上限
- 実装: orchestration/quota