这份文档替代原书第 3-4 章,你不需要看过书,直接学这份就行。 前置:看过阶段 0 心智模型(Operator开发.md),知道"订阅+缓存+队列+对账"那张图。 这两章干一件事:把阶段 0 那张图,翻译成 Go 代码里的具体零件。 学完全文的标准:看到 kubebuilder 生成的代码,每个零件你都认识,知道它为什么在那里。
第一部分:client-go 的零件(原书第 3 章)
01一、先看地图:代码分装在四个仓库里
K8s 的 Go 代码不是一个大仓库,而是四个分工明确的库。以后你 import 东西、看报错信息,都会碰到这些路径:
| 仓库 | 装什么 | 类比 |
|---|---|---|
k8s.io/api |
资源类型的定义:Pod、Deployment 这些 struct 本身 | 菜谱上每道菜的配料表 |
k8s.io/apimachinery |
通用机制:序列化、版本转换、Scheme、meta 类型 | 厨房的标准化流程,与具体菜无关 |
k8s.io/client-go |
客户端:连接 API Server、读写、informer、workqueue | 跑腿买菜的 |
k8s.io/code-generator |
代码生成器 | 复印机(后面讲) |
最容易混的是前两个。判断标准:
api里是具体的类型——"Pod 这个 struct 有哪些字段"apimachinery里是操作所有类型的通用机器——"任何一个 struct 怎么注册、怎么转成 JSON"
比如 Pod 结构体在 api 里,而所有对象共用的 ObjectMeta(name、labels 那些)在 apimachinery 里。
02二、连上集群:rest.Config(书上 3.2)
一切从 rest.Config 开始——它就是"怎么连上 API Server"的全部信息(地址 + 认证)。只有两种获取方式:
| 场景 | 方式 | 认证从哪来 |
|---|---|---|
| 你在自己电脑上开发调试 | clientcmd.BuildConfigFromFlags() |
读 ~/.kube/config(你 kubectl 用的那个) |
| Operator 部署到集群里跑 | rest.InClusterConfig() |
自动读 Pod 里 ServiceAccount 挂载的 token |
有了 config,一行拿到客户端:kubernetes.NewForConfig(config)。
同一份代码,两种姿势都要支持——你在本地 make run 调试用 kubeconfig,打成镜像部署后用 InClusterConfig。kubebuilder 生成的 main.go 就是这么做的,你以后看到就认识了。
版本兼容性顺带记一条:client-go 版本和集群版本允许正负 1 个小版本的偏差(集群 1.28,client-go 用 1.27~1.29 都安全)。
03三、★ 对象在 Go 里长什么样(书上 3.4)
这是全书最重要的基础知识。 任何一个 K8s 对象——不管是内置的 Pod 还是你未来的 Blog——在 Go 里都是同一个三段式:
type Blog struct {
metav1.TypeMeta `json:",inline"` // apiVersion + kind:"我是谁"
metav1.ObjectMeta `json:"metadata"` // name/namespace/labels...:"我的档案"
Spec BlogSpec `json:"spec"` // 期望状态:用户写
Status BlogStatus `json:"status"` // 观测状态:controller 写
}对照你写过的 YAML,一目了然:
apiVersion: mycompany.com/v1 ← TypeMeta
kind: Blog ← TypeMeta
metadata: ← ObjectMeta
name: my-blog
labels: {...}
spec: ← Spec
title: "小明的博客"
status: ← Status(你不写,controller 写)3.1 ObjectMeta:所有对象共享的"档案袋"
| 字段 | 作用 | 你什么时候碰到 |
|---|---|---|
name / namespace / uid |
身份。uid 由 API Server 分配,重建同名对象 uid 会变 | 天天 |
labels |
可查询的键值对,selector 靠它找对象 | 找子资源就靠它 |
annotations |
不可查询的键值对,存"非标识性"数据(描述、工具私有状态) | 存工具数据 |
resourceVersion |
乐观锁:任何修改都 +1。更新时带上它,被别人先改了就会冲突报错 | 理解"更新冲突"报错 |
generation |
spec 的版本号:只有 spec 变才 +1,status 变不加 | 过滤"status 抖动"就靠它 |
ownerReferences / finalizers |
级联删除 / 删除保护 | 阶段 3 五大件 |
labels vs annotations 的判断标准:需要被 selector 查询的(要找得到)用 label;只是给人或工具看的用 annotation。
generation 和 resourceVersion 的区别(高频考点,务必看懂):
两个都是 ObjectMeta 里的数字,都会涨,但触发条件不同:
| 操作 | resourceVersion | generation |
|---|---|---|
| 创建 Blog | 100 | 1 |
| controller 写 status | 101 | 1(status 变,generation 不动) |
| controller 再写 status | 102 | 1 |
| 用户改 spec.replicas | 103 | 2(spec 变,两者都动) |
| 用户加了个 label | 104 | 2(metadata 变,generation 不动) |
规律一句话:resourceVersion 管"对象被碰过几次"(任何写入都算);generation 管"用户的意图改过几次"(只有 spec 变才算)。
resourceVersion 的两个用途:
- 乐观锁:你更新对象时要带上读到的 resourceVersion;如果别人抢先改过,API Server 返回 409 Conflict(
the object has been modified),逼你重读重试——防止两个写入互相覆盖。 - watch 的书签:informer 断线重连时,从上次记下的 resourceVersion 接着收事件,不用全量重拉。版本太旧会被拒(
too old resourceVersion),这时才重新 List。
generation 的用途:过滤噪音。 controller 每次写 status,都会触发一次 watch 事件 → 又触发一次 Reconcile → Reconcile 又写 status → 死循环(hot loop)。解法:GenerationChangedPredicate——"只有 generation 变了才通知我"。status 更新 generation 不动,直接被过滤掉,循环就断了。这就是阶段 0 那句"防 hot loop"在代码里的具体实现。
3.2 Spec 和 Status 的铁律
阶段 0 说过,这里再钉一遍:
- spec 是用户写的期望,status 是 controller 写的观测
- status 必须能从观测中重建——controller 重启丢光内存后,看一遍集群就能重新算出 status。所以 status 里不许存"只有内存里才有"的信息
04四、Clientset:链式调用的类型化客户端(书上 3.5)
内置资源的读写,用 kubernetes.Clientset,按"group → version → resource"一层层点下去:
clientset.AppsV1().Deployments("default").Get(ctx, "web", metav1.GetOptions{})
clientset.AppsV1().Deployments("default").List(ctx, metav1.ListOptions{
LabelSelector: "app=blog",
})三个要点:
- 每个 group-version 一个子客户端(AppsV1、BatchV1、CoreV1...),内置类型全有。你自己的 Blog 类型,内置 clientset 里没有——这就是第 4 章要解决的问题。
- 全是 interface——测试时可以换成 fake clientset,不连真集群也能单测。阶段 5 的单元测试靠这个。
- 写 status 必须用
UpdateStatus()——status 有独立的/status端点,普通Update()写 status 会被 API Server 直接忽略(第四部分细讲)。
List 和 Delete 的常用选项:
metav1.ListOptions{
LabelSelector: "app=blog,env in (prod,staging)", // 服务端过滤
ResourceVersion: "12345", // 从某个版本开始列(informer 靠它拿快照)
Limit: 500, Continue: "...", // 分页,大集群必备
}DeleteOptions 里重点是级联删除策略(PropagationPolicy):
| 策略 | 行为 |
|---|---|
Background(默认) |
先删父,垃圾回收器(GC)在后台删子 |
Foreground |
先把子删干净,最后才删父 |
Orphan |
只删父,子资源变"孤儿"留着 |
这直接对应阶段 3 的 Owner Reference:你给子资源设了 owner,删父对象时 GC 就按这个策略处理子资源。
05五、Watch:能订阅,但很脆(书上 3.7)
Watch() 返回一个 channel,持续推送事件:
watch.Event{Type: Added, Object: ...}
watch.Event{Type: Modified, Object: ...}
watch.Event{Type: Deleted, Object: ...}
// 还有 Error 和 Bookmark关键认知:裸 watch 是易碎品。 连接会断、版本会过期(报 too old resourceVersion),断了之后你必须自己重新 List 全量 + 续 Watch。这个重连循环写起来又烦又容易错——没人手写它,因为 Informer 已经帮你做了。
06六、★★ Informer:订阅 + 缓存的基础设施(书上 3.8)
这一节是阶段 0 那张图的"代码化"。Informer 的定位:把"高负载的轮询"替换成"低负载的事件驱动 + 本地缓存"。
6.1 为什么叫 SharedInformer(共享)?
先看清 LIST 和 WATCH 这两个动作分别在干什么:
- LIST:一次性全量拉取——"把当前所有 Blog 给我一份"(返回一个列表 + 一个 resourceVersion 快照号)
- WATCH:挂着的长连接——"从这个快照号开始,有变化就推给我"
每个 informer 都要先 LIST 一次(灌满缓存),再 WATCH 一辈子(保持新鲜)。问题在于谁来开这个连接:
- 你的 controller 要 watch 好几种资源(Blog + Deployment + Service)
- 一个集群里还跑着几十个别的 controller,很多也关心 Deployment
- 如果每个 controller 各自 LIST/WATCH:同样一份 Deployment 数据,API Server 要给 20 个订阅者各推一遍,连接数、序列化开销、etcd 压力全部 ×20
解法就是"共享":同一种资源,整个进程只开一份 watch、只存一份缓存。谁关心这种资源,就来这份缓存上注册自己的回调——像订报纸:报社(API Server)只给小区送一份报纸(一份 watch),每家每户(每个 controller)自己到小区信箱(共享缓存)里取,而不是让报社给每户单独印一份。
SharedInformerFactory 就是干这个的工厂:你问它要某种资源的 informer,已存在就返回同一个,不存在才新建。kubebuilder 生成的 manager 内部就是这个工厂——所以你在 kubebuilder 里从没手动开过 watch,不是不需要,是 manager 替你共享好了。
6.2 使用 Informer 的三个固定动作
写 controller 时,这三步是铁律,逐个说为什么:
① 注册事件回调(AddEventHandler)——回调里只做一件事:把 key 放进 workqueue。
informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(obj) { queue.Add(keyOf(obj)) }, // 只放 key!
UpdateFunc: func(old, new) { queue.Add(keyOf(new)) },
DeleteFunc: func(obj) { queue.Add(keyOf(obj)) },
})回调里绝不写业务逻辑。为什么?事件推送是连续的流:如果你在这个回调里干活(建 Deployment 要几秒),后面涌来的事件全被堵住;而回调一旦卡住,informer 内部的通知循环也会跟着堵。所以铁律是——收通知的人永远轻快:放张纸条(key)就走,干活的事交给队列另一头的 worker。这就是阶段 0 那张图里 informer 和 workqueue 的分工。
② 等缓存同步(WaitForCacheSync)——不同步完,worker 绝不开工。
回看 6.1:informer 启动时先 LIST 全量灌进缓存,这一步要时间(大集群可能几秒)。这段时间里缓存是空的或不完整的。
想象 worker 提前开工会发生什么:worker 拿到 key default/my-blog,去缓存里查——查不到。它怎么想?"对象不存在,那它管理的子资源就是孤儿,删掉!"——可 my-blog 明明活得好好的,只是缓存还没同步到。 一次启动,误删一片。这是新手写 controller 的第一个大坑,没有之一:
cache.WaitForCacheSync(stopCh, informer.HasSynced) // 必须先等这行返回
// 之后才能启动 worker一句话:WaitForCacheSync 防的是"把'我还没看到'误判成'它不存在'"。
③ 知道 resync(定时巡检)的存在。
默认每隔一段时间(controller-runtime 里约 10 小时),informer 会把缓存里每个对象重新触发一遍 Update 回调——就像每隔一段时间把所有纸条重新抄一遍扔进队列。
为什么要这玩意?兜底:万一某次事件丢了、某次 reconcile 漏了、有人半夜手动改了资源没被注意到,resync 保证"每隔一段时间,所有对象都会被重新对账一次"。
对你的直接影响:Reconcile 会被周期性重跑,即使你什么都没改。 这是"幂等性必须成立"的又一个原因——你的对账函数跑 100 遍,结果必须和跑 1 遍一样。
6.3 Lister:缓存的只读查询口
Lister 是缓存的查询接口,按 namespace/label 查:
blogLister.Blogs("default").Get("my-blog")
blogLister.Blogs("default").List(labels.SelectorFromSet(map[string]string{"env": "prod"}))Reconcile 里所有的"读"都应该走 Lister(缓存),只有"写"才找 API Server。 阶段 0 的规矩——"读走内存,写才走 API Server"——在代码里就体现为这一条。
07七、★★ Workqueue:为什么队列里只放 key(书上 3.9)
阶段 0 讲了"小纸条上只有名字"。这一节回答:为什么放 key 而不是放对象? 三个原因:
- 去重:对象一秒变 10 次,队列里也只有一张纸条(反正对账只看最终状态——电平触发)。
- 拿最新状态:worker 处理时从缓存读的是此刻的对象,不是事件发生时那个旧快照。事件发生到被处理之间,对象可能又改了好几轮。
- 对象可能已被删:key 只是个字符串,对象删了 key 依然合法——worker 读到 NotFound,正常收工。
7.1 接口的固定动作
Get() 取一个 key(阻塞等待)
Done(key) 告诉队列"处理完了"(必须调,否则队列认为你还在处理)
AddRateLimited(key) 失败后重新入队(指数退避:5ms 起步翻倍,上限 1000s)
Forget(key) 成功!清掉这个 key 的退避计数
NumRequeues(key) 重试次数——可以"超过 N 次就放弃并告警"典型的 worker 骨架长这样:
key, shutdown := queue.Get()
defer queue.Done(key)
err := reconcile(key) // 你的业务逻辑
if err != nil {
queue.AddRateLimited(key) // 失败:退避重试
} else {
queue.Forget(key) // 成功:清退避计数 ← 千万别漏!
}经典 bug:只 AddRateLimited 不 Forget。 成功路径上忘了 Forget,这个 key 的退避计时器就一直涨——以后偶尔失败一次,要等很久才重试。
除了单个 key 的指数退避,队列还有全局限速(默认 10 QPS / burst 100)——防止 controller 整体发疯打爆 API Server。
08八、★ API Machinery:Kinds / Resources / RESTMapper / Scheme(书上 3.10)
这一节把阶段 0 速查表里的名词彻底讲透。
8.1 Kinds(类型)有三种
- Object kinds:Pod、Deployment、Blog——真正的对象
- List kinds:PodList、BlogList——
List()返回的集合包装(每个 object kind 都有对应的 List kind) - 特殊 kinds:比如
Scale,被所有支持扩缩容的资源共用——这就是为什么 HPA 能扩一切:它不认识你的 Blog,但它认识/scale这个通用协议(第 4 章细讲)
8.2 Resources(资源)是 URL 里的复数小写
REST 路径模板:
/apis/<group>/<version>/namespaces/<ns>/<resource>/<name>
例:
/apis/mycompany.com/v1/namespaces/default/blogs/my-blogKind: Blog(单数驼峰,代码里的身份证)和 resource: blogs(复数小写,URL 里的地址)是同一个东西的两种写法。子资源(/status、/scale)是 resource 下的二级端点。
动手验证(不用写代码):
kubectl api-resources # 左边一列是 resource(复数小写),右边是 Kind(单数驼峰)
kubectl api-versions # 集群里所有 group/version8.3 RESTMapper:GVK ↔ GVR 的双向翻译
先建立坐标:同一个 Blog,在不同场合有三张"身份证":
| 场合 | 长什么样 | 名字 |
|---|---|---|
| YAML/JSON 里 | apiVersion: mycompany.com/v1 + kind: Blog |
GVK |
| REST URL 里 | /apis/mycompany.com/v1/.../blogs/my-blog |
GVR(R = Resource) |
| Go 代码里 | type Blog struct {...} |
Go 结构体 |
Kind: Blog(单数驼峰)和 resource: blogs(复数小写)是同一个东西的两种写法,但转换没有统一规律(Endpoints 单复数同形、NetworkPolicy → networkpolicies),不能靠猜,得查表。
为什么同一个东西要两个名字?因为两套惯例: Kind 服从编程语言里"类名"的惯例(单数、大驼峰,像 Post、User);Resource 服从 REST URL 的惯例(复数、全小写,像 /posts、/users)。K8s 同时处在这两个世界里,所以同一资源有两张名片。类比:Kind 是你的大名(回答"你是谁"),Resource 是你家的地址(回答"去哪找你")。
一次真实请求的完整旅程(GVK/GVR 各出现在哪,看一遍永不混):
第 1 步——拼 URL,用 GVR(代码先问 RESTMapper:Blog 的 resource 叫什么?答:blogs):
GET /apis/mycompany.com/v1/namespaces/default/blogs/my-blog
└──────┬──────┘ └┬┘ └─┬─┘
Group Version Resource
└──────────────── GVR ────────────────┘第 2 步——API Server 返回 JSON,内容里写的是 GVK(注意:返回体里没有 "blogs" 这个词):
{
"apiVersion": "mycompany.com/v1", ← Group + Version
"kind": "Blog", ← Kind
"metadata": { "name": "my-blog" },
"spec": { "title": "小明的博客" }
}第 3 步——JSON 变 Go 结构体,用 Scheme(拿 JSON 上的 GVK 查户口本 → new &Blog{},这一步跟 GVR 无关)。
一句话记忆:Kind 写在纸上(JSON 内容),Resource 写在信封上(URL 地址)。
RESTMapper 就是查表的人:
- 给你一个
Kind=Blog→ 告诉你 URL 该写blogs - 给一段 JSON 的
kind字段 → 告诉你该 new 哪个 Go struct(这一步要结合 Scheme)
词典数据来自 API Server 的 discovery 接口(/apis)——就是 kubectl api-resources 输出的那份列表。所以你的 CRD 一 apply,RESTMapper 刷新词典立刻就认识 Blog,不用改任何代码。
8.4 Scheme:GVK ↔ Go 结构体的"户口本"
RESTMapper 管"名字 ↔ 网址"(外部世界),Scheme 管另一半:JSON 和 Go 结构体互转(代码内部)。两个真实场景:
场景 1:JSON 进来(解码)。 informer 收到 watch 推送,拿到的是 JSON 字节流,里面只写着 kind: Blog。Go 要把它变成能 .Spec.Title 点字段的结构体——该 new 哪个类型?查 Scheme:户口本上登了,就 new &Blog{} 填进去。没登记?就报 no kind is registered for the type ...。
场景 2:对象发出去(编码)。 你调 client.Update(blog),Go struct 要转回 JSON 发给 API Server。发往哪个 group-version 的端点?还是查 Scheme——户口本上写着 Blog 属于 mycompany.com/v1。
所以写自定义类型时的固定动作:
scheme.AddKnownTypes(SchemeGroupVersion, &Blog{}, &BlogList{})翻译成人话:"在户口本上登记:mycompany.com/v1 这个门牌下,住着 Blog 和 BlogList 两户人家。" 注意 List 也要登记——List() 返回的 BlogList 也是一种 kind,解码时同样要查表。
完整链路串一遍:URL(GVR)→ RESTMapper 找到 → 拿到 JSON(GVK)→ Scheme 认出 → 变成 Go struct。 RESTMapper 负责前半段(找地址),Scheme 负责后半段(认类型)。
记住这个报错:no kind is registered for the type ... = 你忘了 AddToScheme。 这是阶段 1 最常见的报错,没有之一。(kubebuilder 生成的项目里有 init() 自动帮你登记,所以你用 kubebuilder 时碰不到;裸写 client-go 时几乎必踩一次。)
第二部分:CRD——发明你自己的资源(原书第 4 章)
09九、CRD 装上之后:你的资源和内置资源平起平坐(书上 4.1)
第 3 章的 clientset 只认识内置类型。你的 Blog 怎么让 K8s 认识?——注册一个 CRD(CustomResourceDefinition)。
kubectl apply 一个 CRD 后,API Server 立刻、动态地多出一组 REST 端点,不用重启任何东西:
kubectl api-resources | grep blog # blogs 出现了!
kubectl get blogs # 像内置资源一样操作
kubectl explain blog.spec # 连文档都能查你的资源和内置资源享受完全相同的待遇:kubectl 能操作、RBAC 能管、etcd 给持久化、watch 能订阅。这就是 K8s"可扩展 API"的核心魔力。
10十、★ CRD 的 YAML 骨架(书上 4.2)
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: blogs.mycompany.com # ★ 固定格式:<plural>.<group>,写错直接报错
spec:
group: mycompany.com # 你的 API group
scope: Namespaced # Namespaced(按命名空间隔离)还是 Cluster(全局唯一,如 Node)
names:
kind: Blog # 单数驼峰(代码里的身份证)
plural: blogs # 复数小写(URL 用这个)
singular: blog
shortNames: [blg] # 之后可以 kubectl get blg
versions:
- name: v1
served: true # 是否对外提供这个版本
storage: true # etcd 里以哪个版本存(多版本时只有一个 storage:true)
schema: ... # OpenAPI 校验规则(下一节)新手手写 CRD 必踩的坑:metadata.name 必须是 <plural>.<group>(这里是 blogs.mycompany.com),写别的名字直接报错。
served 和 storage 的区别:served 决定用户能用哪个版本读写;storage 决定 etcd 里实际存成哪个版本——多版本并存时,只有一个版本能是 storage。这是阶段 4 多版本演进的基础。
11十一、CRD 的四个高级特性(书上 4.3)
11.1 OpenAPI 校验:入口拦截,不用写代码
在 CRD 里声明 OpenAPI v3 schema:字段类型、required、最大最小值、正则。API Server 在入口处就拒绝非法 YAML:
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [replicas]
properties:
title: { type: string, maxLength: 100 }
replicas: { type: integer, minimum: 1, maximum: 10 }用户 kubectl apply 一个 replicas: -1 的 Blog,当场报错——比"进了 reconcile 才发现"好得多。错误暴露得越早,用户体验越好。
11.2 短名与分类:小但提升体验
shortNames: [blg]→kubectl get blgcategories: ["all"]→ 你的资源出现在kubectl get all里
11.3 Printer Columns:kubectl get 的"质感"来源
additionalPrinterColumns 决定 kubectl get blogs 时额外显示哪几列(用 JSONPath 取值):
additionalPrinterColumns:
- name: Replicas
type: integer
jsonPath: .spec.replicas
- name: Ready
type: string
jsonPath: .status.conditions[?(@.type=='Ready')].status
- name: Age
type: date
jsonPath: .metadata.creationTimestamp效果:
$ kubectl get blogs
NAME REPLICAS READY AGE
my-blog 2 True 3d别人用你的 Operator,第一眼体验就是 kubectl get 的输出——这是"Operator 质感"的重要来源。
11.4 ★★ 子资源:/status 和 /scale
/status 子资源(阶段 3 的理论基础)。在 CRD 里开一行:
subresources:
status: {}开了之后得到三个保证:
- 写分离:普通
Update()只能改 spec/metadata,改 status 必须走专门的UpdateStatus() - 权限分离:RBAC 可以给用户
blogs的写权限、但只给blogs/status的读权限——用户永远改不了 status。这正是阶段 0 那句"status 是输出不是输入"的 API 级强制保证 - 忽略保护:创建/更新主资源时,YAML 里夹带的 status 字段被直接忽略
/scale 子资源——开了之后,你的 CR 可以被 HPA 自动扩缩容:
subresources:
scale:
specReplicasPath: .spec.replicas # 期望副本数在哪个字段
statusReplicasPath: .status.replicas # 当前副本数在哪个字段
labelSelectorPath: .status.selector # 用哪个 selector 数 Pod你只需告诉 HPA 这三个路径,HPA 不需要认识 Blog 这种类型——它只认 /scale 这个通用协议(呼应 8.1:Scale 是被多资源共用的特殊 Kind)。
12十二、★★ 三种读写 CR 的客户端:你的武器选型(书上 4.4)
CRD 注册好了,Go 代码怎么读写 Blog?有三条路:
| 方式 | 类型安全 | 需要代码生成 | 适合谁 |
|---|---|---|---|
| Dynamic Client | ❌(操作嵌套 map) | 不需要 | 通用工具、类型不确定的场景 |
| Typed Clientset | ✅ | 需要(client-gen) | 传统 controller 写法(阶段 1 学原理) |
| controller-runtime client | ✅(靠 Scheme) | 不需要 | kubebuilder 路线 ← 你将走的路 |
12.1 Dynamic Client:零成本,但裸奔
对象表示为 unstructured.Unstructured——就是一个 map[string]interface{} 嵌套 map:
gvr := schema.GroupVersionResource{Group: "mycompany.com", Version: "v1", Resource: "blogs"}
obj, _ := dynamicClient.Resource(gvr).Namespace("default").Get(ctx, "my-blog", metav1.GetOptions{})
title, _, _ := unstructured.NestedString(obj.Object, "spec", "title")- 优点:零代码生成,任何 CRD 拿来就能用——甚至不需要有 Go struct
- 缺点:没有编译期检查,字段名拼错要等运行时才炸;API 很啰嗦
kubectl 这类"什么资源都要操作"的通用工具就走这条路。
12.2 Typed Clientset:代码生成,体验和原生一致
在你的 types.go 上加注解标记,跑 code-generator,生成和内置资源一模一样的链式客户端:
// +genclient ← 注解:为 Blog 生成客户端
// +k8s:deepcopy-gen=true ← 注解:生成 DeepCopy 方法
type Blog struct { ... }生成后:
blogClient.MycompanyV1().Blogs("default").Get(ctx, "my-blog", metav1.GetOptions{})类型安全、体验和原生完全一致;代价是要维护一套代码生成流程。阶段 1 你跟着 sample-controller 学的就是这条路——学它是为了理解原理,不是为了以后天天用。
12.3 controller-runtime client:你未来的主力武器
kubebuilder 用的第三种方案:不生成代码,运行时查 Scheme。任何注册过 AddToScheme 的类型,直接用:
blog := &mycompanyv1.Blog{}
r.Get(ctx, client.ObjectKey{Namespace: "default", Name: "my-blog"}, blog)
r.Create(ctx, deployment)
r.Update(ctx, blog)
r.Status().Update(ctx, blog) // 写 status 走 Status() 子客户端重点:这个 client 内部是"分裂"的(split client)——
Get/List→ 默认走 informer 缓存(读走内存!)Create/Update/Delete→ 直连 API Server(写走服务端)
阶段 0 那条规矩——"读走缓存,写才走 API Server"——在这一个对象里被自动执行了。这就是为什么 kubebuilder 生成的 Reconcile 里,你直接 r.Get() 而不用担心打爆 API Server。
12.4 怎么选?
- 写通用工具、CRD 类型不固定 → Dynamic
- 阶段 1 跟 sample-controller 学原理 → Typed Clientset
- 阶段 2 以后正经写 Operator → controller-runtime client
第三部分:收尾
13十三、和你学习路线的对应
本文部分 你的路线
───────────────────────────── ─────────────────────────────
二、rest.Config 两种连接方式 → 阶段 1 第一天就会写
三、对象三段式 → 阶段 1 写 types.go 时就长这样
六、Informer 三固定动作 → 阶段 1 裸写 controller 的核心
七、Workqueue key 设计 → 阶段 1;Forget 的坑提前知道
八、Scheme / RESTMapper → 阶段 1 排错必备(记住那个报错)
十、CRD YAML 骨架 → 阶段 2 kubebuilder 帮你生成
十一、/status、/scale 子资源 → 阶段 3 五大件之 Status subresource
十二、三种客户端 → 理解 kubebuilder 为什么那样写14十四、学完自检(6 题)
Q1. 一个 K8s 对象在 Go 里的三段式结构是什么?generation 和 resourceVersion 有什么区别?
TypeMeta(apiVersion/kind)+ ObjectMeta(name/labels/...)+ Spec/Status。
resourceVersion任何修改都变,是乐观锁;generation只有 spec 变才递增,所以能用它过滤 status 更新造成的抖动。
Q2. 为什么 workqueue 里放 key 而不是对象?
① 去重:变 10 次只入队一次;② worker 处理时从缓存读的是最新状态,不是事件的旧快照;③ 对象删了 key 依然合法,读到 NotFound 正常收工。
Q3. WaitForCacheSync 防的是什么 bug?
Informer 启动要先 LIST 全量灌缓存,没同步完缓存是空的。worker 若提前开工,会把"还没同步到"误判成"对象不存在",可能误删资源。
Q4. /status 子资源带来哪三个好处?
写分离(普通 Update 改不了 status);RBAC 分离(可以禁止用户写 status);创建/更新时 YAML 夹带的 status 被忽略。
Q5. controller-runtime client 为什么说"自动执行了阶段 0 的规矩"?
它是 split client:Get/List 默认走 informer 缓存(读走内存),Create/Update/Delete 直连 API Server(写走服务端)。
Q6. 报错 no kind is registered for the type ... 是什么原因?
你的类型没注册进 Scheme(忘了
AddToScheme)。Scheme 是 GVK ↔ Go struct 的注册表,没注册就不知道怎么解码/编码这种类型。
15十五、下一步
这份文档学完,你已经有资格进入阶段 1:client-go 裸写一个 Controller——你会写出来的每一行(informer 回调、queue.Add、WaitForCacheSync、Lister 查询),在本文里都见过原理解释。
想对照原书时,各节标题后的"(书上 X.X)"就是章节号。原书第 3.11 节(Vendoring,讲 glide/dep 依赖管理史)已过时,直接跳过——Go modules 早已是唯一答案。