跳转至

API Server:集群的统一入口

kube-apiserver 是控制面中公开 Kubernetes HTTP API 的核心组件。kubectl、Controller、Scheduler 和 Kubelet 都通过它读取或更新 API 对象;通过校验的写请求由其存储层写入 etcd。API Server 通过 etcd Watch 更新内存中的 Watch Cache,完整的数据流和客户端续接语义见 etcd Watch 机制。

这里以创建 default/vllm-demo Deployment 为例,说明一条写请求在 API Server 中的处理过程。kubectl 提交资源后,API Server 依次执行身份认证、授权、Mutating Admission、资源对象校验和 Validating Admission,再通过存储层将 Deployment 写入 etcd。未启用服务端 dry-run 时,返回 201 Created 表示 Deployment 已完成持久化。

Deployment 资源清单

vllm-demo Deployment 中需要关注以下配置:

  • Pod 选择器:spec.selector.matchLabels 与 spec.template.metadata.labels 都使用 app: vllm-demo,满足 Deployment selector 必须匹配 Pod template label 的校验规则。
  • 运行时与 GPU:runtimeClassName: nvidia 选择节点上的 nvidia runtime handler。limits.nvidia.com/gpu: "1" 为 Pod 申请一张 GPU。
  • 模型缓存:vllm-model-cache PVC 挂载到 /root/.cache/huggingface,Pod 重建后可以继续使用已经下载的模型文件。
  • vLLM 进程:容器使用 vllm/vllm-openai:v0.27.1 镜像,执行 vllm serve Qwen/Qwen3-0.6B --port 8000。
examples/chapter-01/12-vllm/02-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-demo
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: vllm-demo
  template:
    metadata:
      labels:
        app: vllm-demo
    spec:
      runtimeClassName: nvidia
      containers:
        - name: vllm
          image: vllm/vllm-openai:v0.27.1
          command: ["vllm", "serve"]
          args: ["Qwen/Qwen3-0.6B", "--port", "8000"]
          ports:
            - containerPort: 8000
          resources:
            limits:
              nvidia.com/gpu: "1"
          volumeMounts:
            - name: model-cache
              mountPath: /root/.cache/huggingface
      volumes:
        - name: model-cache
          persistentVolumeClaim:
            claimName: vllm-model-cache

使用 kubectl apply 提交 Deployment:

kubectl apply -f examples/chapter-01/12-vllm/02-deployment.yaml

首次创建 Deployment 时,请求目标是:

POST /apis/apps/v1/namespaces/default/deployments

下图展示了 Deployment 创建请求从 TLS 连接到持久化的主要路径,包含身份认证、授权、准入、资源对象校验和存储。

flowchart LR
    K["kubectl / REST client"] --> T["TLS 与 RequestInfo"]
    T --> AU["Authentication\n调用者是谁"]
    AU --> AZ["Authorization\n能否创建 Deployment"]
    AZ --> MA["Mutating Admission\n允许修改对象"]
    MA --> V["资源对象校验\nDeployment 固有规则"]
    V --> VA["Validating Admission\n允许拒绝对象"]
    VA --> S["Registry / Storage"]
    S --> E["etcd"]
    E --> R["201 Created"]

Deployment 写入 etcd 后,后续组件继续通过 API Server 读取和更新对象。工作负载对象的创建见 Controller:从 Deployment 到 Pod,节点选择见 Scheduler:Pod 的节点选择,节点执行见 Kubelet:节点上的 Pod 管理。

身份认证

客户端先与 API Server 建立 TLS 连接。kubectl 从 kubeconfig 读取 API 地址、CA 和客户端凭据,认证模块据此得到用户名与用户组。API Server 支持 ServiceAccount Token、客户端证书、OIDC Token 和认证 Webhook 等认证方式。

认证模块完成凭据校验后,使用 user.Info 保存用户名、UID、用户组和附加属性。Kubernetes v1.36.4 的源码定义包含四个方法:

type Info interface {
    GetName() string
    GetUID() string
    GetGroups() []string
    GetExtra() map[string][]string
}

user.DefaultInfo 是这个接口的常用实现。例如,一个经过 OIDC 认证的用户可以表示为:

&user.DefaultInfo{
    Name:   "alice@example.com",
    UID:    "00u123456",
    Groups: []string{"platform-admins", "system:authenticated"},
    Extra: map[string][]string{
        "example.com/tenant": []string{"ai-platform"},
    },
}

Name、UID、Groups 和 Extra 分别对应四个 getter。具体值由认证模块填写,客户端证书认证通常提供用户名和用户组,OIDC 或认证 Webhook 还可以提供 UID 与附加属性。API Server 将这份信息放入请求上下文,授权、审计和准入随后读取它。

以下命令通过当前 kubeconfig 查询自己的 user.Info,输出位于 status.userInfo:

kubectl auth whoami -o yaml

认证失败通常返回 401 Unauthorized;允许匿名请求的集群会将未认证请求识别为匿名用户,并送入授权阶段。

请求授权

授权阶段判断当前身份能否执行请求中的操作。

RBAC 使用 Role 保存权限规则,使用 RoleBinding 将这些权限授予用户、用户组或 ServiceAccount。下面的 Role 允许在 default namespace 创建 Deployment;RoleBinding 将这项权限授予用户 alice@example.com。

examples/chapter-01/02-01-rbac/vllm-deployment-creator.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: vllm-deployment-creator
  namespace: default
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: alice-vllm-deployment-creator
  namespace: default
subjects:
  - kind: User
    name: alice@example.com
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: vllm-deployment-creator
  apiGroup: rbac.authorization.k8s.io

这组配置只允许 alice@example.com 在 default namespace 创建 Deployment。

集群管理员可以模拟 Alice 的身份,验证这项权限:

kubectl auth can-i create deployments.apps \
  --namespace default \
  --as alice@example.com

使用 --as 需要当前身份具备 impersonate 权限。Alice 使用自己的 kubeconfig 时可以省略该参数。RBAC 允许请求后,API Server 继续执行 Admission 和类型校验;授权拒绝时返回 403 Forbidden。

准入控制

准入控制位于授权之后、对象持久化之前,主要处理创建、更新和删除等资源写操作。Mutating Admission 可以修改请求对象,Validating Admission 根据对象和策略决定是否放行。GET、LIST 和 WATCH 等读取操作直接绕过准入控制。

API Server 先运行 Mutating Admission,再完成资源对象校验,随后运行 Validating Admission。任一准入插件拒绝请求,当前对象都不会写入 etcd。完整的处理顺序和插件列表见 Kubernetes 中的准入控制。

准入阶段

准入控制分为修改和校验两个阶段。Mutating Admission 先修改或拒绝对象;通过修改阶段的对象再进入 Validating Admission。校验阶段保留对象内容,可以放行、拒绝、告警或记录审计信息;任一拒绝结果都会终止当前请求。

下图在 Kubernetes 官方流程图中补充了 MutatingAdmissionPolicy。四个准入扩展点的相对顺序是 MutatingAdmissionPolicy、Mutating Webhook、ValidatingAdmissionPolicy 和 Validating Webhook。

Kubernetes Admission Control 阶段

图:Kubernetes v1.36 Admission Control 阶段。在 Kubernetes 官方流程图基础上补充 MutatingAdmissionPolicy,CC BY 4.0。

MutatingAdmissionPolicy

Kubernetes v1.36 提供 Stable 的 MutatingAdmissionPolicy。它在 API Server 进程内执行 CEL,使用 ApplyConfiguration 或 JSON Patch 修改对象,不需要部署 HTTPS Webhook。具体 API 和补丁类型见 Mutating Admission Policy。

配置示例:为 Deployment 添加注解

这组配置为 default 命名空间中新建的 Deployment 添加 admission.example.com/managed-by 注解。Policy 定义修改内容,Binding 激活 Policy,并用 namespaceSelector 限定生效范围。

examples/chapter-01/02-02-admission/01-mutating-admission-policy/policy.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicy
metadata:
  name: add-managed-by-annotation.example.com
spec:
  failurePolicy: Fail
  reinvocationPolicy: Never
  matchConstraints:
    resourceRules:
      # 这条 Policy 只修改新建的 apps/v1 Deployment。
      - apiGroups: ["apps"]
        apiVersions: ["v1"]
        operations: ["CREATE"]
        resources: ["deployments"]
  mutations:
    - patchType: ApplyConfiguration
      applyConfiguration:
        # ApplyConfiguration 合并目标 annotation,保留对象中已有的其他 annotation。
        expression: >-
          Object{
            metadata: Object.metadata{
              annotations: {
                "admission.example.com/managed-by": "mutating-admission-policy"
              }
            }
          }
---
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicyBinding
metadata:
  name: add-managed-by-annotation.example.com
spec:
  # Policy 只有被 Binding 引用后才会执行。
  policyName: add-managed-by-annotation.example.com
  matchResources:
    # Kubernetes 自动为 Namespace 添加这个不可变标签。
    namespaceSelector:
      matchLabels:
        kubernetes.io/metadata.name: default

Policy 和 Binding 写入集群后,服务端 dry-run 也会执行这条准入规则。下面的命令返回修改后的 Deployment,但不会把测试对象写入 etcd:

kubectl apply \
  -f examples/chapter-01/02-02-admission/01-mutating-admission-policy/policy.yaml
kubectl apply \
  --dry-run=server \
  -f examples/chapter-01/02-02-admission/01-mutating-admission-policy/input.yaml \
  -o yaml

输出中的 metadata.annotations 包含新增注解。MutatingAdmissionPolicy 必须配有引用它的 MutatingAdmissionPolicyBinding 才会生效。

可运行输入、Kubernetes v1.36.1 实测输出和清理命令见 MutatingAdmissionPolicy 示例。

Mutating Webhook

MutatingAdmissionWebhook 根据 MutatingWebhookConfiguration 中的资源、操作、命名空间和对象选择器匹配请求,再按顺序调用外部 HTTPS Webhook。API Server 使用 AdmissionReview 发送请求;Webhook 可以返回 JSON Patch 修改对象,也可以拒绝请求。

每个 Webhook 都可以配置超时和调用失败时的处理方式。timeoutSeconds 默认为 10 秒,failurePolicy 默认为 Fail。配置了 reinvocationPolicy: IfNeeded 的 Webhook 还可能在对象被后续 Webhook 修改后再次执行。

配置与代码示例:为 Pod 添加标签

这个示例只匹配 default 命名空间的 Pod 创建请求。API Server 调用 /mutate,Webhook 返回 JSON Patch,为 Pod 添加 admission.example.com/managed=true 标签。

配置中的连接信息需要与 Webhook 服务保持一致:

  • Service:clientConfig.service 指向 admission-demo/admission-webhook,Service 的 443 端口转发到程序监听的 8443 端口。
  • CA:应用配置前,把 ${CA_BUNDLE} 替换为签发服务端证书的 PEM CA 证书经过 base64 编码后的内容。
  • 服务端证书:SAN 包含 admission-webhook.admission-demo.svc,API Server 才能校验 Service 的身份。
examples/chapter-01/02-02-admission/02-mutating-webhook/webhook-configuration.yaml.tmpl
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: pod-labeler.example.com
webhooks:
  - name: pod-labeler.example.com
    admissionReviewVersions: ["v1"]
    sideEffects: None
    failurePolicy: Fail
    matchPolicy: Equivalent
    reinvocationPolicy: IfNeeded
    timeoutSeconds: 5
    clientConfig:
      service:
        namespace: admission-demo
        name: admission-webhook
        path: /mutate
        port: 443
      # 部署脚本生成 CA;注册前将占位符替换为 Secret 中的 base64 数据。
      caBundle: "${CA_BUNDLE}"
    namespaceSelector:
      matchLabels:
        kubernetes.io/metadata.name: default
    rules:
      # 只对 Pod CREATE 返回 JSON Patch,不处理后续 UPDATE。
      - operations: ["CREATE"]
        apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["pods"]
        scope: Namespaced

main.go 注册 /mutate 和 /validate 两个 HTTPS endpoint,并从挂载目录读取 TLS 证书:

examples/chapter-01/02-02-admission/00-webhook-server/main.go
package main

import (
    "log"
    "net/http"
    "time"
)

func main() {
    mux := newServeMux()
    server := &http.Server{
        Addr:              ":8443",
        Handler:           mux,
        ReadHeaderTimeout: 5 * time.Second,
    }

    log.Println("admission webhook listening on :8443")
    // kube-apiserver 只调用 HTTPS Webhook;证书通常由 Secret 挂载到 /tls。
    log.Fatal(server.ListenAndServeTLS("/tls/tls.crt", "/tls/tls.key"))
}

