Mutating & Validating Webhooks — Cơ Chế Gọi & Dry-Run
Webhook là gì và vì sao nó tồn tại
Built-in admission plugins (file 1) là code biên dịch sẵn trong API server — bạn không thêm logic mới vào chúng. Khi cần một policy hay mutation tùy chỉnh mà không có plugin nào đáp ứng (inject sidecar, gán label theo logic riêng, gọi một hệ thống bên ngoài để duyệt), Kubernetes cung cấp cơ chế dynamic admission control: API server gọi ra một HTTP server bên ngoài mà bạn vận hành — gọi là admission webhook (Dynamic Admission Control).
Đây là điểm mạnh và điểm yếu cùng lúc. Mạnh: bạn viết được logic tùy ý bằng bất kỳ ngôn ngữ nào, gọi được hệ thống ngoài. Yếu: webhook nằm ngoài process API server, nghĩa là nó có cert riêng để hết hạn (file 4), service riêng để chết (file 3), và mỗi lần gọi là một round-trip HTTP đồng bộ trên đường ghi nóng (file 3). File này tập trung vào hợp đồng giữa API server và webhook: cấu hình, định dạng request/response, và các cờ điều khiển hành vi. File 3 và 4 xử lý phần failure mode và cert.
Có hai loại webhook, ánh xạ đúng hai pha admission (file 1):
- MutatingAdmissionWebhook — chạy ở pha mutating, sửa được object qua JSON Patch.
- ValidatingAdmissionWebhook — chạy ở pha validating, chỉ chấp nhận/từ chối, không sửa.
Cấu hình: WebhookConfiguration
Bạn đăng ký webhook bằng một object MutatingWebhookConfiguration hoặc ValidatingWebhookConfiguration (API group admissionregistration.k8s.io/v1). Đây là object cluster-scoped — nó ảnh hưởng toàn cluster trừ khi bạn thu hẹp bằng selector.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: pod-policy.example.com
webhooks:
- name: pod-policy.example.com # phải là FQDN
rules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
scope: "Namespaced"
clientConfig:
service:
namespace: webhook-system
name: webhook-service
path: /validate
caBundle: <PEM-base64> # CA để API server verify cert webhook (file 4)
admissionReviewVersions: ["v1"]
sideEffects: None
timeoutSeconds: 5
failurePolicy: Fail
matchPolicy: Equivalent
namespaceSelector:
matchExpressions:
- key: kubernetes.io/metadata.name
operator: NotIn
values: ["kube-system"] # loại trừ namespace hệ thống (file 3)Các trường cốt lõi và ý nghĩa (webhook configuration):
| Trường | Ý nghĩa |
|---|---|
rules | Bộ ba (apiGroups, apiVersions, resources) + operations + scope quyết định request nào kích hoạt webhook |
clientConfig.service | Webhook nằm trong cluster (Service); hoặc clientConfig.url cho endpoint ngoài |
caBundle | CA (PEM base64) để API server verify TLS cert của webhook (file 4) |
admissionReviewVersions | Phiên bản AdmissionReview API server được phép gửi (hiện dùng v1) |
failurePolicy | Fail/Ignore khi webhook lỗi/timeout (file 3) |
timeoutSeconds | Thời gian chờ tối đa, mặc định 10s, tối đa 30s (file 3) |
matchPolicy | Exact hay Equivalent — cách khớp khi có nhiều API version |
sideEffects | Khai báo tác dụng phụ, ảnh hưởng hành vi dry-run |
reinvocationPolicy | (chỉ mutating) Never/IfNeeded — có gọi lại sau mutation của webhook khác không |
namespaceSelector / objectSelector | Thu hẹp phạm vi theo label namespace/object |
Vòng AdmissionReview: request và response
Khi một request khớp rules, API server gửi một POST Content-Type: application/json chứa một object AdmissionReview tới clientConfig. Webhook đọc request, xử lý, và trả về một AdmissionReview chứa response.
Request mà webhook nhận
{
"apiVersion": "admission.k8s.io/v1",
"kind": "AdmissionReview",
"request": {
"uid": "705ab4f5-...",
"kind": {"group":"","version":"v1","kind":"Pod"},
"resource": {"group":"","version":"v1","resource":"pods"},
"name": "myapp",
"namespace": "default",
"operation": "CREATE",
"userInfo": {"username":"alice","groups":["system:authenticated"]},
"object": { "...": "object sau khi đã qua mutation trước đó" },
"oldObject": null,
"dryRun": false,
"options": {}
}
}Các field quan trọng:
uid— webhook bắt buộc echo lại đúnguidnày trong response. Sai uid → API server từ chối response.object— trạng thái mong muốn. Với UPDATE,oldObjectlà trạng thái cũ — cho phép webhook so sánh và chặn các thay đổi cụ thể (ví dụ "không cho đổispec.serviceAccountName").userInfo— danh tính người gọi (đã qua authentication). Cho phép policy theo người gọi: "chỉsystem:serviceaccount:ci:deployermới được đặt labelrelease".dryRun—truenếu đây là request dry-run server-side. Webhook phải tôn trọng cờ này (xem phần dry-run bên dưới).
Response mà webhook trả về
Response tối thiểu cho validating:
{
"apiVersion": "admission.k8s.io/v1",
"kind": "AdmissionReview",
"response": {
"uid": "705ab4f5-...",
"allowed": false,
"status": {
"code": 403,
"message": "image phải đến từ registry asia-docker.pkg.dev"
}
}
}status.message là chuỗi mà người dùng thấy khi kubectl apply bị từ chối — nên viết rõ ràng, kèm cách sửa. Webhook cũng có thể trả warnings: [...] (cảnh báo không chặn) và auditAnnotations: {...} (ghi vào audit log để truy vết).
Mutation bằng JSON Patch
Mutating webhook trả thêm patchType: JSONPatch và patch là chuỗi base64 của một JSON Patch (RFC 6902):
{
"response": {
"uid": "705ab4f5-...",
"allowed": true,
"patchType": "JSONPatch",
"patch": "W3sib3AiOiJhZGQiLCJwYXRoIjoiL3NwZWMvc2VjdXJpdHlDb250ZXh0L3J1bkFzTm9uUm9vdCIsInZhbHVlIjp0cnVlfV0="
}
}Chuỗi base64 trên giải mã thành:
[{"op":"add","path":"/spec/securityContext/runAsNonRoot","value":true}]API server áp patch này lên object trước khi chuyển sang pha validating. Lưu ý: webhook không trả về object đã sửa — nó trả về patch mô tả sự thay đổi. Đây là khác biệt tinh tế nhưng quan trọng cho idempotency (bên dưới).
reinvocationPolicy: vì sao mutating webhook bị gọi lại
Đây là một trong những cơ chế gây bug âm thầm nhất khi viết mutating webhook. Mặc định reinvocationPolicy: Never — mỗi mutating webhook được gọi đúng một lần. Nhưng có một vấn đề thứ tự: nếu webhook A chạy trước webhook B, và B sửa object theo cách mà A lẽ ra phải phản ứng, thì A đã bỏ lỡ.
Để xử lý, Kubernetes cung cấp reinvocationPolicy: IfNeeded: nếu bất kỳ mutating webhook nào sửa object sau lần gọi webhook này, webhook sẽ được gọi lại (reinvocation). Hệ quả bắt buộc phải thiết kế cho:
Mọi mutating webhook với
reinvocationPolicy: IfNeededphải idempotent — chạy nhiều lần phải cho cùng kết quả như chạy một lần.
Anti-pattern kinh điển: webhook "luôn thêm một sidecar container" bằng JSON Patch add. Nếu bị gọi lại, nó thêm hai sidecar. Cách đúng: webhook phải kiểm tra trạng thái hiện tại (object nhận được đã có sidecar chưa) rồi mới quyết định patch — nếu đã có thì trả patch rỗng. Vì object trong mỗi lần gọi lại phản ánh trạng thái đã-mutate-từng-phần, kiểm tra này khả thi.
Ngay cả với Never, idempotency vẫn nên là nguyên tắc: API server có thể retry, và UPDATE trên object đã mutate trước đó cũng cần webhook không nhân đôi thay đổi.
matchPolicy, objectSelector, namespaceSelector — thu hẹp phạm vi
Webhook là cluster-scoped và nằm trên đường ghi nóng — thu hẹp phạm vi là vấn đề cả hiệu năng lẫn an toàn:
matchPolicy: Equivalent(khuyến nghị) vsExact: cùng một loại tài nguyên có thể truy cập qua nhiều API version (apps/v1vs một version cũ).Equivalentkhớp request bất kể version client dùng, rồi chuyển đổi sang version webhook khai báo.Exactchỉ khớp version khai báo chính xác — dễ để lọt request qua version khác. Gần như luôn dùngEquivalent.namespaceSelector— chỉ áp webhook cho namespace có (hoặc không có) label nhất định. Đây là cơ chế chính để loại trừkube-systemvà các managed namespace của GKE (file 3), và để bật webhook chỉ cho namespace opt-in.objectSelector— chỉ áp cho object có label nhất định. Dùng để webhook bỏ qua chính các object hạ tầng của nó (tránh deadlock tự-validate, file 3).
Quy tắc vận hành: rule càng rộng, blast radius càng lớn. Một webhook bắt resources: ["*"] trên mọi namespace là quả bom — mỗi thao tác ghi trên cluster phải chờ nó.
sideEffects và dry-run
sideEffects khai báo webhook có gây tác dụng phụ ngoài việc sửa object trong request hay không (ví dụ ghi vào một database ngoài, gọi API tính phí) (side effects):
None— webhook không có tác dụng phụ. An toàn cho dry-run.NoneOnDryRun— webhook có tác dụng phụ, nhưng khirequest.dryRun == truenó bỏ qua các tác dụng phụ đó. Bắt buộc webhook phải đọc và tôn trọng cờdryRun.Unknown/Some(cũ) — webhook có tác dụng phụ không kiểm soát được; API server sẽ từ chối mọi request dry-run khớp webhook này.
Vì sao dry-run là công cụ rollout quan trọng
kubectl apply --dry-run=server chạy request qua toàn bộ admission pipeline — kể cả webhook — nhưng không ghi vào etcd. Đây là cách an toàn nhất để trả lời "nếu tôi apply cái này, có policy nào chặn không?" mà không thực sự thay đổi cluster. Nó là nền của:
- CI/CD gate: chạy
--dry-run=servertrong pipeline để bắt vi phạm policy trước khi merge. - Rollout policy mới: trước khi bật một validating webhook ở chế độ chặn, chạy dry-run hàng loạt object hiện có để ước lượng có bao nhiêu sẽ bị từ chối.
Nhưng dry-run chỉ đáng tin nếu mọi webhook khớp đều khai báo sideEffects: None hoặc NoneOnDryRun. Một webhook khai Unknown sẽ làm dry-run thất bại. Vì vậy: webhook đúng chuẩn phải luôn xử lý cờ dryRun và khai sideEffects chính xác.
Audit vs enforce: rollout có kỷ luật
Một webhook (hay policy bất kỳ) không nên bật thẳng ở chế độ chặn trên production. Mô hình rollout chuẩn đi qua ba mức tăng dần độ nghiêm khắc — đây là nguyên tắc xuyên suốt cả chương (lặp lại ở PSA file 5 và Gatekeeper file 6):
- Observe (audit) — webhook chỉ ghi log/
auditAnnotationsmỗi vi phạm, luônallowed: true. Đo lường: bao nhiêu object thực tế vi phạm? Có false positive không? - Warn — webhook trả
warnings(người dùng thấy cảnh báo khi apply) nhưng vẫn cho qua. Tạo áp lực sửa mà không chặn. - Enforce — webhook trả
allowed: falsecho vi phạm. Chỉ bật khi audit đã chứng minh không chặn nhầm.
Với validating webhook tự viết, ba mức này do logic của bạn quyết định (kết hợp allowed, warnings, auditAnnotations). Với ValidatingAdmissionPolicy và Gatekeeper, có field chuyên dụng (validationActions, enforcementAction) — đó là một lý do để ưu tiên chúng (file 6, 8).
Production architecture patterns
Sidecar injection (mutating) — mô hình Istio/Linkerd
Service mesh dùng mutating webhook để inject sidecar proxy vào Pod. Pattern chuẩn: namespaceSelector chỉ inject vào namespace có label istio-injection=enabled (opt-in), objectSelector loại trừ Pod có annotation sidecar.istio.io/inject: "false", và logic webhook idempotent (kiểm tra sidecar đã tồn tại chưa). Đây là ví dụ điển hình cho reinvocationPolicy và idempotency.
Policy gate ngoài (validating) — duyệt qua hệ thống ngoài
Khi quyết định cho qua phụ thuộc hệ thống ngoài (ví dụ kiểm tra image với một scanner CVE, hay tra một CMDB), validating webhook là lựa chọn duy nhất (CEL/Gatekeeper không gọi ngoài được). Nhưng đây cũng là pattern rủi ro nhất: hệ thống ngoài chậm/chết → webhook timeout → ảnh hưởng cả cluster. Bắt buộc: timeout ngắn, cache kết quả, và cân nhắc failurePolicy: Ignore nếu hệ thống ngoài không phải bảo-mật-tới-hạn (file 3).
Internal model: API server gọi webhook như thế nào
Để debug và thiết kế webhook đúng, cần hình dung chính xác cơ chế gọi bên trong API server:
- Khớp rule và selector. Với mỗi request ghi, API server duyệt mọi WebhookConfiguration, lọc ra các webhook có
ruleskhớp (GVR + operation + scope) vànamespaceSelector/objectSelectorthỏa. Đây là bước O(số webhook) trên mọi request — một lý do nữa để không có quá nhiều webhook rule rộng. - Chuyển đổi version (nếu
matchPolicy: Equivalent). API server chuyển object sang version mà webhook khai báo trongrules. Webhook luôn nhận object ở version nó mong đợi, bất kể client gửi version nào — đây là giá trị củaEquivalent. - Gọi tuần tự trong pha mutating. Các mutating webhook khớp được gọi lần lượt (không song song), vì mỗi cái có thể sửa object mà cái sau cần thấy. Tổng latency cộng dồn. Sau khi tất cả chạy xong, nếu có webhook nào đã sửa object, các webhook
reinvocationPolicy: IfNeededđược gọi lại — có thể thêm một vòng nữa. - Gọi song song trong pha validating. Các validating webhook không sửa object nên API server có thể gọi chúng song song — latency là max chứ không phải tổng. Đây là một khác biệt hiệu năng tinh tế: validating webhook rẻ hơn mutating về latency tổng khi có nhiều cái.
Hệ quả thiết kế rút ra: ưu tiên validating hơn mutating khi có lựa chọn. Một policy chỉ cần kiểm tra (không sửa) nên là validating — vừa chạy song song (nhanh hơn), vừa không kéo theo reinvocation, vừa không có rủi ro mutation sai. Chỉ dùng mutating khi thực sự cần thay đổi object.
Real-world scenarios
Sidecar injection an toàn cho hàng nghìn Pod
Một platform team triển khai mutating webhook inject một sidecar logging vào mọi Pod trong namespace opt-in. Phiên bản đầu thêm sidecar bằng JSON Patch add vô điều kiện. Sau khi cài thêm Istio (cũng là mutating webhook), một số Pod xuất hiện hai sidecar logging — vì webhook logging bị reinvoke sau khi Istio sửa object, và lần reinvoke nó lại thêm sidecar nữa.
Sửa đúng: webhook đọc object.spec.containers, kiểm tra đã có container tên log-sidecar chưa; nếu có thì trả patch rỗng (allowed: true, không patch). Idempotent hóa này biến webhook an toàn dưới reinvocation. Bài học: mọi mutating webhook trên cluster có nhiều webhook phải giả định mình sẽ bị gọi lại và thiết kế cho điều đó từ đầu — không phải vá sau khi gặp bug.
Policy gate qua dry-run trong CI
Một tổ chức muốn chặn merge các manifest vi phạm policy trước khi chúng tới cluster. Thay vì để policy chỉ chặn lúc kubectl apply (quá muộn, developer đã commit), họ chạy kubectl apply --dry-run=server trong CI pipeline đối với cluster staging. Vì dry-run đi qua toàn bộ admission (kể cả validating webhook và VAP), CI bắt được vi phạm và fail PR với cùng message mà cluster sẽ trả. Điều kiện để pattern này hoạt động: mọi webhook khớp phải khai sideEffects: None/NoneOnDryRun — nếu một webhook khai Unknown, dry-run thất bại và CI không chạy được. Đây là động lực thực tế để mọi webhook trong tổ chức tuân chuẩn side-effect.
Common mistakes / anti-patterns
Mutating webhook không idempotent
Như đã phân tích: với reinvocationPolicy: IfNeeded, webhook thêm-mù-quáng sẽ nhân đôi thay đổi. Luôn kiểm tra trạng thái hiện tại trước khi patch. Hệ quả ở scale: Pod có hai sidecar, hai init container, hoặc label bị ghi đè lẫn nhau — bug khó tái hiện vì phụ thuộc thứ tự webhook.
sideEffects: Unknown làm hỏng dry-run toàn cluster
Một webhook khai Unknown (hoặc bỏ trống, mặc định cũ) khiến kubectl --dry-run=server thất bại cho mọi object khớp rule — kể cả khi người dùng chỉ muốn kiểm tra một policy khác. Luôn khai None/NoneOnDryRun và xử lý cờ dryRun.
Rule quá rộng
resources: ["*"], operations: ["*"], không có selector — webhook trở thành điểm nghẽn cho mọi thao tác ghi. Luôn thu hẹp tới đúng loại tài nguyên và operation cần thiết.
Không echo uid
Lỗi sơ đẳng nhưng hay gặp khi tự viết webhook: quên copy request.uid vào response.uid. API server từ chối response, request thất bại với lỗi khó hiểu. Mọi response phải echo đúng uid.
GCP-native implementation guidance
Liệt kê và soi webhook trên cluster GKE:
kubectl get mutatingwebhookconfigurations,validatingwebhookconfigurations
# Xem rule, selector, sideEffects, failurePolicy
kubectl get validatingwebhookconfigurations <name> -o yamlKiểm thử một object qua toàn bộ pipeline (kể cả webhook) mà không ghi:
kubectl apply -f deployment.yaml --dry-run=serverTrên GKE Autopilot, lưu ý có giới hạn với webhook can thiệp vào các managed namespace và một số tài nguyên hệ thống — GKE bảo vệ chính nó bằng cách không cho webhook của bạn chặn các thành phần do Google quản lý (GKE Autopilot overview). Khi thiết kế webhook cho Autopilot, luôn loại trừ namespace hệ thống bằng namespaceSelector (file 3).
Official references
- Kubernetes — Dynamic Admission Control — cấu hình webhook, AdmissionReview, reinvocation, side effects
- Kubernetes — Webhook configuration fields
- Kubernetes — Reinvocation policy
- Kubernetes — Side effects & dry-run
- GKE — Autopilot overview