给 Operator 加 Admission Webhook 踩的 4 个坑(其中 3 个本可以不踩)

更名说明 :本项目原名 KubeSentry ,自 2026-11-03 起更名 KubeStasis (避免与既有项目重名);CRD group 也于 11-04 由 apps.sentry.io 改为 kubestasis.io。前几篇文章里的旧名指的都是同一个项目,代码仓库现为 github.com/weirong-ui/...。
改稿说明 (2026-11-05):本文发布后收到一份外部评审,指出三件事------立论漏了 CEL、坑 4 的修复只停在流程规避、defaulting 存在"开关决定语义"的分叉。我按它重构了第三、四、八节和全文结论。凡标注「复盘结论 / 尚未落地」的都是这次改稿新增的判断,仓库 v0.1.0 里还没有对应代码 ;已实现的部分一律以 docs/STATUS.md 为准。这条边界比多写两段漂亮结论重要。
系列第 5 篇手写完 mini-Informer,讲的是控制器怎么读数据 。这篇讲另一头:一个 App 对象从 kubectl apply 到落进 etcd 过了几道关,以及我事后怎么发现自己跳过了其中一层。

零、环境与复现口径

技术文的信任来源是别人能复现。先把这张表摆出来(数字取自仓库 v0.1.0 的 go.mod / Makefile 和 docs/STATUS.md,不是估的):

组件 本文核对的版本 为什么写它
Kubernetes v1.36.1(Docker Desktop 实测);CI 用 helm/kind-action@v1 默认镜像,未固定版本 CEL 校验规则 1.25 起默认开启、1.29 GA
Go module go 1.22.0(本机 1.27.3) 泛型、*int32 指针字段相关
k8s.io/api / apimachinery / client-go v0.31.1 admission 类型定义
controller-runtime v0.19.0 Defaulter / Validator 接口
controller-gen v0.16.5(Makefile 固定) 支持 XValidation marker
cert-manager 项目不依赖 ;Helm 可选 --set webhook.certManager.enabled=true 证书章节的前提
仓库快照 tag v0.1.0 / commit 82f8e40 main 会动,固定住才能对得上代码

关于原始报错:仓库里留下的是 docs/STATUS.md 的现象记录,不是当时的完整终端输出,所以我不贴一条看着很真但其实是编的 x509 全文 。要复现就用 openssl x509 -in tls.crt -text -noout 看自己证书的 DNSNames,再和 apiserver 报错里 certificate is valid for ... 那一段对------重点是"回连用的名字"在不在 SAN 列表里。

一、三个现场

先看三个我实际遇到的清单:

yaml 复制代码
# 现场 1:用户期望"起步就 3 个副本"
spec:
  image: kubestasis-demoapp:v1
  scaling:
    minReplicas: 3
    maxReplicas: 5
# 没写 replicas ------ 结果第一轮只有 1 个 Pod,
# 伸缩器下一轮才把它扩到 3,白白多一次冷启动抖动
yaml 复制代码
# 现场 2:一个看起来人畜无害的镜像
spec:
  image: nginx:latest
# 第 4 篇的 L3 回滚靠"回滚到上一个不同镜像"实现。
# :latest 让版本不再离散可枚举,自愈引擎的回滚语义直接失效
yaml 复制代码
# 现场 3:OpenAPI schema 完全能接受
spec:
  scaling:
    minReplicas: 10   # Minimum=1 ✅
    maxReplicas: 3    # Minimum=1 ✅
# 两个字段各自都合法,合起来是废配置

共同点是:它们都不是"类型错了",而是"字段之间的关系错了"或"值有语义但没被理解"。

我当时从这里直接跳到了「schema 管单字段,跨字段交给 Admission Webhook」,还写进包注释里当二分法。这个结论建立在一个被缩小的选择集上------中间还有 CEL 这一层。第八节的四个坑里,有三个是因为这一跳才存在的。

二、先把准入链路画对

这张图的顺序是 11-03 真集群上实测确认的,不是背文档:

text 复制代码
kubectl apply
    ↓
解码时应用 CRD schema 默认值 / 剪枝(pruning)
    ↓
Mutating Webhook(AppDefaulter)      ← 看到的是 schema 默认值已落全的对象
    ↓
Validating Webhook(AppValidator)
    ↓
schema 校验 + CEL 规则(x-kubernetes-validations)
    ↓
持久化到 etcd

包注释里的原话:

go 复制代码
// 关键推论:**CRD 默认值比 Mutating Webhook 先落地**。所以凡是要做跨字段
// 推导的字段(replicas 跟随 minReplicas),就不能在 CRD 上挂默认值------
// 否则 webhook 看到的永远是被抢先填好的值,跨字段逻辑形同虚设。
// Validator 看到的则是默认值已落全的对象,可以信任字段值。

两个直接推论:

  1. Defaulter 不能依赖"字段是 nil"来判断用户有没有显式写------除非该字段确实没挂 schema default。
  2. Validator 可以放心读值,不用到处做零值兜底。