func newServeMux() *http.ServeMux {
    mux := http.NewServeMux()
    // 两个 WebhookConfiguration 使用不同 path,共用同一个 HTTPS 服务。
    mux.Handle("/mutate", admissionHandler(mutatePod))
    mux.Handle("/validate", admissionHandler(validatePod))
    // HTTPS 健康检查不进入 AdmissionReview 解码逻辑。
    mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
        w.WriteHeader(http.StatusOK)
    })
    return mux
}

公共 HTTP 入口负责 AdmissionReview 协议:

  • 响应中的 uid 与 request.uid 保持一致。
  • 请求和响应均使用 admission.k8s.io/v1。
  • Patch 保存 JSON Patch 原始字节。Go 编码 AdmissionReview 时会把 []byte 转为 API 所需的 base64 字符串。
examples/chapter-01/02-02-admission/00-webhook-server/admission.go
package main

import (
    "encoding/json"
    "fmt"
    "log"
    "mime"
    "net/http"

    admissionv1 "k8s.io/api/admission/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

type admissionFunc func(*admissionv1.AdmissionRequest) admissionv1.AdmissionResponse

func admissionHandler(handle admissionFunc) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            http.Error(w, "only POST is supported", http.StatusMethodNotAllowed)
            return
        }

        mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
        if err != nil || mediaType != "application/json" {
            http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
            return
        }

        var review admissionv1.AdmissionReview
        // 限制请求体大小,避免异常请求占用过多内存。
        body := http.MaxBytesReader(w, r.Body, 1<<20)
        if err := json.NewDecoder(body).Decode(&review); err != nil {
            http.Error(w, fmt.Sprintf("decode AdmissionReview: %v", err), http.StatusBadRequest)
            return
        }
        if review.Request == nil {
            http.Error(w, "AdmissionReview.request is required", http.StatusBadRequest)
            return
        }

        response := handle(review.Request)
        // AdmissionResponse 必须回显 request.uid,API Server 用它匹配请求和响应。
        response.UID = review.Request.UID
        responseReview := admissionv1.AdmissionReview{
            TypeMeta: metav1.TypeMeta{
                APIVersion: admissionv1.SchemeGroupVersion.String(),
                Kind:       "AdmissionReview",
            },
            Response: &response,
        }

        // allowed=false 也是一次成功的 Webhook 调用,因此业务拒绝仍返回 HTTP 200。
        w.Header().Set("Content-Type", "application/json")
        if err := json.NewEncoder(w).Encode(responseReview); err != nil {
            log.Printf("encode AdmissionReview response: %v", err)
        }
    })
}

func allowed() admissionv1.AdmissionResponse {
    return admissionv1.AdmissionResponse{Allowed: true}
}

func denied(code int32, message string) admissionv1.AdmissionResponse {
    return admissionv1.AdmissionResponse{
        Allowed: false,
        Result: &metav1.Status{
            Code:    code,
            Message: message,
        },
    }
}

/mutate handler 处理没有 label map、已有其他 label 和目标 label 已存在三种情况。JSON Pointer 中 label key 的 / 写成 ~1。

examples/chapter-01/02-02-admission/00-webhook-server/mutate.go
package main

import (
    "encoding/json"
    "net/http"

    admissionv1 "k8s.io/api/admission/v1"
    corev1 "k8s.io/api/core/v1"
)

const managedLabel = "admission.example.com/managed"

type jsonPatchOperation struct {
    Operation string `json:"op"`
    Path      string `json:"path"`
    Value     any    `json:"value,omitempty"`
}

func mutatePod(request *admissionv1.AdmissionRequest) admissionv1.AdmissionResponse {
    var pod corev1.Pod
    if err := json.Unmarshal(request.Object.Raw, &pod); err != nil {
        return denied(http.StatusBadRequest, "decode Pod: "+err.Error())
    }

    if pod.Labels[managedLabel] == "true" {
        // reinvocationPolicy=IfNeeded 可能再次调用 Webhook,重复执行时不再生成补丁。
        return allowed()
    }

    var patch jsonPatchOperation
    if pod.Labels == nil {
        // JSON Patch 不能向不存在的父路径添加子字段,先创建完整的 labels map。
        patch = jsonPatchOperation{
            Operation: "add",
            Path:      "/metadata/labels",
            Value:     map[string]string{managedLabel: "true"},
        }
    } else {
        // JSON Pointer 用 ~1 表示 label key 中的斜杠。
        patch = jsonPatchOperation{
            Operation: "add",
            Path:      "/metadata/labels/admission.example.com~1managed",
            Value:     "true",
        }
    }

    patchBytes, err := json.Marshal([]jsonPatchOperation{patch})
    if err != nil {
        return denied(http.StatusInternalServerError, "encode JSON Patch: "+err.Error())
    }

    patchType := admissionv1.PatchTypeJSONPatch
    return admissionv1.AdmissionResponse{
        Allowed:   true,
        PatchType: &patchType,
        // Patch 保存原始 JSON 字节;外层 AdmissionReview 编码时会自动转换为 base64。
        Patch: patchBytes,
    }
}

TLS、Service、WebhookConfiguration、输入 Pod 和实测输出见 Mutating Webhook 示例。

应用场景:Istio sidecar 自动注入

Istio 的 sidecar 模式会在加入网格的 Pod 中运行一个 istio-proxy 容器。这个容器基于 Envoy,接收重定向后的入站和出站 TCP 流量,并执行服务路由、负载均衡、重试、mTLS 和遥测采集。各 Pod 中的代理从 istiod 获取配置,共同组成 Istio 的数据面。流量重定向规则由 istio-init 或 Istio CNI 写入,具体方式取决于安装配置。详细的组件关系见 Istio Architecture。

自动注入发生在 Pod 的创建阶段。Istio 使用 MutatingWebhookConfiguration 注册 sidecar injector;API Server 根据 namespace 和 Pod label 匹配 Webhook,把 AdmissionReview 发送给 istiod。Webhook 返回的 JSON Patch 会在 PodSpec 中加入 istio-proxy、卷、环境变量和流量重定向所需的配置。直接创建的 Pod,以及由 ReplicaSet、StatefulSet、DaemonSet 或 Job 等控制器创建的 Pod,都按相同规则匹配。

运行下面的示例前,先按照 Install with Istioctl 安装 Istio。命令为 demo namespace 启用自动注入,再创建一个只有 NGINX 容器的 Pod:

kubectl create namespace demo
kubectl label namespace demo istio-injection=enabled --overwrite

kubectl run web \
  --namespace demo \
  --image nginx:1.27-alpine \
  --port 80

kubectl wait \
  --namespace demo \
  --for=condition=Ready pod/web \
  --timeout=120s

注入成功后,原本只有一个应用容器的 Pod 会显示 2/2,容器列表中包含 web 和 istio-proxy:

kubectl get pod web --namespace demo
kubectl get pod web \
  --namespace demo \
  -o jsonpath='{.spec.containers[*].name}{"\n"}'

Namespace label 只影响之后创建的 Pod。已有 Pod 需要重新创建,才能经过 sidecar injector。注入标签、revision label 和手动注入方式见 Installing the Sidecar。

资源对象校验

Mutating Admission 结束后,API Server 调用当前资源的 RESTCreateStrategy.Validate 校验修改后的对象。这些规则属于 Kubernetes API 对象本身的固定约束,由对应资源的服务端实现提供,不是 Admission Controller,也不由集群管理员配置。

对于 apps/v1 Deployment,deploymentStrategy.Validate 调用 ValidateDeployment。校验内容包括副本数不能为负数、selector 必须有效、Deployment strategy 取值合法,以及 Pod template 满足 Deployment 的约束。

spec.selector 与 spec.template.metadata.labels 的匹配检查位于 ValidatePodTemplateSpecForReplicaSet。两者不匹配时,API Server 返回 422 Invalid,Deployment 不会写入 etcd。kubectl 先把 YAML 转换为 API 请求,字段类型和未知字段由 API Server 在更早的解码阶段处理;资源对象校验检查解码后对象的字段取值及字段之间的关系。

ValidatingAdmissionPolicy

ValidatingAdmissionPolicy 让集群管理员用 CEL 校验提交给 Kubernetes API 的对象,例如要求对象包含指定标签、限制字段取值,或者检查多个字段之间的关系。校验在 API Server 进程内完成,不需要部署外部 Webhook。

Policy 定义匹配资源和 CEL 校验表达式,ValidatingAdmissionPolicyBinding 决定规则的生效范围与处理动作。Deny 拒绝请求,Warn 向客户端返回警告,Audit 为审计事件添加注解。

CEL 表达式可以读取 object、oldObject 和 request.userInfo 等请求信息。校验依赖镜像扫描结果或其他外部系统数据时,可以使用 Validating Webhook。

配置示例:Deployment 副本数规则

这组配置校验 default 命名空间中 Deployment 的创建和更新请求。Policy 定义 CEL 表达式,Binding 激活 Policy、限定命名空间,并通过 Deny 指定校验失败时的处理动作。

