简单包学习

第一阶段:打通 YAML 与 Go 代码的“次元壁”

在 K8s 中,所有的资源(Pod、Deployment、以及你自己定义的 CRD)在代码里都是一个 Struct(结构体)

核心包的职责分工

你需要关注的包只有这两个:

  • k8s.io/apimachinery/pkg/apis/meta/v1 (通常别名为 metav1)

    • 职责:定义“元数据”。不管你是 Pod 还是自定义资源,只要在 K8s 里,就必须有名字、标签这些通用的属性。

  • k8s.io/api/... (如 core/v1apps/v1)

    • 职责:定义“具体零件”。比如 Pod 的容器镜像、端口号等具体业务字段。

K8s 对象的“标准四部曲”

几乎所有的 K8s Go 结构体都由这四个部分组成。请看这个对照表,这是最核心的映射关系:

YAML 字段

Go 结构体中的字段

类型

说明

apiVersion / kind

TypeMeta

metav1.TypeMeta

定义这是什么资源(如 v1 版的 Pod)。

metadata

ObjectMeta

metav1.ObjectMeta

重点: 包含 Name, Namespace, Labels。

spec

Spec

自定义结构体

重点: 用户的“期望”。你想要什么样?

status

Status

自定义结构体

重点: 集群的“现实”。现在变成了什么样?

深度拆解:ObjectMeta(你最常操作的部分)

当你创建一个 Operator 时,你经常需要读取或修改 metadata。在 Go 代码里,它对应 metav1.ObjectMeta 结构体。

最常用的字段:

  • Name: 资源的名称。

  • Namespace: 所属命名空间。

  • Labels: 一个 map[string]string,用于过滤和关联资源。

  • Annotations: 存储额外信息(通常不作为搜索条件)。

  • OwnerReferences: 一个数组。Operator 极其关键的字段,用来实现“父子绑定”。删除了父资源,K8s 会根据这个字段自动删除关联的子资源。

第二阶段:玩转“增删改查”(CRUD)

在第一阶段你学会了如何定义资源,现在你要学习如何通过代码去“操控”它们。这是 Operator 逻辑中最核心的部分。在 Kubebuilder 中,我们主要使用 sigs.k8s.io/controller-runtime/pkg/client 这个包提供的 Client 接口

核心工具:client.Client

为什么不用 client-go 的原生接口? 因为 Kubebuilder 提供的这个 Client 做了极强的封装:

  • 读操作: 默认从本地缓存(Informer)读,速度极快。当你执行 GetList 时,它默认是从本地内存(Informer 维护的缓存)读取,而不是每次都冲向 API Server,这极大减轻了集群压力。

  • 写操作: 直接写向 API Server。

  • 智能: 它能根据你传入的结构体自动判断去调哪个 API 路径。

获取资源:Get (查单个)

这是你写 Reconcile 函数的第一步:先拿到你要处理的那个对象。

  • 关键点: 需要 types.NamespacedName(包含 Name 和 Namespace)。

  • 必须关注: 错误处理。如果资源不存在,API 会返回一个错误,你需要判断它是“真出错了”还是“资源被删除了”。

获取列表:List (查一批)

场景:你想知道当前命名空间下有多少个 Pod。

  • 关键点: 使用 client.InNamespaceclient.MatchingLabels 进行过滤。

创建资源:Create (增)

  • 关键点: 必须先手动填好 ObjectMeta(名字和空间)。

  • 重要细节: 在第二阶段,如果你创建的是子资源(比如你的 CR 创建了一个 Deployment),必须建立父子关系(这属于第五阶段提前剧透,但这里必须提,否则删不掉)。

修改资源:UpdateStatus().Update() (改)

这是初学者最容易掉坑的地方!

  • 普通 Update 修改 SpecLabelsAnnotations 等。

  • Status().Update() 专门修改 Status 部分。K8s 建议将 Spec 和 Status 分开更新,因为 Status 是由控制器计算出来的,不是用户填写的。