由此还带出一个顺序问题:给 spec.replicas 挂了 +kubebuilder:default=1 之后,minReplicas: 3 的对象进 Defaulter 时 Replicas 已经是 1,if app.Spec.Replicas == nil 永远不成立。修法是把字段改成 *int32 + +optional、不挂 default。但这条不配占第八节的坑位------它是读文档就能得出的顺序推论,我把它挪到这里,是因为原文把它和三个真正"只有跑过才知道"的问题并列,稀释了"真坑"这个词。

另一个容易忽略的选择:config/webhook/manifests.yaml 里 operations 只有 CREATE 和 UPDATE,DELETE 不拦:

go 复制代码
func (AppValidator) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) {
    return nil, nil // 删除不做拦截:故障时要能一键拆除
}

这个取舍我仍然认为是对的:真出故障时,一个把删除也拦掉的 webhook 会让排障变成拆弹。但改稿时才发现它只解决了一半------摘 finalizer 走的是 UPDATE,不是 DELETE,见坑 4。

三、先别急着上 webhook:被我跳过的 CEL 这一层

在动手写 Defaulter 之前,本该先回答一个问题:这些规则真的需要一条在线拦截链路吗?

Kubernetes 1.25 起 CRD 支持 CEL 校验规则(x-kubernetes-validations,1.29 GA),跨字段约束可以直接写进 schema,由 apiserver 在解码/校验阶段执行。kubebuilder 里就是一行 marker:

go 复制代码
// +kubebuilder:validation:XValidation:rule="self.minReplicas <= self.maxReplicas",message="minReplicas 不能大于 maxReplicas"

它不需要证书、不需要 Service、不需要等 endpoint 就绪,也不需要操心 failurePolicy。kubectl apply --dry-run=server 就能测。

所以正确的分层是三层,不是两层:

约束 该放哪 理由
类型、范围、enum、单字段默认值 OpenAPI schema 声明式,永远在线,零运维
跨字段比较、正则、前缀判断 CEL apiserver 内执行,无网络依赖
跨字段推导默认值 Mutating webhook,或控制器侧算有效值 CEL 不做 defaulting
必须与 Go 运行期逻辑同源 Validating webhook 避免两套解析实现漂移
需要"警告而非拒绝"的分级反馈 Validating webhook CEL 只有拒绝/通过
依赖集群状态或外部系统 webhook,或干脆不做 CEL 读不了其他对象

对着真实 CRD 重分一遍 11 条规则,结论比评审稿给的"7 条可搬"要保守 ------那份估算是按示意字段名算的,而 api/v1alpha1/app_types.go 里字段长这样:

规则 能无损搬进 CEL? 真实原因
minReplicas ≤ maxReplicas ✅ ScalingPolicy 里两个 int32,直接比较
replicas ∈ [min,max] ✅ 但要 has() 处理 scaling/replicas 都可省略
镜像带显式 tag、禁 latest ⚠️ 会缩水 Go 版按最后一个 / 切段以容忍 registry:5000/x:tag;CEL 正则要么写得复杂,要么把合法镜像拒掉
探针 path 以 / 开头 ⚠️ 结构不同 真实结构是 readiness.type + readiness.path 扁平两段,不是 httpGet.path 嵌套对象
金丝雀阈值 ∈ (0,1)、窗口下限 ❌ CanaryPolicy.errorRateThreshold 是字符串 "0.01",不是 float64;CEL 要 double() 转换,且复现不了 Go 的解析失败分支
指标 target 按类型可解析 ❌ 必须与 scaler.parseTarget 同源,见第六节
resources.requests ≤ limits ❌ 数量比较要用 quantity(),有版本要求且 cost 不低;标准答案其实是 LimitRange
三种 warnings、聚合报错 ❌ CEL 没有 warning 通道
与 scaler / healer 的联动约束 ❌ 运行期语义,准入期表达不了

换句话说:能无损搬的只有 2 条,另外 2 条要改字段结构或接受语义缩水,其余必须留 webhook 。但这已经足够改变架构判断了------只要把"跨字段推导默认值"也从 Mutating Webhook 挪到控制器侧(下一节的 effective.Replicas),这个 Operator 的 Webhook 就完全可以不存在 :校验交给 schema + CEL,有效副本数在 Reconcile 里算。证书、endpoint 时序、failurePolicy 三项成本一起消失。

CEL 也不是免费的:规则有 estimated cost 预算,超了 apiserver 直接拒绝这条 CRD,所以长正则要克制、数组要配 maxItems/maxLength;一个 rule 只写一个约束,合成 a && b && c 会让报错只剩一句 message,而 CEL 报错自带出错的 rule 文本,拆开写等于自带定位信息。

复盘结论,仓库 v0.1.0 尚未落地 :当前 11 条规则仍全部在 internal/webhook/app_webhook.go,CRD 里没有 XValidation。这一节写的是"本来应该怎么做",不是"我已经这么做了"。

四、Defaulting:一条规则,两处事实来源

KubeStasis 的 Defaulter 只有一条跨字段规则------replicas 省略时跟随 scaling.minReplicas,而不是恒为 1:

go 复制代码
func (AppDefaulter) Default(_ context.Context, obj runtime.Object) error {
    app, ok := obj.(*appsv1alpha1.App)
    if !ok {
        return fmt.Errorf("expected App, got %T", obj)
    }
    if app.Spec.Replicas == nil {
        replicas := int32(1)
        if app.Spec.Scaling != nil && app.Spec.Scaling.MinReplicas > 1 {
            replicas = app.Spec.Scaling.MinReplicas
        }
        app.Spec.Replicas = &replicas
    }
    return nil
}

Replicas 用 *int32 而不是 int32,是为了区分"用户没写"和"用户写了 0";项目在 P0 阶段就因为 Scaling/Health 用值类型 + omitempty,把省略的字段序列化成 {"strategy":""},被 CRD 的 enum 校验拒到无限报错。三个单测 TestDefault_ReplicasFollowsMinReplicas / TestDefault_NoScalingDefaultsToOne / TestDefault_ExplicitReplicasUntouched 把边界钉死,最后那个尤其重要:默认值注入绝不能覆盖用户显式写的值。

改稿时发现一个真实缺陷 :--enable-webhook 默认是 false,而控制器算目标副本数走的是另一个函数:

go 复制代码
// internal/controller/app_controller.go
func desiredReplicas(app *appsv1alpha1.App) int32 {
    if app.Spec.Replicas != nil {
        return *app.Spec.Replicas
    }
    return 1
}

于是同一份 CR(minReplicas: 3、省略 replicas):开了 webhook 得到 3,默认部署得到 1。一个开关改变了对象的语义,而且默认配置是错的那一种。 原文把这两处各写一句带过,等于默认它是合理的。

正确的收敛不是再补一处兜底,而是只有一个事实来源:

go 复制代码
// internal/effective/replicas.go ------ 复盘结论,尚未落地
// Defaulter 与 Reconciler 都调它,webhook 开着关着行为必须一致。
func Replicas(app *kubestasisv1alpha1.App) int32 {
    if app.Spec.Replicas != nil {
        return *app.Spec.Replicas
    }
    if app.Spec.Scaling != nil && app.Spec.Scaling.MinReplicas > 0 {
        return app.Spec.Scaling.MinReplicas
    }
    return 1
}

Defaulter 调它写回 Spec.Replicas(让 kubectl get 看得见),Reconciler 调它算目标副本数(webhook 关着时也是同一个值)。再补一个表驱动单测,输入 {nil, min=3} / {nil, no scaling} / {ptr(1), min=3},断言两条路径结果相同------这条测试比现有三个更能防回归。

还有一个副作用必须提前说 :Mutating Webhook 会把推导值写进存储对象,而用户的 manifest 里没有这个字段。GitOps 场景下 Argo CD 会判成 OutOfSync 并改回去,改完又被 webhook 改回来,形成持续 diff:

yaml 复制代码
ignoreDifferences:
- group: kubestasis.io
  kind: App
  jsonPointers: ["/spec/replicas"]

如果不接受这个副作用,另一条路是默认值恒为 1、minReplicas 只用来校验不用来推导------更符合 K8s 惯例,但 minReplicas: 3 就必须显式写 replicas。两条都能接受,选了第一条却不提 GitOps 才是问题。这也是上一节说的"把默认值挪到控制器侧"能成立的前提:不写回 spec,就没有 drift。

五、剩下的才交给 Validating

validateApp 一共 11 条规则,按"为什么非 webhook 不可"重新分一遍:

类型 规则 如果不拦 CEL 归属
跨字段 minReplicas ≤ maxReplicas、replicas ∈ [min,max] spec 与集群实际状态分叉 应搬 CEL(见第三节)
跨字段 resources.requests ≤ limits Pod 被 apiserver 拒,在 App 上只表现为一直 Provisioning 留 webhook,或交给 LimitRange
语义 镜像必须有显式非 latest tag :latest 让 L3 回滚失去意义 可搬但要防语义缩水
语义 指标 target 按类型可解析、同类型不重复 解析失败在伸缩器里静默跳过,"看着生效实际没扩" 留 webhook(必须与 Go 同源)
语义 金丝雀阈值 ∈ (0,1)、观察窗 ≥30s 配置错了用户不知道,运行期静默回退 留 webhook(字段是字符串)
语义 探针 HTTP path 以 / 开头;env 保留名/重名 探针静默失效;env 声明被控制器强制覆盖 前者可搬,后者留 webhook
联动 与 internal/scaler、internal/healer 的解析规则对齐 准入期放行、运行期才炸 留 webhook

三个实现选择值得说:

1. 一次把所有错误列全,而不是遇到第一个就返回

go 复制代码
if len(errs) > 0 {
    return warns, fmt.Errorf("App %s/%s 未通过准入校验: %s",
        app.Namespace, app.Name, strings.Join(errs, "; "))
}
return warns, nil

kubectl apply 一次改一堆字段时,"改一个报一个"是很糟的体验。TestValidate_AggregatesAllErrors 守着这条。CEL 做不到这种聚合,这是它替代不了 webhook 的具体一笔。