examples/chapter-01/02-02-admission/03-validating-admission-policy/policy.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: deployment-replica-limit.example.com
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      # 创建和修改 Deployment 都需要重新校验副本数。
      - apiGroups: ["apps"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["deployments"]
  validations:
    # CEL 返回 false 时,Binding 中的 Deny 动作使 API Server 拒绝请求。
    - expression: object.spec.replicas <= 5
      message: Deployment replicas must not exceed 5
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: deployment-replica-limit.example.com
spec:
  # Policy 定义校验内容,Binding 决定生效范围和失败动作。
  policyName: deployment-replica-limit.example.com
  validationActions: [Deny]
  matchResources:
    # 只校验 default 命名空间中的 Deployment。
    namespaceSelector:
      matchLabels:
        kubernetes.io/metadata.name: default

Policy 和 Binding 写入集群后,可以用服务端 dry-run 检查拒绝结果,测试对象不会写入 etcd:

kubectl apply \
  -f examples/chapter-01/02-02-admission/03-validating-admission-policy/policy.yaml
kubectl apply \
  --dry-run=server \
  -f examples/chapter-01/02-02-admission/03-validating-admission-policy/input-denied.yaml

object 是当前 Admission 请求中的 Deployment,表达式结果为 false 时,Binding 中的 Deny 使 API Server 返回拒绝。Policy 没有对应的 Binding 时不生效。完整字段和执行规则见 Validating Admission Policy。

ValidatingAdmissionPolicy:校验 Deployment 副本数 提供了两个可直接运行的 Deployment 清单:replicas: 3 用于验证请求放行,replicas: 6 用于验证 Deny 拒绝。README 还记录了 Kubernetes v1.36.1 的实际命令输出和清理步骤。

Validating Webhook

ValidatingAdmissionWebhook 根据 ValidatingWebhookConfiguration 匹配请求,并行调用外部 HTTPS Webhook。Webhook 读取修改阶段生成的最终对象、userInfo 和请求信息,再返回允许或拒绝结果;对象修改由前面的 Mutating Admission 完成。

外部服务可以结合镜像仓库、漏洞扫描结果或其他系统状态执行校验。匹配规则、AdmissionReview 格式和 TLS 配置见 动态准入控制。

配置与代码示例:Pod 安全字段校验

这个示例校验 default 命名空间中 Pod 的创建和更新请求。pods/ephemeralcontainers 子资源也在匹配范围内,因此通过 kubectl debug 添加的 Ephemeral Container 会进入 /validate。Webhook 检查三类容器的 securityContext.privileged 字段,并返回允许或拒绝结果。

clientConfig 与 Mutating Webhook 使用同一个 Service 和 CA,只把调用路径改为 /validate。Validating Webhook 没有 reinvocationPolicy,也不在响应中返回对象补丁。

examples/chapter-01/02-02-admission/04-validating-webhook/webhook-configuration.yaml.tmpl
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: pod-security.example.com
webhooks:
  - name: pod-security.example.com
    admissionReviewVersions: ["v1"]
    sideEffects: None
    failurePolicy: Fail
    matchPolicy: Equivalent
    timeoutSeconds: 5
    clientConfig:
      service:
        namespace: admission-demo
        name: admission-webhook
        path: /validate
        port: 443
      # API Server 使用这个 CA 校验 admission-webhook.admission-demo.svc 的证书。
      caBundle: "${CA_BUNDLE}"
    namespaceSelector:
      matchLabels:
        kubernetes.io/metadata.name: default
    rules:
      # Ephemeral Container 通过独立子资源更新,需要显式匹配 pods/ephemeralcontainers。
      - operations: ["CREATE", "UPDATE"]
        apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["pods", "pods/ephemeralcontainers"]
        scope: Namespaced

/validate handler 解码 AdmissionReview.object 中的 Pod。发现 privileged: true 时,它返回 allowed: false、status.code: 403 和具体容器名称;通过校验时返回 allowed: true。两种结果对应的 HTTP 响应状态都是 200 OK。

examples/chapter-01/02-02-admission/00-webhook-server/validate.go
package main

import (
    "encoding/json"
    "fmt"
    "net/http"

    admissionv1 "k8s.io/api/admission/v1"
    corev1 "k8s.io/api/core/v1"
)

func validatePod(request *admissionv1.AdmissionRequest) admissionv1.AdmissionResponse {
    var pod corev1.Pod
    if err := json.Unmarshal(request.Object.Raw, &pod); err != nil {
        return denied(http.StatusBadRequest, "decode Pod: "+err.Error())
    }

    for _, container := range pod.Spec.InitContainers {
        if isPrivileged(container.SecurityContext) {
            return denied(http.StatusForbidden, fmt.Sprintf("init container %q cannot be privileged", container.Name))
        }
    }
    for _, container := range pod.Spec.Containers {
        if isPrivileged(container.SecurityContext) {
            return denied(http.StatusForbidden, fmt.Sprintf("container %q cannot be privileged", container.Name))
        }
    }
    // pods/ephemeralcontainers 是独立子资源,WebhookConfiguration 需要显式匹配它。
    for _, container := range pod.Spec.EphemeralContainers {
        if isPrivileged(container.SecurityContext) {
            return denied(http.StatusForbidden, fmt.Sprintf("ephemeral container %q cannot be privileged", container.Name))
        }
    }

    return allowed()
}

func isPrivileged(context *corev1.SecurityContext) bool {
    // 未设置 privileged 与显式设置 false 都符合这条示例策略。
    return context != nil && context.Privileged != nil && *context.Privileged
}

普通 Pod、Privileged Pod 与 Privileged Ephemeral Container 的输入和实测输出见 Validating Webhook 示例。

应用场景:Kyverno Pod 策略校验

Kyverno 安装 ValidatingWebhookConfiguration,由 Admission Controller 根据策略检查 AdmissionReview.object。下面的 ValidatingPolicy 将普通容器、Init Container 和 Ephemeral Container 合并到 allContainers;任一容器设置 securityContext.privileged: true 时,策略返回拒绝。

apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: disallow-privileged-containers
spec:
  validationActions:
    - Deny
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods", "pods/ephemeralcontainers"]
  variables:
    - name: allContainers
      expression: >-
        object.spec.containers +
        object.spec.?initContainers.orValue([]) +
        object.spec.?ephemeralContainers.orValue([])
  validations:
    - expression: >-
        variables.allContainers.all(
          c,
          c.?securityContext.?privileged.orValue(false) == false
        )
      message: Privileged containers are not allowed in Pods

validationActions: [Deny] 使 Pod 的创建或更新直接失败。相同机制还可以限制镜像 registry、必填 label、HostPath、Linux capabilities 和资源配置。

Webhook 接收的是 API Server 解码后的对象,因此这类策略检查字段是否存在、字段值和对象之间的关系。YAML 的缩进、键顺序和注释不会进入 AdmissionReview.object,原始文本格式需要由 YAML linter 检查;Kyverno CLI 可以在提交前执行同一组对象规则。

示例使用 Kyverno 当前的 ValidatingPolicy API;旧版 ClusterPolicy 已在当前文档中列为 Deprecated。字段定义和更多规则见 Kyverno ValidatingPolicy。

内置 Admission Controller

内置 Admission Controller 编译在 kube-apiserver 中。API Server 通过 --enable-admission-plugins 和 --disable-admission-plugins 管理这些插件。

常见插件包括:

  • LimitRanger:根据命名空间中的 LimitRange 补充默认资源请求,并校验资源上下限。
  • ResourceQuota:检查本次写入是否超过命名空间的资源配额。
  • PodSecurity:按照命名空间配置的 Pod Security Standards 执行 enforce、audit 和 warn。
  • ServiceAccount:Pod 未指定 serviceAccountName 时填入 default,并校验引用的 ServiceAccount。
  • RuntimeClass:校验 Pod 引用的 RuntimeClass,并应用对应的 Pod 开销与调度约束。
  • NodeRestriction:限制 Kubelet 可以修改的 Node 和 Pod 对象范围。

各插件的默认启用状态、执行阶段和配置参数记录在 Admission Controller 参考文档。

规则来源与处理方式

Deployment 自身的固定约束与集群策略使用不同的处理机制:

需求 处理方式
Deployment 固有约束,例如 selector 与 Pod template label 必须匹配 资源对象校验(RESTCreateStrategy.Validate)
命名空间级资源默认值、配额、Pod 安全和节点权限 内置 Admission Controller
使用声明式规则修改对象 MutatingAdmissionPolicy
通过外部服务注入 sidecar 或补充动态配置 Mutating Webhook
使用声明式规则校验对象 ValidatingAdmissionPolicy
结合镜像扫描结果或其他外部数据校验对象 Validating Webhook

API 版本转换

apiVersion 指定客户端请求和响应采用的资源结构;storage version 指定 API Server 写入 etcd 的资源结构。两者不同时,API Server 在读写过程中完成版本转换。同一资源可以同时提供多个对外版本,而 etcd 中的新写入统一使用当前 storage version。

API Server 从不同位置获得内置资源和 Custom Resource 的类型定义,因此两类资源使用不同的转换实现:

  • Kubernetes 内置资源:Deployment、Pod 等资源的外部 Go 类型、内部 Go 类型和转换函数都随 Kubernetes 源码编译进 kube-apiserver。例如,客户端提交的 Deployment 对应 apps/v1.Deployment,API Server 内部使用 apps/__internal.Deployment。这些类型和转换函数注册在 runtime.Scheme 中;API Server 通过这份注册表完成请求版本、内部类型和 storage version 之间的转换。整个过程在 API Server 进程内完成,相关概念见 Storage Versions。

  • CRD 提供的 Custom Resource:标准 CRD 不能把资源专用的 Go 类型和转换函数注册到 kube-apiserver 的 runtime.Scheme。API Server 使用 unstructured.Unstructured 保存解码后的字段,也就是以通用的键值结构表示对象;CRD schema 仍然负责字段默认值、裁剪和校验。spec.conversion.strategy 只支持 None 和 Webhook:None 在 API Server 进程内执行,但只修改 apiVersion,不属于 typed conversion;Webhook 通过 ConversionReview 调用外部 HTTPS 服务。Kubebuilder 生成的 Go 类型和 Hub/Spoke 转换方法运行在 Webhook 进程中,不会被动态加载到 kube-apiserver。具体行为见 Versions in CustomResourceDefinitions。

如果自定义 API 必须在提供该 API 的进程内完成 typed conversion,可以实现 Extension API Server,并通过 API Aggregation Layer 接入 Kubernetes。外部版本、内部类型和转换函数会注册到 Extension API Server 自己的 runtime.Scheme;这种资源由 Aggregated API 提供,不再是 CRD。

CRD 转换能力的版本历史
Kubernetes 版本 CRD 的版本与转换能力
v1.7–v1.10 CRD 只有一个 spec.version,不存在跨版本转换。
v1.11–v1.12 CRD 开始支持多个版本,但不提供字段转换,只适合 schema 保持一致的版本升级。
v1.13–v1.14 Conversion Webhook 进入 Alpha,CRD 的转换策略增加 None 和 Webhook。
v1.15 Conversion Webhook 进入 Beta。
v1.16 Conversion Webhook 进入 Stable。

早期的多版本 CRD 允许同一种资源通过多个 API version 访问。API Server 在版本之间只替换 apiVersion,其他字段保持不变;字段改名、嵌套结构调整和语义变化都无法转换。这个行为相当于现在的 conversion.strategy: None。

版本转换解决的问题

API 发布后仍会继续演进,常见变化包括字段改名、字段拆分、嵌套结构调整和默认值变化。已经部署的客户端可能继续提交旧版本,etcd 中也可能保留由旧 storage version 写入的对象。

以 ModelServer 为例,v1beta1 和 v1 表达相同的模型、服务副本和端口,但字段结构不同:

语义 v1beta1 v1
模型 spec.modelRef spec.model.name
副本数 spec.scale.replicas spec.replicas
HTTP 端口 spec.http.port spec.server.port

客户端通过 v1beta1 创建对象时,API Server 调用 Conversion Webhook,把对象转换成当前 storage version v1 后写入 etcd。客户端再次通过 v1beta1 读取时,API Server 从 etcd 取得 v1 对象,再调用 Webhook 转换为 v1beta1 响应。

CRD 版本字段

CRD 在 spec.versions 中分别控制 REST endpoint 和 etcd 写入格式:

  • served:true 时,API Server 为该版本提供 REST endpoint,并把它发布到 API discovery。
  • storage:指定 Custom Resource 写入 etcd 时使用的版本。一个 CRD 必须且只能有一个 storage: true 的版本。
  • status.storedVersions:记录 etcd 中可能仍然存在的 storage version。完成数据迁移后才能从这里和 spec.versions 中移除旧版本。

同一个版本可以同时设置 served: false 和 storage: false。API Server 不会为它安装 REST handler,也不会用它写入 etcd;CRD 仍需保留该版本的 structural schema。

转换机制

资源与策略 转换位置 适用范围
Kubernetes 内置资源 API Server 进程内的 typed conversion Deployment、Pod 等编译进 Kubernetes 的 API
CRD None API Server 只替换 apiVersion 各版本字段结构和语义相同
CRD Webhook 外部 HTTPS Conversion Webhook 字段改名、拆分、合并或语义变化

内置资源的 typed conversion

Deployment 的外部版本是 apps/v1.Deployment,API Server 使用的内部类型是 apps/__internal.Deployment。请求按 apps/v1 解码并应用该版本的默认值,再通过 runtime.Scheme 转换成内部类型。Storage Codec 写入 etcd 前把内部对象转换成 storage version;读取时先恢复内部类型,再按客户端访问的 served version 编码响应。

flowchart LR
    W["apps/v1 wire object"] --> D["Decode + apps/v1 defaults"]
    D --> I["apps/__internal Deployment"]
    I --> R["Registry / REST Strategy"]
    R --> S["Storage Codec"]
    S --> E["etcd storage version"]
    E --> S2["Storage Codec"]
    S2 --> I2["apps/__internal Deployment"]
    I2 --> O["requested served version"]

相关代码按职责分布在以下目录:

路径 作用
staging/src/k8s.io/api/apps/v1/types.go 对外发布的 apps/v1 Go 类型
pkg/apis/apps/types.go API Server 使用的 apps/__internal 类型
pkg/apis/apps/v1/zz_generated.conversion.go conversion-gen 生成的字段转换
pkg/apis/apps/v1/conversion.go 生成器无法表达的手写兼容逻辑
pkg/apis/apps/install/install.go 将内部类型、外部版本和转换函数注册到同一个 Scheme

内置资源的 served version 与 API Server 能解码的历史版本不完全相同。旧 endpoint 被移除后,请求无法再访问该版本;API Server 仍可保留相应类型和转换函数,用于读取升级前写入的历史数据。

CRD 的 None 策略

None 是 CRD 的默认转换策略。API Server 保留对象字段,只把返回对象的 apiVersion 改成目标版本。这个方式不处理字段改名或结构变化,适用于各版本 schema 相同的 CRD。

spec:
  conversion:
    strategy: None

版本之间存在字段差异时,None 可能让目标版本得到缺失字段,或者让 unknown-field pruning 删除无法识别的字段。此类 API 需要 Conversion Webhook。

CRD 的 Webhook 策略

Webhook 策略通过 CRD 的 spec.conversion 指向 HTTPS 服务。API Server 使用 ConversionReview 传递转换请求:

  • request.objects 保存待转换的源对象。
  • request.desiredAPIVersion 指定目标版本。
  • 响应按输入顺序返回相同数量的对象,并保留 name、namespace 和 UID。
{
  "apiVersion": "apiextensions.k8s.io/v1",
  "kind": "ConversionReview",
  "request": {
    "uid": "2b9f5bb9-6fc5-4b3f-a730-4e9d39b0f0a4",
    "desiredAPIVersion": "ai.course.example.com/v1",
    "objects": [
      {
        "apiVersion": "ai.course.example.com/v1beta1",
        "kind": "ModelServer",
        "metadata": {"name": "qwen-demo", "namespace": "default"},
        "spec": {
          "modelRef": "Qwen/Qwen3-0.6B",
          "scale": {"replicas": 1},
          "http": {"port": 8000}
        }
      }
    ]
  }
}

Conversion Webhook 只负责不同 API 版本之间的等价转换。字段合法性由各版本的 OpenAPI schema 和 Admission 规则处理;转换失败不应被用作准入策略。

Conversion Webhook 的 Hub 设计

CRD 需要转换字段结构时,三种方案都配置 conversion.strategy: Webhook,API Server 按相同协议调用外部 Webhook。下表比较的是 Webhook 内部的三种 Hub/Spoke 组织方案,并不是三种并列的 CRD 转换机制。

Hub 方案 Hub 的位置 版本演进方式
固定的非公开 CRD 版本 Hub 保留在 CRD 的 spec.versions 中,并设置 served: false、storage: false 对外版本和 storage version 持续演进,所有版本都与固定 Hub 相互转换
最新公开版本 当前最新版本同时作为 Hub,通常也作为 storage version 发布新版本时更换 Hub 并更新转换代码;同时切换 storage version 时迁移存量对象
Webhook 内部类型 Hub 只定义在 Webhook Server 的 Go 代码中,不出现在 CRD 中 对外版本和 storage version 可以独立演进,源版本先转为内部类型,再转为目标版本

三种方案在 CRD 中都启用同一种转换策略:

spec:
  conversion:
    strategy: Webhook

下图中的双向箭头表示 Webhook Server 内部维护的转换函数。API Server 只提交源对象和目标 apiVersion,不参与 Hub/Spoke 转换。

固定的非公开 CRD 版本

固定 Hub 保留在 CRD 的 spec.versions 中,通过 served: false 关闭 REST endpoint,通过 storage: false 排除 etcd 写入。对外版本和 storage version 可以继续演进,每个新版本只需增加与固定 Hub 之间的双向转换。

flowchart LR
    B["v1beta1<br/>served=true"] <--> H["v1alpha1 Hub<br/>declared in CRD<br/>served=false<br/>storage=false"]
    V["v1<br/>served=true<br/>storage=true"] <--> H

最新公开版本

最新公开版本同时作为 Hub,通常也作为 storage version。发布 v2 后,Hub 从 v1 切换到 v2,原来的 v1 变成 Spoke,Webhook 需要补充旧版本与新 Hub 之间的转换。如果同时把 storage version 切换到 v2,还要另行迁移以旧版本保存的存量对象。

flowchart LR
    subgraph R1["发布 v2 之前"]
        B1["v1beta1 Spoke"] <--> H1["v1 Hub<br/>served=true<br/>storage=true"]
    end
    subgraph R2["发布 v2 之后"]
        B2["v1beta1 Spoke"] <--> H2["v2 Hub<br/>served=true<br/>storage=true"]
        V1["v1 Spoke"] <--> H2
    end
    H1 -. "Hub 随最新版本切换" .-> H2

Webhook 内部类型

内部 Hub 只存在于 Webhook Server 的 Go 代码中,不列入 CRD 的 spec.versions。Webhook 把源版本转换成内部类型,再由内部类型转换成目标版本;对外版本和 storage version 的变化不会改变 Hub 的 API 可见性。

flowchart LR
    subgraph C["CRD spec.versions"]
        B["v1beta1<br/>served=true"]
        V["v1<br/>served=true<br/>storage=true"]
    end
    subgraph W["Conversion Webhook process"]
        H["internal.ModelServer Hub<br/>not declared in CRD"]
    end
    B <--> H
    V <--> H

Kubebuilder Multi-Version API 介绍了 controller-runtime 的 Hub/Spoke 接口;Kubernetes 多版本 API 转换最佳实践 对三种 Hub 结构给出了进一步比较。

Webhook conversion 的实现

本仓库的 Kubebuilder 示例采用固定的非公开 CRD 版本作为 Hub。v1alpha1 保留在 CRD 中,但不提供 REST endpoint,也不作为 storage version;v1beta1 和 v1 分别实现与该 Hub 之间的双向转换。

版本 served storage 作用
v1alpha1 false false CRD 中声明但不对外提供的固定 Hub
v1beta1 true false 使用分组字段的公开版本
v1 true true 稳定公开版本和当前 etcd 存储版本
flowchart LR
    S["Source<br/>v1beta1.ModelServer"] --> C1["v1beta1.ConvertTo()"]
    C1 --> H["v1alpha1.ModelServer<br/>conversion.Hub"]
    H --> C2["v1.ConvertFrom()"]
    C2 --> T["Target<br/>v1.ModelServer"]

v1alpha1 在 controller-runtime 中实现 conversion.Hub。API Server 发送的 ConversionReview 只包含源对象和目标 apiVersion,不会请求 v1alpha1 endpoint。controller-runtime 根据源对象和目标版本构造两个 Spoke,在 Webhook 进程内依次调用源版本的 ConvertTo() 和目标版本的 ConvertFrom()。反向转换使用相同的 Hub 和相反的 Spoke 顺序。

实现位于 examples/chapter-01/02-03-crd-conversion。项目使用 Kubebuilder v4.15.0、controller-runtime v0.24.1 和 controller-tools v0.21.0。

以下命令为 v1alpha1 生成 Hub,为两个公开版本生成 Spoke 方法和 /convert Webhook 配置:

kubebuilder create webhook \
  --group ai \
  --version v1alpha1 \
  --kind ModelServer \
  --conversion \
  --spoke v1beta1,v1

Kubebuilder v4.15.0 会给 Hub 自动添加 +kubebuilder:storageversion。本例删除该 marker,给 v1alpha1 添加 +kubebuilder:unservedversion:

examples/chapter-01/02-03-crd-conversion/api/v1alpha1/modelserver_types.go
// +kubebuilder:object:root=true
// +kubebuilder:unservedversion
// +kubebuilder:subresource:status

// ModelServer 是转换专用 Hub,不提供 REST API,也不作为 storage version。
type ModelServer struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`
    Spec              ModelServerSpec   `json:"spec"`
    Status            ModelServerStatus `json:"status,omitempty"`
}

v1 根类型使用 +kubebuilder:storageversion,生成 CRD 时成为唯一 storage version:

examples/chapter-01/02-03-crd-conversion/api/v1/modelserver_types.go
// +kubebuilder:object:root=true
// +kubebuilder:storageversion
// +kubebuilder:subresource:status

// ModelServer 是 v1 API 的根类型,也是当前 storage version。
type ModelServer struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`
    Spec              ModelServerSpec   `json:"spec"`
    Status            ModelServerStatus `json:"status,omitempty"`
}

