PodDisruptionBudget
実装:
orchestration/pdb// 実行:go test ./orchestration/pdb/
更新も集約もノードの入れ替えも、それぞれ自分の都合しか見ていない。1つずつは正しくても、同時に起これば全部消える。そこで消す側でなくアプリの側から「常に2つ以上」と下限を宣言し、消す側に許可を求めさせる。守れるのは自発的な退避だけで、ノードが落ちる分は止められない。止められるものと止められないものを分けている点に、この仕組みの性格が出ている。
この章で作るもの
ここまでの章で、Pod は色々な理由で消えてきた。ローリング更新で入れ替わり、Cluster Autoscalerの集約で移され、ノードの入れ替えで追い出され、障害で落ちる。1つずつ見れば、どれも正しい動きだ。
だが同時に起これば話が変わる。3つのうち2つを更新のために止めている最中に、残り1つが載っているノードを集約のために空にしたら、全部消える。それぞれの仕組みは自分の都合しか知らない。ローリング更新は自分の入れ替え幅しか見ないし、Cluster Autoscaler は自分の集約しか見ない。全体で何個残っているかを見ている者が、どこにもいない。
消す仕組みの側で調整しようとすると、組み合わせの数だけ話が増える。更新と集約、集約とノード入れ替え、その3つが同時のとき。仕組みが1つ増えるたびに、既存の全部と噛み合わせを考え直すことになる。
そこで向きを変える。消す側でなく、アプリの側から下限を宣言する。「web は常に2つ以上動いていること」。消そうとする側は、消す前にこの宣言に照らして許可を求める。割ってしまうなら断られ、待つ。消す仕組みが何個あっても、宣言は1つでよい。
ローリング更新 ──┐
集約(CA) ──┼─→ 退避の許可を求める ─→ 宣言「常に2つ以上」
ノード入れ替え ──┘ │ │
│ 今 ready な数を数え、│
│ 消した後も下限を保てるか
▼
許可 / 拒否(待つ)
ノード障害 ────────────────────────────→ 止められない
(宣言は参照しない)順に見ていく。
- アプリの側が宣言する: 「一度に何個止めてよいか」でなく「常に何個残っていること」を書く
- 守れるのは自発的な退避だけ: 落ちる分は止められない。約束できることだけを約束する
- 厳しすぎると運用が止まる: 下限とレプリカ数が同じなら、1つも動かせなくなる
① 下限を宣言し、許可を求めさせる
まず、宣言そのものと、それを数える部分を作る:
// Pod は1つのレプリカ。どのノードに載っていて、受けられる状態かを持つ。
type Pod struct {
Name string
Node string
Ready bool
labels map[string]string
readyAt int // この時刻に ready になる(-1 なら予定なし)
}
func (p *Pod) matches(selector map[string]string) bool {
for k, v := range selector {
if p.labels[k] != v {
return false
}
}
return true
}
// Budget は「この条件の Pod は常に何個以上」という宣言。
// 消す側でなく、アプリの側が書く。
type Budget struct {
Name string
MinAvailable int
selector map[string]string
}Budget が宣言で、条件(セレクタ)と下限だけを持つ。誰が消そうとしているかは書かない。書けない、と言うほうが正しい。宣言を書く人はアプリの担当者で、クラスタで今後どんな運用の仕組みが動くかを知らない。知らなくても書けることだけを書く形になっている。
数えるときに Ready を条件にしているのが効いてくる。作り直し中の Pod は頭数に入らない。ヘルスチェックで見たとおり、起動しただけでは受けられないので、これは素直な扱いだ。だが結果として、前に退避したぶんが立ち上がるまで、次の退避は断られ続ける。宣言には速度を書いていないのに、実質的な速度制限が生まれている。
② 退避と、止められない消失
判定の本体はこうなる:
// Evict は自発的な退避を試みる。消した後も、その Pod にかかる宣言の下限を
// 保てるときだけ許可する。割ってしまうなら断る。
//
// 判定に使うのは ready な数だ。作り直し中の Pod は頭数に入らないので、
// 前に退避したぶんが立ち上がるまで、次の退避は断られ続ける。この待ちが
// 「一度に何個まで止めてよいか」を実質的に決めている。
func (c *Cluster) Evict(name string) bool {
p, ok := c.pods[name]
if !ok {
return false
}
for _, b := range c.budgets {
if !p.matches(b.selector) {
continue
}
after := c.Available(b.selector)
if p.Ready {
after-- // この Pod を消すと1つ減る
}
if after < b.MinAvailable {
c.Denied++
c.logf(name + " の退避を断った(" + b.Name + " の下限 " + itoa(b.MinAvailable) +
" を割る。今 " + itoa(c.Available(b.selector)) + ")")
return false
}
}
c.remove(p)
c.Evicted++
c.logf(name + " を退避した")
c.replace(p)
return true
}
// Crash は止められない理由で Pod が消えることを表す。ノードの障害や
// カーネルの停止がこれにあたる。宣言は一切参照しない。参照したところで
// 止められないからだ。宣言が守るのは、こちらから止められるものだけになる。
func (c *Cluster) Crash(name string) {
p, ok := c.pods[name]
if !ok {
return
}
c.remove(p)
c.Crashed++
c.logf(name + " が落ちた(宣言では止められない)")
c.replace(p)
}
// Drain は node に載っている Pod を、載っている順に退避しようとする。
// 断られたぶんは残る。ノードを空にするには、何度も呼ぶことになる。
func (c *Cluster) Drain(node string) (evicted int, remaining []string) {
for _, p := range c.Pods() {
if p.Node != node {
continue
}
if c.Evict(p.Name) {
evicted++
continue
}
remaining = append(remaining, p.Name)
}
return evicted, remaining
}Evict は、その Pod にかかる宣言をすべて見て、消した後も下限を保てるかを確かめる。割るなら断る。テストで、下限ちょうどのときに断られ、代わりが立ち上がると通ることを固定した。
Crash は対照的で、宣言を一切参照しない。ノードの電源が落ちる、カーネルが止まる、といった消え方がこれにあたる。参照したところで止められないからだ。テストで、1つも退避できない厳しい設定でも、落ちる分は下限を割って減っていくことを固定した。
この区別が、この仕組みのいちばん性格が出ているところになる。素朴には「常に2つ以上動いていること」を保証してほしい。だが計算機は、電源が落ちるのを拒否できない。だから約束の範囲を狭める。こちらから止められる分については守る。止められない分については何も言わない。守れることだけを約束するので、約束は必ず守られる。
代わりに、宣言を読むときに注意が要る。「常に2つ以上」と書いてあっても、2つ以上を保証しているわけではない。ノードが2台同時に落ちれば普通に割る。これは仕組みの欠陥ではなく、そもそも約束していない。可用性そのものは、レプリカ数と配置の散らし方で作るもので、宣言はそれを運用の都合で壊さないための歯止めになっている。
③ 厳しくしすぎると何も動かない
Drain は、ノードに載っている Pod を順に退避しようとするだけの短い関数だ。断られたぶんはノードに残る。だから drain は一度で終わらず、立ち上がりを待ちながら何度も試すことになる。
ここで、宣言を厳しくしすぎたときの困り方が出てくる。下限をレプリカ数と同じにすると、1つも退避できない。更新も進まず、集約もできず、ノードを入れ替えることもできない。セキュリティ更新のためにノードを空にしたいのに、永久に空にならない。テストで、この設定では drain が1歩も進まないことを固定した。
厄介なのは、この設定が一見もっとも安全に見えることだ。「1つも減らしたくない」と書いただけなのに、結果としてクラスタの運用そのものが止まる。しかも止まるのは宣言を書いた本人ではなく、ノードを更新しようとした運用側になる。宣言はアプリの側が書き、その影響は運用の側に出る。この非対称が、この仕組みの扱いにくいところになっている。
余裕を1つ持たせておけば、退避は少しずつ進む。「常に何個必要か」でなく「1つ減っても大丈夫か」を先に考えることになる。減っても大丈夫でないなら、宣言を厳しくするのでなく、レプリカを増やすほうが正しい。
動かす
下のデモは、3つのレプリカに下限を宣言して、退避を試みる。下限 2 なら1つ退避でき、代わりが立ち上がるまで次は断られる。下限 3 にすると1つも動かない。「Podが落ちる」は宣言を無視するので、下限を割ったまま減っていく。
許可 0 / 拒否 0 / 落ちた 0 ・ 作り直しは node-c に、3 周期で ready
「退避を試みる」は更新や集約が Pod を止めようとする動きで、下限を割るなら断られる。作り直し中の Pod は 頭数に入らないので、立ち上がるまで次の退避は通らない。この待ちが、実質的に「一度に何個まで止めてよいか」を 決めている。一方「Podが落ちる」は宣言を一切参照しない。止められないものを止める約束はできないからで、 宣言が守るのはこちらから止められる分だけになる。下限を 3 にすると、何も動かせなくなる。
設計の観点
- 制約の向きを変える: 消す側どうしで調整すると、仕組みが増えるたびに組み合わせが増える。守りたい側が1つ宣言し、消す側が許可を求める形にすると、増えても噛み合わせを考えなくてよい
- 約束できることだけを約束する: 止められないものについては何も言わない。範囲を狭めるかわり、約束の中では確実に守る。守れない約束を掲げるより誠実で、実装も単純になる
- ready を数えることが速度制限になる: 宣言には速度を書いていないのに、作り直しの立ち上がりを待つ形になる。宣言が「状態」であって「手順」でないことが、ここでも効いている
- 安全側に倒すと運用が止まる: いちばん厳しい設定が、いちばん危険なことがある。宣言を厳しくする前に、レプリカを増やすほうが筋がよい
- 書く人と困る人が違う: 宣言はアプリの担当者が書き、止まって困るのは運用の側。この非対称に気づいていないと、原因が見つけにくい
- Cluster Autoscalerとの関係: 集約でノードを空にするのも自発的な退避なので、宣言に縛られる。厳しい宣言は、ノードが減らない原因にもなる
対照と実例
| 自発的な退避 | 止められない消失 | |
|---|---|---|
| 例 | 更新、集約、ノードの入れ替え | ノード障害、カーネルの停止 |
| 起点 | クラスタの都合 | 事故 |
| 宣言を見るか | 見る。割るなら断る | 見ない |
| 断られたら | 待って再試行する | 断る余地がない |
| 守れるか | 守れる | 守れない |
裏どり:
- PodDisruptionBudget:
minAvailable/maxUnavailableで下限を宣言し、退避 API がそれを見て許可を出す - 自発と非自発の区別: 公式の文書も、この2つを最初に分けたうえで、宣言が守るのは前者だけだと明示している
- drain との関係:
kubectl drainは退避 API を呼ぶので宣言に縛られる。宣言が厳しいと drain が終わらない - ready でない Pod の扱い: available は ready な Pod だけを数える。作り直し中は頭数に入らない
簡略化したこと
- minAvailable のみ: 実物は
maxUnavailableでも書ける。割合での指定も扱わない - 退避の作法なし: 実際の退避は終了処理を通る。ここでは即座に消える
- 複数の宣言: 同じ Pod に複数の宣言がかかる場合の扱いは簡略化している
- 作り直しは固定のノードへ: 本来はスケジューラが置き場所を決める
- 論理時刻: 実時間でなく周期で数える
参考資料
- Disruptions — 自発と非自発の区別、宣言が守る範囲
- Specifying a Disruption Budget — 書き方と、厳しくしすぎたときの困り方
- 実装: orchestration/pdb