Skip to content

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つ。それぞれが消す前に許可を求める形にすると、組み合わせを考えなくてよくなる

順に見ていく。

  1. アプリの側が宣言する: 「一度に何個止めてよいか」でなく「常に何個残っていること」を書く
  2. 守れるのは自発的な退避だけ: 落ちる分は止められない。約束できることだけを約束する
  3. 厳しすぎると運用が止まる: 下限とレプリカ数が同じなら、1つも動かせなくなる

① 下限を宣言し、許可を求めさせる

まず、宣言そのものと、それを数える部分を作る:

go

// 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 は頭数に入らない。ヘルスチェックで見たとおり、起動しただけでは受けられないので、これは素直な扱いだ。だが結果として、前に退避したぶんが立ち上がるまで、次の退避は断られ続ける。宣言には速度を書いていないのに、実質的な速度制限が生まれている。

② 退避と、止められない消失

判定の本体はこうなる:

go

// 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が落ちる」は宣言を無視するので、下限を割ったまま減っていく。

デモPodDisruptionBudget稼働 3 / 下限 2
下限(常に何個以上)123レプリカは 3
t=0

許可 0 / 拒否 0 / 落ちた 0 ・ 作り直しは node-c に、3 周期で ready

web-1node-a / ready
web-2node-b / ready
web-3node-a / ready
下限 2
稼働 3、下限 2。退避には余裕がある
起きたこと
(まだ何も起きていない)

「退避を試みる」は更新や集約が 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 に複数の宣言がかかる場合の扱いは簡略化している
  • 作り直しは固定のノードへ: 本来はスケジューラが置き場所を決める
  • 論理時刻: 実時間でなく周期で数える

参考資料