v1alpha1 通过空的 Hub() 方法实现 conversion.Hub。v1beta1 和 v1 实现 conversion.Convertible:

examples/chapter-01/02-03-crd-conversion/api/v1alpha1/modelserver_conversion.go
package v1alpha1

import "sigs.k8s.io/controller-runtime/pkg/conversion"

var _ conversion.Hub = &ModelServer{}

// Hub 将 v1alpha1 标记为 ModelServer 的唯一转换中心。
func (*ModelServer) Hub() {}

v1beta1 的转换函数展示了两个方向的字段映射。类型断言失败时返回明确错误,ObjectMeta 使用 DeepCopy,避免转换结果与源对象共享 map 或 slice:

examples/chapter-01/02-03-crd-conversion/api/v1beta1/modelserver_conversion.go
package v1beta1

import (
    "fmt"

    "sigs.k8s.io/controller-runtime/pkg/conversion"

    v1alpha1 "github.com/cr7258/course/examples/chapter-01/02-03-crd-conversion/api/v1alpha1"
)

var _ conversion.Convertible = &ModelServer{}

// ConvertTo 把 v1beta1 的分组字段写入 Hub 的规范化表示。
func (src *ModelServer) ConvertTo(dstRaw conversion.Hub) error {
    dst, ok := dstRaw.(*v1alpha1.ModelServer)
    if !ok {
        return fmt.Errorf("expected *v1alpha1.ModelServer, got %T", dstRaw)
    }

    dst.ObjectMeta = *src.ObjectMeta.DeepCopy()
    dst.Spec.ModelID = src.Spec.ModelRef
    dst.Spec.ReplicaCount = src.Spec.Scale.Replicas
    dst.Spec.HTTPPort = src.Spec.HTTP.Port
    dst.Status.Phase = src.Status.Phase
    return nil
}

// ConvertFrom 把 Hub 的规范化字段恢复成 v1beta1 的分组字段。
func (dst *ModelServer) ConvertFrom(srcRaw conversion.Hub) error {
    src, ok := srcRaw.(*v1alpha1.ModelServer)
    if !ok {
        return fmt.Errorf("expected *v1alpha1.ModelServer, got %T", srcRaw)
    }

    dst.ObjectMeta = *src.ObjectMeta.DeepCopy()
    dst.Spec.ModelRef = src.Spec.ModelID
    dst.Spec.Scale.Replicas = src.Spec.ReplicaCount
    dst.Spec.HTTP.Port = src.Spec.HTTPPort
    dst.Status.Phase = src.Status.Phase
    return nil
}

manager 将三个版本注册到同一个 Scheme。Hub 即使不提供 REST endpoint,也必须注册,否则 controller-runtime 无法构造 Hub 对象:

examples/chapter-01/02-03-crd-conversion/cmd/main.go
utilruntime.Must(aiv1alpha1.AddToScheme(scheme))
utilruntime.Must(aiv1beta1.AddToScheme(scheme))
utilruntime.Must(aiv1.AddToScheme(scheme))

Webhook manager 为 ModelServer 注册 controller-runtime 的 conversion handler:

examples/chapter-01/02-03-crd-conversion/internal/webhook/v1alpha1/modelserver_webhook.go
package v1alpha1

import (
    ctrl "sigs.k8s.io/controller-runtime"

    aiv1alpha1 "github.com/cr7258/course/examples/chapter-01/02-03-crd-conversion/api/v1alpha1"
)

// SetupModelServerWebhookWithManager 为 ModelServer 注册 conversion handler。
func SetupModelServerWebhookWithManager(mgr ctrl.Manager) error {
    return ctrl.NewWebhookManagedBy(mgr, &aiv1alpha1.ModelServer{}).
        Complete()
}

CRD 的 conversion 配置指向 Webhook Service 的 /convert。Conversion Webhook 使用 CRD 字段,不创建 MutatingWebhookConfiguration 或 ValidatingWebhookConfiguration:

examples/chapter-01/02-03-crd-conversion/config/crd/patches/webhook_in_modelservers.yaml
# The following patch enables a conversion webhook for the CRD
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: modelservers.ai.course.example.com
spec:
  conversion:
    strategy: Webhook
    webhook:
      clientConfig:
        service:
          namespace: system
          name: webhook-service
          path: /convert
      conversionReviewVersions:
      - v1

下图展示了客户端通过 v1beta1 更新对象、API Server 以 v1 写入 etcd,再把响应转换回 v1beta1 的过程:

sequenceDiagram
    participant C as Client
    participant A as API Server
    participant W as Conversion Webhook
    participant E as etcd

    C->>A: PUT ai.course.example.com/v1beta1
    A->>W: ConversionReview(objects=v1beta1, desiredAPIVersion=v1)
    Note over W: v1beta1.ConvertTo(v1alpha1 Hub)
    Note over W: v1.ConvertFrom(v1alpha1 Hub)
    W-->>A: convertedObjects=v1
    A->>E: store ai.course.example.com/v1
    E-->>A: stored v1 object + revision
    A->>W: ConversionReview(objects=v1, desiredAPIVersion=v1beta1)
    Note over W: v1.ConvertTo(v1alpha1 Hub)
    Note over W: v1beta1.ConvertFrom(v1alpha1 Hub)
    W-->>A: convertedObjects=v1beta1
    A-->>C: v1beta1 response

读取方向使用相同协议。客户端访问 v1beta1 endpoint 时,API Server 从 etcd 读取 v1,再请求 Webhook 转换为 v1beta1。LIST 请求可以在一份 ConversionReview 中携带多个对象,Webhook 必须保持输入顺序和对象数量。

Conversion Webhook 位于 Custom Resource 的读写路径。服务不可用、证书失效或返回对象不符合协议时,需要转换的读取和写入都会失败。生产部署需要覆盖以下运行条件:

  • Webhook 使用多个可用副本。
  • 服务端证书能够自动轮换。
  • 转换延迟和超时受到监控。
  • 协议错误和调用失败触发告警。