2. warnings 和 errors 分开

三种情况只警告不拒绝:spec.port < 1024、声明了 CPU request 却没 limit、Type=TCPSocket 的探针却配了 path。相对地,APP_NAME/POD_NAMESPACE 保留名、env 重名、HTTPGet 的 path 不以 / 开头都是直接拒绝。分寸是:能确定是错的就拒,只是"大概率不是你想要的"就提示。准入控制不该替用户做架构决策。

3. ValidateCreate 和 ValidateUpdate 走同一个 validateApp

go 复制代码
func (AppValidator) ValidateUpdate(_ context.Context, _, newObj runtime.Object) (admission.Warnings, error) {
    app, ok := newObj.(*appsv1alpha1.App)
    ...
    return validateApp(app)
}

只在创建时校验是最常见的漏防线------用户 kubectl edit 一下就把 :latest 塞回来了。而且伸缩器和自愈引擎都会回写 spec,那些内部写入同样要过 Validating Webhook。但这行"无条件全量校验"正是坑 4 的漏洞所在:它没有给"spec 一个字没改"的拆除路径留门。

六、两个容易被追问的实现细节

Q:校验镜像 tag,为什么不能直接 strings.Contains(image, ":")?

因为镜像名里可以带 registry 端口:

go 复制代码
func validateImage(image string) error {
    // 取最后一个 / 之后的部分看有没有 tag(镜像名里可带 port,如 registry:5000/x:tag,
    // 所以要按最后一个 / 切段再找 :)
    last := image
    if i := strings.LastIndex(image, "/"); i >= 0 {
        last = image[i+1:]
    }
    if !strings.Contains(last, ":") {
        return fmt.Errorf("spec.image %q 没有显式 tag(等价于 :latest),回滚与审计都要求版本可枚举", image)
    }
    if strings.HasSuffix(last, ":latest") {
        return fmt.Errorf("spec.image %q 禁止使用 :latest------它让 L3 回滚失去意义", image)
    }
    return nil
}

registry:5000/myapp 这种没有 tag 的镜像,整体字符串里确实有冒号,直接判断会漏过。这也是为什么第三节说"镜像规则搬进 CEL 会缩水"------要复刻这个切段逻辑,CEL 正则得写得比示意代码长得多。

Q:为什么准入期要把 target 字符串解析一遍?

go 复制代码
// validateMetricTarget 按指标类型校验 target 字符串可解析。
// 规则与 scaler.parseTarget 保持一致(那里是运行期解析,这里是准入期拦截)。
func validateMetricTarget(m appsv1alpha1.MetricTarget) error {
    switch m.Type {
    case appsv1alpha1.MetricQPS, appsv1alpha1.MetricQueueDepth:
        if v, err := strconv.ParseFloat(m.Target, 64); err != nil || v <= 0 { ... }
    case appsv1alpha1.MetricErrorRate, appsv1alpha1.MetricKVCacheUsage:
        if v, err := strconv.ParseFloat(m.Target, 64); err != nil || v <= 0 || v >= 1 { ... }
    case appsv1alpha1.MetricP99Latency, appsv1alpha1.MetricTTFT:
        // 裸数字按秒,或 time.ParseDuration 可解析("200ms"/"1s")
        ...
    default:
        return fmt.Errorf("未知指标类型 %q", m.Type)
    }
}

(switch 节选,各分支的错误构造省略了,完整实现见仓库。)

因为 target 在 CRD 里是字符串(要同时容纳 "200ms"、"0.01"、"100"),schema 只能校验它是个 string。准入期和运行期必须用同一套解析规则,否则会出现"webhook 放行了、伸缩器解析失败静默跳过"的最坏情况。这类"两份解析逻辑漂移"的 bug 比直接报错难查得多,也正是"必须与 Go 同源"这条约束不能搬进 CEL 的理由。

七、TLS 与注册:不依赖 cert-manager 也能跑起来

Webhook 是 apiserver 主动回连你的 Service,所以必须 HTTPS、必须有证书,还得把 CA 告诉 apiserver。

注册 (cmd/main.go):

go 复制代码
if enableWebhook {
    if err := ctrl.NewWebhookManagedBy(mgr).
        For(&appsv1alpha1.App{}).
        WithValidator(webhook.AppValidator{}).
        WithDefaulter(webhook.AppDefaulter{}).
        Complete(); err != nil {
        setupLog.Error(err, "unable to create webhook", "webhook", "App")
        os.Exit(1)
    }
    setupLog.Info("admission webhook enabled", "port", 9443)
}

--enable-webhook 默认 false:裸 make deploy 不装证书,开了会 CrashLoop。这个默认值本身没问题,问题在第四节------它让默认部署的副本语义和带 webhook 的部署不一样。

+kubebuilder:webhook 注解里的 path 必须和 controller-runtime 的默认路径 /mutate|validate-<group>-<version>-<kind> 完全一致:

go 复制代码
// +kubebuilder:webhook:path=/mutate-kubestasis-io-v1alpha1-app,mutating=true,failurePolicy=fail,sideEffects=None,groups=kubestasis.io,resources=apps,verbs=create;update,versions=v1alpha1,name=mapp.kubestasis.io,admissionReviewVersions=v1
// +kubebuilder:webhook:path=/validate-kubestasis-io-v1alpha1-app,mutating=false,failurePolicy=fail,sideEffects=None,groups=kubestasis.io,resources=apps,verbs=create;update,versions=v1alpha1,name=vapp.kubestasis.io,admissionReviewVersions=v1

写错的表现很吓人:WebhookConfiguration 指向一个 manager 里不存在的 URL,所有 App 增改全被拒,而报错是连接失败,看不出是路径拼错。

config/webhook/manifests.yaml 里值得说的字段:

yaml 复制代码
- admissionReviewVersions: [v1]     # v1beta1 在 1.22 已移除,v1 是唯一正确选项
  failurePolicy: Fail               # webhook 挂了就拒绝写入(见坑 4)
  sideEffects: None                 # 声明无副作用,dry-run 才敢调用
  rules:
  - operations: [CREATE, UPDATE]    # 不拦 DELETE
    resources: [apps]               # 不含 apps/status、apps/finalize

resources 只写 apps 是对的:状态回写走 apps/status 子资源,不匹配这条规则,Reconcile 高频写 status 的路径完全不经过 webhook;写成 ["*"] 就会让每次状态更新都过一次在线拦截。当前 manifest 没有显式设置 timeoutSeconds ,也就是用默认 10s------Fail 模式下每次写入最坏要等满 10s,坑 4 里会讲为什么建议收到 5s。

证书方案 :项目没引入 cert-manager,用 hack/webhook-certs.sh 一把 openssl 自签解决(脚本节选,临时变量路径做了简化):

bash 复制代码
# 1) 自签 CA + 带 SAN 的服务端证书
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 3650 \
  -subj "/CN=kubestasis-webhook-ca"

# csr.conf 里必须把四个名字都塞进 SAN
# DNS.1 = ${SVC}   DNS.2 = ${SVC}.${NS}
# DNS.3 = ${SVC}.${NS}.svc   DNS.4 = ${SVC}.${NS}.svc.cluster.local

# 2) Secret(幂等写法)
kubectl -n "${NS}" create secret tls "${SECRET}" --cert=tls.crt --key=tls.key \
  --dry-run=client -o yaml | kubectl apply -f -

# 3) 注入 caBundle ------ 注意路径在 clientConfig 下面
kubectl patch "${cfg}" --type=json \
  -p "[{\"op\":\"add\",\"path\":\"/webhooks/${i}/clientConfig/caBundle\",\"value\":\"${CA_BUNDLE}\"}]"

Helm 形态用 genSelfSignedCert 在渲染期生成证书,同样零外部依赖;也支持 --set webhook.certManager.enabled=true 切到 cert-manager 的 Certificate 资源。

开关由 config/webhook-enabled/manager_webhook_patch.yaml 这个 overlay 打开:给 manager 加 --enable-webhook=true、暴露 9443,把证书 Secret 挂到 controller-runtime 默认目录(节选):

yaml 复制代码
containers:
  - name: manager
    args: [--leader-elect, --enable-webhook=true]
    ports:
      - name: webhook-server
        containerPort: 9443
    volumeMounts:
      - name: cert
        mountPath: /tmp/k8s-webhook-server/serving-certs
        readOnly: true
volumes:
  - name: cert
    secret: {secretName: webhook-server-cert}

make deploy-webhook 就是 overlay 部署 + 证书注入 + 滚动重启四步,rollout restart 不能省:Secret 首次创建时 manager 启动时还没挂上证书。

八、四个只有真跑过才会踩的坑

四个坑都在 docs/STATUS.md 的"新踩的坑"里有记录。改稿后按"技术坑 / 流程教训"重排:原坑 1(schema 默认值抢跑)移到第二节作为链路推论,原来一笔带过的 Helm crds/ 升为主坑------它的排查成本比另外三个加起来都高。

归类先说清楚 :坑 1、2、4 都是"引入在线 webhook 链路"才有的成本(证书、endpoint 时序、failurePolicy)。按第三节的分层方案,校验交给 schema + CEL、默认值在控制器侧算,这个 Operator 根本不需要 webhook,这三个坑就不存在。坑 3 跟 webhook 无关,是 CRD 分发的坑,踩得不算冤。

坑 1|caBundle 挂错层级,以及 SAN 少一个名字

  • 现象 A :证书和 Secret 都建好了,apiserver 仍报 x509: certificate signed by unknown authority。
  • 根因 A :caBundle 是 webhooks[i].clientConfig.caBundle,不是 webhooks[i].caBundle。JSON patch 路径必须是 /webhooks/N/clientConfig/caBundle。
  • 现象 B :CA 对了,仍报 x509: certificate is valid for ..., not ...。
  • 根因 B :apiserver 回连用的是它自己解析出来的 DNS 名,Service 简称、svc、.svc.cluster.local 三种形态都可能出现。脚本塞了 4 个 DNS SAN,Helm 模板也把 $cn 和 FQDN 都列进去。核对自己证书用 openssl x509 -text -noout,不要拿我这篇文章里的报错文本去 grep------我没有保留当时的完整输出,也不打算编一条。
  • 顺带一个 Helm 模板坑 :{{- if }} 会开新作用域,在 if 块里赋的变量出了块就不可见。$cn、$cert、$caBundle 必须赋在 if 外层。