删除资源:Delete (删)

相对简单,直接传入对象即可。

第二阶段的高级“性价比”知识点

冲突处理:RetryOnConflict

当你执行 Update 时,如果此时有别人也在改这个资源,K8s 会报 Conflict 错误导致更新失败。 最快应对方案: 使用 k8s.io/client-go/util/retry 包。

所有的调用都要传 ctx (Context)

你会发现每个方法第一个参数都是 ctx。这是为了控制超时和取消。在 Reconcile 函数开头传入的那个 ctx 直接透传给 client 即可。

永远传递指针

不管是 Get 还是 Update,传入的资源对象必须是指针(例如 &instance),因为 client 需要修改该对象的内容。

零值处理: 在修改资源并 Update 时,确保你没有无意中把某些字段改成了零值(如把副本数改成了 0),这会导致集群状态发生非预期的剧烈波动。

添加权限

别忘了 RBAC 权限: 你在代码里写了 r.Create(pod),但如果你没在 controller.go 的注释里加上 // +kubebuilder:rbac:groups=core,resources=pods,verbs=create,你的 Operator 运行起来就会报 Forbidden

数据读取逻辑

读数据默认是“过时的”: 记住,r.Get 读的是 Cache(内存缓存)。如果你刚刚 Create 了一个 Pod,下一行立即 Get,可能拿不到。这是分布式系统的最终一致性。不要为此写死循环等待,而要依靠下一次 Reconcile

第三阶段:控制“大脑”——深度理解 Reconcile(调解)循环与幂等性设计

核心概念:什么是幂等性(Idempotency)?

在分布式系统中,网络会抖动,进程会崩溃。Reconcile 函数可能会因为各种原因被反复调用(比如一分钟调用 100 次)。 幂等性要求: 无论 Reconcile 被调用 1 次还是 100 次,最终对集群产生的结果必须是一样的。

大脑的逻辑公式: 期望状态 (Spec) - 实际状态 (Actual) = 动作 (Action)

核心包介绍

  • sigs.k8s.io/controller-runtime/pkg/reconcile

    • 作用:定义了调解循环的输入(Request)和输出(Result)。

    • 核心结构Request 仅包含资源的名称和命名空间;Result 决定了下一次调解什么时候发生。

  • sigs.k8s.io/controller-runtime/pkg/log

    • 作用:提供结构化日志(基于 zap)。

    • 重要性:在 Operator 中不要使用 fmt.Println,因为结构化日志可以自动带上控制器名称和请求上下文,方便排查大规模集群中的问题。

  • reflect (Go 标准库)

    • 作用:用于深度比较两个对象是否相等。

    • 场景:判断“现在的 Deployment 配置”是否真的需要更新,避免无效的 API 调用。

最佳实践代码演示:一个完整的调解逻辑

我们以一个管理 Deployment 的自定义资源为例。这段代码展示了如何处理:检测 -> 创建 -> 比对规格 -> 更新状态

深度解析:返回值与参数的奥秘

reconcile.Request 为什么只传名字不传对象?

原因:Reconcile 被调用时,对象在缓存中可能已经变了。传名字要求你每次都从 r.Get 获取最新的快照。这保证了你的决策是基于当前真实数据的,而不是几秒前的过时数据。

reconcile.Result 的三种返回姿势:

  1. Result{}, nil

    • 含义:我做完了,且很成功。

    • 目的:停止调解。直到这个资源再次被修改(如用户改了 YAML),我才会被再次叫醒。

  2. Result{Requeue: true}, nil

    • 含义:我刚刚做了一个动作(比如创建了 Pod),我想立刻再检查一遍。

    • 目的:快速进入下一轮循环。

  3. Result{RequeueAfter: time.Minute}, nil

    • 含义:现在没事了,但 1 分钟后请务必再叫醒我。

    • 目的:用于处理那些不是由 K8s 资源触发的变化(比如你的 Operator 正在监控一个外部 API 接口的状态)。

