ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于MutatingAdmissionWebhook的K8s资源超卖实践

基于MutatingAdmissionWebhook的K8s资源超卖实践

很多运维朋友都有过这种经历:集群看起来节点数不少,每个节点上的 requests 也排得满满当当,可你跑到业务高峰期一看,CPU 实际利用率往往连 20% 都不到。这个“账面上全满,实际上空闲”的尴尬局面,本质是 Pod 的 requests 被写得过高,调度器又只认 requests,导致大量计算资源被“预定”却没有真正被使用。要打破这个僵局,比较优雅的手段之一就是基于MutatingAdmissionWebhook做资源超卖:在 Pod 创建写入 etcd 之前,由准入控制器悄悄改写它的资源请求,让节点能塞进更多 Pod,同时保留原始值用于审计和回滚。这篇文章我就把这个方案从原理、设计、代码到部署踩坑完整拆开讲,适合已经熟悉 K8s 基础、正在纠结集群利用率上不去的同学。

1. 资源超卖这件事,为什么非做不可

1.1 requests 带来的双输局面

Kubernetes 调度器选择节点时,看的并不是容器实际会吃多少 CPU 和内存,而是你写在 Pod 里的requests。它把每个节点上所有 Pod 的 requests 累加,只要总和不超过节点的allocatable,这个 Pod 就被允许调度上去。问题就在这里:业务方为了“稳”,经常把 requests 写成实例配置的两倍甚至三倍,比如实际只需要 500m CPU,却写了一个核(1000m)。

结果就是集群里所有节点都显示“已分配接近饱和”,新 Pod 排不进去,不得不扩容;而跑到机器上看负载,CPU 只有百分之十几,钱花了,资源却闲着。反过来,如果把 requests 写得很低,又可能让调度器过度装箱,真正的业务流量一来,节点直接被压垮。所以大家需要的是:让考核配额的 requests 和真实使用的资源之间有一段“可回旋”的空间,这就是资源超卖要解决的问题。

1.2 超卖的本质和几条实现路径

所谓超卖,简单说就是让节点上所有 Pod 的 requests 总和可以大于节点的allocatable,赌的是“大家不会同时用满”。这和酒店超售一个逻辑:统计上总有游客退订或晚到,卖出去的房间数可以略多于实际房间数,只要在极端情况到来前有预案就行。

实现超卖,业界常见几条路:

  • 改业务方 YAML:让所有人把 requests 调小。这需要推动业务改造,周期长、口径不统一,还容易误伤真正需要保障的实例。
  • 直接改 kubelet 上报的 capacity:这属于修改 Node 状态,K8s 并不支持,很容易触发系统一致性问题和各种诡异的调度异常。
  • 写调度器 extender / 自定义 scheduler:能控制调度算法,但要替换或旁路原生调度流程,维护成本高,升级集群风险大。
  • 用 MutatingAdmissionWebhook 改写 Pod 的 requests:在调度发生之前,把 Pod 的 requests 按比例缩小(或者把实际资源放大),调度器自然能装下更多 Pod。业务方不用改 YAML,你还能通过 Webhook 的规则精准控制哪些命名空间、哪些 Pod 超卖,哪些豁免。

最终我选的是第四条:无侵入、可控、可灰度,而且实现一个 Webhook 服务本身并不复杂。这也是目前社区做多集群混部、在线离线超卖时常见的手段。

1.3 为什么我不用 LimitRange 或 VPA 来处理

有人会问,那用LimitRange调整默认 requests 不就行了?LimitRange 的作用是在 Pod 没有显式写 requests 时给一个默认值,或者约束最小/最大值。它做不到“按比例缩放已有 requests”,也不能把原始值保存下来,更不支持针对 node 维度做差异化配置。VPA 的目标是根据实际用量自动调 requests,但这玩意儿需要部署组件、还要等 recommendation 刷出来,延时高,而且对已经写死的 YAML 没法在创建瞬间无缝下手。

