K8s Operator 讲人话:删掉的 Pod 为什么自己回来了

删掉一个 Pod,它几秒后自己回来了——"K8s 响应了删除事件"这个解释恰恰是错的。从这个常见误解讲起,把调谐循环、水平触发和 Operator 模式讲成人话:恒温器不是遥控器,事件只负责叫醒,不带决策信息。


几乎每个刚接触 Kubernetes 的人都做过同一个实验:kubectl delete pod 删掉一个 Pod,几秒钟后 kubectl get pods 一看,它又回来了,连名字后缀都换了个新的。第一次见到会觉得挺神奇,然后很自然地脑补出一个解释:“K8s 监听到了删除事件,触发了重建逻辑。”

这个解释听起来毫无破绽,但它恰恰是错的。而且这个错误很有价值——把它掰直了,K8s 控制器乃至整个 Operator 模式的设计哲学就都通了。今天把这件事讲成人话。

恒温器,不是遥控器

先从两种做事方式说起。

空调遥控器是命令式的:你按一下”降温”,它执行一次降温,事情就结束了。房间后来又热起来,它不管——你没再按,它就不动。

恒温器是声明式的:你设定”26 度”,它就开始不断测量当前温度,和 26 度比,偏高就制冷,到了就停。你从头到尾没有下过任何”动作指令”,只是声明了一个期望状态,剩下的事它自己持续负责。

K8s 里所有的控制器都是恒温器。你在 Deployment 里写 replicas: 3,不是在说”去创建 3 个 Pod”,而是在说”我要一直有 3 个 Pod”。这份期望被存进集群,控制器的全部工作就是一个死循环:

for {
    实际状态 = 看一眼集群现状()
    if 实际状态 != 期望状态 {
        纠正()
    }
}

这个循环有个正式名字:调谐循环(reconcile loop)。

调谐循环:恒温器不是遥控器

现在回头看开头那个实验。删掉的 Pod 会回来,不是因为谁”响应了删除事件”,而是因为下一轮调谐时控制器发现”期望 3 个,实际只有 2 个”,于是补了一个。它甚至不知道、也不关心那个 Pod 是被你手删的、被节点故障弄没的、还是被驱逐的——差多少,补多少,就这么朴素。

事件只负责叫醒,不带决策信息

到这里你可能会反驳:这不还是事件驱动吗?总得有个删除事件来触发这轮检查吧。

区别很微妙,但正是精髓所在。K8s 确实用事件来”叫醒”控制器——不然就只能傻轮询,大集群扛不住。但事件只负责叫醒,不携带决策信息。用 controller-runtime 写过控制器的人都知道一个初看很反直觉的事实:Reconcile 函数收到的参数里只有对象的 namespace 和 name。没有事件类型,没有”变化前是什么、变化后是什么”。控制器只被告知”这个对象你该去看一眼了”,至于看完做什么,全靠现场对比期望和实际。

这种风格叫水平触发(level-triggered),和它相对的是边沿触发(edge-triggered):

  • 边沿触发关心”变化的那一瞬间”:收到删除事件,就执行删除对应的处理逻辑。
  • 水平触发关心”当前处在什么状态”:不管刚才发生过什么,只看现状和期望的差距,把差距填平。

为什么 K8s 坚定地选了水平触发?因为事件会丢。控制器重启的那几秒里发生的事,它错过了;watch 长连接断开重连,中间的事件漏掉了;队列里同一个对象的多个事件被去重合并了。这些在分布式系统里不是异常,是日常。靠事件序列维护的状态机,只要丢一个事件就开始漂移,而且永远追不回来——它自己都不知道自己错了。水平触发天然免疫这个问题:丢就丢了,下一轮全量对比会把一切拉回正轨。

边沿触发 vs 水平触发

这个设计带来两条铁律,写控制器的人逃不掉:

  1. Reconcile 必须幂等。同一个对象连续调谐一百次,结果得跟调谐一次完全一样。因为你根本不知道这次被叫醒是因为什么——也许什么都没变,纯粹是重试。
  2. 不依赖记忆。不能在内存里记”这个我上次已经创建过了”——控制器随时可能重启,记忆随时清零。一切判断依据,都从集群的当前状态现读。

幂等加上无记忆,换来的就是自愈:系统怎么折腾、进程怎么重启,状态总能收敛回期望。

Operator:把运维员写成代码

原生 K8s 只认识通用资源:Pod、Deployment、Service 这些。它知道怎么维持”3 个副本”,但不知道怎么维持”一个健康的 PostgreSQL 主从集群”——主库挂了要提升从库、备份要每天做、升级要按特定顺序滚动。这些是领域知识,传统上装在 DBA 的脑子里。

Operator 模式做的事,就是把这套领域知识写成代码,塞进同一个调谐循环里。具体是两件套:

CRD(自定义资源定义):教会 K8s 认识一种新对象。装了 PostgreSQL 的 Operator 之后,你可以像写 Deployment 一样写一份 kind: PostgresCluster 的 YAML,声明”3 个节点、每天凌晨备份、版本 16”。K8s 的 API 从此多了一种它原本不认识的资源。

自定义控制器:盯着这种新对象跑调谐循环。发现从库少了一个就补一个,发现今天的备份还没做就去做,发现版本不对就按安全顺序滚动升级。

所以我最喜欢的一个说法是:Operator 是写成代码的运维员。而且这个运维员有恒温器的心智——它不是接到告警才起来干活,而是每时每刻在对比现实与期望。

再举一个把这个模式用得很妙的例子:cert-manager,管 TLS 证书的 Operator。证书的期望状态是”始终有效”,实际状态是”还有 20 天就过期”——于是调谐动作就是续签。你会发现”期望状态”这个概念被从空间维度扩展到了时间维度:不只是”有几个副本”,还包括”永远不过期”。声明一次,永久生效,这就是声明式的味道。

一点自己的看法

我越琢磨越觉得,水平触发是一种值得从 K8s 里”偷走”的世界观。

靠”记住每件事处理过没有”来维持正确性的系统是脆弱的:人会忘,进程会重启,消息会丢。健壮的做法是反过来——让”接下来该做什么”随时可以从”现状与目标的差距”里推导出来,并且做重复了也无所谓。这样系统里就没有”错过就完了”的时刻,任何一轮检查都是一次完整的自愈机会。

这个思路其实不限于写控制器。管理待办、维护文档、跟进长期项目,本质上都可以选边:是靠”记住每个变化”,还是靠”定期对比现状和目标”。前者精确但脆弱,后者粗糙但皮实。K8s 用十年的生产实践投了后者一票,我觉得这一票很有说服力。

评论