坑 2|rollout 完成 ≠ webhook 可服务

  • 现象 :CI 的 Webhook rejects :latest image 时红时绿,报错是 InternalError: failed calling webhook ... connection refused,不是预期的 Forbidden。
  • 根因 :rollout status 只保证 Pod Ready,endpoint 注册和证书挂载还有秒级延迟 。这段时间 apiserver 调 webhook 拿到连接错误;因为 failurePolicy=Fail,请求直接被拒------但拒绝理由是"webhook 没起来",不是"校验没通过"。
  • 当前 CI 的写法 (v0.1.0 现状):12 次 × 5s 盲重试,只对明确的 Forbidden 断言。
yaml 复制代码
ok=""
for i in $(seq 1 12); do
  if kubectl apply -f /tmp/bad-app.yaml 2>/tmp/webhook-err.txt; then
    echo "FAIL: webhook accepted a :latest image"; exit 1
  fi
  if grep -q "Forbidden" /tmp/webhook-err.txt; then ok=yes; break; fi
  sleep 5
done
[ -n "$ok" ] || { echo "webhook never became ready:"; cat /tmp/webhook-err.txt; exit 1; }
  • 教训 :负向测试要断言"被正确的理由拒绝",只断言"失败了"会把基础设施故障当成校验成功。这一条当前 CI 做对了。
  • 复盘结论,尚未落地:盲重试既有 60s 最坏耗时,又把"证书真配错了"和"还没起来"混成一类。更干净的是先等端点、再断言:
bash 复制代码
# 1) 等 Pod Ready
kubectl -n kubestasis-system wait --for=condition=Ready pod \
  -l control-plane=controller-manager --timeout=180s

# 2) 等 Service 真的有后端(rollout status 不保证这一步)
for i in $(seq 1 30); do
  addrs=$(kubectl -n kubestasis-system get endpoints kubestasis-webhook-service \
          -o jsonpath='{.subsets[*].addresses[*].ip}')
  [ -n "$addrs" ] && break
  sleep 2
done

# 3) 此时再发负向用例,仍拿到 InternalError 才是真故障,不该被重试吃掉

坑 3|Helm crds/ 只在首次 install 生效,upgrade 静默剪掉字段

  • 现象 :升级 chart 后新增的 readiness 等字段,在 kubectl apply 时不报错 ,字段却凭空消失;last-applied-configuration 注解里还明明白白留着。
  • 根因 :Helm 约定 crds/ 目录里的 CRD 只在首次 install 创建,upgrade 不更新。CRD schema 还是旧的,apiserver 按旧 schema 做剪枝(pruning)。
  • 为什么它最阴:三个 webhook 坑至少会给你一条报错,这个连报错都没有。"我明明 apply 了、注解里也有、get 回来却没有"------排查方向一开始全在控制器和 webhook 上,没人怀疑 CRD 根本没更新。
  • 修复 :NOTES.txt 里提示升级后手动 kubectl apply -f charts/kubestasis/crds/,CI 的 chart job 加"Chart CRD 与 controller-gen 产物逐字节 diff"这道门,让静默漂移在 CI 里变成红灯。

坑 4|failurePolicy=Fail 把整个 CRD 锁死,而摘 finalizer 走的正是 UPDATE

这个坑最值得讲,因为它把"安全加固"反过来变成了可用性事故,而且原文给出的修复是不到位的。

  • 现场 :Helm Chart 装到新命名空间做迁移验证,把旧 kustomize manager 缩到 0 副本。随后所有 App 写入全部被拒,包括控制器摘 finalizer 的那次 UPDATE ,App 卡在 Terminating 删不掉,reconcile 日志刷 connection refused。
  • 表象:看起来像"新控制器挂了"。
  • 根因 :旧的 MutatingWebhookConfiguration 没被删,还挂在集群里,failurePolicy=Fail,指向的 Service 已经没有后端。apiserver 每次处理 App 都要先问这个死掉的 webhook,问不到就拒绝。拦人的不是新控制器,是那套已经下线的旧配置。
  • 我第一版的修复 :迁移顺序改成先 make undeploy 拆干净旧 webhook,再 helm install,写进 charts/kubestasis/README.md。这是流程规避,不是修复------换个人做迁移照样复现。
  • 改稿时才发现的技术漏洞 :第二节那个 ValidateDelete 恒返 nil,只解决了 DELETE。摘 finalizer、打标签、加 annotation 都走 UPDATE ,而当前的 ValidateUpdate 无条件全量校验(见第五节),所以拆除路径照样能被自己拦死。