仓库中的 verify-kind.sh 创建独立的 crd-conversion-demo Kind 集群,生成临时 TLS 证书并部署 Webhook。脚本检查以下结果:

  • v1alpha1 不出现在 API discovery,v1beta1 和 v1 都有 REST endpoint。
  • 对象通过 v1beta1 创建后,可以通过 v1 读取。
  • 对象通过 v1 更新后,v1beta1 和 v1 返回相同副本数。
  • control-plane 中的 etcd 原始值使用 ai.course.example.com/v1。
  • CRD 的 status.storedVersions 只有 v1。
cd examples/chapter-01/02-03-crd-conversion
./verify-kind.sh

Kubernetes v1.36.1 的实测结果如下。输入对象、完整输出、依赖版本和清理命令记录在 CRD 多版本转换示例。

v1alpha1 REST endpoint: not served
Created with v1beta1: modelRef=Qwen/Qwen3-0.6B scale.replicas=1 http.port=8000
Read with v1: model.name=Qwen/Qwen3-0.6B replicas=1 server.port=8000
Updated with v1: replicas=3
Read after update: v1beta1.scale.replicas=3 v1.replicas=3
etcd apiVersion: ai.course.example.com/v1
CRD status.storedVersions: v1
Conversion webhook verification passed.

Storage version 迁移

把 storage: true 从 v1beta1 切换到 v1,只影响之后的写入。etcd 中已有对象仍保持 v1beta1 编码,CRD 的 status.storedVersions 会继续记录曾经用作存储的版本。

切换 storage version 后不必立即重写旧对象。只要 CRD 仍保留旧版本,并且 Conversion Webhook 支持新旧版本之间的转换,API Server 就能读取旧对象,并按客户端请求的版本返回。此时旧对象仍依赖旧版本 schema 和对应的转换代码,版本迁移还没有完成。

从 CRD 的 spec.versions 删除旧版本,或者从 Webhook 删除旧版本的转换代码之前,必须先把已有对象重写为当前 storage version。Kubernetes v1.36 提供 Beta 的 StorageVersionMigration API,默认关闭。启用时,需要在 kube-apiserver 和 kube-controller-manager 打开 StorageVersionMigrator feature gate,并在 kube-apiserver 启用 storagemigration.k8s.io/v1beta1 API。

无法启用该功能时,可以按照 CustomResourceDefinition 的版本 中的手动迁移流程,列出并重新写入全部现有对象,使 API Server 按当前 storage version 保存。确认迁移完成后,再更新 status.storedVersions。

仅切换 storage version 不会让 status.storedVersions 自动删除旧条目。对象重写完成后检查该字段;如果迁移工具没有更新它,再显式保留当前版本:

kubectl patch crd modelservers.ai.course.example.com \
  --subresource=status \
  --type=merge \
  -p '{"status":{"storedVersions":["v1"]}}'

存储迁移与客户端迁移是两项独立工作。仍有客户端访问旧 API 时,可以继续保留该 served version;所有客户端完成迁移后,再把旧版本设为 served: false。旧版本既不再 served,也不再出现在 status.storedVersions 后,才可以从 CRD schema 和 Webhook 转换代码中删除。

对象版本

以下命令读取 vllm-demo Deployment 的对象标识和版本字段:

kubectl get --raw \
  '/apis/apps/v1/namespaces/default/deployments/vllm-demo' \
  | jq '{
      name: .metadata.name,
      uid: .metadata.uid,
      resourceVersion: .metadata.resourceVersion,
      generation: .metadata.generation
    }'

metadata.uid 标识一个对象实例;同名对象删除重建后会得到新的 UID。resourceVersion 的顺序、读取路径与续接方式见 etcd Watch 机制。对于 Deployment,PrepareForUpdate 在 spec 或 metadata.annotations 变化时递增 generation。

核心源码

API Server 在启动时组装服务、注册资源路由,并创建准入插件与存储对象。请求到达后,HTTP 过滤器补充请求信息并执行认证、流控和授权;资源 Handler 负责解码与准入,Registry 组织资源校验和读写,Storage 实现最终的数据访问。

以下调用关系以 Kubernetes v1.36.4 源码为依据,写入路径使用内置的 apps/v1 Deployment。源码片段保留关键字段和调用,使用 ... 省略与当前机制无关的分支。

  • 服务组成与启动:CreateServerChain 组装 APIExtensionsServer、KubeAPIServer 和 APIAggregator,PrepareRun 与 Run 完成服务准备和监听。
  • API 注册与请求路由:资源提供者创建 REST Storage,InstallREST 根据它实现的接口安装 GET、LIST、CREATE、UPDATE 等操作对应的 HTTP Handler。
  • HTTP 过滤器链:DefaultBuildHandlerChain 将请求信息、认证、审计、流控和授权包装在路由外层,请求通过这些过滤器后进入资源 Handler。
  • 对象解码与创建:createHandler 把请求体解码为内部对象,执行 Mutating Admission,并把对象和校验回调交给资源的 Create 方法。
  • 准入插件管理与调用:Plugins 保存插件工厂,NewFromPlugins 创建有序插件链;创建请求在 Handler 中调用 Admit,在 Registry 中通过回调调用 Validate。
  • REST Strategy 与通用 Registry:Deployment Strategy 提供资源规则,Store 复用通用创建、更新和读取流程,并连接底层 Storage。
  • 对象持久化与响应:Storage 对对象编码并执行 etcd 写入,再把保存结果交回 Handler;服务端 dry-run 执行准入与校验后返回对象。
  • 更新、补丁与并发控制:PUT 与 PATCH 汇入 Store.Update,通过 GuaranteedUpdate 处理当前对象、更新回调和条件写入。
  • 读取、监听与 Watch Cache:CacheDelegator 根据请求语义选择缓存或底层存储,Cacher 提供缓存读取与事件流,后台 LIST/WATCH 持续更新缓存。

API Server 源码总览

服务组成与启动

kube-apiserver 在同一进程内组合 CRD、内置资源和聚合 API 三套服务,通过委派链把请求交给对应的处理器。服务构建完成后,进程准备 OpenAPI 与健康检查,再启动 HTTPS 监听和启动后钩子。

服务组成与启动

服务构建与委派

CreateServerChain 按依赖顺序创建三个服务,每次把已经创建的服务作为下一层的委派目标:

  • APIExtensionsServer:安装 CRD API,并为自定义资源提供处理入口;委派链末端连接未找到资源的处理器。
  • KubeAPIServer:安装 Pod、Deployment 等内置资源 API;将 APIExtensionsServer 作为委派目标。
  • APIAggregator:管理 APIService 并接入扩展 API 服务;将 KubeAPIServer 作为委派目标,最终返回给进程入口。

构建顺序是 APIExtensionsServer → KubeAPIServer → APIAggregator。请求从最外层 APIAggregator 进入,本层未接管的路径沿相反方向委派;命中内置资源的处理器后,执行对应的 REST 操作。聚合 API 的代理路径由 APIService 配置决定。

三个构造调用分别接收下一层 Handler,并返回当前层服务对象:

kubernetes/cmd/kube-apiserver/app/server.go
func CreateServerChain(config CompletedConfig) (*aggregatorapiserver.APIAggregator, error) {
    notFoundHandler := notfoundhandler.New(
        config.KubeAPIs.ControlPlane.Generic.Serializer,
        genericapifilters.NoMuxAndDiscoveryIncompleteKey,
    )
    // 先创建委派链最内层的 CRD 服务。
    apiExtensionsServer, err := config.ApiExtensions.New(
        genericapiserver.NewEmptyDelegateWithCustomHandler(notFoundHandler),
    )
    ...
    crdAPIEnabled := config.ApiExtensions.GenericConfig.MergedResourceConfig.ResourceEnabled(
        apiextensionsv1.SchemeGroupVersion.WithResource("customresourcedefinitions"),
    )

    // 内置资源服务把未接管的路径委派给 CRD 服务。
    kubeAPIServer, err := config.KubeAPIs.New(apiExtensionsServer.GenericAPIServer)
    ...

    // 聚合服务位于最外层,接收进程的请求入口。
    aggregatorServer, err := controlplaneapiserver.CreateAggregatorServer(
        config.Aggregator,
        kubeAPIServer.ControlPlane.GenericAPIServer,
        apiExtensionsServer.Informers.Apiextensions().V1().CustomResourceDefinitions(),
        crdAPIEnabled,
        apiVersionPriorities,
    )
    ...
    return aggregatorServer, nil
}

三个服务通过进程内 Handler 连接。通用服务的构造代码 将下一层的 UnprotectedHandler() 传给 NewAPIServerHandler,后者把它设置为路由未匹配时的处理入口。委派使用下一层的路由 Handler,已执行的外层过滤器链无需再次运行。

监听与启动后钩子

Run 读取选项、完成配置并创建委派链,再调用返回对象的 PrepareRun 和 Run:

kubernetes/cmd/kube-apiserver/app/server.go
func Run(ctx context.Context, opts options.CompletedOptions) error {
    ...
    config, err := NewConfig(opts)
    ...
    completed, err := config.Complete()
    ...
    server, err := CreateServerChain(completed)
    ...

    // API 安装完成后,准备服务运行所需的路由和检查。
    prepared, err := server.PrepareRun()
    ...

    // 运行返回的最外层聚合服务。
    return prepared.Run(ctx)
}
  • PrepareRun:聚合服务调用通用服务的 PrepareRun。通用实现先递归准备委派目标,再安装 OpenAPI、healthz、livez 和 readyz;聚合服务随后准备聚合 OpenAPI。
  • RunWithContext:preparedAPIAggregator.Run 将调用交给通用服务,后者组织运行及关闭阶段,并调用 NonBlockingRunWithContext 启动监听。
  • NonBlockingRunWithContext:先通过 SecureServingInfo.Serve 启动 HTTPS 服务,再调用 RunPostStartHooks,为每个钩子启动独立 goroutine,启动 Informer、控制器等后台任务。

启动后钩子与 HTTP 服务并发运行;是否可以接收正常流量,由就绪检查反映。监听端口已启动与全部初始化任务完成是两个不同的状态。

API 注册与请求路由

内置资源在启动时把 REST Storage 注册到 API 组,再由安装器根据 Storage 实现的 Go 接口创建 HTTP 路由。以 Deployment 为例,POST /apis/apps/v1/namespaces/default/deployments 最终匹配创建路由,并调用持有 Deployment Storage 的处理函数。

API 注册与请求路由

资源与 Storage 映射

StorageProvider.NewRESTStorage 创建 apps API 组信息,调用 v1Storage 收集资源,再把返回值写入 APIGroupInfo.VersionedResourcesStorageMap["v1"]。这个字段的类型是 map[string]map[string]rest.Storage,两层键分别为 API 版本和资源路径。

deployments、deployments/status 和 deployments/scale 是三个独立的映射项,分别连接主资源、状态子资源和扩缩容子资源的 REST 实现。deploymentstore.NewStorage 在内部调用 NewREST,再组装这些实现;请求路由安装器接收的是这组资源处理接口。

v1Storage 在 Deployment API 启用时创建这些 Storage,并按资源路径写入映射:

kubernetes/pkg/registry/apps/rest/storage_apps.go
func (p StorageProvider) v1Storage(
    apiResourceConfigSource serverstorage.APIResourceConfigSource,
    restOptionsGetter generic.RESTOptionsGetter,
) (map[string]rest.Storage, error) {
    storage := map[string]rest.Storage{}

    if resource := "deployments"; apiResourceConfigSource.ResourceEnabled(
        appsapiv1.SchemeGroupVersion.WithResource(resource),
    ) {
        deploymentStorage, err := deploymentstore.NewStorage(restOptionsGetter)
        if err != nil {
            return storage, err
        }
        // 为主资源和子资源分别提供 REST 实现。
        storage[resource] = deploymentStorage.Deployment
        storage[resource+"/status"] = deploymentStorage.Status
        storage[resource+"/scale"] = deploymentStorage.Scale
    }
    ...
    return storage, nil
}

InstallAPIs 收集各资源提供者返回的 APIGroupInfo,将具名 API 组交给 InstallAPIGroups。随后按以下层次完成安装:

  • InstallAPIGroups:检查 API 组与版本信息,准备 OpenAPI 类型信息,再调用 installAPIResources。
  • installAPIResources:遍历组内启用的版本,创建对应的 APIGroupVersion,将资源映射、编解码器等配置交给版本级安装器。
  • APIGroupVersion.InstallREST:组合 /apis/apps/v1 前缀,创建 APIInstaller,安装资源路由和版本发现接口,再把 WebService 加入 GoRestfulContainer。
  • APIInstaller.Install:按排序后的资源路径遍历 Storage,逐项调用 registerResourceHandlers。

