学习目标
学完本章,你应该能够:
- 用自己的话说清 Operator 是什么、解决什么问题——本质就是把"运维人员的操作经验代码化",把复杂有状态应用的部署、升级、备份、恢复、故障自愈变成声明式配置。
- 讲清 CRD 与 Controller 的分工,以及"期望状态 vs 实际状态 + 控制循环"这套核心心智,理清
Reconcile的四种返回语义。 - 分清两大开发框架 kubebuilder 与 operator-sdk 的定位差异,并看懂 kubebuilder 的 Manager / Controller / Reconciler / Source / WorkQueue 内部架构。
- 用 kubebuilder 照着
KubeVela Application的思路,实现一个能用一个 CR 同时管理 Deployment / Service / Ingress 三个资源的 Operator,并理解ownerReference的级联删除。 - 用 kubebuilder 模仿阿里云定时弹性伸缩器,实现一个带 Cron 表达式的
CronHPA定时扩缩容 operator,通过additionalPrinterColumns让kubectl get展示更多列。 - 用 operator-sdk 的 Helm 模式把一个现成的 Helm Chart 直接变成 Operator,并把 operator 用 OLM(Operator Lifecycle Manager)打包、下发、部署。
- 掌握 Operator 开发的几条最佳实践(幂等 reconcile、只关注期望与当前状态),并用 envtest 做无需真实集群的端到端测试。
前置知识:
- 基本 K8s 概念:Pod、Deployment、Service、Ingress、Workload 是什么。
- 会用
kubectl apply、kubectl get、kubectl delete操作资源。 - 会写一点 Go(struct、interface、方法),不需要很熟——跟着抄就行。
- 知道 YAML 基本格式,知道什么叫
apiVersion / kind / metadata / spec。 - 听过
KubeVela、HPA、Helm、Cron这些词即可,不熟也没关系,本章会展开。
本章你会动手做的事:
- 用
kind在本地拉起一个测试集群,kubebuilder init+kubebuilder create api生成 Operator 骨架。 - 实现一个
ApplicationCRD,用一份配置同时生成 Deployment / Service / Ingress,并验证 ownerReference 的级联删除。 - 实现一个
CronHPACRD,用 Cron 表达式定时扩缩容 Deployment,观察make run日志与副本数变化(还会踩一两个坑)。 - 用 operator-sdk 的 Helm 插件把 redis Chart 变成 Operator,处理权限告警。
- 用 OLM 把 operator 打包成 bundle 镜像并在集群下发。
- 用 envtest 写一个不需要真实集群的端到端测试。
先建立直觉:这一章的"主线"是什么
本章其实就是围绕一句话展开——把一个人肉运维的活,交给一个跑在 K8s 里的"机器人"去干,而这个机器人就是我们说的 Operator。前面的笔记我们学了很多 K8s 概念,但那些都是 K8s 原生的能力;这一章开始,我们进入"自己给 K8s 造插件"的阶段:定义你自己的资源(CRD),再写一个控制器去盯着它干活。
这章干货很多、有大量实战,但骨架并不复杂。你只要一直记住 Operator = Controller(大脑,负责对账)+ CRD(数据结构,负责定义期望) 这条主线,后面的例子全都是往这个骨架上填肉。
什么是 Operator
建立直觉:Operator 是一个"会自动收敛的"机器人
Operator 到底是什么
Operator 本质上是一种特殊的控制器。它把这个"控制循环"的机制应用到了**自定义资源(CRD)**的状态管理上。一句话:
Operator = Controller + CRD
这个概念最早由 CoreOS 公司在 2016 年前后提出(后来 CoreOS 被 Red Hat 收购)。它的工作流程大致是这样的:
用户 apply 一个 CR -> API Server 存进 etcd
-> Operator Watch 到这个 CR 的变更
-> Controller 创建/更新原生子资源(Deployment / ConfigMap / Service / StatefulSet)
-> 监听创建结果 -> 根据结果更新 CR 的 status 字段
其中:
- CRD(Custom Resource Definition):用它来描述一个全新的资源类型。它是一种声明式的扩展方式——你要声明一个新的
kind(比如一个定时伸缩的 kind),这个 kind 的 spec 里有哪些字段,全部都是通过声明出来的,而不是写代码硬编码的。 - Controller:负责监听 CRD 的变更,执行对账逻辑,让期望状态与实际状态最终保持一致。
期望状态 vs 实际状态
在深入 Controller 之前,得先搞清楚两个贯穿始终的概念:
- 期望状态(Desired State):就是资源对象里
spec字段描述的情况。比如一个 Deployment 的 spec 里声明了"我要 3 个副本、镜像是 xxx",这就是期望状态。 - 实际状态(Actual State):正常情况下我们要主动去查询工作负载当前的真实状态;但为了对账方便,我们通常还会在 CR 的
status字段里把实际状态记录下来,这样期望状态和实际状态就能很容易地对比。
控制循环(Control Loop) 是 Controller 的核心,它实现的就是让"实际状态"和"期望状态"最终保持一致。注意是"最终"——因为收敛是有过程的,可能一开始副本是 2,期望是 5,控制循环会一步步把副本补到 5,并且越接近目标越精细。
用一张图总结这个闭环:
flowchart LR
U[用户在 YAML 里声明 spec 期望状态] --> API[API Server 写入 etcd]
API --> W[Controller Watch 变更]
W --> C{实际状态 == 期望状态?}
C -->|否| R[Reconcile 创建 更新 子资源]
R --> S[读实际状态 写入 status]
S --> C
C -->|是| D[什么都不做 保持现状]为什么需要 Operator
K8s 原生自己带的控制器(比如 Deployment、StatefulSet 的内置控制器)能做的事是有限的——它只能处理 K8s 原生资源,处理不了你自定义的、跟具体业务相关的运维需求。所以当你需要扩展时,就可以用 Operator 这种模式去实现。
用 Operator 模式开发有这几大好处:
- 复用 Controller 的控制循环:控制循环的内部逻辑其实挺复杂的,但你用 Operator 框架之后,就不需要自己实现控制循环了——你只需要关注你自己的业务逻辑怎么写。
- 复用 K8s 声明式 + 资源管理能力:所有对象都存 etcd,CRD 本质上也是一个对象,你可以直接用
kubectl对这个 CR 做增删改查。 - 简化有状态应用的管理:尤其是有状态应用(数据库、中间件),这是 Operator 最能发挥的地方。
- 继承 K8s 的应用管理能力:Operator 本身也是一个跑在 K8s 平台上的工作负载,所以它自动获得了 K8s 提供的自愈、滚动更新、自动重启等能力。
Operator 的典型使用场景
- 把数据库/中间件实例定义成 CRD:比如 Redis、CockroachDB、ClickHouse……你只要声明一个 CR 并部署进集群,就能直接得到一个对应的数据库或中间件实例,甚至云资源(还记得之前讲的 Crossplane 吗?它就是把 VPC、云数据库这类云资源声明成 CRD)。类似 CapThet。
- 自动化的运维任务:备份数据库、备份 K8s 上的任意资源等,任何业务逻辑都能借助 Operator 实现。
- CI/CD workflow:把流水线也做成自定义资源。
- 存储系统管理 等。
Operator 开发框架:kubebuilder 与 operator-sdk
主流的 Operator 开发框架有两个:
- kubebuilder:K8s 社区(SIG)主导的官方框架,底层依赖
controller-runtime和controller-tools这两个核心库。 - operator-sdk:Red Hat 主导。它跟 kubebuilder 类似,底层其实复用了 kubebuilder 的代码生成能力,但额外封装了一些能力——最典型的就是提供 OLM(Operator Lifecycle Manager),让 operator 的打包、分发、部署更容易。
一个关键点:kubebuilder 是整个 Operator 开发的核心底座。无论你用 kubebuilder 还是 operator-sdk,本质底层都是那套东西。operator-sdk 更多是能力的叠加。
对比一下:
kubebuilder operator-sdk
────────────── ─────────────────── ─────────────────────
主导方 Kubernetes SIG Red Hat
语言支持 Go Go / Ansible / Helm
底层依赖 controller-runtime 复用 kubebuilder + 更多封装
与 OLM 集成 弱 强(OLM 本来就是它家的)
部署承载方式 打成 kustomize / helm 可直接打成 OCI bundle,配合 OLM
定位 核心脚手架 更完整的一站式能力
operator-sdk 除了用 Go 开发,还支持 Ansible 和 Helm 两种特殊模式——可以基于一只 Ansible 脚本或一个 Helm Chart 直接生成一个 operator。这章最后一个实战就用 Helm 模式来做。
深入 kubebuilder 架构
既然 kubebuilder 是底座,我们把它的架构拆开看一下。它的架构主要分成这么几大模块:
- Manager:用来初始化 Controller 使用的(Controller 的生命周期归它管)。它还管着 cache、client、metrics 这些公共组件。
- Controller:是核心调度器,具备 cache(缓存)、工作队列(WorkQueue) 和失败重试的能力。它内部封装了 Reconciler。
- Reconciler:这就是我们自己真正要写业务代码的地方。循环控制的业务逻辑全部写在这个
Reconcile函数里。 - Source(informers 缓存) + Client / Cache:一般我们不直接使用 Source、Client、Cache,了解即可。Source 主要基于 informers 监听资源变化。
- Webhook:用来开发 admission webhook(准入控制器),本章不展开。
整个事件流转是这样的:
flowchart LR
S[Source 基于 informers
监听资源变化] --> Q[工作队列 WorkQueue]
Q --> W[Controller 订阅队列
processNextWorkItem 出队]
W --> R[Reconciler
咱写的业务逻辑]
R -->|返回结果决定是否重试| Q
R -->|错误| W[重新入队 重试]
R -->|RequeueAfter| Q[延迟入队]Controller 从 Source 拿到变更后,把元素丢进工作队列,然后由 processNextWorkItem 方法出队,再去调用 Reconciler 的业务逻辑。Reconciler 的返回值决定了这条消息要不要重新入队——这跟你之前用 client-go 手写一个简单控制器很像,只是这里所有底层细节都帮你封装好了,还有自动代码生成帮你把架子搭好,你只管写业务。
Reconcile 的四种返回
触发 Reconcile 的过程,本质上就是从工作队列里取元素的过程。根据你业务逻辑的执行结果,会有四种情况,通过返回 ReconcileResult + error 来控制:
- 执行成功,且不需要再重试:返回
ctrl.Result{}且error = nil。此时这条消息会从工作队列里删除,事件消费完毕。 - 执行错误,需要重试:返回一个非 nil 的
error。消息会被重新放回工作队列,重新消费,并能拿到重试次数。 - 执行成功,但还有其他原因需要重试:在
Result里给Requeue: true,error返回 nil。这相当于标记"把我重新入队",好处是你还能从队列里拿到这条消息被消费/重新入队的次数。 - 需要等一段时间再重试(延迟):在
Result里给RequeueAfter: 5 * time.Second这种值,本质就是一个延迟队列。定时任务场景(比如后面 CronHPA)就靠它。
case 1 成功,无需重试 -> Result{} , nil (从队列删除)
case 2 失败,需要重试 -> Result{}, err (立即重新入队)
case 3 成功,但要重试 -> Result{Requeue:true}, nil(重新入队,可查次数)
case 4 延迟一段时间重试 -> Result{RequeueAfter:5s}, nil(延迟队列)
这张流程图把"四种返回"画成 Reconcile 结束时的一次决策:按返回值和 error 走向四条不同的路,每一条路都决定了这条消息是出队、立即重试,还是隔一段时间再回来。
flowchart TB
R[Reconcile 执行完] --> D{取哪种返回}
D -->|Result 空 且 nil| S[从队列删除
事件消费完毕]
D -->|error 非 nil| E[立即重新入队
可取重试次数]
D -->|Requeue: true| Q[重新入队
标记再试一次]
D -->|RequeueAfter: 5s| W[延迟再入队
定时对账循环]实战一:kubebuilder 实现类似 KubeVela Application 的定义
理论讲完了,开始动手。第一个实战是模仿 KubeVela 的 Application 定义:我们用一个 CRD 资源,一次性管理三个子资源——Deployment、Service、Ingress。你创建这个 CR,Operator 就帮你创建这三个资源;你删掉这个 CR,它就帮你删掉这三个资源。是不是很像 KubeVela 帮你"一键编排应用"的思路?
准备环境
先在本地用 kind 起一个集群(一句话就得到一个集群):
# 按官方文档 install kind(前提有 docker)
kind create cluster
kubectl get nodes # 能看到一个单节点集群
# 安装 kubebuilder CLI
# 按官方文档安装即可(示例 3.x 版本)
kubebuilder version
初始化项目骨架
mkdir application && cd application
# 初始化 Go module
go mod init application.co/application
# 用 kubebuilder 初始化项目布局,--domain 决定 API group 的后缀
# 这里演示用 co 作为 domain,生产环境换成你自己的域名段
kubebuilder init --domain co \
--repo application.co/application \
--license apache2
初始化完,项目里会出现一大堆目录和文件——这些都是 kubebuilder 自动生成的,后面我们会一一解释。
创建 API(CRD + Controller 一起生成)
kubebuilder create api --group application --version v1 --kind Application
它会交互式问你两个问题,都要选 yes:
Create Resource under [application.co/application]? # 生成 CRD -> y
Create Controller under [application.co/application]? # 生成 Controller -> y
这样就会生成 API 目录,里面核心就是我们定义自定义资源数据结构的 api/v1/application_types.go 文件。我们只需要改这个文件和 Controller 里的 Reconcile 业务逻辑,其他文件都是自动生成、不用手改的。
设计 CRD:写 types.go
打开 api/v1/application_types.go。我们需要的是 spec 里有三个字段:deployment、service、ingress。其中 service 和 ingress 我们直接引用 K8s 原生的 Service / Ingress 的 Spec 结构体,省得重复定义。
package v1
import (
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// ApplicationDeploymentSpec 自定义的 Deployment 定义(简化)
type ApplicationDeploymentSpec struct {
Image string `json:"image"`
Replicas *int32 `json:"replicas,omitempty"`
Port int32 `json:"port"`
}
// ApplicationSpec 定义 Spec:deployment / service / ingress
type ApplicationSpec struct {
// Foo 是 kubebuilder 给的示例,删掉
Deployment *ApplicationDeploymentSpec `json:"deployment"`
// 直接复用原生 Service 的 Spec
Service *corev1.ServiceSpec `json:"service,omitempty"`
// 直接复用原生 Ingress 的 Spec
Ingress *networkingv1.IngressSpec `json:"ingress,omitempty"`
}
// ApplicationStatus 记录实际状态,这里放一个可用副本数
type ApplicationStatus struct {
AvailableReplicas int32 `json:"availableReplicas"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
type Application struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ApplicationSpec `json:"spec,omitempty"`
Status ApplicationStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
type ApplicationList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Application `json:"items"`
}
func init() {
SchemeBuilder.Register(&Application{}, &ApplicationList{})
}
下面这个 ApplicationDeploymentSpec 之外,ApplicationList 这类都是自动生成样板,照抄即可。
生成 CRD YAML
写完 types.go,改好字段,就需要让 kubebuilder 根据类型定义自动生成 CRD 文件:
make manifests
跑完后,config/crd/bases/ 下就会多出一个自动生成的 CustomResourceDefinition YAML(application.co_applications.yaml 之类)。这个文件我们不需要管,它包含了我们刚定义的 image、replicas,也把我们引用的整个 ServiceSpec、IngressSpec 都带了进来。以后真要改字段,改 types.go 再重跑 make manifests 就行。
编写 Reconcile 业务逻辑
现在来到本节最重要的一步:在 internal/controller/application_controller.go 里写 Reconcile。kubebuilder 已经给我们预留好了这个函数,并且帮我们封装好了获取 CRD、监听变更等所有琐事。我们只需要:
- 把 CR 获取到手;
- 根据 CR 里的 spec,创建或更新 Deployment / Service / Ingress 三个对象;
- 删除逻辑不用单独写——后面用
ownerReference自动处理。
package controller
import (
"context"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
applicationv1 "application.co/application/api/v1"
"github.com/go-logr/logr"
)
type ApplicationReconciler struct {
client.Client
Scheme *runtime.Scheme
}
func (r *ApplicationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := log.FromContext(ctx)
// 1. 获取我们的 CR(Application)
var app applicationv1.Application
if err := r.Get(ctx, req.NamespacedName, &app); err != nil {
// 拿不到可能是被删,不用重试
return ctrl.Result{}, client.IgnoreNotFound(err)
}
log.Info("application fetched", "name", app.Name)
// 给子资源准备统一的 label
labels := map[string]string{"app": app.Name}
// 2. 创建或更新 Deployment
if app.Spec.Deployment != nil {
dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: app.Name, Namespace: app.Namespace}}
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, dep, func() error {
replicas := app.Spec.Deployment.Replicas
if replicas == nil {
t := int32(1)
replicas = &t
}
dep.Spec.Replicas = replicas
dep.Spec.Selector = &metav1.LabelSelector{MatchLabels: labels}
dep.Spec.Template = corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: app.Name,
Image: app.Spec.Deployment.Image,
Ports: []corev1.ContainerPort{{ContainerPort: app.Spec.Deployment.Port}},
}},
},
}
// 标记归属:让 CR 成为这个 Deployment 的所有者
return controllerutil.SetControllerReference(&app, dep, r.Scheme)
})
if err != nil {
return ctrl.Result{}, err
}
log.Info("deployment created/updated")
}
// 3. 创建或更新 Service(直接复用 CRD 里定义的 ServiceSpec)
if app.Spec.Service != nil {
svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: app.Name, Namespace: app.Namespace}}
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, svc, func() error {
svc.Spec = *app.Spec.Service
return controllerutil.SetControllerReference(&app, svc, r.Scheme)
})
if err != nil {
return ctrl.Result{}, err
}
log.Info("service created/updated")
}
// 4. 创建或更新 Ingress
if app.Spec.Ingress != nil {
ing := &networkingv1.Ingress{ObjectMeta: metav1.ObjectMeta{Name: app.Name, Namespace: app.Namespace}}
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, ing, func() error {
ing.Spec = *app.Spec.Ingress
return controllerutil.SetControllerReference(&app, ing, r.Scheme)
})
if err != nil {
return ctrl.Result{}, err
}
log.Info("ingress created/updated")
}
// 5. 更新 status(把 Deployment 的实际副本数写进去)
_ = r.updateStatus(ctx, &app)
return ctrl.Result{}, nil
}
func (r *ApplicationReconciler) updateStatus(ctx context.Context, app *applicationv1.Application) error {
var dep appsv1.Deployment
if err := r.Get(ctx, types.NamespacedName{
Name: app.Name, Namespace: app.Namespace}, &dep); err == nil {
app.Status.AvailableReplicas = dep.Status.AvailableReplicas
_ = r.Status().Update(ctx, app)
}
return nil
}
// SetupWithManager 注册 controller:盯 Application 这个 kind,并 Owns Deployment
func (r *ApplicationReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&applicationv1.Application{}).
Owns(&appsv1.Deployment{}).
Complete(r)
}
需要 metav1 的 import,这里为了排版省略,实际补上即可。
几个关键点:
controllerutil.CreateOrUpdate:它帮你判断"资源不存在就创建、存在就更新",这是幂等对账的核心工具。它会先尝试 Get,拿不到就走 create 的 mutate,拿到了就走 update 的 mutate。controllerutil.SetControllerReference:给子资源打上ownerReference,标记"我是被这个 CR 管的"。这是级联删除的关键——你删 CR,子资源会自动跟着删,所以我们的 Reconcile 里根本不用写删除逻辑。这是 ownerReference 自带的好处。
部署到本地集群
先装 CRD,再起本地运行,最后 apply 示例:
# 1. 把 CRD 安装进 kind 集群(用 kustomize 方式部署 config/crd)
make install
# 2. 本地跑 operator(还没打成镜像,就在本机 make run)
make run
# 3. 写一个示例 CR:config/samples/application_v1_application.yaml
示例 CR 长这样(对应前面设计的三个字段):
apiVersion: application.co/v1
kind: Application
metadata:
name: application-sample
spec:
deployment:
image: nginx:latest
replicas: 1
port: 80
service:
ports:
- port: 80
targetPort: 80
protocol: TCP
ingress:
ingressClassName: nginx
rules:
- host: aiops.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: application-sample
port:
number: 80
apply 之后验证:
kubectl apply -f config/samples/application_v1_application.yaml
kubectl get deployment
kubectl get service
kubectl get ingress
# 三个名为 application-sample 的资源都出来了,说明 operator 符合预期
再验证更新和级联删除:
# 更新:改镜像版本后再次 apply,Deployment 的 image 应该跟着变
kubectl get deployment -o yaml
# 级联删除:这时 operator 并没写删除逻辑,但删 CR 三个资源会一起消失
kubectl delete -f config/samples/application_v1_application.yaml
kubectl get deployment # 没了
kubectl get service # 没了
kubectl get ingress # 没了
亲眼看到 ownerReference 的威力了吧?这就是实战一的核心收获。
实战二:kubebuilder 实现阿里云定时弹性伸缩器
第二个实战,做一个类似阿里云定时弹性伸缩器(或者说模拟 K8s 原生 HPA 之外的一种"定时水平扩容")的 Operator,我们给它起名叫 CronHPA:让一个 Deployment 按照 Cron 表达式定时扩缩容。
CRD 设计
kind: CronHPA
spec:
scaleTargetRef: # 要操作哪个工作负载
apiVersion: apps/v1
kind: Deployment
name: nginx
jobs: # 定时伸缩任务(数组)
- name: scaleUp
schedule: "*/1 * * * *" # 每分钟执行(测试用,生产可写每天11点等)
targetSize: 3
这样一个 CRD 就可以定时地把目标 Deployment 扩容到指定副本数。
创建项目与 API
mkdir cronhpa && cd cronhpa
go mod init autoscaling.co/cronhpa
kubebuilder init --domain co --repo autoscaling.co/cronhpa --license apache2
kubebuilder create api --group autoscaling --version v1 --kind CronHPA
# Create Resource -> y, Create Controller -> y
写 types.go
package v1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// ScaleTargetReference 指定目标工作负载
type ScaleTargetReference struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Name string `json:"name"`
}
// CronJobSpec 一个定时任务
type CronJobSpec struct {
Name string `json:"name"`
Schedule string `json:"schedule"` // cron 表达式
TargetSize int32 `json:"targetSize"` // 目标副本数
}
// CronHPASpec 定义 spec
type CronHPASpec struct {
ScaleTargetRef ScaleTargetReference `json:"scaleTargetRef"`
Jobs []CronJobSpec `json:"jobs"`
}
// CronHPAStatus 记录实际状态
type CronHPAStatus struct {
CurrentReplicas int32 `json:"currentReplicas"`
LastScaleTime *metav1.Time `json:"lastScaleTime,omitempty"`
// 每个 job 各自的最后运行时间
LastRunTime map[string]string `json:"lastRunTime,omitempty"`
}
为了让 kubectl get cronhpa 时能展示更多列(而不是只有 NAME / AGE),我们给 status 字段加上 kubebuilder 的 printcolumn 注解。可以换个位置,直接把注解写在 CronHPA 结构体上方:
// +kubebuilder:object:root=true
// +kubebuilder:printcolumn:name="Target",type=string,JSONPath=".spec.scaleTargetRef.name",description="目标工作负载"
// +kubebuilder:printcolumn:name="Schedule",type=string,JSONPath=".spec.jobs[0].schedule",description="Cron 表达式"
// +kubebuilder:printcolumn:name="TargetSize",type=integer,JSONPath=".spec.jobs[0].targetSize",description="目标副本数"
type CronHPA struct { ... }
然后重新生成:
make manifests
编写 CronHPA 的 Reconcile
创建/更新逻辑跟实战一类似,重点是定时的对账逻辑。我们先引入一个解析 cron 的 Go 库(音频里说的 “CRN” 其实是 cron):
go get github.com/robfig/cron/v3
核心思路:遍历 Jobs,读取该 job 上次运行的 lastRunTime(存在 status 里),算出"下一次调度时间",判断当前时间是否已经到达/超过计划时间;是就去更新目标 Deployment 的副本数。并设置 RequeueAfter,让它像一个"定时器"一样周期性地被触发。
这张时序图展示 CronHPA 如何把自己变成"定时器":每次 Reconcile 都算出下一个调度时间并设置 RequeueAfter,触发下一次,形成周而复始的对账循环。
sequenceDiagram
participant K as Kubernetes
participant CR as CronHPA CR
participant R as Reconciler
participant D as Deployment
K->>CR: 生成/更新 CronHPA
CR->>R: 触发 Reconcile
R->>R: 遍历 Jobs 算 nextScheduleTime
R->>D: 到达计划时间则更新副本数
R->>R: RequeueAfter=距下次的间隔
R-->>K: 返回 Result 延迟入队
K->>CR: 时间到 再次触发 Reconcile
CR->>R: 周而复始对账循环func (r *CronHPAReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := log.FromContext(ctx)
log.Info("cronhpa reconciling")
// 获取 CR
var chp autoscalingv1.CronHPA
if err := r.Get(ctx, req.NamespacedName, &chp); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
now := time.Now()
// 记录下一次应该运行的时间
var nextRunTime *time.Time
// 遍历每个 job,检查调度时间并更新目标副本数
for _, job := range chp.Spec.Jobs {
// 取上次运行时间(可能有不同 job 分别记录)
var lastRunTime *time.Time
if t := chp.Status.LastRunTime[job.Name]; t != "" {
if parsed, err := time.Parse(time.RFC3339, t); err == nil {
lastRunTime = &parsed
}
}
log.Info("check job", "job", job.Name, "lastRunTime", lastRunTime)
nextScheduleTime, err := r.getNextScheduleTime(job.Schedule, lastRunTime)
if err != nil {
return ctrl.Result{}, err
}
// 当前时间已到达/超过计划时间 -> 更新副本数
if now.After(*nextScheduleTime) || now.Equal(*nextScheduleTime) {
log.Info("update job", "name", job.Name, "targetSize", job.TargetSize)
if err := r.updateDeploymentReplicas(ctx, &chp, &job); err != nil {
log.Error(err, "failed to update replicas")
return ctrl.Result{}, err
}
chp.Status.LastScaleTime = &metav1.Time{Time: now}
chp.Status.CurrentReplicas = job.TargetSize
// 记录该 job 的最后运行时间
if chp.Status.LastRunTime == nil {
chp.Status.LastRunTime = map[string]string{}
}
chp.Status.LastRunTime[job.Name] = now.Format(time.RFC3339)
}
// 计算下一次运行时间(从现在起)
next := r.getNextScheduleTime(job.Schedule, &now)
if next == nil { continue }
if nextRunTime == nil || next.Before(nextRunTime) {
nextRunTime = next
}
}
_ = r.Status().Update(ctx, &chp)
// 关键!用 RequeueAfter 实现"定时器":到了时间再触发一次
requeueAfter := time.Hour
if nextRunTime != nil {
d := nextRunTime.Sub(now)
if d < 0 {
d = time.Second // 已经过期,1 秒后就再试
}
requeueAfter = d
}
log.Info("will requeue", "after", requeueAfter)
return ctrl.Result{RequeueAfter: requeueAfter}, nil
}
// getNextScheduleTime 用 robfig/cron 解析并计算下次调度时间
func (r *CronHPAReconciler) getNextScheduleTime(schedule string, after *time.Time) (*time.Time, error) {
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
sched, err := parser.Parse(schedule)
if err != nil {
return nil, err
}
t := sched.Next(*after)
return &t, nil
}
// updateDeploymentReplicas 更新目标 Deployment 的副本数
func (r *CronHPAReconciler) updateDeploymentReplicas(ctx context.Context, chp *autoscalingv1.CronHPA, job *autoscalingv1.CronJobSpec) error {
dep := &appsv1.Deployment{}
if err := r.Get(ctx, types.NamespacedName{
Name: chp.Spec.ScaleTargetRef.Name, Namespace: chp.Namespace}, dep); err != nil {
return err
}
// 已经是目标值就什么都不做(幂等)
if dep.Spec.Replicas != nil && *dep.Spec.Replicas == job.TargetSize {
return nil
}
dep.Spec.Replicas = &job.TargetSize
return r.Update(ctx, dep)
}
部署和验证(会踩两个坑)
make install
make run
先造一个 nginx Deployment(副本 1),再 apply 一个 CronHPA:
apiVersion: autoscaling.co/v1
kind: CronHPA
metadata:
name: cronhpa-sample
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: nginx
jobs:
- name: scaleUp
schedule: "*/1 * * * *" # 每分钟
targetSize: 3
坑一:Cron 表达式报错 expected exactly 5 fields, found 5 类错误。第一次演示时我把表达式写成了七段(多了秒那段),会报 expected exactly 5 fields, found ...。标准 CronHPA 常规写法是五段(分 时 日 月 周),别多写。
坑二:漏写了 RequeueAfter / 收尾状态更新。第一次跑的时候,我只写了"到点更新副本",忘掉了最后那段"计算 nextRunTime 并 RequeueAfter 重新入队"的逻辑,结果一分多钟后不会再次伸缩。补上 return ctrl.Result{RequeueAfter: ...} 之后,日志里就会出现 RequeueAfter,Deployment 会严格按分钟反复把副本数拉回目标值。
验证现象:
kubectl get cronhpa # 能看到 Target / Schedule / TargetSize 列(printcolumn 生效)
kubectl get deploy nginx # 副本从 1 -> 3(定时扩容)
kubectl scale deploy nginx --replicas=1 # 人为缩到 1
# 等一分钟,它又被拉回 3 —— 定时伸缩能力达成
这个实战里 RequeueAfter + cron 解析 + LastRunTime 状态记录是三个核心点,逻辑比实战一复杂不少。若要上生产,还要处理时区、任务去重、并发等更多细节。
把 kubebuilder Operator 打包部署到集群
实战一、二我们都是在本机 make run 跑 operator。真正生产要把它打成镜像丢到集群里。步骤:
# 1. 设 IMG 环境变量(含镜像仓库路径 + 版本号)。
# 省略 docker.io 前缀时默认推 Docker Hub。
export IMG=docker.io/<your-name>/application-operator:v0.1
# 2. 构建并推送 operator 镜像(项目根目录有自动生成的 Dockerfile,make 帮你简化)
make docker-build # 若本机有 docker-daemon
make docker-push
# 3. 生成一个"一键部署"的 install 清单文件,输出到 dist/ 目录
make build-installer
# dist/install.yaml 里包含 namespace、CRD、ServiceAccount、ClusterRole、
# ClusterRoleBinding、operator 自身的工作负载(Deployment)……整套都齐了
kubectl apply -f dist/install.yaml
这样 operator 就作为一个 Deployment 跑进集群了。你拿着 dist/install.yaml 这一个文件,就可以在任何集群里安装这个 operator。
实战三:operator-sdk 基于 Helm 开发 Operator
operator-sdk 支持一种很特殊的构建方式:直接把一个现成的 Helm Chart 变成一个 Operator。你只要声明一个 CR,就能把一个数据库/中间件实例(比如 Redis)自动部署进集群。它会把 Helm Chart 里 values.yaml 的配置自动映射到 CRD 的 spec 下。
初始化 Helm-based operator
# 先安装 operator-sdk CLI
# 创建一个 redis-operator 目录
mkdir redis-operator && cd redis-operator
go mod init redis.co/redis-operator
# 初始化,注意插件要指定为 helm
operator-sdk init --domain co --plugins=helm
# 创建 API:_--helm-chart 指定一个 Helm Chart。这里先给仓库地址下载
# 第一次可能因依赖构建报错:进 chart 目录手动 build 依赖
operator-sdk create api --group app --version v1 \
--kind Redis --helm-chart=embed/prefix? # 略,实际写 chart 源
视频中因为下载 chart 时依赖没构建,报了一个错误,处理方式是进 helm-charts/redis/ 目录执行 helm dependency build 把子依赖补上,然后重新 create api 时直接指定本地的 chart 目录即可修复。
第二步正确写法(指定本地 chart 目录):
operator-sdk create api --group app --version v1 \
--kind Redis --helm-chart=helm-charts/redis
完成之后,项目里自动生成了:
helm-charts/redis/values.yaml:chart 的配置,就是 operator 的"安装参数"。config/samples/app_v1_redis.yaml:示例 CR,其 spec 内容全部来自 values.yaml,你看它的image、replicas、auth、master等字段,跟 values 一一对应。
也就是说,你不用写任何 reconcile 业务代码——operator-sdk 会帮你:当 CR 创建或变化时,自动用 CR 的 spec(即安装参数)去部署对应 Helm Release、更新配置、管理实例。
构建并部署
# 构建 operator 镜像并 push(直接通过 --image 传 IMG)
operator-sdk build --image docker.io/<name>/redis-operator:v0.1
docker push docker.io/<name>/redis-operator:v0.1
# 部署 operator 到集群
make deploy IMG=docker.io/<name>/redis-operator:v0.1
kubectl get ns # 会新增一个 redis-system,operator 工作负载部署在这里
# 部署示例 CR(相当于声明一个 redis 实例)
kubectl apply -f config/samples/app_v1_redis.yaml
kubectl get pods # redis 应该被拉起来
可能遇到的权限坑
我第一次部署后,Redis 没能拉起来。查 operator 日志看到:
is forbidden: usercannot get resource ... API group ...
意思是 operator 拉起的 Redis 部署,其绑定的 ServiceAccount 权限不够(这些特殊安装可能需要更宽的 RBAC)。解决:找到它实际绑定的 ServiceAccount(一般是 redis-operator-controller-manager),给它创建一个 ClusterRoleBinding 绑上 cluster-admin 之类的权限,然后重启 operator Pod:
kubectl get deployment -n redis-system -o yaml | grep serviceAccount
# 把该 SA 绑到集群级权限上(演示用,正式环境应最小授权)
kubectl create clusterrolebinding redis-operator-cluster-admin \
--clusterrole=cluster-admin \
--serviceaccount=redis-system:redis-operator-controller-manager
kubectl rollout restart deployment -n redis-system
重启后 Redis 就出来了——一般一个 master、一个 slave 副本。到这里我们就亲眼看到了"一个 Helm Chart 变成 operator"的神奇能力,整个过程中一行业务代码都没写:CRD 是自动生成的、values 映射是自动的、实例生命周期是 operator-sdk 管理的。
operator-sdk 的 OLM:Operator 生命周期管理
OLM 全称 Operator Lifecycle Manager(Operator 生命周期管理器)。它负责管理 operator 的完整生命周期,能做安装、卸载、升级。你可以把它理解成"operator 的应用商店 + 装机师傅",而它部署的单位是 bundle(打包镜像)。
OLM 与 kubebuilder 打包的差别
用 kubebuilder 写完 operator,要打包成一个很复杂的 install.yaml。OLM 更进一步:把这个"软件部署清单"打包成一个标准的 OCI 镜像(bundle),可以推到任意镜像仓库。部署时你直接 operator-sdk run bundle 引用一个 OCI bundle 即可,都不用去处理那个 install.yaml 文件的存储问题。
使用步骤
# 0. 先把之前手动部署的 redis-operator 卸载干净
make uninstall
# 1. 在集群里安装 OLM(会安装一批工作负载)
operator-sdk olm install
# 2. 设两个环境变量:operator 镜像 + bundle 镜像
export IMG=docker.io/<your-name>/redis-operator:v0.1
export BUNDLE_IMG=quay.io/<your-name>/redis-operator-bundle:v0.0.1
# 3. 生成 bundle(交互式,会问你一堆元信息)
make bundle
# 它会问你 operator 名称(redis-operator)、description、keywords、developer、email
# 生成完成后 bundle 名为 docker.io/xxx/redis-operator-bundle:v0.0.1
# 4. 构建并推送 bundle 镜像
make bundle-build bundle-push
# 5. 校验 bundle 是否合法
operator-sdk bundle validate "$BUNDLE_IMG"
# 6. 一条命令在任意集群安装 operator
operator-sdk run bundle "$BUNDLE_IMG"
一个演示中的坑
视频演示里最后 operator-sdk run bundle 报了个 creating catalog 相关的错,原因是之前手动部署过的 operator 在集群里留下了"残留资源"污染了环境(比如 CatalogSource、Subscription 状态不对、旧的 CRD 冲突)。最暴力的解决办法:把 kind 集群直接删掉重建(kind delete cluster && kind create cluster),再跑一遍就干净了。
这个现象也提醒我们:OLM 这类工具对环境状态比较敏感,反复实验前最好从干净集群开始。
Operator 开发最佳实践
最后一个专题,讲几条开发 operator 时的金科玉律,出自 K8s 官方 / Red Hat 的实践总结:
- Reconcile 不要关注具体事件:千万不要针对 Create / Update / Delete 分别写不同逻辑。因为触发 reconcile 的事件可能会重复(网络抖动、重启、周期 requeue),区分事件会让你的逻辑不稳。而是应该设计一个幂等的 reconcile——不管运行多少次,结果都应该一样。幂等是第一条要素。
- 只关注期望状态与当前状态:尽量简化业务逻辑,只做一件事——拿到期望状态(spec),读当前实际状态,然后让实际去对齐期望。其他各种特殊判断能砍则砍,否则 operator 很容易出 bug。
- 端到端测试用 envtest。
用 envtest 做端到端测试(不需要真实集群)
envtest 是一个假的 API Server:你给它发请求它能真正返回内容、还带 etcd 持久化,但它没有控制器,也不生成真实工作负载。它是单机运行的一个二进制,跑起来就能用。它非常适合做"跟 CRD 进行交互、验证字段是否正确"的端到端测试。
使用 envtest 的步骤:
# 1. 安装并 setup(会装 kube-apiserver 和 etcd 两个二进制)
setup-envtest use latest
# 2. 让测试环境能读取到 envtest 的二进制目录
# 默认从某个目录读 kube-apiserver 和 etcd,用 setup-envtest 设置 KUBEBUILDER_ASSETS 环境变量
kubebuilder 初始化的项目里自带测试框架:
internal/controller/
├── suite_test.go # 测试入口(BeforeSuite / AfterSuite)
└── <name>_controller_test.go # 写测试用例的地方
以一个简单的 suite_test.go 为例:
package controller
import (
"path/filepath"
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
applicationv1 "application.co/application/api/v1"
ctrl "sigs.k8s.io/controller-runtime"
)
var (
cfg *rest.Config
k8sClient client.Client
testEnv *envtest.Environment
)
func TestControllers(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Controller Suite")
}
var _ = BeforeSuite(func() {
ctrl.SetLogger(zap.New(zap.UseDevMode(true)))
// 初始化 envtest 环境,把我们的 CRD 目录挂进来
testEnv = &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
ErrorIfCRDPathMissing: true,
}
var err error
cfg, err = testEnv.Start() // 启动了一个 K8s 集群(无控制器)
Expect(err).NotTo(HaveOccurred())
Expect(applicationv1.AddToScheme(scheme.Scheme)).NotTo(HaveOccurred())
k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
Expect(err).NotTo(HaveOccurred())
})
var _ = AfterSuite(func() {
_ = testEnv.Stop()
})
testEnv.Start() 就相当于启动了一个 K8s 集群,它会复用我们 specs 目录里的 CRD。然后我们就可以写一个简单测试用例:创建自定义资源并重新获取,验证字段正确:
var _ = Describe("CRD e2e", func() {
Context("create and fetch", func() {
It("should create and fetch CRD", func() {
myResource := &applicationv1.Application{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
Spec: applicationv1.ApplicationSpec{ /* 填 spec */ },
}
Expect(k8sClient.Create(context.Background(), myResource)).To(Succeed())
var got applicationv1.Application
Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(myResource), &got)).To(Succeed())
// 这里还可以继续验证 spec 字段 / status 字段
})
}
})
跑测试:
make test # 或按项目 Makefile 里的 target
看输出,能看到 starting control plane、installed CRD,然后 PASS。这样我们就用不启动任何真实集群的方式,完成了对 CRD + operator 交互的端到端验证。需要注意:因为 envtest 没有控制器、不会真正生成 Pod,它本质上是"数据的增删改 + 字段校验"层面的验证。
课后作业
给实战一的 Application CRD 增加一个 configmap 字段,让 operator 能在一个 CR 里同时生成 Deployment + Service + Ingress + ConfigMap 四种资源。练完你就会对 operator 的 resource 编排有一个完整的把握。
总结
本章从"什么是 Operator"一路做到"把它打包、下发、测试"。核心是这几条主线:
- Operator = Controller + CRD,本质是把人肉运维经验代码化,复用 K8s 控制循环,实现声明式管理复杂有状态应用。
- 控制循环的心智:期望状态(spec)vs 实际状态(status),Controller 让两者最终一致;Reconcile 有四种返回控制重试策略。
- kubebuilder:核心底座,架构由 Manager / Controller / Reconciler / Source / WorkQueue 组成,我们只写 types.go 和 Reconcile 两处。两个实战分别学会了"多资源编排"(Application)和"定时对账"(CronHPA + RequeueAfter + cron 解析)。
- operator-sdk:底层复用 kubebuilder,额外提供 Helm / Ansible 模式和 OLM。可以把现成 Chart 直接变 operator,一行业务代码不写。
- 最佳实践:幂等 reconcile、只关注期望/当前状态、用 envtest 做无集群端到端测试。
这套能力搭建好之后,后续就能在这个骨架上叠加 AIOps 的智能能力——比如让 operator 结合告警数据自己决策、自己扩容/缩容/自愈,实现"系统自己诊断自己修"。
自测题与动手练习
Operator = Controller + CRD。
- CRD(Custom Resource Definition):声明式地定义一个新的资源类型,描述这个 kind 的 spec/status 字段结构,相当于给 K8s API 加一张新表。
- Controller:监听 CR 的变更,执行 reconcile 对账逻辑,让"期望状态(spec)“与"实际状态(status)“最终保持一致。
- 期望状态就是资源
spec里声明的目标,比如 Deployment 声明"3 副本、某镜像"。 - 实际状态是对象当前的真实情况,通常记录在
status字段里(也会主动查询获得)。 - 控制循环让实际状态最终收敛到期望状态。就像定速巡航:你设定 110(期望),车现在 80(实际),系统不断提速、接近时再微调(控制力度收敛),直到达到 110。
- 成功无需重试:
Result{}+nil,消息从工作队列删除。 - 失败需重试:
Result{}+ 非 nilerror,立即重新入队。 - 成功但要重试:
Result{Requeue:true}+nil,重新入队且可取到入队次数。 - 延迟重试:
Result{RequeueAfter: 5*time.Second}+nil,相当于延迟队列,定时任务就靠它。
kubebuilder 是核心底座(底层依赖 controller-runtime / controller-tools),operator-sdk 底层复用了 kubebuilder 的代码生成,并额外封装能力——最典型是 OLM 和 Helm/Ansible 模式。
- 纯 Go 写 controller,两者都能用,产物布局类似;
- 想让 operator 更好地配合 OLM 分发、或想直接用 Helm Chart / Ansible playbook 生成 operator,就用 operator-sdk。
controllerutil.SetControllerReference(&app, &dep, r.Scheme),给子资源打上了 ownerReference,标记"我是这个 CR 管理的"。删除 CR 时,K8s 会根据 ownerReference 级联删除所有从属资源。所以 operator 只需准备"创建/更新",删除交给 ownerReference 自动完成。return ctrl.Result{RequeueAfter: nextRunTime.Sub(now)},以及 getNextScheduleTime 用 cron 库算出每个 job 的下一次调度时间(存在 status.LastRunTime 里做去重)。第一次演示漏掉了 RequeueAfter 那部分,导致只扩容一次、之后不再触发;补上后日志里出现 RequeueAfter,Deployment 就按分钟反复把副本拉回目标值。还踩过一个坑:cron 表达式多写了字段,报
expected exactly 5 fields——标准写法是 5 段。若部署后 Redis 拉不起来,查 operator 日志看
forbidden,找到 operator 实际绑定的 ServiceAccount(如 redis-operator-controller-manager),给该 SA 创建 ClusterRoleBinding 绑定足够权限,再重启 operator Pod 即可。operator-sdk run bundle <bundle镜像> 就能在任何集群安装/卸载/升级,不用再维护 install.yaml 那个大文件的存储。注意:bundle 对环境状态敏感,反复实验时尽量从干净集群开始,避免残留 CatalogSource / CRD 冲突。
因为触发 reconcile 的事件可能因网络等原因重复触发,并且 controller 重启后要能基于当前状态重新对账。只关注"期望状态 vs 当前状态",让实际对齐期望,既幂等又简单。
适合做"创建/获取自定义资源、验证 spec/status 字段是否正确"的端到端测试,不需要启动任何真实集群。验证的是 CRD 与 operator 交互层面的正确性,而非真实资源是否拉起。
动手练习(建议真做一遍):
- 用
kind起本地集群,kubebuilder init --domain co+kubebuilder create api --group application --version v1 --kind Application生成骨架,改 types.go + Reconcile 实现"Application 管三个资源",验证创建/更新/级联删除。 - 给实战一加一个
configmap字段(课后作业),让一个 CR 生成四种资源。 - 实现 CronHPA,用一个 nginx Deployment 测试定时扩缩容;故意把 cron 表达式写成七段,亲眼看看
expected exactly 5 fields报错。 - 给 CronHPA 的字段加 printcolumn 注解,重跑
make manifests,kubectl get cronhpa看多出来的列。 - 用 operator-sdk Helm 模式把一个 redis Chart 变成 operator,练习处理 SA 权限问题。
- 用 envtest 写一个"创建并获取 Application"的测试,
make test跑通,观察starting control plane+PASS。
本章小结
- Operator = Controller + CRD:把运维领域知识代码化,复用 K8s 控制循环实现复杂应用/中间件的声明式管理。
- 核心心智:期望状态(spec)vs 实际状态(status),Controller 通过 reconcile 让两者最终一致;reconcile 有"成功 / 失败-重试 / 成功-重试 / 延迟重试"四种返回。
- 两大框架:kubebuilder 是核心底座(Manager/Controller/Reconciler/Source/WorkQueue 架构),operator-sdk 复用 kubebuilder 并叠加 OLM 与 Helm/Ansible 模式。
- 实战一:用
CreateOrUpdate+SetControllerReference用一个 CR 管 Deployment/Service/Ingress,ownerReference 自动级联删除。 - 实战二:CronHPA 用 cron 解析 +
LastRunTime状态 +RequeueAfter定时对账实现定时弹性伸缩;printcolumn 让kubectl get展示更多列。 - 打包:kubebuilder 用
make build-installer产出dist/install.yaml一键部署;operator-sdk + OLM 用 OCI bundle 下发。 - 最佳实践:幂等 reconcile、只关注期望/当前状态,用 envtest 做无集群端到端测试。