Kubernetes Operator:什么时候该写,写了要注意什么

“我们是不是应该写一个 Operator?”

这个问题在架构评审里出现的频率,远超 Operator 实际应该被使用的频率。Operator 是一个功能强大但成本不低的扩展机制——它值得被认真对待,不值得被滥用。


先想清楚为什么要写

Kubernetes 原生提供的控制器(Deployment、StatefulSet、CronJob)已经能处理大多数无状态服务和简单有状态应用的生命周期管理。

Operator 值得引入的场景只有一种:你有一类应用,它的运维知识足够复杂,无法用现有原生对象表达,并且这个运维逻辑会被重复执行

典型例子:

  • 分布式数据库的滚动升级——需要先升级 follower,再切换 leader,不能直接用 RollingUpdate
  • 消息队列扩容——新 broker 加入后需要触发分区重均衡,不是简单 scale up
  • AI 推理服务的蓝绿发布——需要根据 GPU 型号匹配节点亲和性,挂载特定的模型卷

如果你的场景只是”想定义一些自定义配置”,ConfigMap + Helm 就够了。Operator 是给运维逻辑编码,不是给配置管理增加复杂度。


Reconcile 循环是核心,不是样板代码

所有 Operator 的核心都是一个 Reconcile 函数。它的语义非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
func (r *MyAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var myApp myv1.MyApp
if err := r.Get(ctx, req.NamespacedName, &myApp); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}

if err := r.ensureDeployment(ctx, &myApp); err != nil {
return ctrl.Result{RequeueAfter: 10 * time.Second}, err
}

myApp.Status.Phase = "Running"
return ctrl.Result{}, r.Status().Update(ctx, &myApp)
}

触发条件:监听的资源发生变更(Create/Update/Delete),或者你主动返回 RequeueAfter

关键是理解它不保证幂等性——你的 Reconcile 逻辑必须自己保证幂等。同一个对象可能在很短时间内被 Reconcile 多次(API Server 重启、控制器重启、用户频繁修改),每次执行应该得到相同结果。

实践准则:在每个子操作里,先 GET 当前状态,再判断是否需要修改,而不是直接 CREATE 或 UPDATE。

1
2
3
4
5
6
7
8
9
10
// 错的:直接创建,第二次执行会报 AlreadyExists
r.Create(ctx, deployment)

// 对的:先检查,存在就比对 spec 是否需要更新
existing := &appsv1.Deployment{}
err := r.Get(ctx, types.NamespacedName{Name: deployment.Name, Namespace: deployment.Namespace}, existing)
if errors.IsNotFound(err) {
return ctrl.Result{}, r.Create(ctx, deployment)
}
// 对比 existing.Spec 和期望 spec,按需 Update

Status 是 Operator 的诚信记录

Operator 和用户之间的沟通渠道是 Status 字段。写得准确的 Status 让用户知道发生了什么;写得马虎的 Status 会让用户陷入”是操作没生效,还是 Operator 在工作”的迷惑中。

几个实践原则:

Phase 要有明确语义。用 Pending / Running / Failed / Terminating 而不是自定义的模糊字符串。

Conditions 比 Phase 携带更多信息。Condition 可以同时存在多个,反映多个维度的状态:

1
2
3
4
5
6
7
8
9
status:
conditions:
- type: DeploymentReady
status: "True"
reason: MinimumReplicasMet
- type: StorageBound
status: "False"
reason: PVCPending
message: "Waiting for PVC to be bound"

不要写可能引起误导的 Status。如果还不确定某个状态,不要写”Running”,写”Initializing”。用户宁愿等待真实信息,也不愿意被误导后花时间排查。


Finalizer:资源删除是最容易出问题的地方

Operator 管理的资源通常有外部依赖(数据卷、云资源、外部服务注册)。直接删除 CR,这些外部依赖不会自动清理。Finalizer 解决的就是这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const myFinalizer = "myapp.example.com/finalizer"

func (r *MyAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var myApp myv1.MyApp
if err := r.Get(ctx, req.NamespacedName, &myApp); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}

// 对象正在被删除
if !myApp.DeletionTimestamp.IsZero() {
if controllerutil.ContainsFinalizer(&myApp, myFinalizer) {
if err := r.cleanupExternalResources(ctx, &myApp); err != nil {
return ctrl.Result{}, err
}
controllerutil.RemoveFinalizer(&myApp, myFinalizer)
r.Update(ctx, &myApp)
}
return ctrl.Result{}, nil
}

// 确保 Finalizer 存在
if !controllerutil.ContainsFinalizer(&myApp, myFinalizer) {
controllerutil.AddFinalizer(&myApp, myFinalizer)
return ctrl.Result{}, r.Update(ctx, &myApp)
}

// 正常 Reconcile 逻辑
// ...
return ctrl.Result{}, nil
}

注意:Finalizer 的清理逻辑必须幂等。如果清理中途失败,Reconcile 会重试,清理逻辑会再次执行,不要假设清理只发生一次。


框架选择:Kubebuilder 还是 Operator SDK

两个框架做的是同一件事,选哪个更多是团队习惯问题,但有几点实际差异值得注意。

Kubebuilder 是社区维护的基础框架,Operator SDK 构建在它之上。如果 Operator 逻辑是纯 Go,直接用 Kubebuilder 依赖更少,升级路径更清晰。

Operator SDK 额外提供 Ansible 和 Helm 模式,适合没有 Go 背景的团队快速启动,但 Ansible Operator 的运维经验相对冷门,遇到问题社区支持有限。

不管选哪个,不要绕过框架自己管 informer 和 cache。这两个框架的核心价值就是帮你处理 List/Watch 的客户端缓存和事件去重,手动管理会引入难以排查的一致性问题。


测试:本地开发是最大的摩擦点

Operator 的测试有几个层次:

单元测试:用 fake client 测试 Reconcile 逻辑,不需要真实集群。

1
2
3
fakeClient := fake.NewClientBuilder().WithObjects(myApp).Build()
reconciler := &MyAppReconciler{Client: fakeClient}
result, err := reconciler.Reconcile(ctx, req)

集成测试:用 envtest 启动轻量 API Server 和 etcd,在接近真实的环境里测试完整 Reconcile 流程。这个方式测的是控制逻辑,不是网络行为。

端到端测试:在真实集群(或 kind/minikube)里部署 Operator,验证完整行为。对于有 Finalizer、Webhook、外部资源依赖的场景,单元测试覆盖不到真实问题,这一层必须有。

一个容易忽略的点:Operator 的控制循环是异步的。在测试里用 Eventually 而不是同步断言:

1
2
3
4
5
6
7
8
// 错的:Reconcile 还没来得及响应
Expect(myApp.Status.Phase).To(Equal("Running"))

// 对的:等待最终一致
Eventually(func() string {
r.Get(ctx, req.NamespacedName, &myApp)
return myApp.Status.Phase
}, timeout, interval).Should(Equal("Running"))

写 Operator 之前,先问一个问题

如果这个运维知识只需要执行一次,它不应该是 Operator,它应该是 Job 或脚本。

Operator 是为持续运行的控制循环设计的。如果需求是”部署时执行一次数据初始化”,用 init container 或 Helm hook 更直接,不要为了用 Operator 而用 Operator。

这条边界,通常也是运维工具和运维哲学真正分叉的地方。


Kubernetes Operator:什么时候该写,写了要注意什么
https://www.krli.org/2023/09/20/Kubernetes-Operator开发指南/
作者
李科燃
发布于
2023年9月20日
许可协议