更名说明 :本项目原名 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 看到的则是默认值已落全的对象,可以信任字段值。
两个直接推论:
- Defaulter 不能依赖"字段是 nil"来判断用户有没有显式写------除非该字段确实没挂 schema default。
- 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 的chartjob 加"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 里以更难查的方式爆发------那是拿一致性问题换可用性问题,而且更隐蔽。
更稳的是四件事一起做,而不是调一个开关:
- 收窄拦截范围 :
resources只列真需要的,必要时加 selector; - 让 webhook 别单点 :至少 2 副本 + PDB + 合理
timeoutSeconds; - 控制器里再校验一次:准入是防线不是唯一防线,Reconcile 开头对关键不变量做断言,不合规就进降级状态并告警,而不是一路执行下去;
- 负向测试断言"被正确的理由拒绝"(坑 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,就得把它的可用性当成设计目标,而不是等出事了当运维事故处理。
系列其他文章:
- HPA 只会伸缩不会自愈?我设计了一个 K8s 应用自愈 Operator:KubeStasis
- 从零写一个 K8s Operator:kubebuilder 初始化到第一个 Reconcile
- 为什么 HPA 会震荡?手写一个带冷却窗口的伸缩决策引擎
- 给 K8s 加一个"自动值班员":分级故障自愈引擎
- 图解 client-go Informer:手写一个 mini-Informer 才敢说懂
代码与完整能力清单:github.com/weirong-ui/...(v0.1.0 快照) · docs/STATUS.md。
下一篇 聊一个更"打脸"的话题:和原生 HPA 的对照压测。五轮实验里前四轮全被我自己推翻------Prometheus 静默挂掉导致数据全 0、status/spec 写入顺序引发连跳缩容、"对照组输入根本不存在"却差点写成结论。一篇讲怎么用数据证明项目不是玩具,也讲清楚那份结论在哪些条件下不成立。对 CEL 该搬多少、failurePolicy 的取舍或者"默认值该不该写回 spec"有不同意见,欢迎评论区拍砖。