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 ラベルが無ければ拒否 │ → 付いているので通る
└──────────┬─────────────────┘
▼
保存される
逆順にすると: 検証を通った後で書き換えられ、その結果は誰も見ない順に見ていく。
- 書き換えが先、検証が後: 書き換えた結果も検証を通る
- 応答が無いときを決めておく: 拒否するか、素通しするか。どちらも危険
- 自分自身を止められる: webhook もクラスタの中の Pod
① 2つの段と、その順序
まず関門の形を作る:
// 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
}Webhook は Stage を持ち、書き換えを担うか検証を担うかが決まっている。1つの関門が両方をやることはない。
分けているのは、順序を保証するためだ。書き換えが全部終わってから、検証が始まる。だから書き換えで足したものも検証の対象になり、書き換えで壊したものは検証で止まる。もし1つの関門が書き換えと検証を両方やって、それが順不同に並ぶと、検証を通った後で書き換えられたものが素通りしてしまう。誰も見ていないものが保存される。
Available と Failure が入っているのは、関門が外部のサーバだからだ。実物では HTTP で問い合わせに行く。相手が落ちていることも、遅くて返ってこないこともある。そのときどうするかを、あらかじめ決めておく必要がある。
② 順序が結果を変える
通す処理はこうなる:
// 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: "すべての関門を通った"}
}Admit は Mutating の段を全部通してから 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 で作ろうとすると、復旧できない状態になる。
書き換えの関門があると、ラベルを書いていなくても足されて検証を通る。関門を「ない」にすると、同じ Pod が 検証で止まる。webhook が落ちているとき、Fail は合格するはずのものまで拒否し、Ignore は検証されていないものを 通す。どちらも危険で、失うものが違う。そして「webhook自身のPod」を Fail で作ろうとすると、直すための Pod が 作れなくなる。webhook もクラスタの中で動く Pod だという当たり前が、ここで効いてくる。
設計の観点
- 層で見るものを分ける: RBAC は「誰が」、admission は「何を」。混ぜると、どちらの理由で弾かれたのかが分からなくなる
- 順序を仕組みで保証する: 書き換えが先という保証があるから、検証は最終形だけを見ればよい。順序を運用に任せると、抜けが出る
- 落ちたときの振る舞いは設計:
FailとIgnoreは、可用性と安全性のどちらを優先するかの表明。関門ごとに決める - 自分を対象から外す: 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 がある
参考資料
- Admission Controllers Reference — 段の順序と、内蔵の関門
- Dynamic Admission Control — failurePolicy と、自分自身を除外する必要性
- 実装: orchestration/admission