尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Kubernetes扩展实战:用Go开发CRD与Operator

Kubernetes扩展实战:用Go开发CRD与Operator 1. 从“贵的服务器”说起K8s 为什么需要自定义资源如果你已经入坑 Kubernetes 一段时间大概会发现一个很微妙的现象K8s 里自带的资源就那么几种——Deployment、Service、ConfigMap、Pod……它们确实能解决大部分“跑容器”的问题但你一旦想表达一些更贴近业务的东西比如“帮我管理一个 MySQL 集群”“把这个模型版本灰度到 30% 流量”“每天凌晨两点跑一次数据校验任务”你会发现用原生资源表达起来非常别扭。原因是 K8s 的原生资源是面向“通用容器编排”设计的它不知道 MySQL 是什么、模型是什么、你的业务巡检逻辑是什么。K8s 能做的是把镜像拉起来、把流量转发过去、把副本数保持在 N 个但至于“这个 MySQL 集群该有几个从节点”“从节点落后主节点超过 10 秒要不要重新拉起”“模型灰度到 30% 之后指标异常要不要回滚”K8s 一概不知。这时候你就需要两个东西一个是自定义资源CRD用来告诉 K8s“我要表达一种新的事物类型”另一个是Operator用来告诉 K8s“这种新事物该如何创建、更新、销毁”。这两个概念组合在一起就是标题里说的“用 Go 扩展 K8s 能力”的核心路径。我一直觉得理解 CRD 和 Operator 最好的方式是把 K8s 想象成一个“超级聪明的物业公司”。原生资源是物业公司本来就提供的服务水电维修Pod、快递代收Service、钥匙备份ConfigMap。而 CRD 是你跟物业说“我要养一只宠物龙”物业说“行那你先定义一下什么叫宠物龙我来登记”。然后 Operator 就是“宠物龙饲养员”他负责定期检查龙的状态——饿了喂食、生病了治疗、不小心把阳台烧了重新装修。没有 Operator你只是让物业登记了“宠物龙”这个概念但没有真正的人去养它。这篇文章我会从一个实际做过的项目案例出发把 CRD 的定义、Operator 的开发、以及中间踩过的坑完整地梳理一遍。目标读者是已经熟悉 K8s 基本操作会写 Deployment、会用 kubectl但对“扩展 K8s API”这件事还比较陌生的同学。如果你之前只是用过 Helm 部署别人的 Operator这篇文章也能帮你理解这些 Operator 背后到底是怎么运作的。2. 用 CRD 定义“你的世界”从声明式 API 说起2.1 为什么 CRD 是 K8s 扩展的第一块基石先聊一个基础但容易被忽略的点K8s 的所有能力本质上都挂在 API 上。你写一个kubectl apply -f deployment.yamlkubectl 做的事情是把这份 YAML 的内容 POST 到 API Server 的某个路径上比如/apis/apps/v1/namespaces/default/deployments。API Server 会校验这个请求的格式校验通过后把它存入 etcd。然后 controller-manager 里的 Deployment controller 监听到这个事件开始创建 ReplicaSetReplicaSet controller 再创建 Pod。所以你会发现任何你想让 K8s 管理的东西第一步永远是“让 API Server 认识它”。CRDCustom Resource Definition就是干这个事的。它本身是一个 K8s 原生资源定义好之后API Server 就会为你的自定义资源生成一套 RESTful API 路径。举个例子。我曾在一个内部平台里定义过一个叫AppDeployment的资源用来描述“一个包含构建配置、发布策略、健康检查阈值、回滚策略”的应用发布单元。定义好 CRD 之后我就可以写这样的 YAMLapiVersion: platform.example.com/v1 kind: AppDeployment metadata: name: order-service spec: image: harbor.example.com/order-service:v1.2.3 replicas: 3 strategy: type: canary canaryPercent: 30 canaryCriteria: - metric: error_rate threshold: 0.5写完之后kubectl get appdeployments就能看到这个对象kubectl describe appdeployment order-service也能看到它的详情。这个对象就躺在 API Server 里像是 K8s 世界里新出现的一种“官方物件”。当然它暂时只是个“登记在案”的数据记录。要让这个记录真正驱动实际资源的创建就需要 Operator 出场了。注意CRD 本身只是定义数据结构它不包含任何业务逻辑。这也是很多初学者一开始容易误解的地方——以为定义了 CRD 就能自动创建 Pod、Service。真正执行逻辑的是后面要说的 Operator。2.2 CRD 如何做到“像原生资源一样”严谨K8s 的原生资源之所以用起来放心是因为它有严格的 schema 校验字段类型不对、必填字段缺失、枚举值超出范围API Server 都会直接拒绝。CRD 也可以通过 OpenAPI v3 规则做到几乎同等的严谨性。我用实际经验说几个比较重要的校验点第一必填字段。如果你的自定义资源里spec.image是必需的但用户没写你希望 API Server 直接报错而不是等 Operator Run 到一半才发现缺东西。在 CRD 里可以这样声明schema: openAPIV3Schema: type: object required: - spec properties: spec: type: object required: - image properties: image: type: string第二字段格式约束。比如版本号字段可以要求正则匹配properties: version: type: string pattern: ^v[0-9]\.[0-9]\.[0-9]$这样version: latest就会被直接拒绝API Server 压根不会把这条记录存进 etcd大大减轻了 Operator 的防御性编程负担。我在实际项目里甚至见过有人把replicas字段的上限设成 100就是为了防止用户手滑填了个一万导致集群资源被打爆。第三枚举约束。发布策略只接受rolling、canary、recreate三种可以用enum限定strategy: type: object properties: type: type: string enum: [rolling, canary, recreate]CRD 的校验能力越强Operator 的代码越简单。因为很多无效状态已经在入口处被拦截了你不需要在 Reconcile 逻辑里写一堆 if-else 去判断“image 是不是空的”这种低级问题。2.3 CRD 版本管理不只是加个 v2 那么简单当你的自定义资源已经有一些线上实例在运行后你会发现 CRD 的版本演进是一件相当微妙的事情。加字段容易删字段难改字段类型更难。K8s 提供的解决方案是支持多个版本共存并通过 conversion webhook 在不同版本之间转换。我第一次设计 CRD 时只写了v1一个版本当时觉得“反正都是内部使用没必要搞那么复杂”。结果不到两个月就后悔了——需要加一个storageClass字段如果直接改 v1 的 schema集群里所有已存在的 CR 实例都会面临字段缺失的问题。后来老老实实加了v1beta1和v1alpha1的转换逻辑。我的建议是一开始就为自己的 CRD 设计好多个版本哪怕前几个版本实际不对外使用也要把 conversion webhook 的架子搭好。具体做法通常是用 controller-gen 生成转换函数模板然后实现ConvertTo、ConvertFrom两个方法。这里引出一个经验给 CRD 里的每个字段都加上详细描述描述description并且要有“未来可能会变”的思想准备。比如spec.canaryPercent这种字段可能一开始是int类型后面发现需要支持小数就要考虑是否把它改成string类型并搭配解析函数。3. Operator把“登记的花名册”变成“真正的饲养员”3.1 什么是 Operator 模式声明式 API 的闭环有了 CRDK8s 的世界里就多了一种“新物种”的登记机制。但是谁来负责让这个“新物种”真正运转起来回到开头的比喻如果没有宠物龙饲养员物业登记册上写着“xx户养了一只龙”但龙该吃吃该喝喝该飞飞没人管那这个登记毫无意义。Operator 就是这个饲养员。Operator 的核心工作模式是“观察-分析-执行”的循环在 K8s 的语境里通常叫做reconcile loop调谐循环。它会持续监视你定义的 CR 对象以及这个 CR 相关的原生资源Deployment、Service、Secret 等然后对比“当前状态”和“期望状态”之间的差异再通过调用 K8s API 操作资源来消除这些差异。这里有一个特别重要的思维转变写 Operator 不是在写“创建资源的脚本”而是在写一个“持续保证系统处于期望状态的闭环”。举个例子。你要写一个 MySQL Operator。如果按照传统脚本思维你可能会写“创建 PVC、创建 StatefulSet、创建 Service、初始化账号……”执行完就结束了。但真实世界不是这样有用户半夜把my.cnf配置改坏了、有节点宕机导致 Pod 被驱散、有磁盘写满导致实例进入只读状态。如果你的代码只是“创建完就不管了”这些故障就没人管。而 reconcile 循环的思路是每次循环都从“当前集群里实际是什么样”出发朝着“CR 里期望的状态”努力。Pod 被删了控制器发现数量不足重建配置被改坏了控制器发现和期望配置不一致改回去磁盘满了控制器看到实例状态异常尝试重启。这才叫“自我修复”。3.2 为什么用 Go 写 OperatorK8s 本身是用 Go 写的所以用 Go 写 Operator 有天然优势。但这个理由其实不够充分——你用 Python、Java、甚至 Bash 也能写一个循环定时去查 API Server 再做操作。真正的理由有三点第一client-go 是 K8s 官方维护的 Go 客户端。它的 informer 机制实现了事件监听、本地缓存、并发安全开发者不需要自己处理 watch 断线重连、事件去重、资源版本冲突等问题。如果你用其他语言这些东西要么自己造轮子要么用社区方案可靠性很难保证。第二controller-runtime 这个库提供了一套非常成熟的控制器骨架。你只需要实现一个Reconcile函数它会帮你处理事件入队、workqueue 的速率限制、leader election、指标暴露、日志、健康检查等一堆“脏活累活”。这个库在 Go 生态里已经成了事实标准社区里的 operator-sdk 和 kubebuilder 都是基于它封装的。第三类型安全性。CRD 定义好之后controller-gen 工具能从 schema 生成对应的 Go 结构体。这意味着你在代码里访问cr.Spec.Replicas这类字段时编译器会帮你做类型检查。用 Python 写虽然有动态类型的灵活但也意味着一不小心就把replicas这个 int 字段当字符串拼接进 API 请求里。3.3 两个脚手架工具怎么选operator-sdk 与 kubebuilder提到用 Go 写 Operator绕不开两个工具operator-sdk和kubebuilder。这两个工具做的事情高度重叠初始化项目骨架、生成 CRD 和 CR 的样板代码、生成 controller 的骨架、生成 Dockerfile 和部署清单。我最开始用的是 operator-sdk它的 v1 版本体验相当顺滑对新手更友好命令也比较直白。后来切换到 kubebuilder发现它生成的代码更轻量可读性更好而且在生成 CRD 和 Webhook 时与上游工具链controller-gen衔接得更紧密。如果给你一个直接的选型建议如果你是第一次接触 Operator 开发或者你对“直接写 controller-runtime”没什么信心选 operator-sdk。它的文档更友好模板更丰富并且内置了 Helm 和 Ansible 的 Operator 支持虽然你可能用不到。如果你是个人开发者、希望生成的代码高度可定制、想比较“干净”地看 Controller 的真正实现原理选 kubebuilder。它的默认模板很少有多余封装学完基本就懂 controller-runtime 的核心机制了。不用太纠结选哪个因为带你入门之后本质都是在写 controller-runtime 的代码。我见过用 operator-sdk 生成项目然后手动把里面无用的注解层删掉改得和 kubebuilder 生成的一模一样的人也见过反过来从 kubebuilder 往 operator-sdk 迁移的。核心能力是“会写 controller”工具只是提供了脚手架。4. 动手实现从零搭建一个“定时任务版”的 Operator4.1 场景设定和整体设计为了把前面理论落地我设计一个不太复杂但很有代表性的例子定义一种CronJob之外的定时任务资源我们叫它ScheduleTask。实际的背景是这样的我们团队有大量“整点跑一次数据核对”“每两小时拉一次外部接口数据”这类定时任务。直接用 K8s 原生 CronJob 当然可以但团队希望有一个统一的平台层抽象比如在ScheduleTask里可以配置“并发策略”“失败重试次数”“任务超时时间”并且希望平台自动维护一个TaskRun状态资源记录每次执行的起止时间和结果。这个场景非常典型因为它既有“创建原生资源”的部分为每个 ScheduleTask 创建一个 CronJob又有“维护状态”的部分更新 CR 的 status 字段记录任务运行情况。在设计层面我定义两个资源ScheduleTask描述“我要跑什么任务、什么时候跑、跑成什么样算成功”。TaskRun描述“某一次任务执行的实际结果”由 Operator 自动创建类似 CronJob 对应的 Job。对应的目录结构大致如下基于 kubebuilder 生成project-root/ ├── api/v1/ │ ├── scheduletask_types.go │ ├── taskrun_types.go │ └── zz_generated.deepcopy.go ├── controllers/ │ ├── scheduletask_controller.go │ └── taskrun_controller.go ├── cmd/ │ └── main.go ├── config/ │ ├── crd/ │ ├── rbac/ │ └── manager/ └── Dockerfile4.2 定义 ScheduleTask 这个 CRD在api/v1/scheduletask_types.go里我们定义它的 Spec 和 Statustype ScheduleTaskSpec struct { // 定时表达式比如 0 */2 * * * Schedule string json:schedule // 每次任务运行的容器镜像 Image string json:image // 传给容器的命令如果不填则使用镜像默认入口 Command []string json:command,omitempty // 并发策略Allow / Forbid / Replace ConcurrencyPolicy string json:concurrencyPolicy,omitempty // 失败后重试的最大次数 BackoffLimit int32 json:backoffLimit,omitempty // 单次任务超时秒数超过则认为失败 ActiveDeadlineSeconds int64 json:activeDeadlineSeconds,omitempty } type ScheduleTaskStatus struct { // 最近一次成功运行的时间 LastScheduleTime *metav1.Time json:lastScheduleTime,omitempty // 最近一次运行对应的 TaskRun 名称 LastTaskRunName string json:lastTaskRunName,omitempty // 总共触发了几次任务 TriggerCount int32 json:triggerCount,omitempty // 当前是否有任务正在运行 Active bool json:active,omitempty }这个数据结构很直观。需要特别注意的一点是Status 里的字段必须用omitempty标记并且指针类型或可选类型要优先使用指针否则会在创建对象时因为 status 为空报校验错误。这也是很多初学者在第一次写 CRD 时踩到的坑。生成 CRD 的 YAML 不用手写运行make manifestskubebuilder 会调用 controller-gen根据 Go 结构体中定义的 tag 自动生成对应的 OpenAPI schema并生成到config/crd/目录下。4.3 编写 Reconcile 逻辑从“期望”到“现实”核心代码是controllers/scheduletask_controller.go里的Reconcile方法。我把核心逻辑拆分了一下大致是这样func (r *ScheduleTaskReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger : log.FromContext(ctx) var task api.ScheduleTask if err : r.Get(ctx, req.NamespacedName, task); err ! nil { return ctrl.Result{}, client.IgnoreNotFound(err) } // 1. 如果 CR 被标记为删除执行清理逻辑不做重建 if !task.DeletionTimestamp.IsZero() { return r.handleDeletion(ctx, task) } // 2. 构造期望的 CronJob 对象比较与实际存在的 CronJob 的差异 desiredCronJob : r.buildCronJob(task) var existingCronJob batchv1.CronJob err : r.Get(ctx, client.ObjectKey{Namespace: task.Namespace, Name: task.Name}, existingCronJob) if err ! nil client.IgnoreNotFound(err) ! nil { return ctrl.Result{}, err } // 3. 如果不存在就创建如果存在但配置不一致就更新 if err ! nil { if err : r.Create(ctx, desiredCronJob); err ! nil { return ctrl.Result{}, err } logger.Info(created cronjob, name, desiredCronJob.Name) } else { desiredCronJob.Spec.DeepCopyInto(existingCronJob.Spec) if err : r.Update(ctx, existingCronJob); err ! nil { return ctrl.Result{}, err } logger.Info(updated cronjob, name, existingCronJob.Name) } // 4. 更新 CR 的 Status记录最近一次触发时间和次数 // ... 这里会根据 CronJob 关联的 Job 条件更新 TaskScheduleStatus return ctrl.Result{}, nil }可能你会觉得这段代码看起来没有很复杂。确实一个基础的 Operator 最核心的循环逻辑代码量并不多。真正的复杂度来自对各种边界情况的处理CR 删除时要不要清理关联资源创建 CronJob 失败时要不要重试重试会不会造成重复创建更新时字段冲突怎么处理这些才是 Controller 开发里真正让人头疼的地方。在这个例子里我刻意选择了 CronJob 作为底层资源是因为 CronJob 本身已经处理了“定时触发”这个复杂的调度语义Operator 只需要负责“把我的期望配置翻译成 CronJob 的配置”以及“帮你维护状态”。如果换成写一个真正的 MySQL Operator你需要自己实现节点探活、主从切换、备份恢复、配置变更热加载等逻辑那个复杂度比这个例子大一个数量级。4.4 Setup 与 Webhook别忘了把你写的控制器“接进系统”编写完 Reconcile 逻辑后还需要在main.go里把它注册进 managerfunc main() { // ... 省略各种 flag 解析和日志初始化 ... mgr, err : ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, Metrics: metricsserver.Options{BindAddress: metricsAddr}, }) if err ! nil { setupLog.Error(err, unable to start manager) os.Exit(1) } if err (controllers.ScheduleTaskReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), }).SetupWithManager(mgr); err ! nil { setupLog.Error(err, unable to create controller, controller, ScheduleTask) os.Exit(1) } // 如果需要也可以用代码自动注册 CRD 和 Webhook if err (api.ScheduleTask{}).SetupWebhookWithManager(mgr); err ! nil { setupLog.Error(err, unable to create webhook, webhook, ScheduleTask) os.Exit(1) } if err : mgr.Start(ctrl.SetupSignalHandler()); err ! nil { setupLog.Error(err, problem running manager) os.Exit(1) } }这里有一点值得注意SetupWithManager方法会自动设置控制器监听哪些资源的哪些事件。默认情况下controller-runtime 会对 CR 的 create/update/delete 事件都触发 Reconcile。但如果你还需要监听关联资源比如 CronJob 被其他工具意外修改就需要额外传递Owns(batchv1.CronJob{})之类的参数。这样可以实现“我关联的资源变了我的控制器也要重新检查一遍”的能力。我在实际项目里就是因为漏了这句导致有人手动改了 CronJob 配置后Operator 根本没反应直到发现问题后补上了Owns才恢复。另外一个需要特别注意的是Webhook。如果你的 CRD 里有跨字段校验比如schedule字段要求某种格式或者默认值填充比如用户没写backoffLimit时给一个默认值默认值这种方式用 CRD 的 default 注解就可以实现跨字段校验则需要 Admission Webhook。开发阶段可以用make manifests加make generate自动生成部分代码但部署时需要签发证书并且把 Webhook 配置指向你的 Operator 服务的 HTTPS 端口。如果你是本地用kind或minikube测试可以用cert-manager自动签发证书或者干脆使用make install配合WEBHOOK_CERTS_PATH这类开发参数简化流程。5. 部署、调试与优化真实落地时绕不开的几个环节5.1 本地开发环境别一上来就折腾远端集群我踩过最大的一个坑是最开始写 Operator 时直接在一个生产环境的测试集群里反复迭代。每次改代码都要重新打镜像、推镜像、改 deployment 的 image tag、等待滚动更新一个周期下来至少五分钟。后来才改用kubebuilder提供的本地运行模式make install make runmake install会把 CRD 安装到当前 kubeconfig 指向的集群make run则直接在本地跑 Operator 进程。这样改代码后 CtrlC 再重新make run就行编译和启动三秒内完成。对于确认基础逻辑这是最高效的方式。等逻辑稳定了再 Docker 化部署到测试集群。不过本地运行有一个问题你本地的进程访问集群时使用的是 kubeconfig 里的权限。如果用管理员账号权限不受限制容易掩盖 RBAC 问题如果权限过窄又会频繁遇到Forbidden。我的建议是本地调试时用一个相对宽松的账号等部署到集群后再用 RBAC 清单严格收敛权限。5.2 分发的几种姿势裸 YAML、Helm Chart 还是 OLM当 Operator 开发完成之后你面临一个“怎么把它交付给使用者”的问题。至少有三条路裸 YAML 方式最简单。make deploy就会生成一堆 YAML包括 namespace、ServiceAccount、CRD、ClusterRole、ClusterRoleBinding、Deployment。直接kubectl apply -f config/deploy/就能用。这种方式适合内部小团队缺点是升级时要手动处理 CRD 变更而且没有版本的语义管理。Helm Chart 方式是把这些 YAML 打包成一个 Chart。这样你可以用helm upgrade --install一键安装和升级并且可以在 values.yaml 里暴露一些可配置项比如镜像版本、资源限制、日志级别。我在公司内部就是用的这种方式团队里的其他同学不需要了解 Operator 内部结构只需要修改 values.yaml 即可。第三种是 OLMOperator Lifecycle Manager方式适合要把 Operator 发布到公开的 OperatorHub 场景。它会管理 Operator 的安装、升级、权限变更还支持在集群里通过 UI 点击安装。如果你的 Operator 是面向外部用户的商品型产品OLM 是必要的如果只是内部工具我建议先用 Helm Chart 就好OLM 的 CSVClusterServiceVersion清单写起来挺繁琐的。5.3 可观测性Instrumentation 写好了排查问题少一半Operator 本身是一个常驻进程在生产环境里如果状态卡住你很难知道它内部发生了什么。所以我在实际项目里会在 Reconcile 逻辑里加入以下几类可观测性信息第一是日志。使用 controller-runtime 的 logzap 实现在关键节点输出结构化日志包括资源名、reconcile 耗时、操作结果。这样通过kubectl logs能很快定位到某次修改是成功还是失败。第二是指标。controller-runtime 内置了/metrics端点默认暴露一些控制器运行指标比如 reconcile 的请求数量、错误数、队列深度等。我额外在代码里定义了一个自定义指标用于统计“每次 Reconcile 里创建/更新了哪些资源”然后接入 Prometheus 和 Grafana。这能让你直观看到系统在一段时间内的行为模式是创建多还是更新多哪个资源的错误率最高第三是事件。通过r.EventRecorder.Event(...)往 CR 对象上记录事件。这样用户执行kubectl describe scheduletask xxx时能清楚地看到这个自定义资源的生命周期在哪一步创建了 CronJob在哪一步更新失败在哪一步发生了冲突。这个对使用者来说非常友好因为他们不需要翻 Operator 的日志只靠 describe 就够了。经验给 CR 打事件比打日志更有价值因为日志是给 Operator 开发者看的而事件是给使用者看的。很多优秀 Operator比如 cert-manager、velero都维护了非常详尽的事件记录。6. 我在实战中遇到的 4 个经典故障与对应解法6.1 CR 创建成功但 Reconcile 从未被触发这个问题的现象是kubectl apply返回 successkubectl get也能看到对象但控制器完全没有反应kubectl logs里什么都看不到。排查思路检查控制器是否真的在监听该 CR 的 GroupVersionKind。常见原因是 RBAC 权限缺失——控制器 watch 了资源但 API Server 返回 Forbidden控制器静默失败了。通过kubectl auth can-i watch scheduletasks.platform.example.com --assystem:serviceaccount:operator-system:operator-controller-manager可以快速验证。另一个容易被忽略的原因是SetupWithManager里的For(api.ScheduleTask{})注册了但 manager 启动时 CRD 还没安装成功比如部署顺序问题informer 初始化失败。此时要检查 Operator 的启动日志里面通常会明确提示 informer 获取失败。6.2 更新 CR 的 spec 后控制器无限循环地更新底层资源典型的“期望状态”和“实际状态”不一致导致振荡问题。我在一次实现里把 CronJob 的schedule字段直接来自用户 CR然后每次 Reconcile 都调用 Update。结果发现因为 cronjob 的 schedule 字段在 API Server 存储时会自动格式化成标准格式比如用户写0 2 * * *存成0 2 * * *但注释信息在有差异时导致 reflect.DeepEqual 返回 false于是控制器陷入“每次都觉得需要更新”的循环。解决办法是在比较时不要直接比较整个对象而是挑出核心业务字段进行对比并且在计算 diff 时剔除 K8s 自动填充的元数据字段如resourceVersion、generation、managedFields。其实标准做法是使用apiequality.Semantic.DeepEqual来比较它能够忽略掉某些默认值的差异。6.3 CR 删除后关联的 CronJob 没有一起清理K8s 的垃圾回收机制对原生资源之间是通过 ownerReference 传递的但CR 和它创建的 CronJob 之间并不会自动建立 ownerReference除非你在创建 CronJob 时手动添加。我最初创建 CronJob 时只设置了 name、namespace结果用户删掉 CR 后CronJob 成了“孤儿”继续按旧配置跑任务造成重复执行。正确做法是在构造 CronJob 时添加 OwnerReferenceownerRef : metav1.NewControllerRef(task, api.GroupVersion.WithKind(ScheduleTask)) desiredCronJob.OwnerReferences []metav1.OwnerReference{*ownerRef}这里还有一个小坑如果多个 CR 共享同一个 CronJob比如设计失误ControllerRef 只能设置一个 owner其他 CR 的删除事件不会级联删除 CronJob。所以最好从一开始就设计成一对一的映射关系。6.4 Reconcile 里直接调用了阻塞式操作整个控制器卡死有些人在 Reconcile 函数里直接调用第三方的 HTTP 接口等待响应或者用time.Sleep做延迟重试。这在并发场景下会把 worker 线程全部占满导致控制器整体的处理能力降为零。你可能会想“那我把等待时间设短一点”。但实际上正确的方式是把这种阻塞逻辑拆分出来要么用异步 goroutine 处理要么在 Reconcile 里返回ctrl.Result{RequeueAfter: 30 * time.Second}让 controller-runtime 的 workqueue 在稍后重新触发一次 Reconcile。这种方式不会阻塞其他资源的处理。controller-runtime 的 workqueue 默认会有 rate limit如果你频繁返回 Requeue 并且失败重试的频率会指数退避。这是一个保护机制但它也会让你感到“为什么我改了资源后要等这么久才生效”。如果遇到这种情况可以通过调整Options里的RateLimiter来覆盖默认配置。7. 更进一步从“能跑”到“好用”的细节打磨7.1 用 Finalizer 处理优雅清理在删除 CR 时如果关联资源比较复杂比如需要调用外部 API 通知下游系统简单地依赖 ownerReference 级联删除是不够的。因为级联删除只负责删 K8s 对象但不负责执行“通知外部系统”“注销 DNS 记录”这类外部副作用。这时候需要给 CR 添加 Finalizer。Finalizer 是 K8s 提供的一个机制CR 被删除时deletionTimestamp会被设置但对象不会立即消失直到所有 Finalizer 都被移除。你的 Reconcile 里可以这样写if !task.DeletionTimestamp.IsZero() { if controllerutil.ContainsFinalizer(task, myFinalizer) { // 执行外部清理逻辑 if err : r.cleanupExternalResources(ctx, task); err ! nil { return ctrl.Result{}, err } // 清理成功后才移除 Finalizer controllerutil.RemoveFinalizer(task, myFinalizer) if err : r.Update(ctx, task); err ! nil { return ctrl.Result{}, err } } return ctrl.Result{}, nil }这里需要注意如果你在 Finalizer 清理逻辑里调用了外部 API而外部 API 挂了Finalizer 永远不会被移除CR 就会一直卡在 Terminating 状态。所以清理逻辑要设计成幂等且可重试的并且最好有超时控制的机制。7.2 Status 子资源不要直接更新主对象K8s 从 1.19 开始支持了 status 子资源。CRD 定义里subresources.status这个字段一旦启用spec和status会分开存储。也就是说你通过kubectl apply更新 spec 不会影响 status反之通过控制器更新 status 也不会误改 spec。这在多人协作或自动化工具并发操作的场景下非常有用。在代码里更新 Status 需要使用下面这种方式err : r.Status().Update(ctx, task)而不是err : r.Update(ctx, task)如果直接用r.Update更新整个对象很容易因为resourceVersion冲突导致写失败——尤其是在 Reconcile 过程中既有 Spec 变更又有 Status 变更的情况下。这个细节我踩过几次坑后来已经形成肌肉记忆了凡是只改 status一律走 Status().Update()。7.3 多版本 CRD 的转换 Webhook前面提过 CRD 版本管理的问题。这里再补充一个实操细节如果你的 CRD 声明了v1和v1alpha1两个版本K8s 在存储时只会存一个版本通常是最新稳定版。当用户用v1alpha1提交资源API Server 会通过 conversion webhook 把它转成v1再存入 etcd读取时再转回用户请求的版本。实现转换 Webhook 的方式是定义一个实现了ConversionHub和Convertible接口的类型。controller-gen 会生成对应的模板。你只需要在后者里实现ConvertTo和ConvertFrom把字段从一个版本复制到另一个版本。有一个容易踩的坑Webhook 服务在转换时如果目标版本存在源版本没有的字段你需要定义默认值。比如v1有concurrencyPolicy字段v1alpha1没有那么从v1alpha1转成v1时你要明确给一个默认值否则转换后的资源可能会因为缺少必填字段而无法通过校验。8. 写在最后Operator 开发中我体会最深的三件事第一CRD 的设计决定了 Operator 的上限。很多人在写 Operator 时恨不得把所有业务逻辑都塞进 CR 里导致 CR 的 Spec 膨胀得像个“万能配置中心”。但真正好的设计是让 CR 像一份“合同”——它只描述用户关心的意图比如“我要 3 个副本”至于“如何创建 PVC”“如何配置负载均衡”这些都是 Operator 内部实现细节不该暴露给用户。如果 CR 里充满了enableA、enableB、customFlag一类的开关你很难把它用好也很难长期维护。第二Reconcile 逻辑必须幂等。一份 CR 可能被 Reconcile 十次、百次、千次结果应当是一样的。如果你在 Reconcile 里写了“每次执行生成一个资源名”那第二次 Reconcile 时就会产生不同的结果系统就失控了。写 Operator 时可以在心里默念这句话“无论我在哪个阶段中断重试之后都必须回到轨道上”。这是几乎所有 Operator 事故的根源。第三不要重复造轮子。很多看似很酷的能力K8s 生态里其实已经有成熟实现了。比如你要做滚动更新Deployment 本身就支持你要做多集群分发karmada、open-cluster-management 早就帮你做了你要做 GPU 资源调度NVIDIA 官方提供的 GPU Operator 已经覆盖了设备插件、驱动管理、监控等一系列能力。你真正需要写的通常是和你的业务强相关的那部分逻辑。把每个 Operator 项目都当成从零开始“重新发明一遍 K8s”的机会只会让你的系统变得又大又脆。如果你打算认真走这条路建议先去读一读 controller-runtime 的源码再找一个成熟的开源 Operator比如 cert-manager、prometheus-operator看它的组织方式。不用照搬但能被验证过的代码里学到非常多文档里不会写的东西。CRD 和 Operator 的组合确实能在云原生时代给复杂应用的“自动化运维”提供一条非常扎实的落地方案但它需要你沉下心来把声明式 API 的思维方式内化成自己的习惯。
返回列表