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 落到工程上:

  1. list-watch 把对象同步到本地 informer 缓存,断线后 relist 重建;
  2. 事件处理器不做业务,只把对象 key 塞进 workqueue(天然去重);
  3. worker 取出 key,从缓存重新读最新对象,调用 reconcile(key);
  4. 失败按指数退避重新入队,成功则丢弃。

所以 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 容器与虚拟机的本质差异。