第三阶段核心避坑指南(重要信息)

  1. 禁止在循环中做长时阻塞动作Reconcile 是并发执行的,但如果你在里面 time.Sleep(10 * time.Minute),会占死 Worker 线程,导致你的 Operator 响应变得极慢。

  2. 永远假设资源可能不存在: 在操作任何资源前,先判断 if err != nil && errors.IsNotFound(err)

  3. 避免“写冲突”导致的死循环: 如果你在 Reconcile 里不停地修改 Spec,而 K8s 发现 Spec 变了又去调用 Reconcile,你就创造了一个永不停歇的“死循环”,这会消耗大量 CPU。规则:只有用户能改 Spec,Operator 尽量只改 Status。

第四阶段:掌控生死——血缘绑定(OwnerReference)与善后处理(Finalizer)

在前面的阶段,你学会了如何让 Operator “思考”和“行动”。但这里有一个致命问题:如果你把自定义资源(CR)删了,它创建出来的那些 Pod 和 Deployment 还在集群里“流浪”怎么办?

核心包介绍

  • sigs.k8s.io/controller-runtime/pkg/controller/controllerutil

    • 作用:这是处理资源关系的“工具箱”。

    • 核心功能

      • SetControllerReference:建立父子关系,实现自动垃圾回收。

      • AddFinalizer / RemoveFinalizer:管理“终结器”,处理自定义删除逻辑。

  • k8s.io/apimachinery/pkg/runtime

    • 作用:定义了 K8s 的对象转换协议。

    • 重要性:在建立父子关系时,代码需要知道当前集群的 Scheme(架构方案),以便确认父资源和子资源的版本是否匹配。

最佳实践:父子绑定(自动垃圾回收)

目的:当用户删除 MyWebApp 时,Kubernetes 的 Garbage Collector (GC) 会自动识别并删除所有关联的 Deployment。

最佳实践:终结器(Finalizer)处理逻辑

场景:如果你的 Operator 在外部(如阿里云、腾讯云)创建了一个负载均衡器。仅仅删除 K8s 里的 CR 是不够的,你必须在它消失前,先调用云 API 把负载均衡器删了。

原理:只要对象中存在 finalizers 列表,K8s 就不允许彻底删除它,只会给它打上 DeletionTimestamp 标记。

深度解析:为什么要这样做?

Q: 为什么有了 SetControllerReference 还需要 Finalizer

  • SetControllerReference 只能管 K8s 内部 的资源(如:删了 CR 自动删 Pod)。

  • Finalizer 专门管 K8s 外部 的资产(如:数据库账号、云端 LB、磁盘卷)。

Q: 为什么 SetControllerReference 需要传递 r.Scheme

Scheme 是 K8s 的“户口登记表”。如果你想让 MyWebAppDeployment 的父亲,代码需要通过 Scheme 查到 MyWebApp 属于哪个 GroupVersion(比如 web.example.com/v1),这样生成的 OwnerReference 才是合法的。

Q: 如果 deleteExternalResources 一直失败会怎样?

你的 CR 会一直卡在 Terminating 状态,删不掉。这虽然痛苦,但它是安全的——它防止了“对象没了,但云端资源还在计费”的情况发生。

第四阶段核心避坑指南(重要信息)

  1. Finalizer 必须幂等: 由于 Reconcile 会多次触发,你的清理逻辑(如 deleteExternalResources)可能会被调用多次。一定要确保即使资源已经删过了,代码也不会报错挂掉。

  2. 更新冲突: 在 AddFinalizerRemoveFinalizer 时,如果 r.Update 报了 Conflict,说明资源刚好被别人改了。最佳实践: 捕获冲突并重试,或者直接返回错误让整个 Reconcile 重来。

  3. 循环依赖陷阱: 千万不要在 Finalizer 逻辑里又去创建一个依赖于父资源的子资源,这会导致你的对象永远无法被删除。

