7.1 声明式 API 与控制器模式
一句话
声明式 API 只描述「要什么」,控制器循环把「实际状态」拉回「期望状态」;自愈与幂等是白送的,代价是收敛有延迟、多控制器会互相覆盖。
命令式 vs 声明式:差别在「谁负责重试」
| 维度 | 命令式 | 声明式 |
|---|---|---|
| 你提交的 | 一个动作:start / scale 3 / iptables -A | 一个状态:replicas: 3 |
| 失败后 | 你重试,或脚本重试 | 控制器无限重试直到一致 |
| 幂等性 | 通常没有(执行两次结果不同) | 天然幂等(同一个 spec 反复 apply 结果相同) |
| 漂移处理 | 不知道,需要人巡检 | 持续对比,自动纠偏 |
| 审计 | 只能看到「谁执行了什么」 | 能看到「期望是什么、谁改的、当前差多少」 |
| 典型代表 | docker run、systemctl start、kubectl scale | Deployment YAML、Terraform、NetworkPolicy |
| 代价 | 简单直接 | 收敛延迟、控制器复杂度、调试链路长 |
同一个意图的两种写法:
# 命令式:执行一次,失败就靠人或脚本重试
kubectl scale deploy api --replicas=3
# 声明式:持续表达意图,控制器负责让现实追上它
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: api}
spec:
replicas: 3
EOF
第二条命令返回 created/configured 时,Pod 可能一个都还没起来。它只表示「期望状态已写入」。
声明式系统的三个要件
| 要件 | 内容 | 谁写 |
|---|---|---|
| 期望状态 spec | 可序列化、可版本化、可 diff 的目标 | 用户 / 上层控制器 |
| 实际状态 status | 观测到的事实 + 条件(conditions) | 控制器(用户只读) |
| 调谐循环 reconcile | 观察 → 对比 → 行动,永不停止 | 控制器 |
再加三个工程件,才撑得住生产:
| 工程件 | 作用 |
|---|---|
| watch / informer 缓存 | 不全量轮询 API server;本地缓存 + 增量事件 |
| 乐观并发(resourceVersion) | 两个控制器同时写同一对象时,后写的失败并重试,避免静默覆盖 |
| ownerReferences / finalizers | 级联删除、删除前清理外部资源 |
🔑 spec 与 status 分离是硬约束:**用户写 spec,控制器写 status。**让用户改 status,等于让「事实」可伪造;让控制器改 spec,等于控制器越权替用户做决定。
为什么必须是 level-triggered(水平触发)
事件驱动系统都会遇到三件事:事件会丢、会重复、会乱序。watch 连接断开要 relist,缓存溢出要重建,API server 重启会重放。
- edge-triggered(边沿触发):只在「发生变化」的那一刻动作。丢一个事件,状态永久漂移。
- level-triggered(水平触发):每次都问「现在实际是什么、期望是什么、差多少」。事件只用来提醒「该看一眼了」,丢了也无所谓——下一次调谐照样能发现差异。
本机模拟(目标 3 副本,每次调谐只允许 ±1,窗口 60 tick,随机丢掉 30% 事件,2000 次试验):
level-triggered:收敛 2000 次 (100.0%),平均 3 tick,平均动作 3 次
edge-triggered :收敛 669 次 (33.5%),平均 1 tick,平均动作 1 次
边沿触发的「平均 1 tick」是幸存者偏差:没丢事件的那 33.5% 一次就到位,丢了事件的那 66.5% 永远停在原地。三级动作需要三个事件,0.7³ ≈ 34.3%,与实测 33.5% 吻合。
K8s 的做法是把 level-triggered 落到工程上:
- list-watch 把对象同步到本地 informer 缓存,断线后 relist 重建;
- 事件处理器不做业务,只把对象 key 塞进 workqueue(天然去重);
- worker 取出 key,从缓存重新读最新对象,调用
reconcile(key); - 失败按指数退避重新入队,成功则丢弃。
所以 reconcile 的输入只有 key,不是事件里的对象快照——快照可能已经过期。
一个能跑的最小调谐器
下面这段程序把上面的结论跑出来(不依赖 K8s,纯内存模拟):
package main
import (
"fmt"
"math/rand"
)
const (
desired = 3 // 期望副本数
ticks = 60 // 观察窗口
trials = 2000 // 试验次数
dropP = 0.3 // 事件丢失率
)
// level-triggered:每个 tick 都观测实际状态,与期望比对;不消费事件内容
func levelTriggered(r *rand.Rand) (convergedAt, actions int) {
actual := 0
for t := 1; t <= ticks; t++ {
if actual != desired { // 只看"现在差多少",不看"刚才发生了什么"
actions++
if actual < desired {
actual++
} else {
actual--
}
}
if actual == desired && convergedAt == 0 {
convergedAt = t
}
}
return
}
// edge-triggered:只在收到事件时动作,事件会丢,且没有周期对账
func edgeTriggered(r *rand.Rand) (convergedAt, actions int) {
actual := 0
queue := []int{desired} // 初始一次"扩容到 3"的事件
for t := 1; t <= ticks; t++ {
if len(queue) == 0 {
continue
}
ev := queue[0]
queue = queue[1:]
if r.Float64() < dropP { // 事件丢失:没有任何补偿机制
continue
}
if actual != ev {
actions++
if actual < ev {
actual++
} else {
actual--
}
queue = append(queue, ev) // 状态变了才产生下一个事件
}
if actual == desired && convergedAt == 0 {
convergedAt = t
}
}
return
}
func main() {
r := rand.New(rand.NewSource(42))
var lvOK, edOK, lvT, edT, lvA, edA int
for i := 0; i < trials; i++ {
t, a := levelTriggered(r)
lvT, lvA = lvT+t, lvA+a
if t > 0 {
lvOK++
}
t2, a2 := edgeTriggered(r)
edT, edA = edT+t2, edA+a2
if t2 > 0 {
edOK++
}
}
fmt.Printf("level-triggered:收敛 %d 次 (%.1f%%),平均 %d tick,平均动作 %d 次\n",
lvOK, float64(lvOK)*100/trials, lvT/trials, lvA/trials)
fmt.Printf("edge-triggered :收敛 %d 次 (%.1f%%),平均 %d tick,平均动作 %d 次\n",
edOK, float64(edOK)*100/trials, edT/trials, edA/trials)
}
真实控制器的骨架长这样(关键在「从缓存读最新 + 失败退避」):
func (c *Controller) reconcile(key string) error {
obj, exists, err := c.informer.GetStore().GetByKey(key)
if err != nil {
return err // 返回错误 → workqueue 指数退避后重试
}
if !exists {
return c.cleanupExternal(key) // 对象被删:清理外部资源(finalizer 场景)
}
cur := obj.(*appv1.Api)
if cur.DeletionTimestamp != nil {
return c.finalize(cur) // 删除中:先清外部依赖,再摘 finalizer
}
actual := c.countPods(cur) // 观测:现状
if actual == int(cur.Spec.Replicas) {
return nil // 已收敛:什么都不做(这一步省掉,就会热循环)
}
return c.scale(cur, int(cur.Spec.Replicas)) // 行动:把现状推向期望
}
收敛延迟:声明式的代价
| 环节 | 典型延迟 | 说明 |
|---|---|---|
| 事件到达 | ms 级 | watch 推送 |
| workqueue 出队 | ms~s | 有并发 worker 与限速 |
| reconcile 执行 | ms~s | 取决于要调用的外部 API |
| 失败重试 | 指数退避,秒级累加 | 下游不可用时可能几分钟才收敛 |
| 滚动更新整体 | 十秒~分钟级 | 受 maxSurge / maxUnavailable 与就绪探针影响 |
所以 kubectl apply 成功 ≠ 服务可用。要判断「真的好了」,必须等状态:
kubectl rollout status deploy/api --timeout=120s # 等到可用副本数达标
kubectl wait --for=condition=Available deploy/api --timeout=120s
五个必踩的坑
| 坑 | 现象 | 根因 | 修法 |
|---|---|---|---|
| 热循环 | API server QPS 暴涨,控制器 CPU 高,状态其实没变 | 每次 reconcile 都无条件 Update,写入又触发 watch,再 reconcile | 先比较(DeepEqual / Generation / ObservedGeneration),无变化不写 |
| 删除卡住 | 对象一直 Terminating | finalizer 加了没删,或清理逻辑永远失败 | 清理逻辑必须幂等、可超时兜底,并监控 finalizer 堆积 |
| 多控制器打架 | 同一字段被反复改回,事件风暴 | 两个控制器都认为自己是 owner | 字段级 owner(server-side apply field manager)或拆对象 |
| 用旧快照决策 | 刚扩容又被缩回去 | reconcile 用了事件参数里的旧对象 | 永远从 informer 缓存重读 |
| 无限重试打爆下游 | 外部 API 被打满 | 失败立即重入队,没有退避 | workqueue 限速 + 指数退避 + 熔断 |
热循环的量级可以算出来:1000 个对象、每秒调谐一次、每次都写 status,就是 1000 次/s 的写请求 + 1000 次/s 的 watch 事件,而真正有变化的可能一天一次。这不是「性能优化」,是控制器设计错误。
声明式适合什么、不适合什么
| 场景 | 适合? | 原因 |
|---|---|---|
| 长期存在的资源(副本数、路由、证书、DNS 记录) | ✅ | 状态持续存在,需要纠偏 |
| 需要自愈与审计(GitOps) | ✅ | 期望状态即文档,diff 即审计 |
| 多云/多集群统一交付 | ✅ | 同一份 spec 可下发多处 |
| 一次性动作(迁移、清理、发版脚本) | ❌ | 用 Job / 工作流;持续调谐会反复执行 |
| 强顺序业务流程(订单状态机) | ❌ | 状态机 + 事件,不是「调谐到某个副本数」 |
| 亚秒级控制回路(实时调度、交易撮合) | ❌ | watch + 队列 + 重试的延迟在百 ms~秒级 |
| 外部系统没有幂等 API | ⚠️ | 需要适配层做幂等,否则重试会重复下单 |
反模式
| 反模式 | 后果 |
|---|---|
生产环境 kubectl edit 热改 | 与 Git 中的 spec 漂移,下次 apply/reconcile 被覆盖,故障不可复现 |
CI 里 kubectl apply 后立刻发流量 | 副本未就绪,5xx 集中爆发 |
| 在 reconcile 里 sleep 或调长耗时同步接口 | worker 被占满,所有对象调谐停滞 |
| 一个 Operator 管所有资源(上帝 Operator) | 升级即全站风险,权限过大,无法分团队交付 |
| 把 status 当配置改 | 控制器覆盖你的修改,且掩盖真实状态 |
| 忽略 CRD 版本转换 | 升级 CRD 版本时旧对象读不出来,控制器崩 |
| 没有 leader election 就跑多副本控制器 | 两个实例同时调谐,重复创建/删除 |
| 事件处理器里做重活 | 事件队列阻塞,watch 积压,缓存过期 |
自检
Q1 为什么 K8s 的控制器用 level-triggered?举一个 edge-triggered 会永久出错的场景。
▸ 答案:因为事件不可靠(会丢、重复、乱序)。level-triggered 每次都用「当前实际状态 vs 期望状态」重新计算差距,事件丢失只会让收敛晚一点,不会让系统停在错误状态。反例:Pod 被节点驱逐,删除事件在 watch 断线期间丢失;edge-triggered 控制器收不到「少了 1 个」的事件,副本数永远少 1;level-triggered 下一次调谐发现 actual=2, desired=3,直接补一个。本机模拟里 30% 事件丢失下,edge 只有 33.5% 的试验能收敛。
Q2 reconcile 为什么要从 informer 缓存重新读对象,而不是直接用事件回调传进来的对象?
▸ 答案:回调参数是事件发生时刻的快照,可能已经过期:同一对象在队列里排队期间被改了多次,队列里可能只有最早的那条事件;watch 重连后的 relist 也可能带来乱序。用旧快照做决策,会出现「刚扩到 5 又被缩回 3」这类反复。正确做法是事件只传 key(入队去重),worker 处理时从缓存读当前最新对象。
Q3 一个 Operator 上线后 API server 写入 QPS 从 50 涨到 3000,控制器日志显示反复处理同一批对象。最可能的原因和修法?
▸ 答案:热循环——reconcile 无条件 Update 对象(常见是写 status),每次写入都产生 watch 事件,事件又触发自己,形成自激。修法:写入前比较(equality.Semantic.DeepEqual 或看 metadata.generation 与 status.observedGeneration),无变化直接返回;只写真正变化的字段;必要时给 status 更新加节流。定位方法:看控制器写入 QPS 与对象 resourceVersion 的增长速度,再看是否所有写入内容都相同。
Q4 kubectl apply 返回 0,为什么服务可能还没好?正确姿势是什么?
▸ 答案:apply 只把 spec 写进 API server,控制器还没调谐、Pod 还没调度、镜像还没拉、探针还没通过。正确姿势是等条件:kubectl rollout status(等可用副本达标)或 kubectl wait --for=condition=Available,并在 CI 里设置超时;对外发布还要等就绪探针与流量接入(见 55.1 发布策略)。
Q5 什么场景不该用控制器模式?
▸ 答案:① 一次性动作——数据库迁移、清理脚本、批量重命名,用 Job 或工作流,控制器会「坚持不懈地重做」;② 强顺序流程——订单状态机、审批流,需要显式状态与顺序,不是「调谐到某个数量」;③ 低延迟控制回路——实时调度、撮合,watch+队列的延迟在百 ms~秒级;④ 下游没有幂等 API——重试会重复产生副作用,必须先做幂等适配。
跨篇相关:Pod / Deployment / StatefulSet 的对象语义见 7.2 K8s 对象模型;控制器如何驱动滚动更新与回滚见 7.6 自愈、滚动更新与回滚;Operator 与 CRD 是控制器模式的延伸,见 7.10 Operator 与 CRD;调度的期望状态由 scheduler 消费,见 7.5 调度与亲和性;基础设施层的同一声明式思想见 14.2 Terraform 与声明式基础设施;etcd 的 watch 与日志复制见 21.6 Raft:日志复制、48.5 Watch 与通知机制;发布流程的上层编排见 55.1 发布策略;控制器跑在容器里,边界见 6.1 容器与虚拟机的本质差异。