Skip to content

admission webhook

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

RBAC は誰が操作してよいかを見た。だが、権限のある人が作ろうとしている中身が妥当かは見ていない。そこで保存される直前にもう一段の関門を置く。書き換えてから検証するという順序が肝で、逆だと書き換えた結果が検証されない。そして webhook 自身もクラスタの中の Pod なので、応答しないときの扱いを間違えると、直すための Pod すら作れなくなる。

この章で作るもの

RBACは「誰が」を見た。alice が Pod を作ってよいか。だが、作ってよい人が作ろうとしている中身が妥当かは見ていない。資源の要求を書き忘れた Pod も、特権を要求する Pod も、権限さえあれば通る。

そこで、作られる前にもう一段の関門を置く。admission webhook は、保存される直前のオブジェクトを受け取り、書き換えるか、拒否する。「誰が」でなく「何を」を見る層になる。

段が2つあることに意味がある。まず書き換え(mutating)、次に検証(validating)。そして、この仕組みには固有の危険がある。webhook 自身がクラスタの中で動いている Pod だということだ。

  作ろうとしている Pod(ラベル無し)


  ┌── ① 書き換え(mutating) ────┐
  │ team ラベルが無ければ足す   │ → team=unknown が付く
  └──────────┬─────────────────┘

  ┌── ② 検証(validating) ──────┐
  │ team ラベルが無ければ拒否   │ → 付いているので通る
  └──────────┬─────────────────┘

        保存される

  逆順にすると: 検証を通った後で書き換えられ、その結果は誰も見ない
関門は2段。書き換えてから検証するので、書き換えた結果も検証を通る

順に見ていく。

  1. 書き換えが先、検証が後: 書き換えた結果も検証を通る
  2. 応答が無いときを決めておく: 拒否するか、素通しするか。どちらも危険
  3. 自分自身を止められる: webhook もクラスタの中の Pod

① 2つの段と、その順序

まず関門の形を作る:

go

// Object は作られようとしているオブジェクト。
type Object struct {
	Kind        string
	Name        string
	Labels      map[string]string
	Annotations map[string]string
}

// NewObject は空のオブジェクトを作る。
func NewObject(kind, name string) *Object {
	return &Object{Kind: kind, Name: name,
		Labels: map[string]string{}, Annotations: map[string]string{}}
}

// clone は複製を返す。関門を通す前の姿を残しておくために使う。
func (o *Object) clone() *Object {
	c := NewObject(o.Kind, o.Name)
	for k, v := range o.Labels {
		c.Labels[k] = v
	}
	for k, v := range o.Annotations {
		c.Annotations[k] = v
	}
	return c
}

// Keys はラベルの鍵を名前順に返す(観測用)。
func (o *Object) Keys() []string {
	out := make([]string, 0, len(o.Labels))
	for k := range o.Labels {
		out = append(out, k)
	}
	sort.Strings(out)
	return out
}

// Stage は関門の段。
type Stage int

const (
	// Mutating は書き換える段。先に走る。
	Mutating Stage = iota
	// Validating は検証する段。書き換えの後に走る。
	Validating
)

func (s Stage) String() string {
	if s == Validating {
		return "Validating"
	}
	return "Mutating"
}

// FailurePolicy は webhook が応答しないときの振る舞い。
type FailurePolicy int

const (
	// Fail は応答が無ければ拒否する。検証の抜けを許さない代わり、
	// webhook が落ちるとオブジェクトが1つも作れなくなる。
	Fail FailurePolicy = iota
	// Ignore は応答が無ければ素通しする。作成は止まらない代わり、
	// 検証されていないものが通る。
	Ignore
)

func (f FailurePolicy) String() string {
	if f == Ignore {
		return "Ignore"
	}
	return "Fail"
}

// Webhook は1つの関門。書き換えるか、検証するかのどちらかを担う。
type Webhook struct {
	Name  string
	Stage Stage
	// Kinds は対象の種類。空ならすべてに当たる。
	Kinds []string
	// Available が偽なら応答しない(webhook 自体が落ちている)。
	Available bool
	// Failure は応答しないときの扱い。
	Failure FailurePolicy
	// Mutate は書き換えを行い、何をしたかを返す(空なら何もしていない)。
	Mutate func(o *Object) string
	// Check は検証し、拒否理由を返す(空なら合格)。
	Check func(o *Object) string
}

// applies は obj がこの webhook の対象かを返す。
func (w *Webhook) applies(o *Object) bool {
	if len(w.Kinds) == 0 {
		return true
	}
	for _, k := range w.Kinds {
		if k == o.Kind {
			return true
		}
	}
	return false
}

