手撕 K8s MutatingWebhook:sidecar 自动注入的 mutatePod 函数

前言:那个让我熬夜的 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 的整条调用链。今天把这部分拆出来分享,重点讲清楚 serveMutatemutatePodcreatePatch 这三个核心函数到底在干什么、为什么这么干、以及哪里容易踩坑。


本节重点

  • 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/v1beta1K8s 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,
    }
}

判断逻辑有三条规则:

  1. 如果 Pod 在高权限的 namespace(kube-systemkube-public)里,不注入
  2. 如果 Pod 的 annotations 中标记为已注入(status: injected),不注入
  3. 如果 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 的 terminationMessagePathimagePullPolicy),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+)。如果项目允许,把 addContaineraddVolume 合并成一个 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 过程中真实踩过的坑,建议每条都对照检查:

#坑点现象解决方案
1admission/v1beta1 在 K8s 1.22+ 被移除webhook 被静默跳过,无任何报错切换到 admission/v1,同时升级 controller-runtime
2mutationRequired 的 switch 逻辑反了annotation 写了 true 也不注入case "true": return true(见 §2.2)
3updateAnnotation 整体替换 annotationsPod 上其他 annotation 全丢区分单个 key 的 add/replace(见 §3.4)
4漏写 PatchType: JSONPatchpatch 不生效,无报错显式声明 PatchType
5不区分 first / 后续 containerAPIServer 报 unable to apply patch第一个用数组 value,后续用 /- 追加
6webhook 没有 timeout 配置APIServer 卡 30s 后 fail openMutatingWebhookConfiguration 里设 timeoutSeconds: 5
7webhook 没有 failurePolicy: Ignorewebhook 一挂全集群 Pod 创建失败非核心注入用 Ignore,核心策略校验用 Fail
8没排除 kube-system / kube-publicsidecar 注入到 etcd/CoreDNS 上ignoredNamespaces 必须配(见 §2.2)

六、本节小结

回到开头:serveMutatemutatePodcreatePatch 这条链路,本质就是一个 HTTP handler 在做"收请求 → 决定要不要改 → 用 JSON Patch 改"的事情。难点不在代码量(其实就 200 行),而在:

  1. 判断逻辑要严密:哪些 Pod 不能注入?已经注入过的怎么判断?annotation 怎么解析?
  2. JSON Patch 要写对:第一个元素 vs 后续元素的差异、annotation 的覆盖问题、PatchType 显式声明
  3. 失败语义要清楚:webhook 挂了 APIServer 怎么办?timeout 设多少?failurePolicy 怎么选?

本节代码已基本完成 webhook 的核心逻辑。下一节会讲怎么把 webhook 部署到集群里——证书签发、MutatingWebhookConfiguration 配置、Deployment / Service 编排,以及最关键的 CA Bundle 怎么塞


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

加倍巴巴

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值