S SRE Notes

Operator 阶段 0:心智模型(从零开始版)

这份文档假设你:会用 kubectl apply,知道 Deployment、Pod、Service 是什么。 不需要任何编程基础,不需要懂 Go。 看完的标准:能给别人讲清楚"Operator 是怎么回事"。


01一、从你已经会的东西说起

你用过 Deployment。你写过这样的 YAML:

apiVersion: apps/v1
kind: Deployment
spec:
  replicas: 3

apply 之后,K8s 会保证集群里永远有 3 个 Pod 在跑:

  • Pod 崩了 → 自动补一个新的
  • 节点挂了 → 在别的节点上重建
  • 你手动删掉一个 → 马上又长回来

问题来了:是谁在保证"永远有 3 个"?

你 apply 之后,kubectl 就退出了,它不管了。真正干活的,是 K8s 里一个看不见的循环程序,它一直在重复做三件事:

1. 看:现在实际有几个 Pod?
2. 比:期望是 3 个,实际够吗?
3. 干:少了就补一个,多了就删一个

这个循环程序,叫 Controller(控制器)

K8s 里内置了几十个 controller,每个只管一种资源:

你 apply 的东西 背后盯着的 controller
Deployment Deployment controller
Job Job controller
StatefulSet StatefulSet controller

而 Operator,就是让你造一个"自己的 Deployment 系统": 你发明一种新的资源类型,再写一个属于它的 controller。

这就是全部。剩下所有概念,都是为了让这两件事转起来。


02二、一个贯穿全文的例子:Blog

假设你们公司经常要部署博客系统。每次都要手动建 Deployment + Service + ConfigMap,很烦。

你梦想中的用法是——别人只需要写:

apiVersion: mycompany.com/v1
kind: Blog
metadata:
  name: my-blog
spec:
  title: "小明的博客"
  replicas: 2

apply 一下,Deployment、Service 就自动全出来了。title 改了,前端内容自动跟着改;replicas 改成 5,自动扩容。

要做到这件事,你需要两个东西:

东西 干什么 在 Blog 例子里
CRD(自定义资源定义) 告诉 K8s:"世界上存在一种新资源叫 Blog,它有 title、replicas 这些字段" 注册 Blog 这种类型
Controller(控制器) 一个一直运行的程序,盯着所有 Blog 对象,看到就干活 看到 my-blog → 去建对应的 Deployment 和 Service

所以:Operator = CRD + Controller + 你对博客系统的运维知识。

  • CRD 解决"K8s 不认识 Blog"的问题
  • Controller 解决"没人盯着 Blog 干活"的问题
  • 你的运维知识(先建什么后建什么、出问题怎么办)写在 Controller 里

下面两节,分别把这两个东西讲清楚。


03三、Controller 怎么"盯着"资源?

3.1 笨办法:不停问

最简单的想法:controller 每秒问一次 API Server——"Blog 列表变了吗?变了吗?变了吗?"

能工作,但有个大问题:一个集群里可能有几百个 controller,每个都这么问,API Server 会被活活打爆

3.2 聪明办法:订阅 + 本地缓存

真实做法是两件事的组合:

  1. 订阅(watch):跟 API Server 说"Blog 有变化就通知我",然后就不用反复问了。
  2. 本地缓存(cache):把所有 Blog 的最新内容,在自己内存里存一份拷贝。

这个"订阅 + 缓存"的组合拳,有个专门的名字:Informer

        变化通知(watch)
API Server ──────────────▶ Informer ──▶ 本地缓存(内存里的一份拷贝)
   ▲                                                 ▲
   │                                                 │
   └────────── 你的代码"读对象"时,读这里 ──────────┘
              (不直接查 API Server)

Informer 内部还有两个零件,知道名字就行:

零件 一句话
Reflector 负责跟 API Server 保持同步的人:先全量拉一次,再持续收变化通知;连接断了自动重连
Indexer 就是那份本地缓存本身,线程安全,可以随时查

