S SRE Notes

client-go 与 CRD 教学(《Programming Kubernetes》第 3-4 章内容)

这份文档替代原书第 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。

generationresourceVersion 的区别(高频考点,务必看懂):

两个都是 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 的两个用途:

  1. 乐观锁:你更新对象时要带上读到的 resourceVersion;如果别人抢先改过,API Server 返回 409 Conflict(the object has been modified),逼你重读重试——防止两个写入互相覆盖。
  2. 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",
})

三个要点:

  1. 每个 group-version 一个子客户端(AppsV1、BatchV1、CoreV1...),内置类型全有。你自己的 Blog 类型,内置 clientset 里没有——这就是第 4 章要解决的问题。
  2. 全是 interface——测试时可以换成 fake clientset,不连真集群也能单测。阶段 5 的单元测试靠这个。
  3. 写 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 而不是放对象? 三个原因:

  1. 去重:对象一秒变 10 次,队列里也只有一张纸条(反正对账只看最终状态——电平触发)。
  2. 拿最新状态:worker 处理时从缓存读的是此刻的对象,不是事件发生时那个旧快照。事件发生到被处理之间,对象可能又改了好几轮。
  3. 对象可能已被删: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:只 AddRateLimitedForget 成功路径上忘了 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-blog

Kind: Blog(单数驼峰,代码里的身份证)和 resource: blogs(复数小写,URL 里的地址)是同一个东西的两种写法。子资源(/status/scale)是 resource 下的二级端点。

动手验证(不用写代码):

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

8.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 单复数同形、NetworkPolicynetworkpolicies),不能靠猜,得查表。

为什么同一个东西要两个名字?因为两套惯例: Kind 服从编程语言里"类名"的惯例(单数、大驼峰,像 PostUser);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),写别的名字直接报错。

servedstorage 的区别: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 blg
  • categories: ["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: {}

开了之后得到三个保证:

  1. 写分离:普通 Update() 只能改 spec/metadata,改 status 必须走专门的 UpdateStatus()
  2. 权限分离:RBAC 可以给用户 blogs 的写权限、但只给 blogs/status 的读权限——用户永远改不了 status。这正是阶段 0 那句"status 是输出不是输入"的 API 级强制保证
  3. 忽略保护:创建/更新主资源时,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 里的三段式结构是什么?generationresourceVersion 有什么区别?

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 早已是唯一答案。

本页目录