Kubernetes 基础体系 · 第 79/83 篇。示例基于 Kubernetes 当前稳定 API;弃用、版本偏差、云厂商差异和生产风险会明确说明。

Kubernetes Admission Webhook 开发:Mutating、Validating、证书和高可用

Kubernetes API 请求经过认证和授权后,还会进入 Admission Control 阶段。Admission Webhook 是其中一种扩展机制:API Server 将待处理的对象发送给外部 HTTPS 服务,由该服务决定是否修改对象,或决定是否允许请求继续。

本文围绕四个核心问题展开:

  1. Mutating Webhook 如何修改对象,修改结果如何返回;
  2. Validating Webhook 如何校验对象,以及它与 Mutating 的执行关系;
  3. API Server 如何通过 TLS 证书安全调用 Webhook;
  4. Webhook 故障、超时、升级和多副本场景下,如何保证集群可用性。

示例使用当前稳定的 admissionregistration.k8s.io/v1admission.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

如果同一个请求被重复处理,或者由于重新调用机制被再次调用,就可能得到两个同名容器。这会导致对象非法,或者使行为难以预测。

更安全的逻辑是:

  1. 查找是否已经存在名为 injector 的容器;
  2. 如果存在且配置符合预期,不再修改;
  3. 如果不存在,才生成 Patch;
  4. 如果存在但配置错误,明确拒绝或进行确定性替换。

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,使它有机会根据新对象重新计算结果。

例如:

  1. Webhook A 根据容器列表添加注解;
  2. Webhook B 注入 sidecar;
  3. sidecar 改变了容器列表;
  4. 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 CREATEUPDATEDELETECONNECT
object 请求中的新对象
oldObject 更新或删除前的旧对象
dryRun 是否为试运行请求
namespace 命名空间资源的命名空间
subResource 子资源,例如 status
userInfo 用户名、用户组和附加信息
options API 操作选项

删除请求通常没有新的 object,需要从 oldObject 读取被删除对象。

Webhook 必须:

  1. 读取 AdmissionReview.request
  2. 根据 resourceoperation 和对象内容处理请求;
  3. 返回相同 uidAdmissionReview.response
  4. 正确设置 allowed
  5. Mutation 时正确设置 patchpatchType

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",
	))
}

上面的代码还缺少 fmttypes 导入。完整导入应为:

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 是否调用它,由 MutatingWebhookConfigurationValidatingWebhookConfiguration 决定。

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 写复数资源名,如 podsdeployments
  • 子资源要显式写成 pods/status 等;
  • scope 可以是 NamespacedCluster*

规则过宽会带来两个问题:

  1. Webhook 接收到大量不相关请求,增加延迟;
  2. 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 串行执行,则:

Tmutatingi=1NtiT_{\text{mutating}} \approx \sum_{i=1}^{N} t_i

若 Validating Webhook 并行执行,则其等待时间更接近:

Tvalidatingmax(t1,t2,,tN)T_{\text{validating}} \approx \max(t_1,t_2,\ldots,t_N)

这不是 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: ...

含义是:

  1. API Server 通过集群网络访问 Service;
  2. Service 将请求转发到 Webhook Pod;
  3. Webhook Pod 提供 TLS 服务;
  4. API Server 使用 caBundle 中的 CA 验证 Webhook 服务端证书;
  5. 服务端证书的 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 和自动轮换控制器。

无论使用哪种方案,都必须同时更新:

  1. Webhook Pod 挂载的服务端证书;
  2. clientConfig.caBundle 中的 CA 链;
  3. 证书轮换后的所有副本。

只更新 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 或命名空间,就可能形成启动死锁:

  1. Webhook Pod 被删除;
  2. Deployment 控制器创建新 Pod;
  3. 新 Pod 尚未 Ready;
  4. API Server 需要调用 Webhook 才能处理某些相关请求;
  5. Webhook 不可用,相关请求失败;
  6. 修复过程依赖被阻塞的 API 请求。

常见缓解方式是排除 Webhook 所在命名空间:

namespaceSelector:
  matchExpressions:
    - key: kubernetes.io/metadata.name
      operator: NotIn
      values:
        - webhook-system

或者让 Webhook 只匹配明确带有业务标签的命名空间,而不是全局匹配。

这不是无条件规则:如果 Webhook 的设计就是管理自身命名空间,就必须为启动、升级和故障恢复设计独立路径,例如临时修改 WebhookConfiguration、保留集群管理员绕过手段,或使用不依赖该 Webhook 的恢复流程。

8.4 升级和证书轮换

Webhook 升级需要同时考虑三个状态:

  1. 旧 Pod 是否仍能处理请求;
  2. 新 Pod 是否已经通过 readiness;
  3. 新证书是否与 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 对象并不存在。

更稳妥的分层是:

  1. Admission Webhook 只做快速、确定性的对象检查和修改;
  2. 对象持久化成功后,由控制器异步执行外部副作用;
  3. 控制器通过状态字段报告异步结果。

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 侧可以通过审计日志确认:

  1. 请求是否到达 API Server;
  2. 是否进入 Admission;
  3. 是否被某个 Webhook 拒绝;
  4. 最终响应耗时和错误原因。

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 是否意外排除了目标命名空间;
  • failurePolicytimeoutSeconds 是否符合预期。

在集群内测试 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 写入的影响,但它同时创造了策略旁路。正确做法不是统一选择 FailIgnore,而是按策略性质拆分:

  • 安全边界策略:通常更接近 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 通常应满足以下因果条件:

  1. 匹配范围足够小,因为每个匹配请求都会增加 API 写路径延迟;
  2. Mutation 幂等,因为对象可能被重复处理或重新调用;
  3. Validation 只判断,避免把外部副作用放进同步请求;
  4. AdmissionReview 响应严格合法,尤其是 UID、PatchType 和 Content-Type;
  5. TLS SAN、Service DNS 和 caBundle 一致,否则 API Server 无法信任服务;
  6. 副本无状态并通过 Service 暴露,单个 Pod 故障不应导致全部请求失败;
  7. 故障策略与安全目标一致FailIgnore 分别代表不同的风险;
  8. 排除自管理资源或准备恢复路径,防止 Webhook 启动和升级形成死锁;
  9. 通过审计、日志、指标和实际 Dry Run 验证,不能只检查配置对象是否存在。

Admission Webhook 的核心不是“实现一个 HTTPS 接口”,而是把一个同步、可失败、位于所有匹配 API 写请求关键路径中的策略组件,设计成确定性、可观测、可升级并且能够在故障时恢复的 Kubernetes 扩展。


系列导航与关联阅读

官方资料

本文依据 Kubernetes、CNCF 与相关项目官方文档重新梳理;正文和生产清单由 WR BLOG 编写。