速查表

阶段

核心包

主要用途

关键函数/结构

1. 定义

metav1 (apimachinery)

定义元数据(名字、标签、血缘)

ObjectMeta, TypeMeta

2. 操控

client (controller-runtime)

与 API Server 通信(增删改查)

Get, List, Update, Status()

3. 逻辑

reconcile / log

处理触发事件、控制重试、打日志

Request, Result, FromContext

4. 生命周期

controllerutil

建立父子关系、处理删除前后的清理

SetControllerReference, Finalizer

核心包深度解析

1. k8s.io/apimachinery/pkg/apis/meta/v1 (别名 metav1)

  • 它是干什么的:所有 K8s 资源的“公共身份证”。

  • 重点字段

    • Name, Namespace: 资源的唯一坐标。

    • OwnerReferences: 极其重要。这是一个数组,记录了“谁是我的父亲”。设置了它,父亲被删,儿子自动被 GC(垃圾回收)。

  • 为什么要这么用:为了让 K8s 引擎知道资源之间的逻辑隶属关系,防止资源泄漏。

2. sigs.k8s.io/controller-runtime/pkg/client

  • 它是干什么的:Operator 的“手”,负责所有 API 操作。

  • 关键函数详解

    • Get(ctx, key, obj):

      • 传参Context(超时控制)、NamespacedName(找谁)、对象指针(结果存哪)。

      • 返回error。如果是 NotFound,说明资源不存在。

    • Status().Update(ctx, obj):

      • 为什么要这么用:这是最佳实践。Status 是子资源,独立更新它不会导致 metadata.generation 增加,从而避免因为触发了不必要的 Spec 变更而导致死循环。

3. sigs.k8s.io/controller-runtime/pkg/reconcile

  • 它是干什么的:定义了大脑的“单次思考任务”。

  • 关键返回值的含义

    • Result{}, nil: “我任务完成了,不用再叫我。”

    • Result{Requeue: true}, nil: “我刚做了个动作,请立刻让我再检查一遍。”(常用在创建完资源后)

    • Result{RequeueAfter: 1*time.Minute}, nil: “现在正常,但 1 分钟后请准时叫醒我巡检。”

4. sigs.k8s.io/controller-runtime/pkg/controller/controllerutil

  • 它是干什么的:处理复杂的对象关系和删除逻辑。

  • 关键函数

    • SetControllerReference(owner, controlled, scheme):

      • 传参:父对象、子对象、Scheme 户口本。

      • 返回error

      • 什么时候用:在 r.Create() 子资源之前调用,确保子资源出生就有“父亲”。

    • Add/RemoveFinalizer(obj, string):

      • 为什么要用:当资源被删时,你想在它彻底消失前执行一些动作(如清理云端数据库),必须用这个防止它被瞬间抹除。

💡 开发最佳实践 (Best Practices)

  1. 幂等性 (Idempotency) 第一原则: 永远假设你的代码会在任何一行报错退出。当它第二次进来时,必须能接上进度。做法: 每次操作前先 Get 查一下,存在就不创建,一致就不更新。

  2. 读写分离client.Client 默认从缓存读,往集群写。不要手动去写缓存,始终相信 r.Get 拿到的就是当前的最优状态。

  3. 细颗粒度的 RBAC 权限: 只给 Operator 申请它需要的权限。如果你只需要改 Pod,不要申请集群级别的权限。

  4. 状态驱动 (State Driven): 不要让你的 Operator 试图去记住历史,它应该像一个“金鱼”,每次进来都通过观察现在的集群状态来决定下一步。