Webhook 最大的优势在于时机:它发生在 Admission 阶段,Patch 一旦带上,后续持久化、调度、kubelet 启动看到的都是改后的对象。这就给了我们一个天然的“资源改写窗口”。只要把改写的逻辑和策略写清楚,超卖这个事就能变成集群侧的一项自动能力,而不是业务侧的负担。

2. MutatingAdmissionWebhook 的工作原理和组件构成

2.1 Admission 阶段的数据流

要理解这个方案,先把 Pod 创建的链路捋一遍。当用户执行kubectl apply -f pod.yaml后,请求先到 kube-apiserver,经过认证、鉴权,然后进入 Admission 阶段。Admission 又分 Mutating 和 Validating 两段,Mutating 段先执行,它允许返回一个 JSON Patch,这个 Patch 会在对象真正写入 etcd 之前被合并进对象。

MutatingAdmissionWebhook 就是这一段里的一个特殊准入插件。它会把包含原始对象(这里是 Pod)的 AdmissionReview 请求发送给注册的 Webhook 服务,Webhook 解析对象后返回一份 patch 和结果。如果结果是allowed,API Server 就用这个 patch 去修改对象,再走后续的 Validating 和持久化。

关键点在于:调度器看到的是 patch 之后的 Pod。超卖就是利用这一点——用户写的 requests 是 2C4G,Webhook 把 requests 改成 1C2G,调度器对每个 Pod 的 request 消耗按 1C2G 计算。节点上原本只能放 4 个,现在可以放 8 个,而每个 Pod 实际允许使用的上限还是由limits决定,业务在突发时依然能冲到 2C4G 的资源量,只是平日按 1C2G 的“名义占用”计入调度。

2.2 整套方案需要哪些组件

一个可用的超卖 Webhook 方案,至少需要这几块:

  • Webhook Server:一个 HTTP/HTTPS 服务,接收 AdmissionReview,解析 Pod,按策略改写资源,返回 AdmissionResponse。语言不限,但 Kubernetes 生态里 Go 最顺手。
  • TLS 证书:API Server 与 Webhook 之间必须是 HTTPS,请求会被校验。证书常见方式是用集群 CA 来签发,然后通过caBundle配置信任。
  • MutatingWebhookConfiguration:这是注册配置,告诉 API Server 什么请求、发给谁、超时多少、失败怎么处理。
  • 配置源(ConfigMap 或 Annotation):存放超卖系数、豁免名单、原始值记录。我们用 ConfigMap 保存系统级默认系数,Pod 上打 Annotation 保存原始 requests,方便追踪和恢复。

整体请求链路可以这样理解:用户的 Pod 创建请求到达 API Server -> MutatingAdmissionWebhook 插件把 AdmissionReview POST 到我们的 Webhook 服务 -> Webhook 读 ConfigMap 中的系数,决定是把 2C 缩到 1C 还是原样放行 -> 返回带 JSON Patch 的响应 -> API Server 把 Patch 合并进 Pod 对象 -> 存储、调度、运行。

3. 超卖方案的详细设计:系数、范围和状态记录

3.1 超卖系数先分清 CPU 和内存

超卖不是所有资源都能无脑超额。CPU 是可分时复用的,几个 Pod 同时不忙,核数超过物理核问题不大,所以 CPU 超卖系数可以给得比较激进,常见 1.5 到 3.0 之间。内存不一样,内存一旦分出去,Pod 即使不用,被 cgroup 限制的部分也不会还给其他 Pod,除非进程主动释放。更别说内存爆了就是 OOM,属于直接影响可用性的故障。所以内存超卖系数我建议保守,默认 1.0 或最多 1.2,而且一定要搭配合理的limits和监控告警。

我当时的方案是这样的:

  • 系统级默认:CPU 超卖系数oversell-cpu-ratio=2.0,内存超卖系数oversell-memory-ratio=1.0。
  • 命名空间级覆盖:业务方如果明确能接受更大风险,可以在 Namespace 上打 label 调整,比如oversell.cpu=3.0。
  • 节点级覆盖:某些新机型或离线混部节点池,可以给 Node 打 label,Webhook 根据目标节点或节点池选择系数。不过要注意,Webhook 在 Admission 阶段看不到最终调度节点,所以节点级系数通常只能做成“给指定节点池承载的超卖 Pod 加特殊标记”,真正的 node 选择还是要靠调度器前面的 nodeSelector 或 taint 来约束。