记住这一条就行:以后写代码,"读对象"永远读本地缓存,"写对象"才去找 API Server。这是 K8s 所有 controller 的通用规矩——读走内存,快;写才惊动 API Server。


04四、收到变化通知后,怎么干活?

4.1 不直接干活,先放张小纸条

Informer 发现 my-blog 变了,它不会马上调用你的业务逻辑,而是往一个队列里放一张"小纸条":

纸条内容: default/my-blog
          (就是"命名空间/名字",别的什么都没有)

这个队列叫 Workqueue。注意:纸条上只有名字——没有"是创建还是修改",也没有对象的内容。

4.2 为什么要多这一层?

因为干活可能失败,失败了要重试

想象一下没有队列:通知一来就直接干活。你的逻辑一卡住,后面所有通知全堵死;你想"过 5 秒重试一次",这 5 秒里通知还在不断涌进来,越堆越多,系统崩溃。

有了队列这个"减震器":

  • 收通知的人永远轻快:放张纸条就走,绝不阻塞。
  • 干活的人按自己节奏来:失败了把纸条放回去,过会儿再试——而且每次重试等的时间会自动翻倍(这叫"指数退避"),避免疯狂重试打爆集群。
  • 自动去重:my-blog 一秒钟变了 100 次,队列里也只有一张纸条,不会干 100 次活。

4.3 干活这件事,叫 Reconcile(对账)

队列的另一头,有几个工人(worker)在等着取纸条。工人拿到纸条后做的事,就是你唯一要写的业务逻辑,它有个正式名字:Reconcile(对账)

工人拿到纸条 "default/my-blog"
   │
   ▼
拿着名字,去本地缓存里读出 my-blog 的最新内容
   │   (读到:title="小明的博客", replicas=2)
   ▼
对账:它要的 Deployment 存在吗?replicas 是 2 吗?
   ├─ 不存在          → 创建一个 Deployment,replicas=2
   ├─ 存在但数量不对   → 改成 2
   └─ 一切正常         → 什么都不做,直接下班

"对账"这个名字非常形象:就像会计对账——拿账本上记的(spec,期望状态)和保险柜里实际的(集群现状)核对,不一致就调平,一致就收工。

你写 Operator,90% 的工作量就是写这个"对账"函数。 别的都是框架帮你做的。


05五、把整张图连起来

现在把前面所有零件拼成一张图(对照着你 apply 一个 Blog YAML 的旅程):

你: kubectl apply -f blog.yaml
        │
        ▼
┌───────────────────────────────┐
│  API Server                   │  检查 YAML 合法,存进数据库(etcd)
└───────────────────────────────┘
        │  变化通知(watch)
        ▼
┌───────────────────────────────┐
│  Informer                     │  Reflector 收通知,更新本地缓存(Indexer)
└───────────────────────────────┘
        │  放小纸条:default/my-blog
        ▼
┌───────────────────────────────┐
│  Workqueue                    │  去重、失败重试、按节奏发纸条
└───────────────────────────────┘
        │  工人取出纸条
        ▼
┌───────────────────────────────┐
│  Reconcile(你写的对账逻辑)   │  从缓存读最新状态 → 比期望 → 补差
└───────────────────────────────┘
        │  创建/修改 Deployment、Service
        ▼
   写回 API Server ──┐
        │            │
        ▼            │
   集群状态真的变了   │
        │            │
        └────────────┘
   这个变化又会触发通知,再次对账
   ——直到"期望 = 实际",循环安静下来

读图时抓住三个要点:

  1. 缓存里存的是对象的完整内容,队列里只有名字。 工人拿到名字,自己去缓存里查最新内容。
  2. 这是一个永不停歇的循环,不是一次性任务。 它会一直转,直到集群变成你要的样子——并且永远保持下去。
  3. 你写的代码只在最后一格(Reconcile)。 上面所有零件(Informer、Workqueue)都是现成的框架,不用你写。

06六、最重要的一个观念:只看现在,不管历史