🛠️ 推荐的开发路径 (Roadmap)

  1. 脚手架生成: 使用 kubebuilder initcreate api 生成代码框架。

  2. 定义 Spec (数据模型): 在 _types.go 里想清楚用户需要填什么参数。原则: 字段越少越好,能推导出来的字段不要让用户填。

  3. 编写 Reconcile (核心逻辑): 按照“获取 CR -> 获取子资源 -> 对比差异 -> 执行同步 -> 更新 Status”的五步法写逻辑。

  4. 本地测试 (Local Debug): 利用 Minikube,直接在本地运行 make installmake run。这样你可以在 IDE 里打断点看变量。

  5. 镜像部署: 测试完成后,执行 make docker-build docker-pushmake deploy 将 Operator 真正运行在集群里。

实战

定义数据模型

定义数据模型(Schema)—— 设计你的“资源协议”

在 Kubernetes 中,所有的资源本质上都是一段 JSON。在这一阶段,我们要去修改 api/v1/myconfig_types.go 文件,决定用户在 YAML 里能填什么参数(Spec),以及我们的 Operator 反馈什么信息(Status)。

1. 明确我们要实现的字段

我们要做的 MyConfig 资源,目标是生成一个 ConfigMap

  • 期望(Spec):用户需要提供 ConfigMap 的数据内容,我们定义一个 Data 字段。

  • 状态(Status):我们要告诉用户这个 ConfigMap 是否已经同步成功,我们定义一个 SyncStatus 字段。

2. 修改 api/v1/myconfig_types.go

找到 MyConfigSpecMyConfigStatus 结构体,按如下方式修改:

🔍 深度拆解:为什么要这么做?

+kubebuilder:subresource:status

  • 为什么要这么做?

    Kubernetes 将资源分为“主资源”和“子资源”。如果你不加这行注释,你的 Operator 在尝试调用 r.Status().Update() 时会报错,因为 API Server 会认为这个资源没有 status 这个接口。

  • 不这么做的后果:

    你的 Operator 逻辑能跑,但是无法把运行结果(比如“同步成功”)反馈给用户。用户执行 kubectl get myconfig -o yaml 时,status 永远是空的。

② JSON 标签 (`json:"data"`)

  • 为什么要这么做?

    Go 语言的变量首字母必须大写(Public)才能被其他包访问,但 Kubernetes 的 YAML 规范习惯使用小写(如 spec.data)。这个标签就是翻译官。

  • 不这么做的后果:

    如果你漏掉了标签或者写错了,用户在 YAML 里填了 data,Go 代码里的 Data 变量拿不到任何值。

+kubebuilder:printcolumn (最佳实践)

  • 为什么要这么做?

    你一定用过 kubectl get pods,它会显示 STATUS、AGE 等列。这行代码就是让你自定义 kubectl get myconfig 时的显示列。

  • 不这么做的后果:

    不加这行,你执行 kubectl get myconfig 时只能看到名字,必须加 -o yaml 才能看到同步结果,非常不直观。

3. 生成 Manifests (同步代码到 YAML)

每当你修改了 _types.go 文件,你必须运行以下命令,否则 Kubernetes 不认识这些新字段:

Bash

  • 这个函数干了什么?

    它会扫描你的 Go 代码和注释(那些 +kubebuilder 开头的行),然后在 config/crd/bases/ 目录下生成一个庞大的 YAML 文件。

  • 不这么做的后果:

    即使你代码写得完美,只要没执行这一步,当你把 YAML 发给集群时,集群会报错:unknown field "data" in spec。

将 CRD 安装到 Minikube

现在,我们要把这个“设计图”交给 Minikube。

  • 验证结果:

    执行 kubectl get crds | grep myconfigs

    如果你看到了 myconfigs.web.example.com,说明你已经在集群里成功登记了你的新资源类型!

编写 Reconcile 逻辑

我们将分三步完成控制器的编写:设置权限、建立逻辑循环、实现父子绑定。

设置 RBAC 权限(必须先做)

Reconcile 函数上方,你会看到一堆以 // +kubebuilder:rbac 开头的注释。这些不是普通注释,它们是权限声明。

