Admission Chain
ไอเดียหลักในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียหลักในหนึ่งประโยค”Admission คือสอง phase ที่แยกกันตามลำดับ plugin mutating ทั้งหมดรันจนจบก่อน แล้ว schema/strategy validation ถึงรัน แล้ว plugin validating ทั้งหมดรันจนจบ — ไม่เคยสลับกัน และภายในแต่ละ phase ลำดับที่ compile ไว้ตายตัว (ไม่ใช่ลำดับ registration ไม่ใช่ลำดับใน webhook config) เป็นตัวตัดสินว่า plugin ไหนรันก่อนใคร
ลำดับจริงต่อ request จาก create.go
หัวข้อที่มีชื่อว่า “ลำดับจริงต่อ request จาก create.go”staging/src/k8s.io/apiserver/pkg/endpoints/handlers/create.go คือ HTTP handler จริงของ request CREATE (path โค้ดเดียวกับที่ kubectl apply — client-side หรือ server-side — สุดท้ายก็วิ่งผ่าน) ลำดับจริงของมัน ตามที่ source รันจริง
// staging/src/k8s.io/apiserver/pkg/endpoints/handlers/create.go, v1.37.0obj, gvk, err := decoder.Decode(body, &defaultGVK, original) // 1. decode the request body
// ... later, inside FinishRequest:if mutatingAdmission, ok := admit.(admission.MutationInterface); ok && mutatingAdmission.Handles(admission.Create) { if err := mutatingAdmission.Admit(ctx, admissionAttributes, scope); err != nil { // 2. ALL mutating admission return nil, err }}result, err := requestFunc() // 3. calls Store.Create -> BeforeCreate (schema/strategy validation) // -> createValidatingAdmission callback (4. ALL validating admission) // -> 5. write to etcdmutatingAdmission ในขั้น 2 กับ callback validating ในขั้น 4 แต่ละตัวคือ chainAdmissionHandler — []Interface ธรรมดาที่รันทุก handler ใน slice ตามลำดับ slice หยุดเมื่อเจอ error แรก
// staging/src/k8s.io/apiserver/pkg/admission/chain.go, v1.37.0type chainAdmissionHandler []Interface
func (admissionHandler chainAdmissionHandler) Admit(ctx context.Context, a Attributes, o ObjectInterfaces) error { for _, handler := range admissionHandler { if !handler.Handles(a.GetOperation()) { continue } if mutator, ok := handler.(MutationInterface); ok { if err := mutator.Admit(ctx, a, o); err != nil { return err // stops the whole chain on first error } } } return nil}// Validate() is the identical loop, calling ValidationInterface.Validate insteadchain type เดียวกันนี้รันทั้ง slice ของ mutating และ slice ของ validating — การแยก phase มาจาก create.go เรียก .Admit() ครั้งเดียว (ขั้น 2) กับ callback validating (ขั้น 4) ที่จุดต่างกันคนละเวลา ไม่ได้มาจากความต่างของกลไก chain เองเลย
flowchart TD decode["1. Decode request body"] --> mutate["2. mutating admission ทั้งหมด (chainAdmissionHandler.Admit)\nin-tree plugin -> MutatingAdmissionPolicy -> MutatingAdmissionWebhook"] mutate --> schema["3. Schema + strategy validation (BeforeCreate)"] schema --> validate["4. validating admission ทั้งหมด (chainAdmissionHandler.Validate)\nin-tree plugin -> ValidatingAdmissionPolicy -> ValidatingAdmissionWebhook -> ResourceQuota"] validate --> etcd["5. เขียนลง etcd"]
อะไรตัดสินลำดับ ภายใน แต่ละ phase
หัวข้อที่มีชื่อว่า “อะไรตัดสินลำดับ ภายใน แต่ละ phase”AllOrderedPlugins ของ pkg/kubeapiserver/options/plugins.go คือ slice จริงที่ compile ไว้ที่ API server สร้างสอง chain จากมัน — ลำดับของ array นี้คือลำดับจริง ไม่ว่า webhook จะลงทะเบียนลำดับไหน หรือ --enable-admission-plugins จะเรียงยังไง
// pkg/kubeapiserver/options/plugins.go, v1.37.0 -- tail of AllOrderedPlugins// ... ~30 in-tree plugins earlier (NamespaceLifecycle, LimitRanger, ServiceAccount,// NodeRestriction, PodSecurity, Priority, ResourceQuota is NOT here -- see below, ...)
// new admission plugins should generally be inserted above here// webhook, resourcequota, and deny plugins must go at the end
mutatingadmissionpolicy.PluginName, // MutatingAdmissionPolicymutatingwebhook.PluginName, // MutatingAdmissionWebhookvalidatingadmissionpolicy.PluginName, // ValidatingAdmissionPolicyvalidatingwebhook.PluginName, // ValidatingAdmissionWebhookresourcequota.PluginName, // ResourceQuotadeny.PluginName, // AlwaysDenycomment ตรงจุดนั้นเป๊ะใน source จริง (// webhook, resourcequota, and deny plugins must go at the end) คือ developer ของ API server เองบันทึกไว้ว่านี่เป็นเรื่องตั้งใจ plugin in-tree ทุกตัว (NamespaceLifecycle, PodSecurity, LimitRanger และที่เหลือ) รันก่อน MutatingAdmissionPolicy ซึ่งรันก่อน MutatingAdmissionWebhook — และฝั่ง validating ValidatingAdmissionPolicy รันก่อน ValidatingAdmissionWebhook ซึ่งรันก่อน ResourceQuota chainAdmissionHandler แบ่ง array ที่เรียงมาแล้วชุดนี้ออกเป็น plugin ที่ implement MutationInterface (chain mutating) กับที่ implement ValidationInterface (chain validating รันทีหลังแยกต่างหาก) — บาง plugin เช่น PodSecurity implement ทั้งสองแบบและอยู่ในทั้งสอง chain ที่ตำแหน่งที่ AllOrderedPlugins ให้มัน
plugin ที่ active จริงบน kind cluster ของคอร์สนี้ อ่านตรง ๆ จาก spec ของ Pod kube-apiserver จริง (ที่เหลือคือชุด default ที่ compile ไว้ kind/kubeadm เพิ่มแค่ flag เดียวทับ)
$ kubectl -n kube-system get pod -l component=kube-apiserver -o jsonpath='{.items[0].spec.containers[0].command}'["kube-apiserver", ..., "--enable-admission-plugins=NodeRestriction", ...]NodeRestriction ปิดโดย default บน upstream (DefaultOffAdmissionPlugins() ในไฟล์เดียวกันคำนวณชุด off เป็น AllOrderedPlugins ลบชุด default-on ตายตัวที่ไม่รวม NodeRestriction) — kind เหมือน cluster ที่ใช้ kubeadm ทั่วไป เปิดมันกลับเอง ด้วยเหตุผลความปลอดภัยจริงที่ docs ระบุ ถ้าไม่มีมัน credential ของ kubelet ที่ถูกแฮ็กสามารถแก้ object Node และ Pod บน node ที่มันไม่ได้เป็นเจ้าของได้
ValidatingAdmissionPolicy: พิสูจน์ว่ารันใน process เดียว ไม่มี webhook hop
หัวข้อที่มีชื่อว่า “ValidatingAdmissionPolicy: พิสูจน์ว่ารันใน process เดียว ไม่มี webhook hop”Pod and Cluster Security พิสูจน์ ValidatingAdmissionPolicy ที่ reject request ไปแล้วด้วย error จริงจาก API server ที่รันอยู่จริง — proof นั้นยืนเป็นหลักฐานของ ValidatingAdmissionPolicy ในบทนี้แทนที่จะ derive ซ้ำ
$ kubectl apply -f bad-image-pod.yaml # image: nginx:alpineThe pods "bad-image" is invalid: : ValidatingAdmissionPolicy 'registry-allowlist' with binding 'registry-allowlist-binding' denied request: container images must come from registry.example.comการ evaluate เกิดขึ้นข้างใน Dispatch() ของ staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/dispatcher.go (บรรทัด 72 ใน source ที่คอร์สนี้ pin ไว้) — CEL รันตรง ๆ กับ object ที่ decode แล้วใน process ของ API server เอง ภายใต้ cost budget จริง (celconfig.RuntimeCELCostBudget อ้างที่บรรทัด 221) นี่คือเหตุผลเป๊ะที่ docs อธิบายว่า “ไม่มี webhook ไม่มี network hop เพิ่ม” ไม่มี process ที่สองเลยใน path นี้ มีแค่ entry อีกตัวที่ execute ข้างใน loop chainAdmissionHandler.Validate() เดียวกับที่แสดงข้างบน
MutatingAdmissionPolicy: model เดียวกัน แค่ mutating phase
หัวข้อที่มีชื่อว่า “MutatingAdmissionPolicy: model เดียวกัน แค่ mutating phase”พิสูจน์แล้วเช่นกัน ในบทเดียวกัน MutatingAdmissionPolicy (stable ตั้งแต่ v1.36 เปิดโดย default) อยู่ใน phase mutating ก่อน MutatingAdmissionWebhook ตาม AllOrderedPlugins ข้างบน
$ kubectl apply -f map-demo.yamlmutatingadmissionpolicy.admissionregistration.k8s.io/add-default-label created$ kubectl apply -f map-target-pod.yamlpod/map-target created$ kubectl get pod map-target -n sec-demo -o jsonpath='{.metadata.labels}'{"managed-by":"platform-team"}label ที่ manifest ไม่เคยตั้งเลย ถูก admission ฉีดเข้าไปล้วน ๆ — พิสูจน์ว่า MutatingAdmissionPolicy แก้ object จริงระหว่าง phase mutating งานเดียวกับที่ mutating webhook ทำ ก่อน schema validation หรือ validating plugin ตัวไหนจะเห็น object ด้วยซ้ำ
--dry-run=server รัน chain จริงทั้งเส้น
หัวข้อที่มีชื่อว่า “--dry-run=server รัน chain จริงทั้งเส้น”--dry-run=server (ต่างจาก --dry-run=client ที่ไม่ติดต่อ API server เลย) ส่ง request จริงผ่าน pipeline เป๊ะนี้ — decode, mutating admission, schema validation, validating admission — แล้วข้ามแค่การเขียน etcd สุดท้าย
$ kubectl apply -f good-image-pod.yaml --dry-run=serverpod/good-image created (server dry run)คำต่อท้าย (server dry run) บน success message ที่ดูธรรมดานี้คือตัวบอก admission plugin ทุกตัวที่จะรันสำหรับ create จริงได้รันไปแล้ว (และในกรณีนี้ ยอมรับ object แล้วจริง ๆ) — นี่คือเหตุผลเป๊ะที่ --dry-run=server จับ ValidatingAdmissionPolicy ที่ reject, webhook ที่ reject หรือ schema error ได้ ซึ่ง --dry-run=client (เช็ค OpenAPI schema ในเครื่องล้วน ๆ ไม่มี admission เลย) จับไม่ได้
Kyverno กับ Gatekeeper: webhook โดยการออกแบบ
หัวข้อที่มีชื่อว่า “Kyverno กับ Gatekeeper: webhook โดยการออกแบบ”ทั้งสอง engine รันเป็น admission webhook จริง — เรียก HTTP ออกจาก process ของ API server ลงทะเบียนเป็น object MutatingWebhookConfiguration/ValidatingWebhookConfiguration นี่คือเหตุผลที่มันอยู่ตำแหน่ง MutatingAdmissionWebhook/ValidatingAdmissionWebhook ใน AllOrderedPlugins ข้างบน หลัง in-tree plugin ใด ๆ และหลัง MutatingAdmissionPolicy/ValidatingAdmissionPolicy เสมอ
- Kyverno — policy YAML แบบ Kubernetes-native (รองรับ CEL ด้วย) บวก mutation, resource generation, background scanning และ image verification ที่ policy built-in ไม่ครอบคลุม
- OPA Gatekeeper —
ConstraintTemplateแบบ CRD เขียนด้วย Rego และ (ตาม docs ของมันเอง) integrate กับValidatingAdmissionPolicyตรง ๆ ได้แทนที่จะแทนที่มัน
ต้นทุนที่เป็นรูปธรรมของ “webhook ไม่ใช่ CEL ใน process” คือ network hop ที่ docs ของ ValidatingAdmissionPolicy เปรียบเทียบตัวเองไว้ตรง ๆ การเรียก webhook คือ HTTP request จริงนอก process มี timeout ของตัวเองและพฤติกรรม failurePolicy: Fail/Ignore ของตัวเองตอนการเรียกนั้นล้มเหลว ไม่ใช่แค่ตอน policy เองล้มเหลว
Audit logging: การตัดสินใจ admission ทุกครั้งคือ record
หัวข้อที่มีชื่อว่า “Audit logging: การตัดสินใจ admission ทุกครั้งคือ record”audit log ของ API server (ตั้งค่าผ่าน --audit-policy-file กับ --audit-log-path) บันทึกทุก stage ที่ request ผ่าน รวมถึง admission plugin ไหน (ถ้ามี) ที่แก้หรือ reject มัน publishValidationFailureAnnotations (ใกล้ ๆ vap-validator.go ใน dispatcher.go บรรทัด 327) คือฟังก์ชันจริงที่แนบรายละเอียดการ reject ของ ValidatingAdmissionPolicy เป็น audit annotation บน request ดังนั้น request ที่ถูก deny ไม่ใช่แค่ error ที่คืนให้ผู้เรียก มันคือ record ที่คงอยู่ ค้นหาได้ เอ่ยชื่อ policy และ binding ที่ deny มันตรง ๆ — ชื่อสองชื่อเดียวกัน (registry-allowlist, registry-allowlist-binding) ที่เห็นใน error text ธรรมดาข้างบน
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”request หนึ่งตัวผ่าน admission แค่สองครั้งเป๊ะ ไม่เคยครั้งเดียวและไม่เคยสลับกัน ครั้งแรกเป็น mutating pass (plugin ที่ mutate ได้ทุกตัวจาก AllOrderedPlugins ตามลำดับตายตัวนั้น แต่ละตัวแก้ object ที่ตัวถัดไปจะเห็นได้) แล้วหลังจาก object ผ่าน schema-และ-strategy validation แล้ว ครั้งที่สองเป็น validating pass (plugin ที่ validate ได้ทุกตัว ลำดับตายตัวเดียวกัน ไม่มีตัวไหนแก้ object ได้อีกแล้ว แค่ accept หรือ reject) ValidatingAdmissionPolicy/MutatingAdmissionPolicy ไม่ใช่หมวดที่สามที่แปะเพิ่มข้าง ๆ webhook — มันคือ entry ธรรมดาใน array ที่เรียงลำดับเดียวกันเป๊ะ evaluate ใน process เดียวกัน Kyverno/Gatekeeper ครองตำแหน่ง webhook ใน array เดียวกันนั้น นอก process --dry-run=server ไม่ใช่ validation mode แยกที่เบากว่า มันคือ pipeline ทั้งเส้นนี้ ของจริง แค่ไม่เขียนตอนสุดท้าย
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
ValidatingAdmissionPolicy/MutatingAdmissionPolicy (CEL ใน process) | ไม่มี network hop ไม่ต้อง deploy เพิ่ม CEL ที่มี cost budget จริง (พิสูจน์ข้างบน) | CEL แสดงออกได้น้อยกว่า policy language ทั่วไป ไม่มีการ lookup ข้อมูลภายนอกกลาง evaluation |
| Kyverno/Gatekeeper (แบบ webhook) | authoring policy ที่รวยกว่า mutation + generation + background scan (Kyverno), constraint แบบ CRD-native (Gatekeeper) | network hop จริงต่อการเรียก admission มี failurePolicy/timeout ของตัวเองต้องคิด หลัง in-tree หรือ Policy-based check เสมอ |
--dry-run=server ก่อน apply ทุกครั้ง | จับ admission rejection กับ schema error ก่อนเกิดจริง (พิสูจน์ข้างบน) | ยังเป็น round trip จริงผ่าน pipeline ทั้งเส้น ช้ากว่า --dry-run=client และยังต้องมี cluster access |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “webhook และ admission plugin ทั้งหมดรันในกลุ่มเดียวไม่แยกกัน ตามลำดับ registration” พิสูจน์ข้างบนจาก
create.goและAllOrderedPluginsสอง phase ที่แยกกันจริง (mutating แล้ว validating) แต่ละอันในลำดับตายตัวที่ compile ไว้ ไม่เกี่ยวกับตอน webhook ถูกลงทะเบียนเลย - “
ValidatingAdmissionPolicyคือ webhook ข้างในเหมือนกัน แค่จัดการเอง” พิสูจน์ข้างบนจากdispatcher.goCEL evaluate ตรง ๆ ข้างใน process ของ API server กับ object ที่ decode แล้ว ไม่มี HTTP call ไม่มี server แยก ไม่มี network hop เลย - “
--dry-run=serverแค่เช็ค OpenAPI schema เหมือน--dry-run=clientแค่กับเวอร์ชันจริง” พิสูจน์ข้างบน มันรัน admission chain จริงเต็มเส้น — mutating และ validating — และจะเจอ rejection ของValidatingAdmissionPolicy/webhook เดียวกับที่ apply จริงจะเจอ - “
MutatingAdmissionPolicyยัง alpha/experimental อยู่” ตาม docs ที่อ้างข้างบนและพิสูจน์ในบทที่ลิงก์ไว้ stable ตั้งแต่ v1.36 เปิดโดย default ไม่มี feature gate ให้พลิก
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เวลา review admission configuration ที่ generate มา script ที่สมมติลำดับ admission บางแบบ หรือข้ออ้างเกี่ยวกับ --dry-run เช็คสิ่งเหล่านี้
- สมมติว่า mutating webhook เห็นหรือตอบสนองต่อ rejection ของ validating webhook ได้ (หรือกลับกัน) — พิสูจน์ข้างบน สอง phase เรียงต่อกันและแยกกัน ไม่มีอะไรใน mutating phase สังเกตผลของ validating phase ได้เลย เพราะมันยังไม่รัน
- ใช้
--dry-run=clientตอนเป้าหมายจริงคือ “request นี้จะถูกValidatingAdmissionPolicy/webhook ของเรา reject ไหม” — พิสูจน์ข้างบน มีแค่--dry-run=serverเท่านั้นที่รัน admission จริง - YAML ของ
ValidatingAdmissionPolicy/MutatingAdmissionPolicyที่ไม่มีmatchConstraints.resourceRulesหรือกว้างเกินไป สมมติว่า “ถูกจำกัดด้วย binding อย่างเดียว” —matchConstraintsของ policy เองกับmatchResourcesของ binding เป็น filter จริงที่บังคับใช้อิสระต่อกันทั้งคู่ (ตัวอย่างmatchConditionsในบทที่เสร็จแล้วแสดง filter ระดับ CEL ตัวที่สองซ้อนทับทั้งสองอันอีกที) - โค้ดหรือ runbook ที่อ้างว่าการตัดสินใจของ Kyverno/Gatekeeper เกิด “ก่อน” plugin admission in-tree — พิสูจน์ข้างบนจาก
AllOrderedPluginsplugin webhook ถูกเรียงไว้ท้ายสุดใน phase ของมันเสมอ ด้วย comment ใน source ของ API server เอง MutatingAdmissionPolicyที่ generate มาโดยไม่มีreinvocationPolicy— บทที่ลิงก์ไว้พิสูจน์แล้วว่า API server reject มันตรง ๆ (spec.reinvocationPolicy: Required value) ไม่ได้ default ให้เงียบ ๆ
🐛 หา bug ในโค้ดนี้
# A comment a teammate added above a webhook config# "This webhook runs first, before any of our ValidatingAdmissionPolicy# checks, so it's the best place to reject requests early."apiVersion: admissionregistration.k8s.io/v1kind: ValidatingWebhookConfigurationmetadata:name: reject-earlywebhooks:- name: reject-early.example.com admissionReviewVersions: ["v1"] sideEffects: None rules: - apiGroups: [""] apiVersions: ["v1"] operations: ["CREATE"] resources: ["pods"]เฉลย
comment นี้กลับด้าน พิสูจน์ข้างบนตรง ๆ จากลำดับ AllOrderedPlugins จริงที่ compile ไว้ ภายใน phase validating ValidatingAdmissionPolicy เรียงไว้ก่อน ValidatingAdmissionWebhook — ตรงข้ามกับ “รันก่อน” เป๊ะ ValidatingAdmissionPolicy ที่ bind ไว้ใน namespace นี้แล้วมีสิทธิ์ reject request ก่อนที่ webhook ตัวนี้จะถูกเรียกด้วยซ้ำ ไม่ใช่หลัง สิ่งนี้สำคัญในทางปฏิบัติ webhook ที่เขียนเพื่อ “reject เนิ่น ๆ” เพื่อประหยัดการ evaluate policy กำลัง optimize สิ่งที่ลำดับจริงไม่ได้ให้มันเลย และ request ที่ถูก ValidatingAdmissionPolicy reject ก่อนหมายความว่า logic ของ webhook ตัวนี้เอง (และ side effect ใด ๆ ของมัน แม้จะมี sideEffects: None ตรงนี้) ไม่รันเลยสำหรับ request นั้น