这是整份文档最值钱的一节。理解它,你就理解了 K8s 的设计灵魂。

6.1 恒温器不关心你为什么冷

官方文档用恒温器打比方:

  • 你设定 26°C(期望状态)
  • 房间现在 20°C(当前状态)
  • 恒温器开暖气,让 20 变成 26

恒温器完全不关心房间为什么冷——是窗户开了,还是暖气坏了。它只看两件事:现在是多少,应该是多少。然后行动。

你的 Reconcile 也一样:它不关心 my-blog 是"刚被创建"还是"被修改了第 50 次",它只看——现在 spec 是什么,集群现在是什么样,然后补差。

6.2 GPS 不关心你错过了几个路口

另一个比方:开车导航说"前方右转",你错过了。GPS 会怎么做?

  • ❌ 不会崩溃
  • ❌ 不会要求你倒回去
  • ✅ 只看你现在的位置目的地,重新算下一步

K8s controller 就是这样。这个设计有个正式名字,叫 level-triggered(电平触发) —— 对"当前状态"反应,而不是对"发生过的历史事件"反应。

(反面的设计叫 edge-triggered,边沿触发:对每一个历史事件逐个反应,漏一个就出错。消息队列、传统的 event-driven 系统多是这种。K8s 故意不选它。)

6.3 这个设计带来的三个重要推论

推论 1:自愈是免费的。 Controller 崩溃了、重启了,怎么办?不用"补回放丢失的事件"——它重启后直接看一眼当前状态,接着对账就行。有人半夜手动改了你管的 Deployment?下一次对账自动改回来。

推论 2:Reconcile 必须"幂等"。 幂等 = 同一个对账函数,跑 10 遍的结果和跑 1 遍完全一样

为什么必须这样?因为它真的会被跑很多遍:对象每次变化会跑一遍、失败了重试会再跑、controller 重启会再跑、还有定时巡检会再跑。如果你的函数"跑两遍会出事"(比如重复创建资源报错、重复发钱),那就是 bug。

写法的核心:只做"补差",不做"记账"。不看"我上次干了什么"(重启后你根本不记得),只看"现在还差什么"。

推论 3:spec 写"要什么",不写"做什么"。 你不能在 spec 里写 upgrade: true 这种一次性动作——因为循环明天还会读到它,难道再升级一遍?spec 只能写最终想要的样子(比如 version: "1.2.3"),怎么到达那个样子,是 Reconcile 的事。


07七、第一遍可以跳过的细节(以后回来补)

下面这些你现在不需要懂,但很快会用到。先混个脸熟。

7.1 Reconcile 结束后,怎么告诉队列"下次怎么办"?(四种返回方式)

对账函数结束时,有四种交代方式:

返回方式 意思 什么时候用
成功,没事 干完了,忘了这张纸条吧 一切正常
立刻再来一遍 刚才只干了一半,马上接着干 多步骤操作的中途
N 秒后再来一遍 在等一个外部系统,过会儿再来看看 等数据库初始化、等云资源就绪
报错 真的出错了 框架会自动重试,每次等更久

新手最常犯的错:明明是"等外部依赖",却返回报错。记住——等别人,用"N 秒后再来";真坏了,才报错。

7.2 对账时发现对象被删了,怎么办?

工人拿纸条去缓存里一查:my-blog 不存在了。

这说明它被删了。处理方式简单得出乎意料:当作成功,直接收工。(期望状态是"不存在",实际也"不存在",账已经对平了,还干什么?)

那"删除前我想先备份数据"怎么办?——那需要另一个机制,叫 Finalizer(阶段 3 的内容),先记住名字。

7.3 闭环的坑:hot loop(死循环空转)

还记得全图最后的闭环吗?你的 Reconcile 改了 Deployment → 这个改动又触发通知 → 又对你进行对账……

所以你的对账代码必须能做到:"检查一遍,发现状态已经对了,就什么都不做,直接返回成功"

