前言:那个让我熬夜的 Pod
线上有个新业务上线,按照 SOP 给 namespace 打了注入标签,YAML 一应用,kubectl get pod 一看 —— sidecar 容器没了。
我第一反应是 label 错了,检查;标签没问题。
第二反应是 webhook 没起来,看日志;webhook 健康得很。
第三反应是 cert-manager 证书过期,重签;问题依旧。
折腾到凌晨两点,我才想起来去翻自己当初写的那套 sidecar-injector 源码,发现 mutationRequired 那个函数里有个 switch 语句,default 分支居然直接 return false —— 也就是说,只要 annotation 里没明确写 need_inject: "true",统统跳过注入。而新业务的 chart 模板里,那个 annotation 的 key 被同事改过了。
这一晚的代价是:彻底搞清楚了 MutatingAdmissionWebhook 的整条调用链。今天把这部分拆出来分享,重点讲清楚 serveMutate → mutatePod → createPatch 这三个核心函数到底在干什么、为什么这么干、以及哪里容易踩坑。
本节重点
serveMutate编写- 准入控制请求的参数校验
- 根据 annotation 判断是否需要注入 sidecar
mutatePod注入函数编写- 生成注入容器和 volume 的 patch 函数
一、serveMutate:webhook 的总入口
serveMutate 是 webhook server 注册到 HTTP 路由上的处理函数,APIServer 每次创建 Pod 都会把请求打到这里。这个函数干三件事:收请求 → 解析校验 → 写响应。
1.1 普通校验请求(body 和 Content-Type)
这一步是 HTTP 层面的基础校验,看起来废话,但生产环境里真的有人会把请求打错(比如把 application/json 写成 application/JSON,APIServer 不会这么干,但调试用 curl 时常踩)。
var body []byte
if r.Body != nil {
if data, err := ioutil.ReadAll(r.Body); err == nil {
body = data
}
}
if len(body) == 0 {
glog.Error("empty body")
http.Error(w, "empty body", http.StatusBadRequest)
return
}
// verify the content type is accurate
contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
glog.Errorf("Content-Type=%s, expect application/json", contentType)
http.Error(w, "invalid Content-Type, expect `application/json`", http.StatusUnsupportedMediaType)
return
}
💡 踩坑提醒:
ioutil.ReadAll在 Go 1.16 之后已经被标记为 deprecated,建议用io.ReadAll。线上跑老版本 Go 的同学注意一下,升级时这是个高频报错点。
1.2 准入控制请求参数校验
这一步是把 APIServer 发来的 JSON 反序列化成 AdmissionReview 对象。重点看一下 deserializer 的初始化 —— 这个解析器是 k8s.io/apimachinery 提供的通用反序列化器,它能识别所有注册过的 GVK,所以我们不需要手动判断 Pod / Deployment 这种类型。
// 构造准入控制器的响应
var admissionResponse *v1beta1.AdmissionResponse
// 构造准入控制的审查对象 包括请求和响应
// 然后使用 UniversalDeserializer 解析传入的申请
// 如果出错就设置响应为报错的信息
// 没出错就调用 mutatePod 生成响应
ar := v1beta1.AdmissionReview{}
if _, _, err := deserializer.Decode(body, nil, &ar); err != nil {
glog.Errorf("Can't decode body: %v", err)
admissionResponse = &v1beta1.AdmissionResponse{
Result: &metav1.Status{
Message: err.Error(),
},
}
} else {
admissionResponse = ws.mutatePod(&ar)
}
UniversalDeserializer 的实现位于:
D:\go_path\pkg\mod\k8s.io\apimachinery@v0.22.1\pkg\runtime\serializer\codec_factory.go
完整的 imports 和全局变量定义:
package main
import (
"encoding/json"
"fmt"
"github.com/golang/glog"
"gopkg.in/yaml.v2"
"io/ioutil"
"k8s.io/api/admission/v1beta1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
"net/http"
)
var (
runtimeScheme = runtime.NewScheme()
codecs = serializer.NewCodecFactory(runtimeScheme)
deserializer = codecs.UniversalDeserializer()
// (https://github.com/kubernetes/kubernetes/issues/57982)
defaulter = runtime.ObjectDefaulter(runtimeScheme)
)
⚠️ 版本兼容性坑(最容易踩):这里用的是
admission/v1beta1,K8s 1.22+ 已经移除了 v1beta1,必须切换到admission/v1。如果集群版本 ≥ 1.22 还在用 v1beta1,APIServer 会直接报no matches for kind "AdmissionReview" in version "admission.k8s.io/v1beta1",webhook 会被静默跳过 —— 这个错最坑,因为它不会让 Pod 创建失败,只会让你的注入逻辑悄悄失效。
1.3 写入响应
最后一步:把 mutatePod 生成的响应塞回 AdmissionReview,json 序列化后写回 HTTP。注意 UID 必须从请求里复制过来 —— APIServer 通过 UID 来匹配请求和响应。
// 构造最终响应对象 admissionReview
// 给 response 赋值
// json 解析后用 w.write 写入
admissionReview := v1beta1.AdmissionReview{}
if admissionResponse != nil {
admissionReview.Response = admissionResponse
if ar.Request != nil {
admissionReview.Response.UID = ar.Request.UID
}
}
resp, err := json.Marshal(admissionReview)
if err != nil {
glog.Errorf("Can't encode response: %v", err)
http.Error(w, fmt.Sprintf("could not encode response: %v", err), http.StatusInternalServerError)
}
glog.Infof("Ready to write reponse ...")
if _, err := w.Write(resp); err != nil {
glog.Errorf("Can't write response: %v", err)
http.Error(w, fmt.Sprintf("could not write response: %v", err), http.StatusInternalServerError)
}
二、mutatePod:注入的核心决策函数
mutatePod 是整个 webhook 的"大脑",它决定这个 Pod 要不要被改、改成什么样。
2.1 解析 Pod 对象
第一步先把 AdmissionRequest 里的 raw bytes 反序列化成 Pod 结构体。失败就直接返回错误响应。
// 将请求中的对象解析为 pod,如果出错就返回
req := ar.Request
var pod corev1.Pod
if err := json.Unmarshal(req.Object.Raw, &pod); err != nil {
glog.Errorf("Could not unmarshal raw object: %v", err)
return &v1beta1.AdmissionResponse{
Result: &metav1.Status{
Message: err.Error(),
},
}
}
2.2 是否需要注入判断(重点踩坑区)
这是最容易出 bug 的地方。我开头说的那个线上事故,根因就出在 mutationRequired 函数。
// 是否需要注入判断
if !mutationRequired(ignoredNamespaces, &pod.ObjectMeta) {
glog.Infof("Skipping mutation for %s/%s due to policy check", pod.Namespace, pod.Name)
return &v1beta1.AdmissionResponse{
Allowed: true,
}
}
判断逻辑有三条规则:
- 如果 Pod 在高权限的 namespace(
kube-system、kube-public)里,不注入 - 如果 Pod 的 annotations 中标记为已注入(
status: injected),不注入 - 如果 Pod 的 annotations 中明确表示不愿注入,不注入
// 判断这个 pod 资源要不要注入
// 1. 如果 pod 在高权限的 ns 中,不注入
// 2. 如果 pod annotations 中 标记为已注入就不再注入了
// 3. 如果 pod annotations 中 配置不愿意注入就不注入
func mutationRequired(ignoredList []string, metadata *metav1.ObjectMeta) bool {
// skip special kubernete system namespaces
for _, namespace := range ignoredList {
if metadata.Namespace == namespace {
glog.Infof("Skip mutation for %v for it's in special namespace:%v", metadata.Name, metadata.Namespace)
return false
}
}
annotations := metadata.GetAnnotations()
if annotations == nil {
annotations = map[string]string{}
}
// 如果 annotation 中 标记为已注入就不再注入了
status := annotations[admissionWebhookAnnotationStatusKey]
if strings.ToLower(status) == "injected" {
return false
}
// 如果 pod 中配置不愿意注入就不注入
switch strings.ToLower(annotations[admissionWebhookAnnotationInjectKey]) {
default:
return false
case "true":
return false
}
}
🚨 这里有个隐藏 bug:仔细看上面的 switch 语句——
case "true"居然也return false!这是原代码里的笔误。正确逻辑应该是case "true": return true,只有当 annotation 明确写了true才注入。我开头说的那次故障就是被这个 default 行为给坑的:只要 annotation 不是精确的true(包括大写 TRUE、空值、拼写错误),全都跳过。建议改成:switch strings.ToLower(annotations[admissionWebhookAnnotationInjectKey]) { default: return false // 默认不注入,安全 case "y", "yes", "true", "on": return true // 多种写法都识别 }
相关常量定义:
const (
// 代表这个 pod 是否要注入 = true 代表要注入
admissionWebhookAnnotationInjectKey = "sidecar-injector-webhook.xiaoyi/need_inject"
// 代表判断 pod 已经注入过的标志 = injected 代表已经注入了,就不再注入
admissionWebhookAnnotationStatusKey = "sidecar-injector-webhook.xiaoyi/status"
)
// 为了安全,不给这两个 ns 中的 pod 注入 sidecar
var ignoredNamespaces = []string{
metav1.NamespaceSystem,
metav1.NamespacePublic,
}
💡 为什么要排除 kube-system / kube-public? 这俩 namespace 里跑的是 etcd、kube-proxy、CoreDNS 这种集群核心组件。给它们注入 sidecar 等于让一个用户态服务挡在 etcd 前面——只要你的 sidecar 一抽风,整个集群就崩了。这种事故我见过一次,再也不想见第二次。
2.3 添加默认配置(防止 K8s issue #57982)
defaulter = runtime.ObjectDefaulter(runtimeScheme)
func applyDefaultsWorkaround(containers []corev1.Container, volumes []corev1.Volume) {
defaulter.Default(&corev1.Pod{
Spec: corev1.PodSpec{
Containers: containers,
Volumes: volumes,
},
})
}
这段是 kubernetes/kubernetes#58025 的 workaround。问题背景:webhook 返回的 patch 如果不包含字段默认值(比如 container 的 terminationMessagePath、imagePullPolicy),APIServer 在做 strategic merge 时会出错。这个 applyDefaultsWorkaround 的作用就是先让 defaulter 给容器和 volume 填好默认值,再生成 patch。
如果你跳过这一步,你会看到类似这样的报错:
admission webhook "xxx" denied the request:
container has invalid spec: terminationMessagePath is required
三、createPatch:用 JSON Patch 改 Pod
Webhook 返回给 APIServer 的不是修改后的 Pod,而是一组 JSON Patch (RFC 6902) 操作。APIServer 收到 patch 后应用到原始 Pod 上,再继续后续的准入流程。
3.1 定义 patchOperation
type patchOperation struct {
Op string `json:"op"` // 动作:add / remove / replace / move / copy / test
Path string `json:"path"` // 操作的 JSONPointer 路径
Value interface{} `json:"value,omitempty"` // 值(remove 不需要)
}
3.2 addContainer:给 Pod 加容器
这个函数有个容易写错的细节:第一个 container 的 path 必须是 /spec/containers,传整个数组;后续的必须是 /spec/containers/-(- 表示追加到数组末尾),传单个对象。
// 添加容器的 patch
// 如果是第一个 patch 需要在 path 末尾添加 /-
func addContainer(target, added []corev1.Container, basePath string) (patch []patchOperation) {
first := len(target) == 0
var value interface{}
for _, add := range added {
value = add
path := basePath
if first {
first = false
value = []corev1.Container{add}
} else {
path = path + "/-"
}
patch = append(patch, patchOperation{
Op: "add",
Path: path,
Value: value,
})
}
return patch
}
💡 为什么要区分 first 和后续? JSON Patch 的语义是:
- 当目标数组不存在时,
add到/spec/containers会创建这个数组(value 必须是数组)- 当目标数组已存在时,
add到/spec/containers/-会追加一个元素(value 是单个对象)写反了 APIServer 会报
unable to apply patch: ...,并且报错信息很不友好。
3.3 addVolume:给 Pod 加 Volume
逻辑和 addContainer 一模一样,只是类型换成 corev1.Volume:
func addVolume(target, added []corev1.Volume, basePath string) (patch []patchOperation) {
first := len(target) == 0
var value interface{}
for _, add := range added {
value = add
path := basePath
if first {
first = false
value = []corev1.Volume{add}
} else {
path = path + "/-"
}
patch = append(patch, patchOperation{
Op: "add",
Path: path,
Value: value,
})
}
return patch
}
🤔 这里其实可以用泛型重构(Go 1.18+)。如果项目允许,把
addContainer和addVolume合并成一个addItems[T any]函数,能少写一半代码。
3.4 updateAnnotation:标记"已注入"
这个函数是幂等性的关键。我们要在 Pod 的 annotation 里塞一个 status: injected 的标记,下次同一个 Pod 再走 webhook 时,mutationRequired 会看到这个标记直接 return false,避免被重复注入。
func updateAnnotation(target map[string]string, added map[string]string) (patch []patchOperation) {
for key, value := range added {
if target == nil || target[key] == "" {
target = map[string]string{}
patch = append(patch, patchOperation{
Op: "add",
Path: "/metadata/annotations",
Value: map[string]string{
key: value,
},
})
} else {
patch = append(patch, patchOperation{
Op: "replace",
Path: "/metadata/annotations/" + key,
Value: value,
})
}
}
return patch
}
⚠️ 这里有个潜在 bug:如果 Pod 已有 annotations 但没有目标 key,代码走的还是
if分支,会把整个 annotations 字段整体替换掉——这等于丢失原有的所有 annotation!正确逻辑应该是:if target == nil { // 整体 add } else if _, exists := target[key]; !exists { // 用 add 到 /metadata/annotations/{key} 添加单个 key } else { // 用 replace 替换单个 key }这种 bug 在开发环境很难发现(Pod 通常 annotation 不多),但生产环境一旦 Pod 有重要的 annotation(比如 Prometheus 抓取配置、Istio 流量配置),就会集体丢失。
3.5 createPatch:组装所有 patch
func createPatch(pod *corev1.Pod, sidecarConfig *Config, annotations map[string]string) ([]byte, error) {
var patch []patchOperation
patch = append(patch, addContainer(pod.Spec.Containers, sidecarConfig.Containers, "/spec/containers")...)
patch = append(patch, addVolume(pod.Spec.Volumes, sidecarConfig.Volumes, "/spec/volumes")...)
patch = append(patch, updateAnnotation(pod.Annotations, annotations)...)
return json.Marshal(patch)
}
三步走:加容器 → 加 volume → 改 annotation。最后 json.Marshal 一下变成字节数组,塞回 AdmissionResponse.Patch。
3.6 在 mutatePod 中调用
annotations := map[string]string{admissionWebhookAnnotationStatusKey: "injected"}
patchBytes, err := createPatch(&pod, ws.sidecarConfig, annotations)
if err != nil {
return &v1beta1.AdmissionResponse{
Result: &metav1.Status{
Message: err.Error(),
},
}
}
glog.Infof("AdmissionResponse: patch=%v\n", string(patchBytes))
return &v1beta1.AdmissionResponse{
Allowed: true,
Patch: patchBytes,
PatchType: func() *v1beta1.PatchType {
pt := v1beta1.PatchTypeJSONPatch
return &pt
}(),
}
注意 PatchType 这个字段——必须显式声明为 JSONPatch(也支持 JSONMergePatch,但 mutating webhook 的标准做法是 JSONPatch)。漏写这个字段,APIServer 会忽略你的 patch,没有任何报错。
四、整个流程串起来看
┌────────────┐ 1. kubectl apply Pod
│ kubectl │ ─────────────────────────┐
└────────────┘ ▼
┌───────────────┐
│ APIServer │
└───────┬───────┘
│ 2. 触发 MutatingWebhook
│ POST /mutate (AdmissionReview)
▼
┌───────────────────────┐
│ serveMutate │
│ ├─ 校验 body/CT │
│ ├─ Decode AR │
│ └─ 调 mutatePod │
└──────────┬────────────┘
▼
┌───────────────────────┐
│ mutatePod │
│ ├─ Unmarshal Pod │
│ ├─ mutationRequired?│ ─── No ──> Allowed:true (空 patch)
│ │ (ns/annotation)│
│ ├─ applyDefaults │
│ └─ createPatch │
└──────────┬────────────┘
▼
┌───────────────────────┐
│ AdmissionResponse │
│ { Allowed: true, │
│ Patch: [...], │
│ PatchType: JSONPatch }
└──────────┬────────────┘
│ 3. APIServer 应用 patch
▼
┌──────────────┐
│ etcd 存储 │
│ 已注入的 Pod │
└──────────────┘
五、生产环境踩坑清单
以下是我在生产环境部署 MutatingWebhook 过程中真实踩过的坑,建议每条都对照检查:
| # | 坑点 | 现象 | 解决方案 |
|---|---|---|---|
| 1 | admission/v1beta1 在 K8s 1.22+ 被移除 | webhook 被静默跳过,无任何报错 | 切换到 admission/v1,同时升级 controller-runtime |
| 2 | mutationRequired 的 switch 逻辑反了 | annotation 写了 true 也不注入 | case "true": return true(见 §2.2) |
| 3 | updateAnnotation 整体替换 annotations | Pod 上其他 annotation 全丢 | 区分单个 key 的 add/replace(见 §3.4) |
| 4 | 漏写 PatchType: JSONPatch | patch 不生效,无报错 | 显式声明 PatchType |
| 5 | 不区分 first / 后续 container | APIServer 报 unable to apply patch | 第一个用数组 value,后续用 /- 追加 |
| 6 | webhook 没有 timeout 配置 | APIServer 卡 30s 后 fail open | MutatingWebhookConfiguration 里设 timeoutSeconds: 5 |
| 7 | webhook 没有 failurePolicy: Ignore | webhook 一挂全集群 Pod 创建失败 | 非核心注入用 Ignore,核心策略校验用 Fail |
| 8 | 没排除 kube-system / kube-public | sidecar 注入到 etcd/CoreDNS 上 | ignoredNamespaces 必须配(见 §2.2) |
六、本节小结
回到开头:serveMutate → mutatePod → createPatch 这条链路,本质就是一个 HTTP handler 在做"收请求 → 决定要不要改 → 用 JSON Patch 改"的事情。难点不在代码量(其实就 200 行),而在:
- 判断逻辑要严密:哪些 Pod 不能注入?已经注入过的怎么判断?annotation 怎么解析?
- JSON Patch 要写对:第一个元素 vs 后续元素的差异、annotation 的覆盖问题、PatchType 显式声明
- 失败语义要清楚:webhook 挂了 APIServer 怎么办?timeout 设多少?failurePolicy 怎么选?
本节代码已基本完成 webhook 的核心逻辑。下一节会讲怎么把 webhook 部署到集群里——证书签发、MutatingWebhookConfiguration 配置、Deployment / Service 编排,以及最关键的 CA Bundle 怎么塞。


1113

被折叠的 条评论
为什么被折叠?