WebhookStage を持ち、書き換えを担うか検証を担うかが決まっている。1つの関門が両方をやることはない。

分けているのは、順序を保証するためだ。書き換えが全部終わってから、検証が始まる。だから書き換えで足したものも検証の対象になり、書き換えで壊したものは検証で止まる。もし1つの関門が書き換えと検証を両方やって、それが順不同に並ぶと、検証を通った後で書き換えられたものが素通りしてしまう。誰も見ていないものが保存される。

AvailableFailure が入っているのは、関門が外部のサーバだからだ。実物では HTTP で問い合わせに行く。相手が落ちていることも、遅くて返ってこないこともある。そのときどうするかを、あらかじめ決めておく必要がある。

② 順序が結果を変える

通す処理はこうなる:

go

// Result は1回の関門通過の結果。
type Result struct {
	Allowed bool
	Object  *Object  // 通った場合、書き換え後の姿
	Applied []string // 書き換えの内容
	Reason  string
}

// Chain は関門の並び。
type Chain struct {
	hooks []*Webhook

	Admitted int
	Rejected int
	Bypassed int // 応答が無く素通しした回数
	Log      []string
}

// New は関門を持たない並びを作る。
func New() *Chain { return &Chain{} }

// Add は関門を1つ足す。
func (c *Chain) Add(w *Webhook) *Webhook {
	c.hooks = append(c.hooks, w)
	return w
}

// Hooks は関門を段の順(書き換え → 検証)に返す。
func (c *Chain) Hooks() []*Webhook {
	out := append([]*Webhook(nil), c.hooks...)
	sort.SliceStable(out, func(i, j int) bool { return out[i].Stage < out[j].Stage })
	return out
}

// Admit はオブジェクトを関門に通す。
//
// 段の順序が肝になる。まず書き換えの段を全部通し、その結果に対して検証の段を
// 通す。書き換えたものが検証されるので、書き換えで壊れたものは検証で止まる。
// 逆順にすると、検証を通った後で書き換えられたものが素通りしてしまう。
func (c *Chain) Admit(in *Object) Result {
	obj := in.clone()
	var applied []string

	for _, stage := range []Stage{Mutating, Validating} {
		for _, w := range c.hooks {
			if w.Stage != stage || !w.applies(obj) {
				continue
			}

			// 応答が無いときの扱いは、あらかじめ決めた方針で決まる。
			if !w.Available {
				if w.Failure == Fail {
					c.Rejected++
					c.logf(obj.Name + " を拒否(" + w.Name + " が応答せず、方針は Fail)")
					return Result{Object: obj, Applied: applied,
						Reason: w.Name + " が応答しない。方針が Fail なので拒否する"}
				}
				c.Bypassed++
				c.logf(w.Name + " が応答しないが、方針は Ignore なので素通しする")
				continue
			}

			if stage == Mutating && w.Mutate != nil {
				if msg := w.Mutate(obj); msg != "" {
					applied = append(applied, w.Name+": "+msg)
					c.logf(obj.Name + " を書き換え(" + w.Name + ": " + msg + ")")
				}
				continue
			}
			if stage == Validating && w.Check != nil {
				if why := w.Check(obj); why != "" {
					c.Rejected++
					c.logf(obj.Name + " を拒否(" + w.Name + ": " + why + ")")
					return Result{Object: obj, Applied: applied,
						Reason: w.Name + " が拒否: " + why}
				}
			}
		}
	}

	c.Admitted++
	return Result{Allowed: true, Object: obj, Applied: applied, Reason: "すべての関門を通った"}
}

AdmitMutating の段を全部通してから Validating の段に進む。関門を足した順ではなく、段の順で走る。テストで、検証の関門を先に足しても書き換えが先に走ることを固定した。

in.clone() で複製を作っているのも意図がある。関門を通るのは複製で、元のオブジェクトは変わらない。途中で拒否されたとき、元が中途半端に書き換わっていては困る。テストで、元のオブジェクトが変わらないことを固定した。

書き換えが積み上がる点も見ておきたい。後の関門は、前の関門が書き換えた結果を見る。だから「team ラベルを足す」関門の後に「team ラベルから所有者を導く」関門を置ける。テストで、後の書き換えが前の結果を見られることを固定した。順序に依存するので、書き換えの関門を増やすほど、互いの前提が絡む。実物でも mutating どうしの順序は保証されないので、順序に依存しない書き方が要る。