如果做不到——比如每次都对 Deployment 做个无意义的修改——就会无限循环:改 → 通知 → 对账 → 又改 → 又通知……这叫 hot loop,是新手 Operator 最常见的生产事故。


08八、名词速查表(用到再回来查)

这些名词在文档、报错、面试里到处都是。第一遍只需混个脸熟,不用背

名词 一句话解释 什么时候真正需要懂
CR / CRD CRD 是"新资源的定义"(建表语句),CR 是它的一个实例(表里一行数据) 阶段 2 写自己的 CRD 时
GVK Group/Version/Kind:一种资源在 Go 代码里的"身份证",如 mycompany.com/v1, Kind=Blog 阶段 1 写代码时
GVR Group/Version/Resource:同一种资源在 REST URL 里的"地址",如 /apis/mycompany.com/v1/blogs。Kind 是单数驼峰,Resource 是复数小写 调试 API 问题时
Scheme 一本"户口本":记录哪个 Go 结构体对应哪个 GVK,编解码靠它查 阶段 1,类型报错"not registered"时
Spec / Status spec = 用户想要的(用户写);status = controller 观测到的(controller 写)。永远不要让用户写 status 阶段 3 设计 API 时
RBAC 权限系统。你的 Operator 也是个普通 Pod,它调 API Server 需要授权 阶段 3 部署时
Finalizer 删除前的"安全锁":先清理完外部资源,才允许对象真正消失 阶段 3
Owner Reference "这个 Deployment 是属于那个 Blog 的"——父删子随,级联删除 阶段 3
For / Owns / Watches 声明"我要盯哪些资源"的三种方式:盯自己的 CR、盯我创建的子资源、盯外部资源 阶段 2

动手验证 GVK/GVR(不编程,只用 kubectl):

kubectl api-resources    # 左边一列是 Resource(复数小写),右边是 Kind(单数驼峰)
kubectl api-versions     # 集群里所有 group/version

09九、自检:能答出这 4 题,阶段 0 过关

Q1. 用一句话说清 Operator 是什么?

Operator = CRD + Controller + 运维知识。CRD 发明一种新资源(如 Blog),Controller 一直盯着它,把集群调成 spec 要求的样子。

Q2. Informer 和 Workqueue 各解决什么问题?

Informer 解决"怎么知道资源变了"——订阅通知 + 本地缓存,不用反复查 API Server。Workqueue 解决"变化来了怎么安全地干活"——排队、去重、失败重试,不阻塞通知流。

Q3. Reconcile 为什么必须幂等?

因为它是"电平触发"的:同一个对象会因每次变化、失败重试、定时巡检被反复对账,controller 还可能中途重启。所以只能看"当前差什么"做补差,不能假设"上次执行过"。跑 10 遍必须和跑 1 遍结果一样。

Q4. 为什么"读对象"要走本地缓存而不是直接查 API Server?

一个集群几百个 controller,每个都直接查,API Server 会被打爆。缓存让读操作走内存,快且零压力;只有写操作才找 API Server。


10十、想深入?按这个顺序读

第一遍学习不用读下面这些;卡住了或者想验证自己的理解时再回来。

顺序 资料 读什么
1 [Controllers Kubernetes 官方](https://kubernetes.io/docs/concepts/architecture/controller/)
2 [Operator pattern Kubernetes 官方](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/)
3 [The Reconciler Pattern farishuskovic.dev](https://www.farishuskovic.dev/blog/k8s-reconciler-pattern/)
4 [Kubernetes Reconcile Loop Explained golinuxcloud](https://www.golinuxcloud.com/kubernetes-reconcile-loop-explained/)
5 client-go ARCHITECTURE.md Informer 内部零件的权威文档(偏硬,阶段 1 再细读)
6 [Groups and Versions and Kinds Kubebuilder Book](https://book-v3.book.kubebuilder.io/cronjob-tutorial/gvks)
7 《Programming Kubernetes》(O'Reilly)第 3-4 章 讲得最透的书,进入阶段 1 前精读
本页目录