接口能力与处理函数

registerResourceHandlers 读取资源作用域、类型和 Storage 的接口能力,再决定可以安装哪些操作。例如,rest.Creater 提供创建能力,rest.Getter 提供单对象读取能力,rest.Lister 提供列表读取能力;安装器据此生成 POST、GET 等路由。

对于 Deployment 的创建操作,安装器为 POST 选择 restfulCreateResource,再通过 ws.POST(action.Path).To(handler) 将处理函数绑定到资源路径。RequestScope 随处理函数一起传入,保存资源身份、对象类型、编解码器、名称与命名空间解析方式和字段管理等请求处理上下文。

安装器通过接口类型断言取得创建能力,再将它绑定到 POST 路由的闭包中:

kubernetes/staging/src/k8s.io/apiserver/pkg/endpoints/installer.go
func (a *APIInstaller) registerResourceHandlers(
    path string, storage rest.Storage, ws *restful.WebService,
) (*metav1.APIResource, *storageversion.ResourceInfo, error) {
    ...
    // 资源实现的接口决定可以安装哪些操作。
    creater, isCreater := storage.(rest.Creater)
    namedCreater, isNamedCreater := storage.(rest.NamedCreater)
    lister, isLister := storage.(rest.Lister)
    getter, isGetter := storage.(rest.Getter)
    ...
    for _, action := range actions {
        ...
        switch action.Verb {
        ...
        case request.MethodPost:
            var handler restful.RouteFunction
            if isNamedCreater {
                handler = restfulCreateNamedResource(namedCreater, reqScope, admit)
            } else {
                handler = restfulCreateResource(creater, reqScope, admit)
            }
            ...
        }
    }
    ...
}

func restfulCreateResource(
    r rest.Creater, scope handlers.RequestScope, admit admission.Interface,
) restful.RouteFunction {
    return func(req *restful.Request, res *restful.Response) {
        // 请求命中路由后,进入通用创建 Handler。
        handlers.CreateResource(r, &scope, admit)(res.ResponseWriter, req.Request)
    }
}

注册阶段建立“路径、HTTP 方法、REST 实现”的对应关系;请求到达时,闭包把原始 HTTP 请求、响应写入器和预先保存的 Storage 交给 handlers.CreateResource。对象解码、准入和持久化由后续创建路径完成。

HTTP 过滤器链

请求进入资源 Handler 前,先经过一组嵌套的 http.Handler。这些过滤器解析请求身份、补充 Context、执行访问检查或排队,并在条件满足时调用下一层。DefaultBuildHandlerChain 从资源路由向外包装 Handler,因此源码中的包装顺序与请求进入顺序相反。

HTTP 过滤器链

包装顺序与请求顺序

DefaultBuildHandlerChain 先用 WithAuthorization 包装 apiHandler,再向外加入 APF、用户模拟、审计和认证等过滤器。例如先构造 Authorization(apiHandler),再构造 Authentication(Authorization(apiHandler)) 时,请求会先执行 Authentication。

图中保留的主要执行顺序为:WithAuditInit → WithRequestInfo → WithAuthentication → WithAudit → WithConstrainedImpersonation → WithPriorityAndFairness → WithAuthorization → apiHandler。其中 APF 排队发生在操作授权检查之前;日志、超时、Tracing 等过滤器穿插在完整链中。

APF 已配置、受约束用户模拟开启时,主要包装调用如下。ConstrainedImpersonation 在所述版本中默认开启,其他分支及辅助过滤器用 ... 省略。

kubernetes/staging/src/k8s.io/apiserver/pkg/server/config.go
func DefaultBuildHandlerChain(apiHandler http.Handler, c *Config) http.Handler {
    handler := apiHandler
    ...
    // 先包装靠近资源路由的授权层。
    handler = genericapifilters.WithAuthorization(handler, c.Authorization.Authorizer, c.Serializer)
    ...
    if c.FlowControl != nil {
        workEstimatorCfg := flowcontrolrequest.DefaultWorkEstimatorConfig()
        requestWorkEstimator := flowcontrolrequest.NewWorkEstimator(
            c.StorageObjectCountTracker.Get, c.FlowControl.GetInterestedWatchCount,
            workEstimatorCfg, c.FlowControl.GetMaxSeats,
        )
        ...
        handler = genericfilters.WithPriorityAndFairness(
            handler, c.LongRunningFunc, c.FlowControl,
            requestWorkEstimator, c.RequestTimeout/4,
        )
        ...
    }
    ...
    if c.FeatureGate.Enabled(genericfeatures.ConstrainedImpersonation) {
        handler = impersonation.WithConstrainedImpersonation(
            handler, c.Authorization.Authorizer, c.Serializer,
        )
        ...
    }
    ...
    handler = genericapifilters.WithAudit(
        handler, c.AuditBackend, c.AuditPolicyRuleEvaluator, c.LongRunningFunc,
    )
    ...
    failedHandler := genericapifilters.Unauthorized(c.Serializer)
    failedHandler = genericapifilters.WithFailedAuthenticationAudit(
        failedHandler, c.AuditBackend, c.AuditPolicyRuleEvaluator,
    )
    ...
    handler = genericapifilters.WithAuthentication(
        handler, c.Authentication.Authenticator, failedHandler,
        c.Authentication.APIAudiences, c.Authentication.RequestHeaderConfig,
    )
    ...
    // 后包装的层先收到请求,为内层提供请求信息与审计上下文。
    handler = genericapifilters.WithRequestInfo(handler, c.RequestInfoResolver)
    ...
    handler = genericapifilters.WithAuditInit(handler)
    return handler
}

Context 与短路返回

过滤器通过请求 Context 传递已经解析的数据。WithRequestInfo 调用 NewRequestInfo,将 URL 和 HTTP 方法转换为结构化的资源操作。例如,向 /apis/apps/v1/namespaces/default/deployments 发送 POST,会得到 API 组 apps、版本 v1、命名空间 default、资源 deployments 和逻辑操作 create。

过滤器把 RequestInfo 写入新 Context,再调用内层 Handler:

kubernetes/staging/src/k8s.io/apiserver/pkg/endpoints/filters/requestinfo.go
func WithRequestInfo(handler http.Handler, resolver request.RequestInfoResolver) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
        ctx := req.Context()
        info, err := resolver.NewRequestInfo(req)
        ...

        // 使用携带 RequestInfo 的新 Context 继续执行内层 Handler。
        req = req.WithContext(request.WithRequestInfo(ctx, info))
        handler.ServeHTTP(w, req)
    })
}

认证成功后,WithAuthentication 把用户信息写入同一个请求的 Context。后续用户模拟、APF 和授权过滤器据此读取请求者及资源操作;授权通过后才继续进入 apiHandler。

发生拒绝时,过滤器直接写入响应并返回。认证链未返回有效身份时调用 failedHandler,由 Unauthorized 生成 401 Unauthorized 响应;包装它的 WithFailedAuthenticationAudit 按审计配置记录这条失败路径。操作授权明确拒绝时返回 403 Forbidden。这些分支都在资源 Handler 执行之前结束请求。

对象解码与创建

创建 Handler 把 HTTP 请求体转换成资源的内部对象,执行 Mutating Admission,再把对象和校验回调交给 REST Storage。以 Deployment 为例,客户端提交 apps/v1 对象,Handler 和 Registry 通过 runtime.Object 接口传递内存中的 *apps.Deployment,具体资源规则由后续的 Deployment Strategy 执行。

对象解码与创建

请求上下文与对象解码

同一套 Handler 需要服务不同资源、版本和子资源。RequestScope 保存这条资源路由所需的处理能力:

  • Resource、Kind、Subresource 标识当前资源、对象类型和子资源。
  • Namer 从请求中取得名称与命名空间;Serializer 参与输入、输出格式协商。
  • Creater、Convertor、Defaulter、Typer 提供对象创建、版本转换、默认值填充和类型识别能力。
  • HubGroupVersion 指定内存处理使用的版本;Deployment 使用内部版本。
  • FieldManager 维护字段管理信息,例如 metadata.managedFields。

每次请求的取消信号、用户身份和命名空间保存在请求的 context.Context 中。RequestScope 则由路由安装时配置,供该路由的 Handler 使用。

CreateResource 通过适配器调用 createHandler。Handler 读取请求体和 CreateOptions 后,使用资源的 New 方法取得对象容器,再通过 DecoderToVersion 把解码目标设为 HubGroupVersion。runtime.Object 提供类型信息访问和深拷贝接口,允许通用 Handler 接收不同的 API 对象。

createHandler 的解码路径 根据 fieldValidation 选择解码器。Warn 和 Strict 都使用严格解码器,后续错误分支再决定把未知字段等问题作为警告还是错误处理。

kubernetes/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/create.go
func createHandler(r rest.NamedCreater, scope *RequestScope, admit admission.Interface, includeName bool) http.HandlerFunc {
    return func(w http.ResponseWriter, req *http.Request) {
        ...
        defaultGVK := scope.Kind
        original := r.New()

        // 按字段校验模式选择普通或严格解码器。
        validationDirective := fieldValidation(options.FieldValidation)
        decodeSerializer := s.Serializer
        if validationDirective == metav1.FieldValidationWarn || validationDirective == metav1.FieldValidationStrict {
            decodeSerializer = s.StrictSerializer
        }

        // 解码目标是后续内存处理使用的版本。
        decoder := scope.Serializer.DecoderToVersion(decodeSerializer, scope.HubGroupVersion)
        ...
        obj, gvk, err := decoder.Decode(body, &defaultGVK, original)
        ...
    }
}

创建调用与校验回调

解码完成后,Handler 检查对象的命名空间,清理客户端提供的系统元数据,并把对象、用户、资源、操作类型和 dry-run 标志封装成准入属性。FieldManager.UpdateNoErrors 更新字段管理信息,随后调用 MutationInterface.Admit。

Validating Admission 通过回调传给资源的 Create 方法。AdmissionToValidateObjectFunc 返回一个 ValidateObjectFunc:执行这个回调时,它依据传入对象重新构造准入属性,补齐可能由 Registry 生成的名称,再调用 ValidationInterface.Validate。

requestFunc 保存创建操作;定义闭包时尚未执行 r.Create。调用顺序是更新字段管理信息、执行 Admit,然后执行闭包,把对象和新构造的校验回调传入 Registry。

kubernetes/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/create.go
admissionAttributes := admission.NewAttributesRecord(
    obj, nil, scope.Kind, namespace, name, scope.Resource,
    scope.Subresource, admission.Create, options,
    dryrun.IsDryRun(options.DryRun), userInfo,
)

// 定义创建操作;校验回调由 Registry 在合适的阶段执行。
requestFunc := func() (runtime.Object, error) {
    return r.Create(
        ctx, name, obj,
        rest.AdmissionToValidateObjectFunc(admit, admissionAttributes, scope),
        options,
    )
}
...
result, err := finisher.FinishRequest(ctx, func() (runtime.Object, error) {
    liveObj, err := scope.Creater.New(scope.Kind)
    if err != nil {
        return nil, fmt.Errorf("failed to create new object (Create for %v): %v", scope.Kind, err)
    }
    obj = scope.FieldManager.UpdateNoErrors(liveObj, obj, managerOrUserAgent(options.FieldManager, req.UserAgent()))
    admit = fieldmanager.NewManagedFieldsValidatingAdmissionController(admit)

    // 变更准入通过后才调用资源创建接口。
    if mutatingAdmission, ok := admit.(admission.MutationInterface); ok && mutatingAdmission.Handles(admission.Create) {
        if err := mutatingAdmission.Admit(ctx, admissionAttributes, scope); err != nil {
            return nil, err
        }
    }
    ...
    result, err := requestFunc()
    ...
    return result, err
})

创建成功后,transformResponseObject 按协商的媒体类型和响应版本编码结果,返回 201 Created。服务端 dry-run 也经过这条响应路径,是否持久化由传入存储层的 dry-run 标志决定。

准入插件管理与调用

API Server 在启动时按配置创建准入插件,并把实例保存到同一条有序插件链中。处理请求时,Admit 和 Validate 分别遍历这条链,选择支持当前操作且实现相应接口的插件。一个插件可以同时参与两个阶段。

准入插件管理与调用

插件注册与初始化

RegisterAllAdmissionPlugins 汇总内置插件的注册入口。各插件通过 Plugins.Register 把名称和工厂函数写入注册表,工厂函数负责根据插件配置创建实例。