真正的修复在代码里(复盘结论,仓库 v0.1.0 尚未落地):

go 复制代码
// internal/webhook/app_webhook.go
func (v *AppValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
    oldApp, ok := oldObj.(*appsv1alpha1.App)
    if !ok {
        return nil, fmt.Errorf("expected App, got %T", oldObj)
    }
    newApp, ok := newObj.(*appsv1alpha1.App)
    if !ok {
        return nil, fmt.Errorf("expected App, got %T", newObj)
    }

    // 拆除路径不能被自己拦死:spec 一个字没改,就放行。
    // 摘 finalizer、打标签、加 annotation 都属于这一类。
    // 这条同时救了另一个场景:webhook 上线前就存在的旧对象不符合新规则,
    // 若不特判,它们连删都删不掉。
    if apiequality.Semantic.DeepEqual(oldApp.Spec, newApp.Spec) {
        return nil, nil
    }
    return nil, validateApp(newApp)
}

只比 spec 就够:11 条规则全是 spec 级的,spec 不变时重新校验的结果必然与上次一致------除非上次根本没校验过(webhook 关闭期、或规则是后加的),而恰恰是这种"历史遗留对象"最需要一条拆除通道。

第二层防线在 manifest 里:resources 只列 apps(现状已经如此,不含 apps/status、apps/finalize),必要时加 objectSelector / namespaceSelector 继续收窄;再显式设 timeoutSeconds: 5,让 Fail 模式下每次写入不用等满默认 10s(这条是建议,当前 manifest 未设置)。

关于 failurePolicy,原文建议"生产更稳的组合是 Ignore + 告警",这个建议是错的。 11 条规则里有 replicas ∈ [min,max]、minReplicas ≤ maxReplicas 这类不变量,Ignore 意味着 webhook 挂掉的那几分钟里脏对象可以落库,控制器随后要处理一个自己无法处理的对象,失败推迟到 Reconcile 里以更难查的方式爆发------那是拿一致性问题换可用性问题,而且更隐蔽。

更稳的是四件事一起做,而不是调一个开关:

  1. 收窄拦截范围 :resources 只列真需要的,必要时加 selector;
  2. 让 webhook 别单点 :至少 2 副本 + PDB + 合理 timeoutSeconds;
  3. 控制器里再校验一次:准入是防线不是唯一防线,Reconcile 开头对关键不变量做断言,不合规就进降级状态并告警,而不是一路执行下去;
  4. 负向测试断言"被正确的理由拒绝"(坑 2 已做对)。

Ignore 只在一种情况下合理:这条校验的误报代价明显高于漏检代价。

附:迁移顺序是流程教训,不是技术坑。 先 make undeploy 再 helm install 确实要写进 README,但它只是让下次别再这样。放在这里,是不想让"我改了文档"看起来像"我修了 bug"。

九、CI 里怎么证明它真的生效

单测写得再漂亮,也不能证明 apiserver 真的调了你的 webhook。kind e2e 里放了两条断言。

负向::latest 必须被拒 (坑 2 那段,必须匹配到 Forbidden 和 latest 关键词)。

正向:跨字段默认值真的注入了:

yaml 复制代码
- name: Webhook defaults replicas from minReplicas
  run: |
    cat > /tmp/defaulted-app.yaml <<'EOF'
    apiVersion: kubestasis.io/v1alpha1
    kind: App
    metadata:
      name: defaulted
    spec:
      image: kubestasis-demoapp:v1
      port: 8080
      scaling:
        minReplicas: 3
        maxReplicas: 5
    EOF
    kubectl apply -f /tmp/defaulted-app.yaml
    got=$(kubectl get app defaulted -o jsonpath='{.spec.replicas}')
    [ "$got" = "3" ] || { echo "FAIL: replicas=$got, want 3 (from minReplicas)"; exit 1; }
    echo "ok: replicas defaulted to minReplicas=3"

这条断言的价值在于:它查的是 apiserver 里落库的对象 ,不是 Go 对象。TestDefault_ReplicasFollowsMinReplicas 只能证明函数逻辑对,证明不了 caBundle 配对了、path 没拼错、failurePolicy 生效了------这些只有真集群能给答案。

仓库 v0.1.0 当前是 139 个单测 (scaler 61 + controller 16 + healer 24 + webhook 24 + miniinformer 11 + metrics 3)+ 4 条 envtest 链路 + 6 个 CI job (docs / build(含 go test -race)/ envtest / chart / e2e / image),e2e 里 P0 部署、P3 webhook 负向、P2 自愈回滚三条链路跑在同一个 kind 集群上。按坑 2 的建议,负向用例前还应该显式等 Pod Ready + endpoints 有后端(尚未落地)。

能力清单以 docs/STATUS.md 为准------已实现的说实现,没实现的说没实现。仓库还有一道 hack/doc_consistency.py 机器门查文档内部数字漂移,juejin/ 已发布稿件按"发表时的事实"豁免,所以本文这次改稿把用例数对齐到了 139。

