更名说明:本项目原名 KubeSentry,自 2026-11-03 起更名 KubeStasis(避免与既有项目重名);CRD group 也于 11-04 由
apps.sentry.io改为kubestasis.io。前几篇文章里的旧名指的都是同一个项目,代码仓库现为 github.com/weirong-ui/…。
这是系列的收官篇。 前七篇讲的是"某个能力怎么做对",这一篇讲"把它交出去之前还要补什么"——以及更重要的:哪些我明确不做,为什么。
零、先给结论:6 件事各自解决了什么,没解决什么
| # | 补的事 | 解决了什么 | 没解决什么 |
|---|---|---|---|
| 1 | RBAC 逐条收敛 | 8 条规则、无通配符、不申请 Secret 读权限 | 没做 CIS Benchmark 逐条对照,也没跑 kube-bench |
| 2 | distroless + 多架构 | 镜像里无 shell、无包管理器、非 root;amd64/arm64 双架构 | 换来了"进不去容器",排障只能靠日志与指标 |
| 3 | CI 六道门 | 每次 push 都验证文档、单测、-race、真实 apiserver、真集群、chart | 门的边界是明确留白的,见第四节 |
| 4 | 镜像签名 + 准入策略 | cosign keyless 签 digest,签完立刻自验 | Kyverno 策略当前有结构错误(写这篇时自检发现),见第五节 |
| 5 | 决策可观测性 | "为什么没缩"变成一条 PromQL,含休眠告警 | 指标基数随 App 数线性增长,没做过大规模验证 |
| 6 | Helm 交付形态 | helm upgrade --install 真集群跑通,双证书方案 | crds/ 升级不更新 schema 是 Helm 的既定行为,只能手动补 |
读完这张表你会发现一件事:六件事里有四件,是在补"别人来用"才会暴露的问题。 自己玩的时候,RBAC 给 * 也能跑,镜像带 shell 也无所谓,没有 Helm 也能 kubectl apply。一旦要交出去,这些全都变成"别人的第一个小时"。
一、"生产就绪"是个动词,不是形容词
先把这个词拆开。我在仓库 docs/STATUS.md 里立的验收口径是一句话:
代码能在干净集群上
deploy → apply App → Available,且有可复现的证据。
这个口径是给功能定的。到了"要交出去"这一步,我加四条线:
- 能装:不读源码也能装上——
helm install一条命令,且升级路径说得清。 - 能查:出问题时不用改代码就能定位——日志、
status、Events、指标四层至少有一层能回答。 - 能被拦:错误配置进不来,且拦住的方式是可审计的(不是靠口头约定)。
- 能复现:每条"我们支持 X"背后有 CI 门或真集群实录,不是文档里的一句话。
这四条线不等于"可以上生产"。 后面第九节我会把"这份清单不能证明什么"单独列出来——因为一份只列优点的清单,读者没法判断可信度,这是我在这个系列里反复强调的。
二、第 1 件:RBAC——把权限一条条数出来
config/rbac/role.yaml 是 controller-gen 从代码里的 +kubebuilder:rbac 标记生成的,84 行,8 条规则。逐条摊开:
| API 组 | 资源 | 动词 | 为什么需要 |
|---|---|---|---|
"" | events pods services | create/delete/get/list/patch/update/watch | Events 审计;L2 摘除坏 Pod;Service 自动创建 |
"" | pods/status | get | 自愈读 containerStatuses 判 CrashLoop |
apps | deployments | 全动词 | 托管 Deployment 的 CreateOrUpdate |
apps | replicasets | 只读 | L3 回滚按修订历史挑"上一个不同镜像" |
coordination.k8s.io | leases | 全动词 | Leader 选举 |
kubestasis.io | apps | 全动词 | 自己的 CR |
kubestasis.io | apps/finalizers | update | 加/摘 finalizer 走子资源 |
kubestasis.io | apps/status | get/patch/update | 状态回写 |
三个值得说的点:
第一,没有 secrets。 这不是省事,是设计决定的。Webhook 的证书走文件挂载(kustomize 用 hack/webhook-certs.sh 自签,Helm 用 genSelfSignedCert 渲染期生成),控制器全程不通过 Secret API 读证书。一个 Operator 不碰 Secret,等于砍掉了最容易被审计盯上的那类权限。
第二,replicasets 只给读。 回滚要读修订历史(哪些 RS 对应哪个镜像),但从不写 RS——回滚的动作是改 App.spec.image,然后让 Deployment controller 自己去收敛。权限的形状应该和代码的形状一致:只读的东西给了写权限,说明要么代码里藏着一条你没想清楚的写路径,要么你根本没想过。
第三,pods/status 那条其实是冗余的。 上面 pods 已经有 get,而 kubectl get pod -o yaml 返回的本来就包含 status。这一条我留着,是因为它声明的是意图("我要读 Pod 状态"),不是额外授权。这种"声明意图但实际冗余"的条目,比漏权限安全得多——但如果你在审计别人的 RBAC,看到一堆冗余条目,要分清是"刻意声明"还是"复制粘贴"。
还有一个刻意的留白:没有 deployments/status 的写权限。 测试里(test/envtest/)需要代写 Deployment 的 status 来模拟"Pod 起来了",那段代码用的是 envtest 的管理员身份,不是控制器身份。控制器从不写别人的 status——这条边界如果破了,多控制器共存时就会出现互相覆盖。
顺带一个 ClusterRole vs Role 的取舍:App 是命名空间级资源,但控制器要 watch 全集群(不然每个新 ns 都要改配置)。所以用 ClusterRole + ClusterRoleBinding,代价是权限范围是集群级——这也是为什么每一条都要能说出理由。
三、第 2 件:distroless——省掉的是 shell,付出的是排障方式
Dockerfile 37 行,两个阶段:
ARG RUNTIME_IMAGE=gcr.io/distroless/static-debian12:nonroot
FROM golang:1.22 AS builder
ARG TARGETOS=linux
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o kubestasis ./cmd
FROM ${RUNTIME_IMAGE}
WORKDIR /
COPY --from=builder /workspace/kubestasis .
USER 65532:65532
ENTRYPOINT ["/kubestasis"]
四个决定,每个都有代价:
CGO_ENABLED=0:不是性能选择,是能不能塞进 distroless/static 的前提。开了 CGO 就得带 libc,static 镜像里没有。代价是 DNS 解析走纯 Go 实现,和一些依赖 glibc 的库不兼容——这个项目只用 k8s 客户端,没有这个包袱。
USER 65532:65532:distroless 的 nonroot tag 本来就是这个 uid,显式再写一遍是防"有人把 RUNTIME_IMAGE 换成 latest"。
-trimpath -ldflags="-s -w":去掉构建路径和符号表。副作用是 panic 的栈里没有源码行号——出问题时你得用同 commit 在本地复现。这是个真实的取舍:镜像小了,可调试性差了。
没有 shell 的代价,是这个决定里最容易被低估的。 你不能 kubectl exec -it ... -- sh 进去看。出问题时只剩四条路:
kubectl -n kubestasis-system logs deploy/kubestasis-controller-manager --tail=200 # kustomize 装法
kubectl -n kubestasis-system logs deploy/ks-kubestasis --tail=200 # helm 装法
kubectl get events --sort-by=.lastTimestamp | tail -40
kubectl get app <name> -o jsonpath='{.status}'
kubectl -n kubestasis-system port-forward svc/ks-kubestasis-metrics 8080:8080 # 指标
最后一条是 state、conditions、events、metrics 四层里的最后一层——也是唯一能回答"为什么没动"的一层(Events 只在有动作时留痕)。
所以"上 distroless"从来不是一个镜像优化动作,它倒逼你把可观测性做够——这也是第五件事(决策指标)的真实动机之一。如果日志和指标不够,你会在第一次线上问题时后悔。
顺带一个我在写这篇时发现的注释漂移:
COPY api/ api/
COPY cmd/ cmd/
COPY internal/ internal/
# pkg/ 随 P4(手写 mini-Informer)落地后再加进来
P4(mini-Informer)早就落地了,但 pkg/ 仍然没进镜像——因为 cmd/main.go 根本没 import 它,它是学习模块,不是运行时依赖。行为是对的,注释文字过期了。这种"代码对、注释旧"的漂移最难发现:没有 CI 能查"注释是否还在描述现状",只能靠人读。
多架构在 CI 的 image job 里做:platforms: linux/amd64,linux/arm64,TARGETOS/TARGETARCH 由 buildx 自动注入,和 Dockerfile 里那两个 ARG 对上。本机 docker build 只会出当前架构,所以"我本地能跑"和"多架构可用"是两件事——这条 CI 门就是补这个差距的。
四、第 3 件:CI 六道门,每道门背后都有一次翻车
现在 .github/workflows/ci.yml 307 行,6 个 job。没有一道门是"觉得应该加"加上去的,每一道都能对应到一次具体的事故。
| 门 | 拦什么 | 它是被什么逼出来的 |
|---|---|---|
docs | 必需文件存在、README 图片存在、文档数字与"已完成"状态一致 | README 徽章写 139、同文件开发节还写 113、DESIGN 里 envtest 仍标"计划中" |
build | gofmt / vet / build / 139 用例 / -race | Decide() 曾写 e.nowFunc,只因默认并发=1 才没炸 |
envtest | 真实 etcd + apiserver 的 4 条控制循环链路 | 单测全绿但链路没验证过("测了函数,没测回路") |
e2e | kind 真集群:伸缩 / 自愈 / Webhook 负向 | 自愈原先只靠人工实测,评审一句"不能只靠人" |
chart | helm lint + 双形态渲染 + CRD 逐字节 diff | chart 里的 CRD 副本漏同步,装出来少字段 |
image | 多架构构建 + cosign 签名 + 签完自验 | 签名步骤可能静默坏掉,坏了没人知道 |
挑五个最值得抄的细节讲。
4.1 docs 门:文档自己都开始说谎的项目,别信它的别的数字
这道门跑一个 148 行的 hack/doc_consistency.py。它做三件事:
- 用例数单一事实源:README 徽章里的
tests-N cases是基准,全仓所有"当前时态"的N 用例必须等于它。 - 历史记录豁免,但豁免条件要收紧:
103 → 113 用例这种演进流水是正当的。最初实现是"这行有→就整行跳过",于是139 用例(真集群 1→5→1 验证)这种当前声明被静默放过——现在要求箭头必须紧邻用例计数。 - 现实断言:
test/envtest目录存在,docs/DESIGN.md就不许再说 envtest "计划中"。产物存在 = 已完成,不再靠人记得改文档。
第 2 条那个修正很典型:一个豁免规则写得越宽,门就越像装饰。
4.2 build 门:-race 是给"不会崩的 bug"准备的
- name: go test -race
# 数据竞争不崩溃、不报错,只在特定并发时序下悄悄算错数
# (实例:Decide() 曾有的 e.nowFunc = nowFn,当时只因默认并发=1 才没炸)。
# ubuntu runner 有 gcc;本机 Windows 没有——本地跑不了 ≠ 不用跑。
run: go test -race -count=1 ./internal/... ./pkg/...
Decide() 曾经会把时钟函数写到 Engine 的字段上。单测全绿,因为测试是串行的;真跑起来 Reconcile 可能并发,两个 goroutine 同时改一个函数指针,读到哪个时钟就看运气——而且它不会 panic,只会偶尔算出错的副本数。这类 bug 靠 code review 抓不到,靠 -race 能。
注意注释最后那句:本地跑不了 ≠ 不用跑。Windows 没有 gcc,-race 起不来,所以这条门只能在 CI 上存在。很多人因为"本地跑不了"就把这条门删了。
4.3 envtest 门:KUBESTASIS_REQUIRE_ENVTEST=1 是防"静默变绿"
test/envtest/ 在进程内起真实 etcd + apiserver,验 4 条链路:创建闭环(Deployment/Service/ownerRef/finalizer/status)、就绪状态回写、删除放闸、副本漂移拉回。
设计上有个陷阱:本地没装 envtest 二进制时,测试会 t.Skip()——这很合理(不然每个 clone 仓库的人都跑不了测试),但在 CI 里就是灾难:
env:
KUBESTASIS_REQUIRE_ENVTEST: "1"
设了这个环境变量,装不上二进制就 t.Fatal 而不是 Skip。任何"装不上就跳过"的测试,在 CI 里都必须有强制模式,否则你会在某天发现它已经跳了三个月。
4.4 e2e 门:rollout 完成 ≠ webhook 可服务
Webhook 负向用例的核心不是"能拒绝",而是怎么区分"拒绝了"和"还没准备好":
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; }
kubectl rollout status 绿了,只代表 Pod Ready;Webhook 的 endpoint 就绪和证书挂载还有秒级延迟,这期间请求会拿到 InternalError(failurePolicy=Fail 时表现为拒绝)。测试如果只断言"apply 失败",那么**"还没准备好"也会被算成"正确拒绝"**——CI 时红时绿就是这么来的。这里只认 Forbidden。
4.5 同一道门里最贵的一段:自愈回归要验证"停在回滚结果上"
这段是整个 CI 里跑得最慢、也最值得的一段:
kubectl patch app heal-demo --type=merge -p '{"spec":{"image":"kubestasis-demoapp:v2-crash"}}'
rolled_back=""
for i in $(seq 1 60); do # 60 × 10s = 10 分钟预算
img=$(kubectl get app heal-demo -o jsonpath='{.spec.image}')
if [ "$img" = "kubestasis-demoapp:v1" ]; then rolled_back="yes"; break; fi
sleep 10
done
[ -n "$rolled_back" ] || { echo "FAIL: 10 分钟内没有等到自动回滚"; exit 1; }
# 回滚后继续盯 3 分钟:image 必须停在 v1
for j in $(seq 1 18); do
img=$(kubectl get app heal-demo -o jsonpath='{.spec.image}')
if [ "$img" != "kubestasis-demoapp:v1" ]; then
echo "FAIL: 回滚后乒乓回 $img"; exit 1
fi
sleep 10
done
前半段是"回滚发生了",后半段是"回滚稳住了"。这两件事不是一回事,而后者才是用户真正在意的。
第 4 篇里写过这个 bug 的成因:回滚到 v1 成功后,旧 ReplicaSet 的 crash Pod 还没清完,下一轮 Inspect 仍然看到不健康实例,于是"上一个不同镜像"恰好是刚滚走的 v2-crash——同一秒内两次 L3,v2 → v1 → v2。这个连招在本地没暴露,是 kind CI 抓到的,因为集群里的滚动时序碰巧错开了。
修法是在 Inspect 的 report 里带上 BadImages(不健康 Pod 用的镜像),L3 选版时排除它们。但真正的教训不在代码里:
回滚类逻辑的验证,不能停在"看到回滚发生",必须验证"停在回滚结果上"。
所以这段 e2e 的价值不是"覆盖了自愈",而是用 3 分钟的等待,把一句设计承诺钉成了可执行断言。它也解释了为什么这个 job 单次要跑十几分钟——docs/build/chart 加起来几十秒,剩下的时间全在等故障发生。
五、第 4 件:镜像信任——签 digest,不签 tag
image job 只在一处跑:main 分支的 push(if: github.ref == 'refs/heads/main' && github.event_name == 'push')。构建、推 GHCR、签名、自验:
- name: Sign image (keyless)
# 签 digest 不签 tag:tag 可以被重新指向,digest 不可变。
run: cosign sign --yes ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
- name: Verify signature round-trip
run: |
cosign verify ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }} \
--certificate-identity-regexp "https://github.com/${{ github.repository }}/.github/workflows/ci.yml" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" | head -5
为什么签 digest 而不是 tag:latest 可以被重新指向任何镜像,签名跟着 tag 走等于没签。digest 是内容哈希,改了内容 digest 就变,签名失效。
为什么 keyless 而不是管私钥:验证方不需要拿到你的公钥,只要断言"这个 digest 的签名证书,其 OIDC subject 是 仓库/ci.yml@refs/heads/main、issuer 是 GitHub"。私钥不存在,也就没有私钥泄露这回事。代价是依赖 Fulcio 与 Rekor 两个外部服务,且验证时必须联网查透明日志。
为什么签完要立刻自验:签名这步失败的方式很隐蔽——cosign sign 返回 0 但签到了别的 digest、或者 OIDC token 权限配错导致签了个空。自验让证据链在 CI 里闭环,也让"签名步骤坏了"变成红叉而不是一个安静的谎言。
这里有个我必须写出来的真问题
配套的集群级策略在 policies/kyverno-kubestasis.yaml,注释里写着三条规则:① 禁 :latest ② 必须显式带 tag ③ manager 镜像必须带本仓库 CI 的签名。
写这篇文章时我把它解析了一遍,结果:
rules count: 2
0 name= disallow-latest-tag keys= ['name', 'match', 'validate']
1 name= require-image-tag keys= ['name', 'match', 'validate', 'verifyImages']
--- rule2 match ---
{'any': [{'resources': {'kinds': ['Pod'], 'namespaces': ['kubestasis-system']}}]}
第三条规则的名字丢了。 原因是一行缩进:
- name: require-image-tag
match: ...
validate: ...
# 规则 3:KubeStasis manager 镜像必须有本仓库 CI 的 keyless 签名
match: # ← 少了 "- name: verify-manager-signature",这个 match 撞进上一条
...
verifyImages: ...
YAML 的重复键被后者覆盖,于是连锁三个后果:
- 规则 2 的作用域被改掉了:从"所有 Pod 必须显式带 tag"缩成"只查
kubestasis-system"——等于业务负载完全没被这条规则管到。 - 规则 2 同时带
validate和verifyImages。Kyverno 官方文档写得很明确:"Each rule can contain only a single validate, mutate, generate, or verifyImages child declaration." 所以这个 ClusterPolicy 在创建时就会被 Kyverno 拒绝。 - 签名校验从未在任何集群里生效过,因为它压根没作为一条独立规则存在过。
最扎心的不是这个错本身,是它躲过了六道门:
| 门 | 为什么没抓到 |
|---|---|
docs | 只查 .md 和图片,不碰 policies/ |
build | 只查 Go 代码 |
envtest / e2e | 不装 Kyverno,也不 apply policies/ |
chart | 只渲染 charts/ |
image | 只管镜像本身 |
而 docs/STATUS.md 里这一行的状态是 ✅ 已实现,证据写的是"policies/kyverno-kubestasis.yaml:禁 latest/无 tag + kubestasis-system 强制验签"。
这就是"文件在仓库里"和"策略在集群里生效"之间的全部差距。 也是我在第 6 篇(Admission Webhook)里讲过的同一件事的另一面:那次是"文档写了、代码没有",这次是"文件写了、规则不存在"。
修法很短:补上 - name: verify-manager-signature,把 match 与 verifyImages 挪到新规则里,规则 2 的 match 恢复成只按 kinds: [Pod]。
但这篇文章发布时,仓库里这一行还是错的。我没有顺手改,因为那几天我正在改控制器另一条链路,不想把"策略文件修正"和"代码改动"混进同一个 commit——混在一起,日后 blame 会分不清哪一行属于哪件事。更重要的是:把"发现问题"和"修好问题"分开写,读者才能看见账本出错的完整过程,而不是只看到一个已经完美的结果。
一个能公开承认自己账本漏项的项目,比一个只展示齐全的项目可信。这也是我把这段写进来的原因。
顺带说清分工,别把两件事混起来:
- Webhook 管业务合法性:
AppCR 的镜像 tag、replicas 区间、指标 target 能否解析。 - Kyverno 管供应链:集群里所有 Pod 的镜像来源与签名,包括绕过
AppCR 直接建 Deployment 的路径。
两者不是替代关系。Webhook 只能拦 App,拦不住 kubectl run nginx:latest。
六、第 5 件:可观测性——最危险的失败是"看起来在工作"
这一节三个 bug,全在 deploy/monitoring/ 里,全都是"不报错、但没生效"。
6.1 告警写在了 schema 不认的位置,被静默剪掉
PrometheusRule 的结构是 spec.groups[].rules[]。我最初把告警写成了 group 的一个兄弟字段:
groups:
- name: kubestasis.scaling
rules:
- record: ...
alerts: # ← 错:group 没有 alerts 字段
- alert: KubeStasisScalingDormant
kubectl apply 返回 created,没有 warning。apiserver 的 pruning 把不认识的字段剪掉了,last-applied-configuration 注解里还留着 alerts:,所以 kubectl get -o yaml 看起来你确实配了。
告警从未加载过。 正确写法是把 alert 当成 rules 数组的一个元素:
rules:
- record: kubestasis:scaling_decisions:rate5m
expr: ...
- alert: KubeStasisScalingDormant
expr: ...
for: 5m
这个坑我在文件里留了注释,因为它的迷惑性在于:apply 成功、资源存在、注解里有配置、但规则列表里没有它。 唯一可靠的验证方式是去 Prometheus 的 /rules 页面看它到底在不在。
6.2 分子是空集,面板就显示 "No data"
"缩容被挡比例"这条规则要算:
(被挡的缩容决策速率) / (全部决策速率)
问题是:一个平稳运行的 App,一天里可能一条被挡决策都没有。此时分子是空集,空集除以任何数还是空集,Grafana 显示 No data——看起来像面板坏了,实际是"太好了,一次都没被挡"。
修法是零填充:
(
sum by (namespace, app) (
rate(kubestasis_scaling_decisions_total{action="None",reason=~"stabilizing|cooldown|hysteresis"}[5m])
)
or
0 * sum by (namespace, app) (rate(kubestasis_scaling_decisions_total[5m]))
)
/
clamp_min(
sum by (namespace, app) (rate(kubestasis_scaling_decisions_total[5m])),
0.0001
)
or 0 * <分母> 用同一组 label 造一条贴底的 0 线;clamp_min 防除零。这两个写法在教科书里都算"防御性编码",但在监控里它们是"面板可信"的前提——一个经常显示 No data 的面板,运维很快就会不看它。
6.3 honorLabels 忘了开,按 App 过滤全空
决策指标的 label 是 {namespace, app},其中 namespace 是被伸缩的那个 App 的命名空间(业务语义)。
但 Prometheus 抓取时,目标是控制器 Pod,它的 namespace 是控制器所在的那个命名空间(kubestasis-system 或你 Helm release 的 ns)。默认行为(honor_labels: false)下,目标标签会覆盖指标自带的同名标签,原值被改名成 exported_namespace。
后果:面板按 namespace 过滤 → 所有 App 都落到控制器那个 ns → 按 App 过滤的每一格都是空的。
chart 里的 ServiceMonitor 补了一行:
honorLabels: true
裸 Prometheus 的手写 job 也要显式映射(kubernetes_sd 的 meta 标签不会自动落到时序上):
- source_labels: [__meta_kubernetes_namespace]
target_label: namespace
这三个 bug 有个共同点:系统没有坏,只是它在骗你。 没有报错、没有告警、没有异常日志——只有你在某天需要它的时候,发现它是空的。所以我现在的习惯是:监控建好后先故意制造一次事件,确认面板真的动了。
6.4 顺带说清楚:这 4 个指标为什么值得单独做
Events 只在有动作时留痕,"评估了但决定不动"什么都不留。而运维最常问的问题恰恰是后者:
刚才为什么没缩?
所以 internal/metrics/ 打了 4 个指标:
| 指标 | 回答什么 |
|---|---|
kubestasis_scaling_decisions_total{namespace,app,action,reason} | 每次决策按原因码计数 |
kubestasis_scaling_desired_replicas | 信号算出来"应该几副本" |
kubestasis_scaling_current_replicas | 当前实际几副本 |
kubestasis_scaling_signal_replicas{signal} | 每个信号各自算出几副本(取 max 的那个就是主导信号) |
reason 用固定枚举(no_policy / no_metrics_source / hysteresis / stabilizing / cooldown / scale_up / scale_down / within_bounds),不是自由文本——这是为了防 label 基数爆炸。一个 App 最多 8 个 reason,一千个 App 也才几千条时序;如果 reason 里塞错误信息,基数会失控。
为什么不能直接用 Decision.Reason(那是个给人看的字符串)?因为它带变量:"signal replicas 7 > max 5" 这种每条都不一样,label 基数会随决策次数无限增长。人看的解释留在日志和 Events 里,指标只留枚举。
App 删除时调 Forget 清理,否则删了的 App 的时序会一直留着,面板里越积越多。
七、第 6 件:Helm——交付形态换了,两个假设就变了
charts/kubestasis 和 config/ 装的是同一个控制器,但"交付"和"自己用"的差别,集中在三处。
7.1 CRD 放在 crds/ 里,就只在首次 install 时创建
Helm 的既定行为:crds/ 目录下的资源在 install 时创建,upgrade 时跳过(避免升级时误删 CRD 数据)。所以升级 chart 后新增的字段(比如 scaleDownStabilizationSeconds)不会进 schema。
后果极其安静:
helm upgrade成功,Pod 正常滚动;- 你
kubectl apply一个带新字段的 App,返回成功; - apiserver 把新字段当未知字段剪掉(pruning),对象里根本没有;
last-applied-configuration注解里还留着这个字段,kubectl get -o yaml看起来配了。
表现是"配置了但没生效",而且没有任何报错。修法只有一条,写在 charts/kubestasis/README.md 和 NOTES.txt 里:
kubectl apply -f charts/kubestasis/crds/
这条不是 chart 的疏忽,是 Helm 的设计。所以任何用 crds/ 交付 CRD 的 chart,升级文档里都必须写这句话,否则用户一定会在某个版本踩到。
顺带一个 CI 门就是为它准备的:
- name: Chart 里的 CRD 必须与 controller-gen 产物一致
run: |
diff -u config/crd/bases/kubestasis.io_apps.yaml \
charts/kubestasis/crds/kubestasis.io_apps.yaml
两份 CRD 逐字节比。忘了同步的后果不是"helm 装不上",而是"helm 装出来的少字段"——比装不上难查一百倍。
7.2 两套 webhook 共存,failurePolicy=Fail 会把整个 CRD 变成单点
这是在集群里踩到的,过程很难看:
- 之前用 kustomize 装着,有一套
Mutating/ValidatingWebhookConfiguration,failurePolicy: Fail。 - 改用 Helm 装到新命名空间,把旧的 manager 缩到 0 副本。
- 旧的 webhook 配置还在集群里,
service已经没有后端了。 - 于是所有
App的写入——包括控制器摘 finalizer 的Update——全部被connection refused打回。 - App 卡在
Terminating删不掉;控制器日志里只有connection refused;新装的 Helm release 看起来"有毛病"。
表象是"新控制器坏了",实际是死掉的那套在拦人。原因就在 failurePolicy: Fail:控制器不可用时,准入请求直接拒绝。这个策略在"防止非法配置写入"上是对的,在"迁移"这个场景里是致命的。
修法不是改策略,是迁移顺序:
make undeploy # 或手动删掉旧命名空间与旧 webhook 配置
helm install ...
这条写进了 chart README 的"注意事项",因为它是"顺序错就必定踩"的那类问题,不是概率问题。
那 failurePolicy 到底该设什么?我的取舍是默认 Fail,文档里写明改成 Ignore 的代价:
| Fail(默认) | Ignore | |
|---|---|---|
| 控制器挂掉时 | App 创建/更新全部拒绝 | App 照常写入,非法配置也进得来 |
| 适合 | 准入是硬要求 | 可用性优先,事后靠审计补 |
没有正确答案,但必须显式选一个并写下来。默认值不写进文档,等于把这个决定留给运气。
7.3 自签证书每次 upgrade 都会重签
chart 默认用 Helm 的 genSelfSignedCert 在渲染期生成证书(零外部依赖)。但 Helm 的 genSelfSignedCert 每次渲染都会生成新证书——所以每次 helm upgrade 证书都变。
如果 Deployment 不重启,就会变成"Pod 里是旧证书、caBundle 是新证书",所有准入请求 TLS 校验失败。解法是给 Pod 模板加一个 checksum 注解:
annotations:
checksum/webhook-cert: {{ include (print $.Template.BasePath "/webhook.yaml") . | sha256sum }}
证书变 → 注解变 → 滚动重启。这类"配置变了但进程没重启"的问题,在 Kubernetes 里有一个通用解法:把配置的哈希写进 Pod 模板。
八、明确不做的 5 件,以及为什么
docs/STATUS.md 有一节叫"已知不解决的问题 / 主动画边界"。存在的原因很直接:一份只列"能做什么"的清单,读者没法判断可信度。
| 不做的事 | 为什么现在不做 | 真要做需要什么 |
|---|---|---|
| 有状态应用(PVC 生命周期、主从选举、PD 分离) | 另一套知识体系(StatefulSet 语义、卷拓扑、数据迁移),硬塞进来会让无状态主链路不扎实 | 独立的 StatefulApp CRD;或直接集成 TiDB Operator / CloudNativePG |
| 多集群 / 联邦 | 跨集群服务发现、状态同步、冲突消解是独立课题;单集群还没被真实流量打磨过 | 先有 metrics 聚合层与全局调度;基于 Karmada 做上层编排比自研现实 |
| eBPF 无侵入采集 | 要内核态开发、节点特权、版本兼容矩阵;收益只是省掉应用埋点 | 用 Pixie 作为 MetricsSource 的另一种实现即可,不必自研 |
| 按权重引流式金丝雀 | 现在做的是"发布后观察错误率、超阈值回滚"——观察真实流量,不做权重控制。名字叫 canary,但不是流量治理 | 引入 Gateway API / Istio 的权重路由,还要管 VirtualService 生命周期 |
| 控制面高可用 | chart 已开 leaderElection,但 replicaCount 默认 1——双副本下的选主接管、动作不重复执行从未实测 | 副本 ≥2 真集群验证切换 + PDB + 反亲和 |
第 4 条我要特别强调,因为它最容易被说成自己做到了:"金丝雀回滚"和"金丝雀发布"不是一件事。 前者是"发出去,看着不对就退回来";后者是"只放 1% 流量进去试"。前者不需要流量治理,后者需要。简历上写"实现金丝雀发布",面试官问一句"权重怎么切的"就露了。
第 5 条同理:leaderElection: true 只是打开了开关,不等于验证过切换。开关是配置,切换是行为。
九、这份清单不能证明什么
这一节是全文最该被认真读的部分。上面六件事做完,能得到的是"这个项目对自己的边界是清楚的",不是"这个项目可以上生产"。
四个硬缺口,全部有据可查:
- 单副本控制面:
replicaCount: 1,双副本切换未实测(第八节第 5 条)。控制器挂了,伸缩、自愈、准入全部停摆。 - 自签证书 +
failurePolicy: Fail:证书由 chart 渲染期生成,没有轮转体系;Fail意味着控制器不可用时 App 写不进去。这两条叠在一起,是"控制面不能用"时的双重打击。 - Prometheus 是单点且外部依赖:
--prometheus-url指向别人家的 Prometheus。它挂了,伸缩静默休眠——而休眠是有告警的(KubeStasisScalingDormant),这条告警存在的意义就是让"静默"不静默。 - CRD 只有
v1alpha1,没有 conversion webhook:字段演进没有迁移路径。现在改字段语义,只能靠"删了重建"。
三处证据的适用范围,也说清楚:
- 压测结论:单节点、单应用、CPU 忙等模拟;扩容响应是
n=1单次观察,方差未补齐(HPA 侧三轮准重复 σ≈8.2s,组间差 17s ≈ 2.1σ)。主结论"副本-分钟 −12.5%"经三轮校核约 6σ,但重测协议虽已预注册、数据尚未采集。所以文中所有"快"的说法都带限定。 - Kyverno 策略:如第五节所述,当前结构是错的,
require-image-tag的作用域被覆盖,签名校验规则不存在。STATUS 里那一行的 ✅ 应该降级为 🚧,直到修好并在集群里 apply 过。 - Grafana 面板:4 行 16 格是真集群截的图,但只覆盖单集群单数据源;App 数量上去之后指标基数的表现没验证过。
还有一个未完成项,藏在 ROADMAP 里:
- [ ] kind 集群混沌验证脚本(随机杀 Pod,第 3 周)
自愈的 CI 回归(crash 镜像 → L2 → L3 → 防乒乓)覆盖的是一种故障模式。随机杀 Pod、节点 NotReady、镜像拉取失败、OOMKilled —— 这些都没进自动化。"能处理 CrashLoop"和"能处理故障"之间还有距离,这个差距我不打算用文字补上。
十、如果只记三句话
第一句:交付形态一变,你原来的假设就失效了。
kubectl apply 变成 helm install,CRD 的更新语义变了;自签证书从"跑一次脚本"变成"每次 upgrade 重签",证书与进程的生命周期就对不上了;准入策略从"一份配置"变成"可能有两份共存",failurePolicy: Fail 就从保护变成炸弹。每一个"只是换个装法"的背后,都至少有一条假设在悄悄改变。
第二句:所有"不报错的失败"都要单独设计验证方式。
告警字段被 schema 剪掉、CRD 新字段被 pruning 剪掉、webhook 规则被 YAML 重复键覆盖、比例规则的分子是空集——这四个的共同点是 kubectl apply 全部返回成功。apply 成功只证明 apiserver 接受了这份 YAML,不证明它按你的意图生效了。 所以:告警要去 /rules 页面确认存在,CRD 要 kubectl get crd -o yaml 确认字段在,策略要 kubectl get clusterpolicy -o yaml 确认规则数。
第三句:六道门的意义不在数量,在于每道门都对应一次真实翻车。 "我们也做了 CI"没有信息量。有信息量的是:"这道门是因为 README 写错了用例数、DESIGN 把已完成的标成计划中,才加的。" 门是事故的墓碑,不是能力的勋章。
顺带纠一个我自己的错
系列规划文档里,简历建议那行原本写着:
开发 Validating/Defaulting Admission Webhook,为受管应用强制注入安全上下文与资源限制
这句话是错的。Webhook 实际做的只有这几类:禁 :latest/无 tag 镜像、minReplicas ≤ maxReplicas、replicas 落在区间内(省略时跟随 minReplicas)、指标 target 按类型可解析且同类型不重复、金丝雀阈值与观察窗下限、探针 path 必须以 / 开头。
没有强制注入 securityContext,也没有强制注入 resources(resources 是 App.spec 里的字段,用户写什么就透传什么)。
这类"规划里写了、代码里没有"的句子,如果照抄进简历,就是给自己埋雷——面试官只要问一句"注入的 runAsNonRoot 是硬编码还是可配的",就全露了。规划文档里这一行目前还留着(和第五节那个策略文件一样,我都还没改);但至少简历上不能这么写,因为简历上的每句话都应该能被代码验证。
面试真能聊的三件事
- 为什么 RBAC 不给
secrets:证书走文件挂载,控制器不需要读 Secret API;权限的形状应该和代码的形状一致。 - 为什么签 digest 不签 tag:tag 可变、digest 不可变;keyless 验证断言的是 OIDC 身份,不需要分发公钥。
- 为什么
failurePolicy: Fail是双刃剑:它保证非法配置进不来,代价是控制器挂掉时 CRD 写入全部失败——包括控制器自己摘 finalizer 的那次 Update,于是 App 卡Terminating。这个连锁反应只有真跑过才知道。
十一、跑一遍
# 交付形态(推荐)
helm upgrade --install ks charts/kubestasis -n kubestasis-system --create-namespace \
--set prometheus.url=http://prometheus.monitoring:9090 \
--set webhook.enabled=true \
--set metrics.serviceMonitor.enabled=true
kubectl apply -f config/samples/app_v1alpha1_app.yaml
kubectl get app sample -w
# 看"为什么没缩"
kubectl -n kubestasis-system port-forward svc/ks-kubestasis-metrics 8080:8080
curl -s localhost:8080/metrics | grep kubestasis_scaling
# 验证 RBAC 真的只有这些权限
kubectl get clusterrole ks-kubestasis-manager -o yaml | grep -A3 'resources:'
系列其他文章:
- HPA 只会伸缩不会自愈?我设计了一个 K8s 应用自愈 Operator:KubeStasis
- 从零写一个 K8s Operator:kubebuilder 初始化到第一个 Reconcile
- 为什么 HPA 会震荡?手写一个带冷却窗口的伸缩决策引擎
- 给 K8s 加一个"自动值班员":分级故障自愈引擎
- 图解 client-go Informer:手写一个 mini-Informer 才敢说懂
- 给 Operator 加 Admission Webhook 踩的 4 个坑(其中 3 个本可以不踩)
- 跟原生 HPA 做对照压测:五轮里前四轮全废,最后一个结论也被我自己降级
代码与完整能力清单:github.com/weirong-ui/…(v0.1.0 快照) · docs/STATUS.md(唯一可信账本,含"已知不解决的问题") · 压测报告。
系列到这里收尾。 八篇下来,写到的坑比功能多得多——这不是谦虚,是这个项目真实的样子。如果这几篇里只能带走一句,我希望是这句:
能跑通的东西很多,能说清"哪里跑不通"的东西很少。后者才是能被信任的那一种。
如果你们团队也在把自研 Operator 交付给别人,欢迎在评论区讲讲你踩过的"apply 成功但没生效"——我猜不会比我这四个少。