3.2 哪些资源字段能改,哪些不能动

K8s 资源声明里,requests是调度依据,limits是 cgroup 限制。超卖方案里我建议只改requests,保留limits不动。原因很直接:limits 是运行时红线,把它改小会让真实突发流量被 cgroup 限制,影响业务;把它改大会放大节点过载风险。只改 requests 的话,调度结果变密,但每个容器运行时仍然被原来的 limit 约束,属于“名义配额缩水,爆发的上限还在”。当然,这也意味着如果所有 Pod 同时打满 limits,节点依然会顶不住,所以必须配套监控和优先级。

另外,改的时候要遍历 Pod 里所有initContainers和containers,不能只改主容器。initContainer 的 requests 也会被调度器纳入计算(取所有 initContainer 的峰值),漏掉它会导致调度判断失真。如果某个容器原本没写 requests,最好不要往里面硬塞一个很小的值,除非你同时给所有容器统一兜底,否则会改变 QoS 分类,可能触发一些策略问题。

3.3 原始值怎么留痕

对被改过的 Pod,我会在 Webhook 里自动加一个 Annotation:

oversell.example.com/original-requests: 'cpu:2,memory:4Gi'

这样有三个作用:一是巡检时能直接看出这个 Pod 被缩放过、原始需求是什么;二是出问题要回滚时,可以人工按原始值重建;三是 Webhook 每次处理前先检查这个 Annotation 是否存在,如果存在就说明已经处理过,直接原样放行,避免反复修改造成请求值被乘多次。

这里要提醒一下:Annotation 本身也是 Pod 对象的一部分,Webhook 的 patch 里要同时包括add该 Annotation 和修改 requests 的两个操作。如果我只要改 requests 而忘了留原始值,后面排查成本会高很多。

3.4 QoS 等级变化带来的坑

一个 Pod 如果 requests 和 limits 都写了且相等,它是Guaranteed;如果只改了 requests 把 requests 缩到远小于 limits,它就变成了Burstable。这个变化本身不可怕,但你要知道 Burstable 的 Pod 在节点内存压力下,是有可能被 kubelet 按 QoS 等级回收的。所以超卖后的 Pod 在极端情况下比原来更容易被杀。我的建议是:核心在线业务要么不要超卖,要么给一个比较保守的内存系数;真需要高密度部署的批任务、离线任务,接受Burstable没问题,但要有快速的失败重试机制。

4. Webhook 服务实现:核心代码逐段拆解

4.1 工程结构和依赖

我用 Go 实现,整体结构非常简单:

oversell-webhook/ ├── main.go ├── Dockerfile ├── deploy/ │ ├── webhook.yaml │ ├── service.yaml │ └── mutatingwebhook.yaml └── scripts/ └── gen-cert.sh

依赖主要是k8s.io/api、k8s.io/apimachinery和用于 JSON Patch 的github.com/evanphx/json-patch。核心 handler 只需要两个函数:解析请求、生成 patch。

4.2 请求处理主流程

Webhook 的入口是 HTTP Handler。K8s 发送给我们的数据是AdmissionReview,其中request.object里是 Pod 的完整 JSON。我们要做的就是把 JSON 反序列化成 Pod 对象,检查是否需要修改,构造 JSON Patch 列表,最后包装成AdmissionReview返回。