AdmissionOptions.ApplyTo 取得启用的插件列表,读取配置,准备 Client、Informer 等共享依赖,再调用 NewFromPlugins。插件的排列遵循选项中的推荐顺序和启用、禁用配置。

NewFromPlugins 逐个读取配置并初始化插件;InitPlugin 调用工厂、注入依赖,再执行初始化检查。完成的链经过重入包装和指标包装后,保存到 Config.AdmissionControl。

kubernetes/staging/src/k8s.io/apiserver/pkg/admission/plugins.go
func (ps *Plugins) NewFromPlugins(pluginNames []string, configProvider ConfigProvider, pluginInitializer PluginInitializer, decorator Decorator) (Interface, error) {
    handlers := []Interface{}
    ...
    for _, pluginName := range pluginNames {
        pluginConfig, err := configProvider.ConfigFor(pluginName)
        if err != nil {
            return nil, err
        }

        // 创建实例、注入依赖并校验初始化结果。
        plugin, err := ps.InitPlugin(pluginName, pluginConfig, pluginInitializer)
        if err != nil {
            return nil, err
        }
        if plugin != nil {
            if decorator != nil {
                handlers = append(handlers, decorator.Decorate(plugin, pluginName))
            } else {
                handlers = append(handlers, plugin)
            }
            ...
        }
    }
    ...
    // 两个准入阶段共用同一组有序插件实例。
    return newReinvocationHandler(chainAdmissionHandler(handlers)), nil
}

请求阶段的插件分派

创建请求在 Handler 内调用 Admit,完成资源策略处理后,Registry 再通过 createValidation 回调调用 Validate。两次调用共享插件配置和实例,接收到的对象状态则取决于各自的执行阶段。

chainAdmissionHandler 先调用 Handles 检查操作类型,再检查插件是否实现当前阶段的接口。匹配的插件按配置顺序执行,任一插件返回错误都会立即终止本次调用。

kubernetes/staging/src/k8s.io/apiserver/pkg/admission/chain.go
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 {
            err := mutator.Admit(ctx, a, o)
            if err != nil {
                return err
            }
        }
    }
    return nil
}

func (admissionHandler chainAdmissionHandler) Validate(ctx context.Context, a Attributes, o ObjectInterfaces) error {
    for _, handler := range admissionHandler {
        if !handler.Handles(a.GetOperation()) {
            continue
        }
        // 校验阶段复用插件列表,并选择校验接口。
        if validator, ok := handler.(ValidationInterface); ok {
            err := validator.Validate(ctx, a, o)
            if err != nil {
                return err
            }
        }
    }
    return nil
}

reinvoker.Admit 在首次调用后检查 ShouldReinvoke()。需要重入时,它标记重入上下文并再次调用变更链,插件根据自身策略和上下文决定是否重新处理对象。reinvoker.Validate 则直接转发给校验链。

这里的有序执行指准入插件链;Mutating Webhook、Validating Webhook 各自的请求分派由对应插件实现。ValidationInterface.Validate 的接口约定要求校验过程保持对象内容不变。

REST Strategy 与通用 Registry

通用 Registry 负责对象创建的公共流程,资源 Strategy 提供对象类型自身的处理规则。Deployment 的 REST Storage 嵌入 genericregistry.Store,把 deployment.Strategy 配置为 CreateStrategy;创建时,Store 调用 Strategy 清理字段、校验内容,再执行准入校验和底层存储操作。

REST Strategy 与通用 Registry

资源策略与存储配置

NewREST 给 Store 配置 NewFunc、NewListFunc 和各类 Strategy,再通过 CompleteWithOptions 补齐存储选项。NewFunc 返回内部类型 *apps.Deployment,让通用流程能够创建与当前资源对应的对象。

创建 Deployment 时,deploymentStrategy 提供以下处理:

  • PrepareForCreate 清空 status,把 metadata.generation 设为 1,并按功能开关处理 Pod template 中的字段。
  • Validate 调用 ValidateDeployment,检查 Deployment 和 Pod template 的资源约束。
  • WarningsOnCreate 收集对象名称、Pod template 等方面的警告。
  • Canonicalize 提供校验后的规范化入口;Deployment 在此版本中的实现为空。

例如,客户端即使提交 metadata.generation: 99,创建策略仍会将它设为 1。这一赋值发生在 Handler 的 Mutating Admission 之后、Validating Admission 之前。

资源对象校验

rest.BeforeCreate 先检查系统元数据、名称和命名空间,再依次执行资源准备、资源校验、通用元数据校验、警告生成和规范化。任一校验返回错误,创建流程立即结束。

kubernetes/staging/src/k8s.io/apiserver/pkg/registry/rest/create.go
func BeforeCreate(strategy RESTCreateStrategy, ctx context.Context, obj runtime.Object) error {
    ...
    // 先应用资源创建规则,再校验处理后的对象。
    strategy.PrepareForCreate(ctx, obj)

    if errs := strategy.Validate(ctx, obj); len(errs) > 0 {
        return errors.NewInvalid(kind.GroupKind(), objectMeta.GetName(), errs)
    }

    // 资源专用校验通过后检查通用元数据。
    if errs := genericvalidation.ValidateObjectMetaAccessor(objectMeta, strategy.NamespaceScoped(), validatePathSegment, field.NewPath("metadata")); len(errs) > 0 {
        return errors.NewInvalid(kind.GroupKind(), objectMeta.GetName(), errs)
    }

    for _, w := range strategy.WarningsOnCreate(ctx, obj) {
        warning.AddWarning(ctx, "", w)
    }
    strategy.Canonicalize(obj)
    return nil
}

准入校验与存储调用

Store.create 在进入 BeforeCreate 之前填充 UID、创建时间等系统元数据;设置 generateName 且未指定 name 时,还会生成对象名称。资源策略处理完成后,Store 把对象的深拷贝交给 createValidation,由 Handler 传入的回调执行 Validating Admission。

校验通过后,Store 根据对象名称和请求命名空间计算存储键,取得 TTL,分配结果对象 out,最后调用 Storage.Create。代码中的 dryrun.IsDryRun 把创建选项转换成存储接口的 dry-run 标志。

kubernetes/staging/src/k8s.io/apiserver/pkg/registry/generic/registry/store.go
func (e *Store) create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
    ...
    if err := rest.BeforeCreate(e.CreateStrategy, ctx, obj); err != nil {
        return nil, err
    }

    // 资源准备和校验完成后,执行 Handler 传入的准入校验。
    if createValidation != nil {
        if err := createValidation(ctx, obj.DeepCopyObject()); err != nil {
            return nil, err
        }
    }

    name, err := e.ObjectNameFunc(obj)
    if err != nil {
        return nil, err
    }
    key, err := e.KeyFunc(ctx, name)
    if err != nil {
        return nil, err
    }
    ...
    ttl, err := e.calculateTTL(obj, 0, false)
    if err != nil {
        return nil, err
    }
    out := e.NewFunc()
    if err := e.Storage.Create(ctx, key, obj, out, ttl, dryrun.IsDryRun(options.DryRun)); err != nil {
        ...
        return nil, err
    }
    ...
    return out, nil
}

对普通 Deployment 创建请求,主要顺序是 Admit → PrepareForCreate → Validate → createValidation → Storage.Create。其中 Validate 执行资源固有约束,createValidation 连接配置好的 Validating Admission;两者在不同位置检查经过相应阶段处理后的对象。

对象持久化与响应

资源校验和 Validating Admission 通过后,Registry 把内部对象交给存储层。普通创建请求按存储版本编码对象,写入 etcd,再把带有 resourceVersion 的对象交回 HTTP Handler;服务端 dry-run 复用前面的处理阶段,在存储接口处跳过写入。

下图展示创建成功后的存储与响应路径。图中的 etcd3.store.Create 经由 storage.Interface 调用;启用 Watch Cache 时,CacheDelegator.Create 将写请求直接转发到底层存储。

对象持久化与响应

存储编码与原子创建

存储层接收的是内部对象,etcd 保存的是字节序列。runtime.Encode(s.codec, obj) 使用配置好的存储 Codec 完成版本转换与序列化;TransformToStorage 再对这些字节执行存储转换。启用静态数据加密时,加密发生在这一转换阶段;使用 identity 转换器时,内容保持明文。

OptimisticPut 把“键的当前修改 revision 等于预期值”和写入放入同一事务。创建时传入预期值 0,只允许键尚不存在时写入;已有同名对象会使条件失败。Registry 随后把存储层的键已存在错误转换为 API 的 AlreadyExists,HTTP 状态为 409 Conflict。

etcd3.store.Create 依次编码对象、执行条件写入并构造返回对象:

kubernetes/staging/src/k8s.io/apiserver/pkg/storage/etcd3/store.go
func (s *store) Create(
    ctx context.Context, key string, obj, out runtime.Object, ttl uint64,
) error {
    ...
    // 清理持久化前需要重置的元数据。
    if err := s.versioner.PrepareObjectForStorage(obj); err != nil {
        return fmt.Errorf("PrepareObjectForStorage failed: %v", err)
    }

    // 使用存储 Codec 编码内部对象。
    data, err := runtime.Encode(s.codec, obj)
    ...

    newData, err := s.transformer.TransformToStorage(
        ctx, data, authenticatedDataString(preparedKey),
    )
    ...

    // 预期 revision 为 0,只创建尚不存在的键。
    txnResp, err := s.client.Kubernetes.OptimisticPut(
        ctx, preparedKey, newData, 0,
        kubernetes.PutOptions{LeaseID: lease},
    )
    ...
    if !txnResp.Succeeded {
        return storage.NewKeyExistsError(preparedKey, 0)
    }

    if out != nil {
        // 解码返回对象,并写入本次事务的版本。
        err = s.decoder.Decode(data, out, txnResp.Revision)
        ...
    }
    return nil
}

这里传给 decoder.Decode 的是转换前的编码数据 data,无需在创建成功后重新 GET 对象。解码器 调用 Codec 还原对象,再通过 Versioner 将事务 revision 写入返回对象的 metadata.resourceVersion。

例如,两个客户端同时创建 default/vllm-demo 时,两个请求都可能完成前置校验;条件事务只允许其中一个创建该键。另一个请求收到 AlreadyExists,而不会覆盖前者。

服务端 dry-run

dryRun=All 请求仍执行解码、默认值、准入和资源校验。Store.create 把 dry-run 状态传给 DryRunnableStorage.Create,由这个包装层选择返回对象还是实际写入。

Storage.Get 用于发现已存在的对象;随后 copyInto 用存储 Codec 编码并解码对象,构造返回值。它不会调用底层 Create,也不会取得一次新写入的 etcd revision。这个存在性检查没有创建事务的并发保证,dry-run 成功不能预留对象名称。

kubernetes/staging/src/k8s.io/apiserver/pkg/registry/generic/registry/dryrun.go
func (s *DryRunnableStorage) Create(
    ctx context.Context, key string, obj, out runtime.Object,
    ttl uint64, dryRun bool,
) error {
    if dryRun {
        // 发现同名对象时拒绝创建。
        if err := s.Storage.Get(ctx, key, storage.GetOptions{}, out); err == nil {
            return storage.NewKeyExistsError(key, 0)
        }
        // 构造返回对象,跳过底层写入。
        return s.copyInto(obj, out)
    }
    return s.Storage.Create(ctx, key, obj, out, ttl)
}

图中“执行准入与校验”表示整个 dry-run 请求保留这些阶段;具体执行点位于 Handler 和 Registry,存储包装层负责跳过写操作。Webhook 的副作用声明也必须允许 dry-run 请求。

响应编码

存储结果沿调用栈返回 createHandler。创建 Handler 在成功分支设置 201 Created,再交给 transformResponseObject 转换和编码 HTTP 响应。存储编码使用存储版本,响应编码则使用请求对应的 API 版本与协商出的媒体类型。

201 Created 的含义需要结合请求判断:普通创建成功表示对象已写入;服务端 dry-run 返回同样的状态码时,表示这条创建请求完成了允许 dry-run 的处理阶段,对象并未持久化。

etcd 的事务、revision 和存储转换细节见 etcd:Kubernetes 的状态存储。这里的写入成功也不要求所有 API Server 的 Watch Cache 已经同步到该版本;缓存通过各自的后台监听继续追赶。

更新、补丁与并发控制

PUT 提交更新后的对象,PATCH 提交基于当前对象计算变化的规则;两者最终都进入 Store.Update。Store.Update 把对象计算、版本检查和资源校验组合成回调,交给 GuaranteedUpdate 在存储层执行条件更新。

下图展示已有对象发生变化时的普通持久化路径。对象内容未变、服务端 dry-run,以及允许“更新时创建”的分支有各自的处理条件。

