这份文档假设你:会用
kubectl apply,知道 Deployment、Pod、Service 是什么。 不需要任何编程基础,不需要懂 Go。 看完的标准:能给别人讲清楚"Operator 是怎么回事"。
01一、从你已经会的东西说起
你用过 Deployment。你写过这样的 YAML:
apiVersion: apps/v1
kind: Deployment
spec:
replicas: 3apply 之后,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: 2apply 一下,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 聪明办法:订阅 + 本地缓存
真实做法是两件事的组合:
- 订阅(watch):跟 API Server 说"Blog 有变化就通知我",然后就不用反复问了。
- 本地缓存(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 ──┐
│ │
▼ │
集群状态真的变了 │
│ │
└────────────┘
这个变化又会触发通知,再次对账
——直到"期望 = 实际",循环安静下来读图时抓住三个要点:
- 缓存里存的是对象的完整内容,队列里只有名字。 工人拿到名字,自己去缓存里查最新内容。
- 这是一个永不停歇的循环,不是一次性任务。 它会一直转,直到集群变成你要的样子——并且永远保持下去。
- 你写的代码只在最后一格(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/version09九、自检:能答出这 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 前精读 |