func handleMutate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var review admissionv1.AdmissionReview if err := json.NewDecoder(r.Body).Decode(&review); err != nil { http.Error(w, fmt.Sprintf("decode AdmissionReview failed: %v", err), http.StatusBadRequest) return } req := review.Request if req.Operation != admissionv1.Create { // 只在创建时做超卖,更新和删除都放行 resp := &admissionv1.AdmissionResponse{ Allowed: true, } review.Response = resp _ = json.NewEncoder(w).Encode(review) return } pod := &corev1.Pod{} if err := json.Unmarshal(req.Object.Raw, pod); err != nil { http.Error(w, fmt.Sprintf("unmarshal pod failed: %v", err), http.StatusBadRequest) return } patches, err := buildPatches(pod) if err != nil { review.Response = &admissionv1.AdmissionResponse{ Allowed: false, Result: &metav1.Status{Message: err.Error()}, } _ = json.NewEncoder(w).Encode(review) return } if len(patches) == 0 { review.Response = &admissionv1.AdmissionResponse{Allowed: true} _ = json.NewEncoder(w).Encode(review) return } patchBytes, _ := json.Marshal(patches) review.Response = &admissionv1.AdmissionResponse{ Allowed: true, PatchType: func() *admissionv1.PatchType { pt := admissionv1.PatchTypeJSONPatch return &pt }(), Patch: patchBytes, } _ = json.NewEncoder(w).Encode(review) }

这里有一个所有 Webhook 新手都会踩的细节:必须在响应里显式返回PatchType并设为JSONPatch,否则 API Server 不知道Patch字段里的字节流是什么格式。另外,如果请求解不出来、Pod 对象不对,不要直接返回500,而是通过 AdmissionResponse 返回Allowed: false和 Result 消息,这样 API Server 才能把错误信息反馈给用户,而不是浑水摸鱼地报内部错误。

4.3 构建超卖 Patch 的核心逻辑

buildPatches是最核心的部分。它的决策顺序是:先看 Pod 是否已经有“已处理”标记,再看命名空间或标签是否命中超卖范围,接着取超卖系数,最后遍历 containers 计算新请求值。

func buildPatches(pod *corev1.Pod) ([]map[string]interface{}, error) { if pod.Annotations == nil { pod.Annotations = map[string]string{} } if _, done := pod.Annotations[originalRequestAnnotation]; done { // 已经处理过,避免重复缩放 return nil, nil } // 按命名空间前缀或标签过滤,这里默认超卖业务命名空间 if !strings.HasPrefix(pod.Namespace, "prod-") { return nil, nil } ratio := getRatio(pod.Namespace) // 从 ConfigMap 读取,这里简化为固定系数 var patches []map[string]interface{} original := map[string]string{} for idx := range pod.Spec.Containers { c := &pod.Spec.Containers[idx] if c.Resources.Requests == nil { c.Resources.Requests = corev1.ResourceList{} } cpu := c.Resources.Requests.Cpu() mem := c.Resources.Requests.Memory() // 记录原始值 original[fmt.Sprintf("container-%d-cpu", idx)] = cpu.String() original[fmt.Sprintf("container-%d-mem", idx)] = mem.String() newCPU := scaleQuantity(cpu, ratio.CPU) newMem := scaleQuantity(mem, ratio.Memory) // 这里用 replace 会更安全,但考虑到 requests 可能不存在,需要判断用 add 还是 replace op := "add" if _, exists := c.Resources.Requests[corev1.ResourceCPU]; exists { op = "replace" } patches = append(patches, patchOperation(op, fmt.Sprintf("/spec/containers/%d/resources/requests/cpu", idx), resourceValue(newCPU))) // 内存同理,省略重复代码 } // 把原始值作为 annotation 记录 oriJSON, _ := json.Marshal(original) patches = append(patches, patchOperation("add", "/metadata/annotations/"+escapeJSONPatchPath(originalRequestAnnotation), string(oriJSON))) return patches, nil }

实际工程里,上面那个“用 add 还是 replace”的判断不够严谨。因为resources存在时,requests可能为null,也可能存在部分 key。最稳妥的做法是:直接构造 “replace” 路径到requests/cpu,如果字段缺失,JSON Patch 的 replace 会失败;所以更通用的写法是先给requests这个层级做一个add空对象,然后再对 cpu、memory 分别做add或replace,但要注意add一个不存在的 key 和replace一个已存在 key 都是安全的,只有反着来会报错。我的做法是统一尝试add,如果 API Server 返回 “path already exists” 再回退到replace——不过生产环境不想有这种重试链路,最好在代码里逐级检查字段是否存在再决定操作符。

4.4 资源数值处理不能想当然

K8s 的资源单位很灵活:CPU 可以写1、1000m、0.5,内存可以写4Gi、4294967296、4G。直接拿字符串做乘法是行不通的。我们用的resource.Quantity自带数值解析,建议转成同一种单位再计算。

func scaleQuantity(q *resource.Quantity, ratio float64) *resource.Quantity { if q == nil { return nil } // 统一为十进制值进行缩放,再保留单位精度 raw := q.AsDec() scaled := new(apiextensions.Decimal) // 这里只是示意,实际用 inf.Dec 或 big.Float 做乘法 result := multiply(raw, ratio) return resource.NewQuantity(result, resource.DecimalSI) }

我实际项目里为了避免 Decimal 类型繁琐,直接转成resource.DecimalSI后按big.Float计算。但注意:内存的原始单位可能是binarySI(Ki、Mi、Gi),转成 DecimalSI 后再输出,单位会变。比如用户写4Gi,你处理后可能变成4294967296,虽然值没变,但用户看 YAML 会觉得莫名其妙,且单位不同还会影响后续管理工具展示。我建议 Patch 直接沿用容器原本的资源单位,或者统一把 CPU 输出成m、内存输出成Mi,并在文档里约定好。调度器只看最终数值,单位只要合法就行,关键是别把1G和1Gi混为一谈。

4.5 Webhook 的自我豁免

这是我在测试环境翻过车的地方。Webhook 服务自身也是以 Pod 形式跑在集群里的,它创建时同样会触发 MutatingAdmissionWebhook。如果我们没有豁免它自己,就可能出现:Webhook 把自身 Pod 的 requests 改小,然后由于它的资源请求变化,导致调度或者运行出现意外。甚至极端情况下,Webhook 服务要扩容却一直创建不出来,因为集群资源已经被它改出来的“高密度 Pod”塞满了。

我这个项目里,Webhook 部署在oversell-system命名空间,而超卖规则只对prod-开头的 Namespace 生效,再配合一个namespaceSelector把系统命名空间排除,这个坑就自然避开了。还有一个保险:给 Webhook 服务的 Deployment 打上单独的 annotation,比如oversell.example.com/exclude: "true",然后 Webhook 在buildPatches里先检查这个 annotation,命中就直接放行。

5. 从证书签发到完整部署:照着做就能跑起来

5.1 用集群 CA 签发 Webhook 证书

K8s 要求 Webhook 必须是 HTTPS。证书的思路是用集群已有的 CA 签发一个 server 证书,然后把 CA 证书内容填到MutatingWebhookConfiguration的caBundle字段。如果 Webhook 服务以 Service 形式暴露,证书的CN(或 SAN)必须包含 Service 的 DNS 名,格式一般是webhook-service.namespace.svc。

开发环境我推荐直接用 openssl 签,别纠结太多:

# 1. 创建私钥和CSR openssl genrsa -out webhook.key 2048 openssl req -new -key webhook.key -subj "/CN=webhook-service.oversell-system.svc" -out webhook.csr # 2. 用集群 CA 签发(这里假设 KUBE_CA 是集群 CA 证书) openssl x509 -req -in webhook.csr -CA ca.crt -CAkey ca.key \ -CAcreateserial -out webhook.crt -days 365 \ -extfile <(printf "subjectAltName=DNS:webhook-service.oversell-system.svc,DNS:webhook-service.oversell-system.svc.cluster.local") # 3. 把证书放进 Secret kubectl create ns oversell-system kubectl -n oversell-system create secret tls webhook-tls \ --cert=webhook.crt --key=webhook.key # 4. base64 编码 CA 证书,后面填到 caBundle(生产建议用 cert-manager 自动轮转) CA_BUNDLE=$(base64 -w 0 ca.crt)

用subjectAltName里同时写上带.cluster.local的完整域名很重要,否则在集群内通过 Service DNS 访问时证书校验可能不通过。证书轮转方面,如果只用自签和手动 base64,一年后会踩到期坑;有预算和时间的话,上cert-manager的Certificate+MutatingWebhookConfiguration自动注入最省心。

5.2 Webhook 服务 Deployment 和 Service

Webhook 是一个无状态服务,镜像里就是编译后的二进制或直接跑 Go 程序。它需要两个参数:TLS 证书路径和监听端口。Deployment 挂载上面生成的 Secret,然后对外暴露 Service,端口固定为 443,因为后面clientConfig中我会用 Service 引用,API Server 访问的就是 Service 的 HTTPS 端口。

apiVersion: apps/v1 kind: Deployment metadata: name: oversell-webhook namespace: oversell-system spec: replicas: 2 selector: matchLabels: app: oversell-webhook template: metadata: labels: app: oversell-webhook annotations: oversell.example.com/exclude: "true" spec: containers: - name: webhook image: registry.example.local/oversell-webhook:v1.0.0 args: - -tls-cert=/etc/webhook/tls/tls.crt - -tls-key=/etc/webhook/tls/tls.key ports: - containerPort: 8443 volumeMounts: - name: tls mountPath: /etc/webhook/tls readinessProbe: httpGet: path: /healthz port: 8443 scheme: HTTPS volumes: - name: tls secret: secretName: webhook-tls --- apiVersion: v1 kind: Service metadata: name: webhook-service namespace: oversell-system spec: selector: app: oversell-webhook ports: - port: 443 targetPort: 8443

注意容器里监听的是 8443,Service 对外是 443,这样clientConfig.service.port可以填 443。readinessProbe一定要写,否则 API Server 可能会把请求打到还没就绪的 Pod,尤其在滚动更新期间会有偶发的超时。

5.3 MutatingWebhookConfiguration 注册配置

这是整条链路的“指挥中心”。配置里定义了:什么资源的什么操作会触发 Webhook、请求发给谁、失败策略、超时时间等。

apiVersion: admissionregistration.k8s.io/v1 kind: MutatingWebhookConfiguration metadata: name: oversell-webhook webhooks: - name: oversell.example.com admissionReviewVersions: ["v1"] sideEffects: None failurePolicy: Ignore reinvocationPolicy: Never timeoutSeconds: 5 clientConfig: service: name: webhook-service namespace: oversell-system path: /mutate port: 443 caBundle: ${CA_BUNDLE} rules: - apiGroups: [""] apiVersions: ["v1"] operations: ["CREATE"] resources: ["pods"] scope: "Namespaced" namespaceSelector: matchExpressions: - key: kubernetes.io/metadata.name operator: In values: ["prod-online", "prod-offline"]

failurePolicy我建议先用Ignore。Ignore 意味着如果 Webhook 服务挂了,Pod 创建请求不会被阻断,集群还能继续工作,代价是超卖自动失效,资源利用率退回“虚高”状态。对生产环境来说,Webhook 故障不能成为业务不可用的放大器,所以 Ignore 是更稳妥的默认值。等你对自己的服务稳定性有足够信心,再考虑对特定命名空间改成Fail。reinvocationPolicy这里设为Never,因为我们的 Webhook 只做一次缩放,如果集群里还有其他 mutating webhook(比如自动注入 sidecar 的),它们的顺序会影响最终结果,这里不做复杂编排。

namespaceSelector用匹配命名空间名的方式,比objectSelector粗放一些但足够清晰。生产里我更推荐给超卖命名空间打一个固定 label,比如oversell=enabled,Webhook 只处理带这个 label 的命名空间。这样以后重新整理命名空间时,不用改 Webhook 配置,只要调整 label 就行。

5.4 API Server 侧和 RBAC 注意事项

现在 Kubernetes 集群里MutatingAdmissionWebhook默认是启用的,一般不需要额外开。但如果你用的发行版做过裁剪,要确认kube-apiserver的--enable-admission-plugins里包含它。这个 Webhook 服务本身不需要访问 API Server 也能工作,因为它只读请求体、返回 patch。但如果后面你想做成按节点维度动态调整(类似根据 node allocatable 决定每个 Pod 缩多少),Webhook 就得通过 client-go 去查询 Node 信息,这时需要给它配置 RBAC 权限,比如nodes的get/list权限。这种设计可以做,但要注意 Admission 请求的超时窗口很短,Webhook 里做 API 调用会增加延迟,最好把 Node 数据缓存到本地或通过 ConfigMap 广播,而不是每收到一个请求都去实时查询。

6. 常见问题与排查技巧实录

6.1 Webhook 根本没有被调用

最常见的问题是:配置都建好了,Pod 也能创建,但日志里 Webhook 一个请求都没收到。这时先检查三件事。第一,MutatingWebhookConfiguration的rules是否覆盖了你测试的资源类型和操作,比如你用 Deployment 创建 Pod,那么触发的还是pods的CREATE,没问题;但如果你直接kubectl create -f pod.yaml,触发条件也是相同的。第二,namespaceSelector是否命中目标命名空间,selector 表达式的语义是 AND,漏一个标签就等于不匹配。第三,failurePolicy: Ignore会让 Webhook 故障被静默吞掉,所以要看 API Server 日志或者暂时改成Fail,确认请求真的被发出过。

我曾经排查过一个很诡异的问题:Webhook 只在新增节点池之后生效,老节点池里创建的 Pod 完全没有被改写。最后发现是objectSelector里写了一个不存在的 label,导致大部分 Pod 被过滤掉了。这个玄学问题的教训是:把namespaceSelector和objectSelector拆成简单、可打印、可解释的表达式,不要在里面嵌套太多复杂条件。

6.2 证书报错与 HTTPS 握手失败

如果你在 Webhook 日志里看到x509: certificate signed by unknown authority,说明 API Server 没有信任你的证书,检查clientConfig.caBundle是否填了签发 Webhook 服务端证书的那个 CA,而不是服务端证书本身。另一个高频问题是x509: cannot validate certificate for ... because it doesn't contain any IP SANs,通常是因为你用了 Service 域名访问,但证书只写了 CN、没写 SAN。在签发时一定把webhook-service.oversell-system.svc和webhook-service.oversell-system.svc.cluster.local都写进 SAN。证书过期的问题在测试环境不容易碰到,一旦用了三个月以上就要开始关注;如果出现“之前好好的,突然所有 Webhook 都不生效”的情况,第一反应就去看证书有效期。

6.3 JSON Patch 格式错误导致 Pod 创建失败

这类报错会直接出现在用户 apply 的结果里,比如jsonpatch add operation does not apply: doc is missing key。原因是:JSON Patch 的add要求目标路径不存在,replace要求目标路径必须存在。我在 4.3 里踩过这个坑后,最终的 patch 生成逻辑是:先检查容器 resources 下是否存在requests字段,不存在就先 add 一个空{},然后再对 cpu 和 memory 分别判断当前是否有值,有则replace,无则add。

此外,/metadata/annotations/<key>路径里的 key 如果包含特殊字符,比如.或/,需要转义。Annotation key 我们用的是oversell.example.com/original-requests,看起来是 DNS 风格,其实在 JSON Pointer 里.不需要转义,但/必须转义成~1。我建议在代码里写一个escapeJSONPatchPath函数统一处理,避免手拼路径出错。

6.4 死循环、重复缩放和二次修改

如果集群里同时存在两个 mutating webhook,而且你没有设置reinvocationPolicy,由于不同 webhook 的执行顺序由配置顺序决定,可能出现 A webhook 改完 requests 后,B webhook 又基于新的值接着改,最终结果完全不可预期。我们的reinvocationPolicy: Never只适用于自己,解决不了其他 webhook 的叠加。

我在代码里加的“原始值 Annotation 如果已存在就直接放行”这个逻辑,就是为了防止这种情况。不管 Webhook 被触发几次,只要它在 Pod 里看到了自己的标记,就不再缩放。这样就算多个 webhook 在同一次请求里被串联执行,我们的 Webhook 也只会生效一次。另一个需要注意的死循环不是代码层面,而是架构层面:Webhook 服务自己所在的 Pod 如果也被本 webhook 拦截,可能导致滚动更新时新副本一直创建不出来。按 4.5 的做法,用命名空间或 annotation 豁免自身即可。

6.5 超卖后节点过载了怎么办

超卖本身是在赌一个概率,赌输了就要面对节点 CPU 抢占和内存压力。我的经验是,CPU 超卖相对安全,因为 cgroup 的 CPU 限流只是让容器变慢,不会让进程死掉;内存超卖一旦敲过量,kubelet 会按 QoS 优先级和实际 OOM 分数杀进程。所以线上我一般会做几道防线:内存超卖系数保持 1.0;对所有超卖 Pod 都要求必须写limits;对节点内存使用率设置告警,超过 80% 就人工介入;核心在线服务所在命名空间不上超卖,或者只超卖 CPU。

如果你想在超卖场景下更精确地控制风险,可以引入Descheduler定期驱逐热点节点上的 Pod,或者用节点压力驱逐策略提前处理。说白了,超卖不是为了把节点压到百分百,而是把资源利用率从 20% 提到 60%~70%,剩下的余量依然是安全垫。

6.6 问题速查表

现象可能原因排查手段
Pod 可创建但 requests 没变namespaceSelector 未命中、objectSelector 过滤、webhook 未注册检查 webhook 配置,看 Webhook 日志请求量
API Server 报 x509 错误caBundle 不对、证书 SAN 缺失重新签发证书,核对 caBundle
报 JSON Patch 相关错误add/replace 路径选择错误检查 patch 生成的路径和字段存在性
集群 Webhook 挂了后业务创建失败failurePolicy 设置为 Fail评估是否改为 Ignore,或提升 Webhook 可用性
同一个 Pod 被多次缩放其他 webhook 串联执行,或代码缺少防重复标记在 Pod annotation 里加“已处理”标记
内存频繁 OOM内存超卖系数过高,或缺少 limits收紧内存系数,补齐 limits,增加监控告警

7. 再聊聊上线后的扩展思路

这个超卖方案上线一段时间后,我最深的体会是:Webhook 只是给了你“改写资源申请”的能力,真正值钱的是你围绕它建立的策略体系。从最开始只对某个离线任务命名空间开启,到后来逐步覆盖在线业务,每一步都要有监控数据和资源反馈支撑。比如把 CPU 超卖系数从 2.0 调高到 2.5 之前,至少要观察一周的 CPU throttling、节点负载分布和任务排队时延,确认没有明显副作用再动。

后面的扩展方向也比较明确:一个是把超卖系数从静态 ConfigMap 升级成动态配置,比如根据节点实时的内存余量自动调低或调高;另一个是结合混部场景,把在线服务和离线任务放在同一个节点池里,用 Webhook 标记队列优先级,让低优任务在节点压力增高时可以被抢占。再有就是审计方面,虽然 Webhook 会把原始值写进 Pod Annotation,但生产环境 Pod 数量大,建议再做一个轻量级的 controller 定期扫描 Annotation,汇总超卖前后资源申请量的变化报表,方便你向团队证明这套机制带来的实际收益。

按我个人这几年的经验,凡是涉及资源利用率的优化,最容易翻车的不是技术实现,而是业务预期。超卖之后,业务方会看到他们提交的 requests 变了,有些人会疑惑甚至反对,所以上线前一定要和业务对齐“原始 requests 保留在 Annotation 里,监控和计费仍按原始值口径”的原则。宁可多花一周沟通,也不要让超卖变成一个“悄悄改别人配置”的黑盒。先从小范围灰度开始跑,把监控和回滚路径都准备好,再慢慢扩大范围,这套东西才能真正成为集群降本增效的稳定工具。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进