操作: 找到这些行,确保添加了对 configmaps 的权限:

  • 为什么要这么做?

    Kubernetes 默认是不允许任何程序随意创建资源的。这些注释会被 make manifests 转化成集群的 ClusterRole。

  • 不这么做的后果:

    你的代码运行到 r.Create(configMap) 时会直接崩溃,报错 Forbidden(无权操作)。

实现核心调解逻辑 (Reconcile)

我们将重写 Reconcile 函数。请仔细阅读代码中的中文注释:

实现“父子绑定”(关键函数)

这是实现自动清理和关联的核心。我们在控制器类下增加这个辅助函数:

  • 为什么要这么做?

    这是 Operator 的最佳实践。设置了 OwnerReference 后,当你 kubectl delete myconfig ... 时,K8s 会自动帮你删掉这个对应的 ConfigMap。

  • 不这么做的后果:

    资源会发生“泄露”。你的自定义资源删了,但它留下的垃圾(ConfigMap)会永远留在集群里,占用命名空间。

更新状态 (Status Update)

  • 为什么要这么做?

    让用户能看到结果。

  • 不这么做的后果:

    用户不知道 Operator 是否在干活,只能去翻日志,效率极低。

监听

当你写下 ForOwns 时,底层的 controller-runtime 其实在为你做以下几件事:

① 注册事件处理器 (Event Handlers)

  • For(&webv1.MyConfig{}): 向 K8s 注册一个监听器(Informer)。一旦 MyConfig 资源有 Create/Update/Delete 动作,K8s 就会把这个事件扔进一个工作队列 (WorkQueue)

  • Owns(&corev1.ConfigMap{}): 同样注册一个针对 ConfigMap 的监听器。

② 自动过滤与映射 (Map to Owner)

这是 Owns 最神奇的地方。 当一个 ConfigMap 发生变动时,controller-runtime 不会盲目地触发 Reconcile。它会执行以下逻辑:

  1. 检查 Metadata:查看这个 ConfigMapownerReferences 列表。

  2. 匹配类型:看看里面有没有一个 OwnerKindMyConfig,且 Controller 字段为 true

  3. 获取名字:如果有,提取出那个 MyConfigName

  4. 触发父资源:把这个父资源的名字扔进工作队列,触发 Reconcile 函数。

重点: 你的 Reconcile 函数接收到的 req.Name 永远是 父资源 (MyConfig) 的名字,即使刚才变动的是 子资源 (ConfigMap)。这保证了你的逻辑始终从“源头”开始检查。

需要修改此函数的 3 大常见场景

除了基础的 ForOwns,实际开发中为了提高性能和处理复杂逻辑,经常需要修改它。

场景一:防止“状态更新”导致的死循环 (使用 Predicates)

问题:当你在 Reconcile 中更新 Status 时,K8s 会认为资源变了,再次触发 Reconcile。如果不加控制,会陷入:更新状态 -> 触发 -> 更新状态的死循环。 解决方案:使用 Predicate 过滤掉非 Spec 的更新。

场景二:监听“非所属”的外部资源 (使用 Watches)

问题:假设你的 MyConfig 逻辑依赖于一个全局的 Secret(比如数据库证书),但这个 Secret 并不是由 MyConfig 创建的(没有 Owner 绑定)。当 Secret 变化时,你也需要重新同步。 解决方案:手动建立映射关系。

场景三:控制并发处理速度

问题:默认情况下,Operator 是一次处理一个 Reconcile。如果你的集群规模很大,处理速度太慢。 解决方案:增加并发 worker 数。

总结

  • For: 定义你的主要观察对象。

  • Owns: 定义你的直接下属(自动反向映射)。

  • Watches: 定义跟你没亲戚关系但你很关心的“邻居”。

  • WithEventFilter: 闭上眼不看那些没意义的琐碎变动。

最后更新于