更新、补丁与并发控制

更新对象与补丁回调

PUT 的 UpdateResource 解码请求体,并通过 UpdatedObjectInfo 把待更新对象和后续转换传给资源接口。PATCH 的 patcher.patchResource 根据 Content-Type 选择 JSON Patch、Merge Patch、Strategic Merge Patch 或 Apply 的实现,再把 applyPatch、applyAdmission 等转换组合成 UpdatedObjectInfo。

实际补丁计算发生在 objInfo.UpdatedObject(ctx, existing) 中。每次调用都以这轮存储回调收到的 existing 为基础,因此存储竞争导致重试时,可以针对新的当前对象重新计算补丁。

Store.Update 随后检查版本,调用 rest.BeforeUpdate 执行资源 Strategy 的准备和校验,再调用 updateValidation 执行 Validating Admission。图中 SSA 节点是 UpdatedObject 内部的一条调用分支,普通 PUT 和其他补丁类型使用各自的对象转换方式。

客户端版本冲突

客户端提交 metadata.resourceVersion,表示这次更新建立在哪个已观察到的版本上。Store.Update 读取当前对象和新对象的版本,再按资源 Strategy 决定是否允许省略版本。

例如,客户端读取到 resourceVersion: "120",而对象在提交前已经更新为 "125",继续携带 "120" 的更新会得到 409 Conflict。客户端需要重新读取对象并重新计算希望提交的内容。

已有对象的版本检查位于传给 GuaranteedUpdate 的回调中:

kubernetes/staging/src/k8s.io/apiserver/pkg/registry/generic/registry/store.go
// 在传给 GuaranteedUpdate 的回调内读取双方版本。
existingResourceVersion, err := e.Storage.Versioner().ObjectResourceVersion(existing)
...
obj, err := objInfo.UpdatedObject(ctx, existing)
...
newResourceVersion, err := e.Storage.Versioner().ObjectResourceVersion(obj)
...
doUnconditionalUpdate := newResourceVersion == 0 &&
    e.UpdateStrategy.AllowUnconditionalUpdate()

...
if doUnconditionalUpdate {
    // Strategy 允许省略版本时,使用当前存储版本。
    err = e.Storage.Versioner().UpdateObject(obj, res.ResourceVersion)
    ...
} else {
    if newResourceVersion == 0 {
        ...
        return nil, nil, apierrors.NewInvalid(qualifiedKind, name, fieldErrList)
    }
    if newResourceVersion != existingResourceVersion {
        return nil, nil, apierrors.NewConflict(
            qualifiedResource, name, fmt.Errorf(OptimisticLockErrorMsg),
        )
    }
}

省略 resourceVersion 不会对所有资源产生同样结果。Deployment 的 AllowUnconditionalUpdate 返回 true,允许使用当前版本完成无条件更新;不允许这种行为的 Strategy 会把缺少版本视为无效请求。显式携带过期版本时,仍然执行冲突检查。

存储条件更新与重试

客户端版本检查通过后,对象仍可能在提交事务前被其他请求修改。etcd3.store.GuaranteedUpdate 因此使用 origState.rev 作为 OptimisticPut 的预期修改 revision,把最终的“版本仍然一致”检查与写入放到同一事务中。

如果条件比较失败,GetOnFailure: true 要求事务返回当前键值。getState 从 txnResp.KV 恢复对象与版本,循环继续执行更新回调。这个分支使用事务返回的数据,不需要再调用一次 GET 才能开始重算。完整循环见 GuaranteedUpdate。

kubernetes/staging/src/k8s.io/apiserver/pkg/storage/etcd3/store.go
for {
    ...
    // 回调基于本轮对象计算新值,并执行资源层检查。
    ret, ttl, err := s.updateState(origState, tryUpdate)
    ...
    data, err := runtime.Encode(s.codec, ret)
    ...
    newData, err := s.transformer.TransformToStorage(ctx, data, transformContext)
    ...

    txnResp, err := s.client.Kubernetes.OptimisticPut(
        ctx, preparedKey, newData, origState.rev,
        kubernetes.PutOptions{
            GetOnFailure: true,
            LeaseID:      lease,
        },
    )
    ...
    if !txnResp.Succeeded {
        // 使用条件事务返回的当前对象,重新执行回调。
        origState, err = s.getState(
            ctx, txnResp.KV, preparedKey, v,
            ignoreNotFound, skipTransformDecode,
        )
        ...
        origStateIsCurrent = true
        continue
    }

    err = s.decoder.Decode(data, destination, txnResp.Revision)
    ...
    return nil
}

内部 CAS 重试只解决“计算新值到提交事务之间又发生了修改”的竞争。重新执行回调时,客户端携带的旧 resourceVersion 仍可能触发 409 Conflict,资源校验或准入也可能拒绝重新计算后的对象。

如果编码结果与已确认的当前存储内容相同,且存储转换没有要求重写,GuaranteedUpdate 的无变化分支 直接返回原对象版本。成功更新请求并不一定产生一次新的 etcd 写入。

SSA 字段所有权

Server-Side Apply 使用声明式补丁与 fieldManager 标识字段管理者。Apply 补丁实现 调用 FieldManager.Apply,基于当前对象和 managedFields 合并字段、计算所有权,再把得到的对象交回更新回调。

当请求尝试改变另一个管理者拥有的字段并产生所有权冲突时,FieldManager.Apply 把字段合并冲突转换为 API 冲突错误。例如,管理者 autoscaler 已拥有 spec.replicas,另一个管理者以不同值应用该字段时,可能收到字段所有权的 409 Conflict。

force=true 用于接管冲突字段,不会跳过身份认证、授权、资源校验和准入,也不能替代客户端的版本检查。字段所有权冲突、客户端版本冲突和存储层 CAS 竞争分别发生在合并、资源更新回调和事务提交阶段。

Apply 还会设置 forceAllowCreate,允许目标对象不存在时进入创建分支;这与图中已有对象的更新路径不同。普通 Deployment PUT 的 Strategy 则不允许通过更新创建对象。

读取、监听与 Watch Cache

GET、LIST 和 WATCH 都先进入资源 Handler 与 Registry,再由存储接口取得单个对象、对象集合或事件流。启用 Watch Cache 的资源使用 CacheDelegator 选择读取路径;Cacher 保存当前对象与有限事件历史,并向多个 API 客户端分发变化。

下图中的实线表示请求调用,绿色虚线表示后台同步的数据流。缓存与底层存储的选择由请求语义、缓存状态和可用历史共同决定。

读取、监听与 Watch Cache

读取入口与资源查询

GetResource 把查询参数解码成 GetOptions,再调用资源的 Get。通用 Store.Get 根据 namespace 和名称构造键,并把 resourceVersion 交给存储层。

LIST 与 WATCH 共用 ListResource。当 opts.Watch 或路由的 forceWatch 为真时,Handler 调用 handleWatch;其余请求调用 handleList。

Store.List 与 Store.Watch 把 Label Selector、Field Selector、namespace 范围等条件组织为 SelectionPredicate。ListPredicate 将查询交给存储的 GetList,WatchPredicate 则调用存储的 Watch。这层转换让缓存和 etcd 存储实现接收相同的资源查询条件。

GET 的缓存选择

CacheDelegator.Get 对未指定 resourceVersion 的 GET 直接调用底层存储,以取得最新对象。指定版本且缓存满足服务条件时,它调用 Cacher.Get;后者通过 WaitUntilFreshAndGet 等待缓存达到要求后读取对象。

resourceVersion=0 允许读取当前可用版本。指定非零版本 N 时,缓存需要至少推进到 N,返回对象自身的版本则取决于该对象最近一次修改;例如,缓存已观察到集合版本 200,其中一个未再修改的 Deployment 仍可能携带对象版本 "150"。

下面保留 GET 的主要分支。缓存未初始化时,启用 ResilientWatchCacheInitialization 的路径将请求转交底层存储;它不会把“设置了版本”无条件解释为“立即读取缓存”。

kubernetes/staging/src/k8s.io/apiserver/pkg/storage/cacher/delegator.go
func (c *CacheDelegator) Get(
    ctx context.Context, key string, opts storage.GetOptions,
    objPtr runtime.Object,
) error {
    ...
    if opts.ResourceVersion == "" {
        return c.storage.Get(ctx, key, opts, objPtr)
    }

    if utilfeature.DefaultFeatureGate.Enabled(features.ResilientWatchCacheInitialization) {
        if !c.cacher.Ready() {
            return c.storage.Get(ctx, key, opts, objPtr)
        }
    }

    // 省略版本解析及关闭该特性时的等待路径。
    ...
    return c.cacher.Get(ctx, key, opts, objPtr)
}

LIST 的版本与快照

LIST 的选择逻辑集中在 ShouldDelegateList。NotOlderThan 可以由达到所需版本的缓存提供;Exact 和分页 continue 需要进一步判断对应历史快照是否可用。未指定版本的一致性 LIST,则检查缓存一致性读取及底层 Watch Progress 支持。

CacheDelegator.GetList 先执行这些语义判断,再决定调用 storage.GetList 还是 cacher.GetList。即使开始走缓存路径,历史快照过期或一致性读取等待超时,也可能触发向底层存储的委托。

kubernetes/staging/src/k8s.io/apiserver/pkg/storage/cacher/delegator.go
func (c *CacheDelegator) GetList(
    ctx context.Context, key string, opts storage.ListOptions,
    listObj runtime.Object,
) error {
    ...
    result, err := delegator.ShouldDelegateList(opts, c.cacher)
    if err != nil {
        return err
    }
    if result.ShouldDelegate {
        return c.storage.GetList(ctx, key, opts, listObj)
    }

    // 省略版本解析及缓存未就绪时的处理。
    ...
    err = c.cacher.GetList(ctx, key, opts, listObj)
    ...
    if err != nil {
        if errors.IsResourceExpired(err) &&
            utilfeature.DefaultFeatureGate.Enabled(features.ListFromCacheSnapshot) {
            return c.storage.GetList(ctx, key, opts, listObj)
        }
        ...
    }
    ...
    return nil
}

图中的“缓存满足请求语义”包含这些判断及等待条件,不表示所有 LIST 都由缓存立即返回。具体参数组合、历史快照和缓存未就绪时的行为见 etcd:Kubernetes 的状态存储。

WATCH 事件与后台同步

前台的 CacheDelegator.Watch 将请求交给 Cacher.Watch。Cacher.Watch 根据起始版本和过滤条件建立 watcher,衔接可用的历史事件与之后的新事件;Handler 通过 serveWatchHandler 把事件编码到客户端连接。

缓存自身的数据来源是另一条后台路径。Cacher 初始化 创建 NewListerWatcher 和 Reflector,startCaching 再运行 Reflector.ListAndWatch。listerWatcher.List 与 Watch 访问底层存储,Reflector 将取得的对象和事件应用到 Watch Cache。

图中的 etcd3.store.GetList / Watch → Reflector → Watch Cache 表示对象和事件的数据方向;实际调用由 Reflector 发起。创建和更新请求直接写底层存储,随后由这条监听路径异步更新缓存。

事件历史有保留窗口。客户端要求的起始版本早于缓存能够衔接的范围时,getAllEventsSinceLocked 返回 ResourceExpired,对应状态码 410 Gone。客户端需要重新取得集合状态和版本,再建立新的监听。

在 Cacher 的这条路径中,历史过期会由 newErrWatcher 转为 ERROR 事件;事件中的 Status 携带过期原因和 410,不能仅根据 HTTP 连接最初的 200 OK 判断监听始终成功。缓存尚未就绪、请求版本高于缓存进度和历史版本过期也属于不同状态,分别走就绪检查、等待或过期处理。

这些路径适用于启用 Watch Cache 的资源。关闭缓存的资源直接使用底层存储;聚合 API 的读写与缓存行为由扩展 API Server 自行实现。

API Server 启动参数

以下命令读取 API Server 容器的启动参数,并筛出认证、授权、Admission 和审计配置:

api_pod="$(kubectl get pod -n kube-system \
  -l component=kube-apiserver \
  -o jsonpath='{.items[0].metadata.name}')"

kubectl get pod "$api_pod" -n kube-system \
  -o jsonpath='{range .spec.containers[0].command[*]}{.}{"\n"}{end}' \
  | grep -E -- \
    '--authorization-|--authentication-|--enable-admission-plugins|--disable-admission-plugins|--audit-'

启动参数显示 API Server 的配置。实验:搭建 nvkind 集群并追踪 vLLM 部署通过 API Audit 记录单次请求的调用者、授权结果和处理阶段。

相关资料