Kubernetes 基础体系 · 第 79/83 篇。示例基于 Kubernetes 当前稳定 API;弃用、版本偏差、云厂商差异和生产风险会明确说明。
Kubernetes Admission Webhook 开发:Mutating、Validating、证书和高可用
Kubernetes API 请求经过认证和授权后,还会进入 Admission Control 阶段。Admission Webhook 是其中一种扩展机制:API Server 将待处理的对象发送给外部 HTTPS 服务,由该服务决定是否修改对象,或决定是否允许请求继续。
本文围绕四个核心问题展开:
- Mutating Webhook 如何修改对象,修改结果如何返回;
- Validating Webhook 如何校验对象,以及它与 Mutating 的执行关系;
- API Server 如何通过 TLS 证书安全调用 Webhook;
- Webhook 故障、超时、升级和多副本场景下,如何保证集群可用性。
示例使用当前稳定的 admissionregistration.k8s.io/v1 和 admission.k8s.io/v1 API。不同 Kubernetes 发行版可能对默认参数、证书管理和网络访问路径做了额外封装,但核心协议和控制器行为仍以 Kubernetes API 语义为准。
一、Admission Webhook 解决什么问题
1. Admission 的位置
一个典型的 Kubernetes API 请求大致经过以下阶段:
sequenceDiagram
participant C as kubectl/客户端
participant A as kube-apiserver
participant Auth as 认证与授权
participant M as Mutating Admission
participant V as Validating Admission
participant S as 持久化层
C->>A: HTTP 请求
A->>Auth: Authentication / Authorization
Auth-->>A: 用户身份与权限
A->>M: 内置 Mutating 插件与 Mutating Webhook
M-->>A: 修改后的对象或拒绝
A->>V: 内置 Validating 插件与 Validating Webhook
V-->>A: 允许或拒绝
A->>S: 写入 etcd
S-->>A: 持久化成功
A-->>C: API 响应
Admission 阶段发生在对象写入存储之前。因此,Webhook 可以:
- 给 Pod 自动注入 sidecar;
- 添加标签、注解或安全上下文;
- 拒绝不符合组织策略的对象;
- 检查跨字段约束;
- 根据用户、命名空间或对象内容实施策略。
它不能替代认证和授权:
- 认证回答“请求者是谁”;
- 授权回答“请求者是否有权限执行这个动作”;
- Admission 回答“即使有权限,这个对象是否允许进入集群,以及是否需要被修改”。
Admission Webhook 也不是控制器。Webhook 处理的是一次 API 请求;控制器则持续观察期望状态和实际状态,并通过后续 API 请求进行调谐。
二、Mutating 与 Validating 的职责差异
2.1 Mutating Webhook:修改请求对象
Mutating Webhook 接收 AdmissionReview,并返回一个 JSON Patch。API Server 将 Patch 应用到原始对象后,继续后续 Admission 流程。
例如,客户端提交:
apiVersion: v1
kind: Pod
metadata:
name: demo
spec:
containers:
- name: app
image: nginx:1.27
Mutating Webhook 可以返回:
[
{
"op": "add",
"path": "/metadata/labels",
"value": {
"example.com/injected": "true"
}
}
]
API Server 应用后,待持久化对象变为:
metadata:
labels:
example.com/injected: "true"
Mutating Webhook 的典型用途是“补全”或“规范化”,例如:
- 注入 sidecar;
- 设置默认资源;
- 添加安全相关字段;
- 添加统一标签;
- 把旧格式转换成规范格式。
Mutation 必须满足一个重要条件:重复执行不会产生错误结果。这称为幂等性。
假设 Webhook 每次都无条件追加一个 sidecar:
spec:
containers:
- name: injector
image: example/injector:v1
如果同一个请求被重复处理,或者由于重新调用机制被再次调用,就可能得到两个同名容器。这会导致对象非法,或者使行为难以预测。
更安全的逻辑是:
- 查找是否已经存在名为
injector的容器; - 如果存在且配置符合预期,不再修改;
- 如果不存在,才生成 Patch;
- 如果存在但配置错误,明确拒绝或进行确定性替换。
2.2 Validating Webhook:只判断,不修改
Validating Webhook 返回:
{
"allowed": true
}
或者:
{
"allowed": false,
"status": {
"code": 403,
"reason": "Forbidden",
"message": "container image must not use the latest tag"
}
}
它适合表达不可自动修复的策略,例如:
- 禁止使用
:latest; - 生产命名空间必须设置资源请求;
- 某些字段必须满足联合约束;
- 一个对象不能引用不存在的外部配置;
- 只有特定用户组可以创建某类资源。
Validating Webhook 不返回 Patch。它看到的是 Mutating 阶段处理后的对象,因此可以校验最终形态。
2.3 为什么通常先 Mutating,再 Validating
如果对象先经过校验,再经过修改,Validating Webhook 可能拒绝一个本来可以被自动补全的对象。
例如策略要求所有 Pod 都必须有:
metadata:
labels:
security.example.com/profile: restricted
Mutating Webhook 可以自动添加该标签。若 Validating Webhook 在 Mutation 之前执行,它看到的是缺少标签的原始对象,就会错误拒绝请求。
因此通常的设计是:
原始对象
↓
Mutating Admission
↓
应用所有 Mutation
↓
必要时重新调用部分 Mutating Webhook
↓
Validating Admission
↓
持久化
需要区分“通常的执行阶段”和“多个 Webhook 之间的精确顺序”:
- Mutating Webhook 按顺序处理,因为前一个 Webhook 的结果会成为后一个 Webhook 的输入;
- Validating Webhook 通常可以并行执行,因为它们只读取对象并返回允许或拒绝;
- 不应依赖多个 Webhook 的偶然排序来表达业务语义;
- 如果多个 Mutating Webhook 修改相同字段,系统的最终行为会变得脆弱,应尽量划分字段所有权。
2.4 Mutating Webhook 的重新调用
MutatingWebhookConfiguration 支持:
reinvocationPolicy: IfNeeded
默认值是 Never。设置为 IfNeeded 后,如果后续 Mutating Webhook 修改了对象,API Server 可能再次调用当前 Webhook,使它有机会根据新对象重新计算结果。
例如:
- Webhook A 根据容器列表添加注解;
- Webhook B 注入 sidecar;
- sidecar 改变了容器列表;
- API Server 可能重新调用 Webhook A。
这不是“无限调用”机制。API Server 会根据 Admission 流程判断是否需要重新调用,但 Webhook 仍必须具备幂等性。IfNeeded 不能用来掩盖不稳定的 Patch 逻辑。
三、AdmissionReview 协议
3.1 请求结构
Webhook 收到的不是直接的 Pod 或 Deployment,而是 AdmissionReview:
apiVersion: admission.k8s.io/v1
kind: AdmissionReview
request:
uid: "..."
operation: CREATE
userInfo:
username: admin
resource:
group: ""
version: v1
resource: pods
object:
apiVersion: v1
kind: Pod
metadata:
name: demo
常用字段包括:
| 字段 | 含义 |
|---|---|
request.uid |
本次 Admission 请求的唯一标识,响应必须原样返回 |
operation |
CREATE、UPDATE、DELETE 或 CONNECT |
object |
请求中的新对象 |
oldObject |
更新或删除前的旧对象 |
dryRun |
是否为试运行请求 |
namespace |
命名空间资源的命名空间 |
subResource |
子资源,例如 status |
userInfo |
用户名、用户组和附加信息 |
options |
API 操作选项 |
删除请求通常没有新的 object,需要从 oldObject 读取被删除对象。
Webhook 必须:
- 读取
AdmissionReview.request; - 根据
resource、operation和对象内容处理请求; - 返回相同
uid的AdmissionReview.response; - 正确设置
allowed; - Mutation 时正确设置
patch和patchType。
3.2 JSON Patch 的路径转义
JSON Pointer 中有两个特殊字符:
/必须编码为~1;~必须编码为~0。
因此标签键:
example.com/injected
在 JSON Patch 路径中必须写成:
/metadata/labels/example.com~1injected
直接使用:
/metadata/labels/example.com/injected
会被解释成两层路径,而不是一个包含 / 的标签键。
3.3 Webhook 端到端 Go 示例
下面的服务同时提供两个端点:
/mutate:为 Pod 添加标签;/validate:拒绝使用:latest镜像的 Pod。
示例使用标准库和 client-go 的 Admission API 类型。
package main
import (
"encoding/json"
"log"
"net/http"
"strings"
admissionv1 "k8s.io/api/admission/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type patchOperation struct {
Op string `json:"op"`
Path string `json:"path"`
Value any `json:"value,omitempty"`
}
func writeReview(w http.ResponseWriter, uid string, response *admissionv1.AdmissionResponse) {
response.UID = typesUID(uid)
review := admissionv1.AdmissionReview{
TypeMeta: metav1.TypeMeta{
APIVersion: "admission.k8s.io/v1",
Kind: "AdmissionReview",
},
Response: response,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(review); err != nil {
log.Printf("write response: %v", err)
}
}
// typesUID 只用于让示例保持简短;实际代码可直接使用 types.UID。
func typesUID(s string) types.UID {
return types.UID(s)
}
func admissionError(w http.ResponseWriter, uid string, err error) {
writeReview(w, uid, &admissionv1.AdmissionResponse{
Allowed: false,
Result: &metav1.Status{
Code: http.StatusBadRequest,
Reason: metav1.StatusReason("InvalidAdmissionReview"),
Message: err.Error(),
},
})
}
func mutate(w http.ResponseWriter, r *http.Request) {
var review admissionv1.AdmissionReview
if err := json.NewDecoder(r.Body).Decode(&review); err != nil {
admissionError(w, "", err)
return
}
if review.Request == nil {
admissionError(w, "", fmt.Errorf("missing admission request"))
return
}
req := review.Request
if req.Resource.Group != "" ||
req.Resource.Version != "v1" ||
req.Resource.Resource != "pods" ||
req.Operation != admissionv1.Create {
writeReview(w, string(req.UID), &admissionv1.AdmissionResponse{Allowed: true})
return
}
var pod map[string]any
if err := json.Unmarshal(req.Object.Raw, &pod); err != nil {
admissionError(w, string(req.UID), err)
return
}
metadata, ok := pod["metadata"].(map[string]any)
if !ok {
admissionError(w, string(req.UID), fmt.Errorf("metadata is missing"))
return
}
labels, exists := metadata["labels"].(map[string]any)
const key = "example.com/injected"
const value = "true"
patches := make([]patchOperation, 0, 1)
if !exists {
patches = append(patches, patchOperation{
Op: "add",
Path: "/metadata/labels",
Value: map[string]string{key: value},
})
} else if _, found := labels[key]; !found {
patches = append(patches, patchOperation{
Op: "add",
Path: "/metadata/labels/example.com~1injected",
Value: value,
})
}
resp := &admissionv1.AdmissionResponse{Allowed: true}
if len(patches) > 0 {
data, err := json.Marshal(patches)
if err != nil {
admissionError(w, string(req.UID), err)
return
}
patchType := admissionv1.PatchTypeJSONPatch
resp.Patch = data
resp.PatchType = &patchType
}
writeReview(w, string(req.UID), resp)
}
func validate(w http.ResponseWriter, r *http.Request) {
var review admissionv1.AdmissionReview
if err := json.NewDecoder(r.Body).Decode(&review); err != nil {
admissionError(w, "", err)
return
}
if review.Request == nil {
admissionError(w, "", fmt.Errorf("missing admission request"))
return
}
req := review.Request
if req.Resource.Group != "" ||
req.Resource.Version != "v1" ||
req.Resource.Resource != "pods" {
writeReview(w, string(req.UID), &admissionv1.AdmissionResponse{Allowed: true})
return
}
var pod struct {
Spec struct {
Containers []struct {
Image string `json:"image"`
} `json:"containers"`
InitContainers []struct {
Image string `json:"image"`
} `json:"initContainers"`
} `json:"spec"`
}
if err := json.Unmarshal(req.Object.Raw, &pod); err != nil {
admissionError(w, string(req.UID), err)
return
}
check := func(image string) bool {
return image == "latest" || strings.HasSuffix(image, ":latest")
}
for _, c := range pod.Spec.Containers {
if check(c.Image) {
writeReview(w, string(req.UID), &admissionv1.AdmissionResponse{
Allowed: false,
Result: &metav1.Status{
Code: http.StatusForbidden,
Reason: metav1.StatusReasonForbidden,
Message: "container image must not use the latest tag",
},
})
return
}
}
for _, c := range pod.Spec.InitContainers {
if check(c.Image) {
writeReview(w, string(req.UID), &admissionv1.AdmissionResponse{
Allowed: false,
Result: &metav1.Status{
Code: http.StatusForbidden,
Reason: metav1.StatusReasonForbidden,
Message: "init container image must not use the latest tag",
},
})
return
}
}
writeReview(w, string(req.UID), &admissionv1.AdmissionResponse{Allowed: true})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/mutate", mutate)
mux.HandleFunc("/validate", validate)
server := &http.Server{
Addr: ":8443",
Handler: mux,
}
log.Println("admission webhook listening on :8443")
log.Fatal(server.ListenAndServeTLS(
"/tls/tls.crt",
"/tls/tls.key",
))
}
上面的代码还缺少 fmt 和 types 导入。完整导入应为:
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
admissionv1 "k8s.io/api/admission/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
)
并且可以将:
response.UID = typesUID(uid)
直接改为:
response.UID = types.UID(uid)
因此,生产代码不应为了缩短示例而保留 typesUID 这个包装函数。
创建 Go 模块:
mkdir admission-webhook
cd admission-webhook
go mod init example.com/admission-webhook
go get k8s.io/api@latest
go get k8s.io/apimachinery@latest
go build ./...
实际项目应固定 Kubernetes 依赖版本,而不是在生产构建中使用不断变化的 @latest。依赖版本应与项目支持的 Kubernetes 版本和 Go 版本一起测试。
四、WebhookConfiguration 如何决定哪些请求被调用
Webhook 服务本身只是 HTTPS 服务。API Server 是否调用它,由 MutatingWebhookConfiguration 或 ValidatingWebhookConfiguration 决定。
4.1 一个最小的 Mutating 配置
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: example-mutator
webhooks:
- name: mutate.pods.example.com
admissionReviewVersions:
- v1
sideEffects: None
failurePolicy: Fail
timeoutSeconds: 5
reinvocationPolicy: IfNeeded
matchPolicy: Equivalent
clientConfig:
service:
name: admission-webhook
namespace: webhook-system
path: /mutate
port: 443
caBundle: ${CA_BUNDLE}
rules:
- operations: ["CREATE", "UPDATE"]
apiGroups: [""]
apiVersions: ["v1"]
resources: ["pods"]
scope: "Namespaced"
namespaceSelector:
matchExpressions:
- key: webhook.example.com/disabled
operator: NotIn
values: ["true"]
${CA_BUNDLE} 不是 Kubernetes 会自动展开的语法。部署前必须将它替换成经过 Base64 编码的 PEM CA 证书。
4.2 重要匹配字段
rules
rules 决定资源范围:
rules:
- operations: ["CREATE", "UPDATE"]
apiGroups: ["apps"]
apiVersions: ["v1"]
resources: ["deployments"]
scope: "Namespaced"
其中:
- 核心组使用空字符串
""; resources写复数资源名,如pods、deployments;- 子资源要显式写成
pods/status等; scope可以是Namespaced、Cluster或*。
规则过宽会带来两个问题:
- Webhook 接收到大量不相关请求,增加延迟;
- Webhook 故障可能阻塞整个集群的更多 API 操作。
matchPolicy
Exact 只匹配规则中明确列出的版本。
Equivalent 允许 API Server 将同一资源的等价版本匹配到该规则。对于存在多个 API 版本的资源,Equivalent 通常更适合避免漏掉请求,但 Webhook 必须理解对象实际被解码后的版本和字段语义。
这与 CRD Conversion Webhook 不同:
- Admission Webhook 判断或修改一次 API 请求;
- Conversion Webhook 在 CRD 版本之间转换对象;
- Conversion 的核心概念是 Hub/Spoke、存储版本和版本兼容;
- Admission Webhook 不能代替 CRD Conversion Webhook。
namespaceSelector
它根据命名空间标签筛选请求。例如:
namespaceSelector:
matchLabels:
admission.example.com/enabled: "true"
只会处理带有该标签的命名空间。
需要特别注意:集群级资源没有命名空间,因此 namespaceSelector 对它们不起作用。
objectSelector
它根据对象自身标签筛选请求。例如:
objectSelector:
matchLabels:
admission.example.com/managed: "true"
对象标签由请求者控制时,不能把它当作强安全边界。具有创建或修改对象权限的用户,通常也能控制这些标签。
matchConditions
当前稳定 API 支持使用 CEL 表达式进一步筛选请求:
matchConditions:
- name: skip-system-serviceaccounts
expression: "!request.userInfo.username.startsWith('system:serviceaccount:kube-system:')"
它适合表达单纯基于请求元数据的过滤条件。但表达式错误、求值失败和与 failurePolicy 的交互必须在目标 Kubernetes 版本上验证,不能只依赖某个云厂商的默认行为。
五、失败策略、超时和 Dry Run
5.1 failurePolicy
Webhook 失败包括:
- DNS 解析失败;
- Service 没有可用 Endpoint;
- TLS 握手失败;
- 连接超时;
- Webhook 返回非 2xx;
- 响应无法解码;
- Webhook 在规定时间内没有响应。
failurePolicy 有两个主要值:
failurePolicy: Fail
调用失败时拒绝原始 API 请求。
failurePolicy: Ignore
调用失败时忽略该 Webhook,API 请求继续。
两者分别代表不同的安全模型:
Fail优先保证策略不被绕过,但 Webhook 故障可能影响集群写入;Ignore优先保证 API 可用,但 Webhook 故障期间策略会失效。
例如,禁止特权容器的安全策略通常不应轻易设置为 Ignore;而只负责添加非关键标签的 Mutation 可以考虑 Ignore,前提是业务确实允许缺少该标签。
5.2 timeoutSeconds
timeoutSeconds 的范围是 1 到 30 秒,默认值为 10 秒。它表示 API Server 等待单次 Webhook 调用的时间上限。
超时不是性能优化参数,而是故障传播边界。设:
N为一次 API 请求需要调用的 Webhook 数量;t_i为第i个 Webhook 的实际或超时耗时;T为 API 请求在 Admission 阶段可接受的延迟。
粗略地说,若多个 Mutating Webhook 串行执行,则:
若 Validating Webhook 并行执行,则其等待时间更接近:
这不是 API Server 对所有内部插件和网络路径的精确性能模型,但能说明一个事实:Mutating Webhook 的数量和延迟会直接叠加到写请求路径中。
5.3 Dry Run 与 sideEffects
如果用户执行:
kubectl apply --dry-run=server -f pod.yaml
API Server 会在不持久化对象的情况下运行 Admission 流程。
Webhook 必须正确声明副作用:
sideEffects: None
表示调用 Webhook 不会产生外部副作用。
sideEffects: NoneOnDryRun
表示普通请求可能有副作用,但 Webhook 能识别 Dry Run 并避免这些副作用。
如果 Webhook 会:
- 写数据库;
- 创建云资源;
- 调用不可回滚的外部 API;
- 发送具有业务含义的通知;
就不能错误声明为 None。Admission Webhook 最好只做确定性的对象计算,避免在 API 写入路径中执行外部事务。
六、证书、TLS 和 caBundle
6.1 API Server 如何验证 Webhook
API Server 通过 HTTPS 连接 Webhook Service。配置中的:
clientConfig:
service:
name: admission-webhook
namespace: webhook-system
path: /mutate
caBundle: ...
含义是:
- API Server 通过集群网络访问 Service;
- Service 将请求转发到 Webhook Pod;
- Webhook Pod 提供 TLS 服务;
- API Server 使用
caBundle中的 CA 验证 Webhook 服务端证书; - 服务端证书的 SAN 必须匹配访问名称。
对于 Service 方式,证书通常至少包含:
admission-webhook.webhook-system.svc
admission-webhook.webhook-system.svc.cluster.local
Kubernetes Service 的 DNS 名称由以下部分构成:
<service-name>.<namespace>.svc
<service-name>.<namespace>.svc.cluster.local
证书的 CN 不是主要匹配依据,现代 TLS 客户端主要检查 SAN。
6.2 使用 OpenSSL 生成测试证书
以下命令生成一个自签名 CA 和服务端证书:
mkdir -p certs
openssl genrsa -out certs/ca.key 2048
openssl req -x509 -new -nodes \
-key certs/ca.key \
-sha256 \
-days 3650 \
-out certs/ca.crt \
-subj "/CN=example-admission-ca"
openssl genrsa -out certs/tls.key 2048
cat > certs/tls.cnf <<'EOF'
[req]
req_extensions = v3_req
distinguished_name = req_distinguished_name
prompt = no
[req_distinguished_name]
CN = admission-webhook.webhook-system.svc
[v3_req]
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = admission-webhook
DNS.2 = admission-webhook.webhook-system
DNS.3 = admission-webhook.webhook-system.svc
DNS.4 = admission-webhook.webhook-system.svc.cluster.local
EOF
openssl req -new \
-key certs/tls.key \
-out certs/tls.csr \
-config certs/tls.cnf
openssl x509 -req \
-in certs/tls.csr \
-CA certs/ca.crt \
-CAkey certs/ca.key \
-CAcreateserial \
-out certs/tls.crt \
-days 365 \
-sha256 \
-extensions v3_req \
-extfile certs/tls.cnf
创建 Secret:
kubectl create namespace webhook-system
kubectl -n webhook-system create secret tls admission-webhook-tls \
--cert=certs/tls.crt \
--key=certs/tls.key
生成 caBundle:
CA_BUNDLE="$(base64 -w0 certs/ca.crt)"
GNU/Linux 使用 base64 -w0。macOS 通常使用:
CA_BUNDLE="$(base64 < certs/ca.crt | tr -d '\n')"
生产环境中不要把 CA 私钥提交到 Git,也不要把测试自签名证书长期用于生产。常见的生产方案包括:
- cert-manager 管理 CA、服务证书和续期;
- 云厂商提供的证书管理方案;
- 自建 PKI 和自动轮换控制器。
无论使用哪种方案,都必须同时更新:
- Webhook Pod 挂载的服务端证书;
clientConfig.caBundle中的 CA 链;- 证书轮换后的所有副本。
只更新 Pod 内证书而不更新 caBundle,会导致 TLS 验证失败;只更新 caBundle 而证书仍由旧 CA 签发,也同样会失败。
6.3 常见 TLS 故障
错误:
x509: certificate is valid for webhook-system.svc, not admission-webhook.webhook-system.svc
说明证书 SAN 与 API Server 实际访问的 Service DNS 名称不匹配。
错误:
x509: certificate signed by unknown authority
通常说明:
caBundle不是签发服务端证书的 CA;- Base64 内容损坏;
- 中间 CA 链缺失;
- Webhook 配置未按预期更新。
Service 没有 Endpoint 时,常见错误可能是:
no endpoints available for service
此时问题不在证书,而在:
- Pod 没有 Ready;
- Service selector 与 Pod label 不匹配;
- Pod 监听端口错误;
- Service
targetPort配置错误。
七、部署一个可用的 Webhook
7.1 Dockerfile
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -o /out/admission-webhook .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/admission-webhook /admission-webhook
USER nonroot:nonroot
ENTRYPOINT ["/admission-webhook"]
构建并推送镜像:
docker build -t registry.example.com/admission-webhook:v1.0.0 .
docker push registry.example.com/admission-webhook:v1.0.0
镜像地址需要替换成集群节点能够访问的实际 Registry。
7.2 Service、Deployment 和探针
apiVersion: apps/v1
kind: Deployment
metadata:
name: admission-webhook
namespace: webhook-system
spec:
replicas: 3
selector:
matchLabels:
app: admission-webhook
template:
metadata:
labels:
app: admission-webhook
spec:
containers:
- name: webhook
image: registry.example.com/admission-webhook:v1.0.0
ports:
- name: https
containerPort: 8443
readinessProbe:
tcpSocket:
port: https
initialDelaySeconds: 2
periodSeconds: 5
livenessProbe:
tcpSocket:
port: https
initialDelaySeconds: 5
periodSeconds: 10
volumeMounts:
- name: tls
mountPath: /tls
readOnly: true
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
volumes:
- name: tls
secret:
secretName: admission-webhook-tls
---
apiVersion: v1
kind: Service
metadata:
name: admission-webhook
namespace: webhook-system
spec:
selector:
app: admission-webhook
ports:
- name: https
port: 443
targetPort: https
探针至少要能反映“是否可以接收请求”。如果进程启动了但证书没有加载成功,TCP 探针可能仍然显示成功。更严格的实现可以提供 /healthz,在证书、配置和必要依赖未准备好时返回非成功状态。
7.3 配置 Validating Webhook
假设 CA Bundle 已保存到 Shell 变量 CA_BUNDLE:
cat > validating-webhook.yaml <<EOF
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: example-validator
webhooks:
- name: validate.pods.example.com
admissionReviewVersions:
- v1
sideEffects: None
failurePolicy: Fail
timeoutSeconds: 5
matchPolicy: Equivalent
clientConfig:
service:
name: admission-webhook
namespace: webhook-system
path: /validate
port: 443
caBundle: ${CA_BUNDLE}
rules:
- operations: ["CREATE", "UPDATE"]
apiGroups: [""]
apiVersions: ["v1"]
resources: ["pods"]
scope: Namespaced
namespaceSelector:
matchExpressions:
- key: webhook.example.com/disabled
operator: NotIn
values: ["true"]
EOF
kubectl apply -f validating-webhook.yaml
类似地,可以创建 Mutating 配置:
cat > mutating-webhook.yaml <<EOF
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: example-mutator
webhooks:
- name: mutate.pods.example.com
admissionReviewVersions:
- v1
sideEffects: None
failurePolicy: Fail
timeoutSeconds: 5
reinvocationPolicy: IfNeeded
matchPolicy: Equivalent
clientConfig:
service:
name: admission-webhook
namespace: webhook-system
path: /mutate
port: 443
caBundle: ${CA_BUNDLE}
rules:
- operations: ["CREATE", "UPDATE"]
apiGroups: [""]
apiVersions: ["v1"]
resources: ["pods"]
scope: Namespaced
namespaceSelector:
matchExpressions:
- key: webhook.example.com/disabled
operator: NotIn
values: ["true"]
EOF
kubectl apply -f mutating-webhook.yaml
7.4 测试成功和拒绝路径
测试 Mutation:
cat > pod-ok.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: webhook-ok
spec:
containers:
- name: app
image: nginx:1.27
EOF
kubectl create -f pod-ok.yaml
kubectl get pod webhook-ok -o jsonpath='{.metadata.labels.example\.com/injected}'
echo
预期输出:
true
测试 Validation:
cat > pod-bad.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: webhook-bad
spec:
containers:
- name: app
image: nginx:latest
EOF
kubectl create -f pod-bad.yaml
预期会被拒绝,错误信息类似:
admission webhook "validate.pods.example.com" denied the request:
container image must not use the latest tag
测试服务端 Dry Run:
kubectl create --dry-run=server -f pod-ok.yaml -o yaml
对于 Mutating Webhook,输出对象应包含注入的标签;但对象不会写入 etcd。若 Webhook 实际会调用外部系统,则必须避免在 Dry Run 中执行不可逆操作。
八、生产高可用不是“把 replicas 改成 3”
8.1 请求路径中的故障放大
Webhook 处在 API 写请求的同步链路上。一个副本故障可能造成连接失败;所有副本不可用则会直接影响 Admission 结果。
如果配置为:
failurePolicy: Fail
那么 Webhook 服务完全不可用时,匹配范围内的 API 请求会失败。这是安全性和可用性的明确取舍,不是 Kubernetes 的异常行为。
如果配置为:
failurePolicy: Ignore
则系统可以继续写入对象,但策略也可能被绕过。对于安全策略,这可能比短暂不可用更危险。
8.2 多副本和 Service
至少应满足:
- Webhook 运行多个副本;
- 副本通过 Service 暴露;
- Pod 使用 readinessProbe;
- 更新期间保留足够可用副本;
- 副本尽量分布到不同节点或故障域;
- Webhook 不依赖单个 Pod 的内存状态;
- 服务端证书在所有副本中一致或都由相同 CA 签发。
可以使用拓扑分布约束:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: admission-webhook
但这仍然不能保证跨可用区分布。节点标签、云厂商故障域标签和调度器配置需要结合实际集群检查。
8.3 Webhook 的自阻塞问题
如果 Webhook 配置匹配它自己的 Deployment、Service、Secret 或命名空间,就可能形成启动死锁:
- Webhook Pod 被删除;
- Deployment 控制器创建新 Pod;
- 新 Pod 尚未 Ready;
- API Server 需要调用 Webhook 才能处理某些相关请求;
- Webhook 不可用,相关请求失败;
- 修复过程依赖被阻塞的 API 请求。
常见缓解方式是排除 Webhook 所在命名空间:
namespaceSelector:
matchExpressions:
- key: kubernetes.io/metadata.name
operator: NotIn
values:
- webhook-system
或者让 Webhook 只匹配明确带有业务标签的命名空间,而不是全局匹配。
这不是无条件规则:如果 Webhook 的设计就是管理自身命名空间,就必须为启动、升级和故障恢复设计独立路径,例如临时修改 WebhookConfiguration、保留集群管理员绕过手段,或使用不依赖该 Webhook 的恢复流程。
8.4 升级和证书轮换
Webhook 升级需要同时考虑三个状态:
- 旧 Pod 是否仍能处理请求;
- 新 Pod 是否已经通过 readiness;
- 新证书是否与
caBundle和 Service DNS 一致。
一个安全的滚动升级顺序通常是:
部署新版本 Pod
↓
等待新 Pod Ready
↓
验证 Service Endpoints
↓
验证 TLS 和 AdmissionReview
↓
再终止旧 Pod
证书轮换则需要避免以下窗口:
WebhookConfiguration 使用 CA-A
Webhook Pod 已切换为 CA-B
此时 API Server 会把新证书当作不可信证书。更安全的轮换方式是先让配置同时信任新 CA,再切换服务端证书,最后移除旧 CA;具体步骤取决于证书控制器和证书链设计。
8.5 PDB 的边界
PodDisruptionBudget 可以限制自愿驱逐造成的同时不可用副本数量,但它不能防止:
- 节点突然宕机;
- 进程崩溃;
- 镜像拉取失败;
- 证书错误;
- Service selector 配置错误;
- API Server 到 Webhook 的网络不通。
因此 PDB 只是减少一种中断来源,不能代替多副本、探针、监控和故障演练。
九、Webhook 的安全边界
9.1 不要默认信任请求对象
Webhook 收到的对象来自 API Server,但对象中的许多字段由用户控制,包括:
- 标签;
- 注解;
- 容器镜像;
- 部分 Pod 安全字段;
- CRD 自定义字段。
Webhook 应基于 API Server 提供的 userInfo 判断请求者,而不是根据对象标签推断身份。
例如:
metadata:
labels:
admin: "true"
不能证明请求者是管理员,因为普通用户可能拥有设置该标签的权限。
9.2 避免把 Admission 变成外部事务系统
如果 Webhook 在响应允许之前创建云主机、写外部数据库或发送通知,那么 API Server 可能因为后续 Admission 插件拒绝请求,导致外部操作已经发生但 Kubernetes 对象并不存在。
更稳妥的分层是:
- Admission Webhook 只做快速、确定性的对象检查和修改;
- 对象持久化成功后,由控制器异步执行外部副作用;
- 控制器通过状态字段报告异步结果。
9.3 防止递归和循环
Webhook 修改对象后可能触发新的 API 请求,新的 API 请求又可能匹配同一个 Webhook。
典型风险包括:
- Webhook 注入配置时修改了另一个会被再次处理的资源;
- 控制器不断更新对象,触发 UPDATE Admission;
- Webhook 根据自身添加的字段再次生成不同 Patch。
需要建立稳定条件:
f(f(object)) = f(object)
这里的 f 是 Mutation 函数。直觉上,第一次处理完成后,第二次处理不应再改变对象。
实践中可通过以下方式实现:
- 使用确定的注入标记;
- 检查目标容器是否已存在;
- 只在字段缺失时添加默认值;
- 不把时间戳、随机数写进每次 Mutation;
- 不在 UPDATE 中无条件修改
resourceVersion之外的业务字段。
十、错误处理和可观测性
10.1 错误响应必须可诊断
只返回:
{"allowed": false}
对用户和运维都不友好。拒绝响应应包含:
- 稳定的错误原因;
- 具体字段或资源;
- 可操作的修复建议;
- 不泄露内部凭据和网络拓扑。
例如:
Result: &metav1.Status{
Code: http.StatusForbidden,
Reason: metav1.StatusReasonForbidden,
Message: "spec.containers[0].image must not use the latest tag",
}
HTTP 200 和 allowed: false 的含义不同于 HTTP 500:
- HTTP 200、
allowed: false:Webhook 正常完成了策略判断,明确拒绝; - HTTP 500 或非法响应:Webhook 调用失败,受
failurePolicy影响。
10.2 记录请求 UID 和延迟
Webhook 日志至少应记录:
request.uid;- operation;
- resource;
- namespace/name;
- 允许、拒绝或处理失败;
- 处理耗时;
- Webhook 版本;
- dry-run 标志。
不要直接记录完整对象到普通日志,因为对象可能包含敏感信息,并且大型对象会放大日志量。
指标应能区分:
admission_requests_total{operation,resource,result}
admission_request_duration_seconds{operation,resource}
admission_errors_total{reason}
API Server 侧可以通过审计日志确认:
- 请求是否到达 API Server;
- 是否进入 Admission;
- 是否被某个 Webhook 拒绝;
- 最终响应耗时和错误原因。
10.3 诊断顺序
当创建对象失败时,可以按以下顺序排查:
kubectl get mutatingwebhookconfiguration
kubectl get validatingwebhookconfiguration
kubectl -n webhook-system get pods -o wide
kubectl -n webhook-system get svc admission-webhook
kubectl -n webhook-system get endpoints admission-webhook
kubectl -n webhook-system logs deploy/admission-webhook
然后检查:
kubectl get validatingwebhookconfiguration example-validator -o yaml
重点确认:
clientConfig.service的名称和命名空间;path是否对应 HTTP 路由;port是否对应 Service 端口;caBundle是否存在;rules是否匹配当前资源;namespaceSelector是否意外排除了目标命名空间;failurePolicy和timeoutSeconds是否符合预期。
在集群内测试 DNS 和端口:
kubectl -n webhook-system run debug \
--rm -it \
--image=curlimages/curl \
--restart=Never \
-- sh
进入后执行:
curl -vk https://admission-webhook.webhook-system.svc/healthz
这个测试只能证明普通 Pod 到 Service 的网络和 TLS 情况,不能完全证明 kube-apiserver 到 Webhook 的路径正常,因为某些集群中 API Server 位于节点网络或独立控制平面网络。
十一、常见误区
11.1 把 Webhook 当作“最终存储前唯一入口”
Admission Webhook 只对匹配到的 API 请求生效。以下情况都可能不符合预期:
- 规则没有匹配子资源;
- 只匹配了一个 API 版本;
- 使用了过窄的命名空间选择器;
- 请求走的是另一个资源或子资源;
- 某些内部控制器使用了不同的操作路径。
如果策略必须覆盖所有入口,应明确列出资源、操作、版本和子资源,并通过测试验证,而不是只看 Webhook 是否被创建。
11.2 认为 Validating 一定能看到“用户提交的原始对象”
Validating Webhook 看到的是经过前置 Mutation 后的对象。若需要检查原始输入,应理解:
request.object是当前阶段对象;- Mutation 可能已经改变它;
oldObject只适用于更新或删除场景;- API Server 可能进行版本转换后再发送对象。
11.3 用 Ignore 解决所有可用性问题
failurePolicy: Ignore 可以降低故障对 API 写入的影响,但它同时创造了策略旁路。正确做法不是统一选择 Fail 或 Ignore,而是按策略性质拆分:
- 安全边界策略:通常更接近
Fail; - 非关键元数据注入:可以评估
Ignore; - 不能接受长时间阻塞的业务策略:缩小匹配范围并降低处理依赖,而不是简单忽略。
11.4 认为 Service 已存在就代表证书正确
Service、Endpoint、TLS 证书和 caBundle 是四个独立状态:
Service DNS 正确
Endpoint 可用
服务端证书 SAN 正确
API Server 信任对应 CA
缺少任何一个条件都可能导致调用失败。
十二、Admission Webhook 与 CRD Conversion Webhook 的区别
两者都使用 Webhook 形式,但职责不同。
Admission Webhook
输入是:
AdmissionReview
处理的是一次 API 请求,结果是:
- Mutation Patch;
- 允许;
- 拒绝;
- 调用失败。
CRD Conversion Webhook
处理的是同一个 CRD 在不同 API 版本之间的对象转换。它需要考虑:
- Hub 版本;
- Spoke 版本;
- 每个版本之间的字段映射;
- 不可逆转换;
- 存储版本;
- 旧版本客户端;
- 升级和回滚兼容性。
例如,v1alpha1 中字段:
spec:
replicas: 3
在 v1 中可能改成:
spec:
scale:
replicas: 3
Conversion Webhook 负责在这些表示之间转换;Admission Webhook 负责判断转换后的请求是否符合策略。把二者混在同一个处理器中,容易造成版本、存储和策略职责耦合。
十三、设计取舍总结
一个可维护的 Admission Webhook 通常应满足以下因果条件:
- 匹配范围足够小,因为每个匹配请求都会增加 API 写路径延迟;
- Mutation 幂等,因为对象可能被重复处理或重新调用;
- Validation 只判断,避免把外部副作用放进同步请求;
- AdmissionReview 响应严格合法,尤其是 UID、PatchType 和 Content-Type;
- TLS SAN、Service DNS 和 caBundle 一致,否则 API Server 无法信任服务;
- 副本无状态并通过 Service 暴露,单个 Pod 故障不应导致全部请求失败;
- 故障策略与安全目标一致,
Fail和Ignore分别代表不同的风险; - 排除自管理资源或准备恢复路径,防止 Webhook 启动和升级形成死锁;
- 通过审计、日志、指标和实际 Dry Run 验证,不能只检查配置对象是否存在。
Admission Webhook 的核心不是“实现一个 HTTPS 接口”,而是把一个同步、可失败、位于所有匹配 API 写请求关键路径中的策略组件,设计成确定性、可观测、可升级并且能够在故障时恢复的 Kubernetes 扩展。
系列导航与关联阅读
- 系列入口:Kubernetes 完整学习路线:从 Pod 与控制面到安全、运维和 Operator
- 上一篇:Kubernetes Operator 模式:领域状态机、升级、备份、恢复和测试
- 下一篇:Kubernetes CRD Conversion Webhook:Hub、Spoke、兼容、存储和回滚
- 延伸:Kubernetes Admission Control:内置插件、Webhook、策略、失败和可用性
官方资料
本文依据 Kubernetes、CNCF 与相关项目官方文档重新梳理;正文和生产清单由 WR BLOG 编写。

评论
0 条讨论