本篇不覆盖什么

  • 做的是 App 层字段校验与默认值 ,不是集群级 Pod Security(后者在 policies/kyverno-kubestasis.yaml,两层互补);
  • 当前代码并未强制注入 securityContext,只做校验与分级提示,别在简历里写成"注入";
  • 自签证书有效期 3650 天且没有自动轮换,生产建议开 cert-manager;
  • requests ≤ limits 这条仍留在 webhook,没有搬 CEL,原因见第三节;
  • 第三节的 CEL 分层、第四节的 effective.Replicas、坑 4 的 ValidateUpdate 放行,都是复盘结论,v0.1.0 尚未落地。

十、跑起来

bash 复制代码
git clone https://github.com/weirong-ui/kubestasis
cd kubestasis
git checkout v0.1.0

# 单元测:24 个 webhook 用例
go test ./internal/webhook/ -v -count=1

# 真集群(kind / Docker Desktop K8s)一键带 webhook 部署
make deploy-webhook IMG=kubestasis:dev

# 负向:应被 Forbidden
cat <<'EOF' | kubectl apply -f -
apiVersion: kubestasis.io/v1alpha1
kind: App
metadata: {name: bad-latest}
spec:
  image: nginx:latest
  port: 8080
EOF

# 正向:省略 replicas,应被注入为 3(仅在带 webhook 形态下)
cat <<'EOF' | kubectl apply -f -
apiVersion: kubestasis.io/v1alpha1
kind: App
metadata: {name: defaulted}
spec:
  image: kubestasis-demoapp:v1
  port: 8080
  scaling: {minReplicas: 3, maxReplicas: 5}
EOF
kubectl get app defaulted -o jsonpath='{.spec.replicas}'   # → 3

最后那条注释标了"仅带 webhook 形态":默认 make deploy 下会得到 1,就是第四节的语义分叉,别以为自己复现失败了。拆的时候记得 make undeploy------别把 webhook 配置留在集群里,否则就是坑 4 的复现实验。


改到最后,这篇文章的主张变了:跨字段约束有三层可放------schema、CEL、webhook,默认应该往最前面一层放,放不进去(要推导默认值、要与 Go 逻辑同源、要给 warning)才往后挪。我一开始直接跳到了最后一层,于是多踩了三个本可以不踩的坑;而一旦引入 webhook,就得把它的可用性当成设计目标,而不是等出事了当运维事故处理。

系列其他文章:

  1. HPA 只会伸缩不会自愈?我设计了一个 K8s 应用自愈 Operator:KubeStasis
  2. 从零写一个 K8s Operator:kubebuilder 初始化到第一个 Reconcile
  3. 为什么 HPA 会震荡?手写一个带冷却窗口的伸缩决策引擎
  4. 给 K8s 加一个"自动值班员":分级故障自愈引擎
  5. 图解 client-go Informer:手写一个 mini-Informer 才敢说懂

代码与完整能力清单:github.com/weirong-ui/...(v0.1.0 快照) · docs/STATUS.md。

下一篇 聊一个更"打脸"的话题:和原生 HPA 的对照压测。五轮实验里前四轮全被我自己推翻------Prometheus 静默挂掉导致数据全 0、status/spec 写入顺序引发连跳缩容、"对照组输入根本不存在"却差点写成结论。一篇讲怎么用数据证明项目不是玩具,也讲清楚那份结论在哪些条件下不成立。对 CEL 该搬多少、failurePolicy 的取舍或者"默认值该不该写回 spec"有不同意见,欢迎评论区拍砖。

相关推荐
Goboy2 小时前
夯爆了,我被一个一句话接入的免费可观测平台策反了,那一刻我决定放弃用了3年的Prometheus
后端·docker·kubernetes
吴声子夜歌2 小时前
Kubernetes权威指南——深入掌握Service(一)
云原生·容器·kubernetes
weirong1 天前
图解 client-go Informer:手写一个 mini-Informer 才敢说懂
kubernetes
.冰块.1 天前
流量大了自动加 Pod!K8s HPA 扩缩容与 ResourceQuota/LimitRange 资源管控实战
linux·云原生·容器·kubernetes·hpa·quota·limitrange
无言听风雨丶2 天前
Linux 容器本质探秘:Cgroup 与 Namespace 深度解析与实战
linux·运维·服务器·kubernetes
无言听风雨丶2 天前
Kubernetes GitOps 持续交付实战:用 Argo CD 让 Git 成为集群事实源
git·elasticsearch·kubernetes
无言听风雨丶3 天前
Kubernetes 控制平面深度剖析:从 kubectl apply 到 etcd 写入的全链路
平面·kubernetes·etcd
openFuyao3 天前
openFuyao x 复旦大学软件工程实验室 Panel 回顾:Agentic时代,集群调度技术体系将如何演进?
kubernetes·openfuyao
螺蛳粉 螺蛳粉3 天前
第 05 篇:etcd 深入:Raft 共识、watch 机制与快照备份恢复实战(底座认知 05)
docker·云原生·容器·kubernetes·etcd·分布式存储·kube-apiserver