③ 落ちたときに何が起きるか

Available が偽のときの分岐が、この章でいちばん実務に効く。

Fail は拒否する。検証されていないものを通さないので、安全側に見える。だが、合格するはずのものまで拒否される。webhook が落ちている間、対象のオブジェクトは1つも作れない。テストで、ラベルが正しく付いていても Fail なら拒否されることを固定した。

Ignore は素通しする。作成は止まらないので、可用性は保たれる。だが、本来なら拒否されるものが通る。「必ず検証される」という前提が崩れ、しかも通ったことは記録に残っても、後から探すのは難しい。テストで、Ignore では検証されないまま通ることを固定した。

どちらが正しいということはない。検証が守っているものの重さで決まる。特権の要求を弾く関門なら、落ちている間は作れなくてよい。ラベルの付け忘れを直す関門なら、通してしまってよい。

そして Fail には固有の事故がある。webhook 自身も、クラスタの中で動いている Pod だ。関門の対象に Pod が含まれていて、その関門が落ちていて、方針が Fail なら、webhook を動かす Pod の作成も止まる。直したいのに直せない。テストで、対象に自分を含めたまま Fail にすると復旧できず、対象から外せば作り直せることを固定した。実物では、webhook を動かす namespace を対象から除外しておくのが定石になる。

動かす

下のデモは、ラベルを書いていない Pod を関門に通す。書き換えの関門があると足されて検証を通り、無いと検証で止まる。webhook を「落ちている」にすると、Fail と Ignore で結果が分かれる。「webhook自身のPod」を Fail で作ろうとすると、復旧できない状態になる。

デモadmission webhook通過
作ろうとしている Pod通常のPodwebhook自身のPod
team ラベル書いていない書いてある
書き換えの関門あるない
webhook の状態応答する落ちているFailIgnore
Pod web-1(ラベルなし)
書き換えadd-team-label適用team=unknown を付けた
検証require-team-label通過team=unknown
保存される: web-1(team=unknown)
すべての関門を通った

書き換えの関門があると、ラベルを書いていなくても足されて検証を通る。関門を「ない」にすると、同じ Pod が 検証で止まる。webhook が落ちているとき、Fail は合格するはずのものまで拒否し、Ignore は検証されていないものを 通す。どちらも危険で、失うものが違う。そして「webhook自身のPod」を Fail で作ろうとすると、直すための Pod が 作れなくなる。webhook もクラスタの中で動く Pod だという当たり前が、ここで効いてくる。

設計の観点

  • 層で見るものを分ける: RBAC は「誰が」、admission は「何を」。混ぜると、どちらの理由で弾かれたのかが分からなくなる
  • 順序を仕組みで保証する: 書き換えが先という保証があるから、検証は最終形だけを見ればよい。順序を運用に任せると、抜けが出る
  • 落ちたときの振る舞いは設計: FailIgnore は、可用性と安全性のどちらを優先するかの表明。関門ごとに決める
  • 自分を対象から外す: webhook もクラスタの中で動く。自分を止める設定は、復旧不能になる。除外は忘れやすく、事故になったときの被害が大きい
  • 書き換えは順序に依存させない: 実物では mutating どうしの順序は保証されない。どの順で走っても同じ結果になるように書く
  • Operatorとの違い: あちらは保存された後の状態を見て調整する。こちらは保存される前に止める。事後に直せないものだけを、ここで止める

対照と実例

書き換え(mutating)検証(validating)
できること中身を変える通すか止めるか
走る順
使いどころ既定値を足す、注入する規約を強制する
落ちたとき既定値が付かない検証されない
順序への依存互いに影響する影響しない

裏どり:

  • Admission Controllers: mutating が先、validating が後という順序が仕様として決まっている
  • failurePolicy: 既定は Fail。webhook が応答しないときに拒否する
  • 自分自身の除外: webhook を動かす namespace を namespaceSelector で対象から外すのが定石。忘れるとクラスタが復旧できなくなる
  • タイムアウト: 既定10秒、最大30秒。超えると失敗として failurePolicy に従う

簡略化したこと

  • 通信なし: 実物は HTTP で外部のサーバに問い合わせる。ここでは関数を呼ぶだけ
  • タイムアウトなし: 応答待ちの上限は扱わない
  • JSON Patch なし: 実物の書き換えはパッチとして返される
  • 順序の保証なし: 実物も mutating どうしの順序は保証されない
  • 組み込みの関門なし: 実物には webhook より前に多数の内蔵 admission controller がある

参考資料