How to Build Kubernetes Operators: A Handbook for Devs

TL;DR · AI 摘要
Kubernetes Operator通过声明式循环管理自定义资源,实现对非原生系统的自动化运维,构建过程需定义CRD并实现重同步逻辑。
核心要点
- Operator通过持续对比期望状态与实际状态实现自动化运维
- 每个Operator必须包含CustomResourceDefinition和Reconciliation Loop
- 生产环境需配置RBAC、Finalizer和跨资源协调机制
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- Kubernetes Operator构建指南
- 核心概念
- Operator vs Controller vs CRD
- Reconciliation Loop原理
- 实现步骤
- CRD定义
- 控制器开发
- 生产就绪特性
金句 / Highlights
值得收藏与分享的关键句。
Operator通过持续的observe-compare-act循环确保系统状态收敛
与Helm Chart不同,Operator能处理有状态应用的复杂生命周期管理
跨资源协调需要实现Predicate和Owned Resources的双向引用关系
如何构建 Kubernetes Operator:开发者的指南
2026年7月29日
/
Karan Pratap Singh
Kubernetes 自带控制器,用于管理一组固定的内置资源:Deployment、Service、Node 等。
Operator 将相同的模式扩展到 Kubernetes 本身不原生支持的资源,使您能够以声明式方式管理自定义的、通常是外部的系统,这种方式与管理集群中其他一切资源的方式相同。
本指南分为四个部分:Operator 究竟是什么、其结构、从零构建一个 Operator,以及为生产环境做准备。
目录
- 第1部分:简介 什么是 Operator? Operator 与 Controller 与 CRD 为什么不用 Helm Chart、CronJob 或脚本?
- 第2部分:Operator 的结构 自定义资源 监听变更 管理器 协调循环
- 第3部分:构建 Operator 初始化模拟提供者 定义 VirtualMachine CRD Reconciler 故障处理与重试 Finalizer Predicate 所有资源 跨资源协调 RBAC
- 第4部分:生产环境与部署 打包与部署 性能与弹性 安全性 可观测性
- 下一步
第1部分:简介
什么是 Operator?
Kubernetes 通过将我们描述的状态与实际状态进行比较来工作。控制器会采取行动以缩小差距,无论是 Deployment 控制器替换我们杀死的 Pod,还是缩减我们不再需要的 Pod。
这种观察、比较和行动的循环称为协调(reconciliation)。这意味着查找资源的期望状态和实际状态,决定下一步该做什么,并在每次运行时重新计算该决策,无论发生了什么变化。
正是这种循环的鲁棒性:它不需要信任自己看到了所有事件,只需要确保它会被再次调用。
Operator 将这种精确的循环应用于 Kubernetes 本身不理解的资源。我们定义一个自定义资源,在其中描述我们领域期望的状态,并编写一个知道如何协调该领域的控制器。
这就是整个概念。本指南中的其他所有内容(如 informers、workqueues、finalizers、status conditions)都旨在让 Kubernetes 仅因我们定义而知晓的资源类型实现该循环的可靠性。
Operator 与 Controller 与 CRD
这三个术语经常被互换使用,但它们描述的是同一系统中的三个不同层次。
- CRD(CustomResourceDefinition):我们向 Kubernetes API 服务器注册的模式,用于教它了解一种新的资源类型。单独的 CRD 本身不会做任何事情。它只给 API 服务器一个存储、验证和提供数据的形状。
- Controller:任何运行针对资源类型的协调循环的软件,从内置的 Deployment 控制器到协调 PostgresCluster 的自定义控制器。
- Operator:一个控制器或一小组控制器,针对自定义资源,并编码足够的领域特定知识,以在无需人工干预的情况下管理其完整生命周期:配置、升级、故障恢复等。
每个 Operator 都是一个控制器,但并非每个控制器都是一个 Operator。没有控制器支持的 CRD 只是一个没有东西作用其上的模式。
CronJob 为我们提供了一种循环机制,但需要付出一定的代价:精度较低、状态可能滞后最多一个时间间隔、运行之间不保留任何状态,也无法让一个 CronJob 对另一个 CronJob 的状态变化做出反应。
而一次性脚本只有在被触发时(无论是手动触发还是通过 CI 管道触发)才会执行,并且在两次运行之间不会处理任何漂移问题。此外,这类脚本通常很少将重试机制和幂等性作为首要考虑因素。
#### 为什么 Operator 在这里更具优势:
Operator 是事件驱动且持续运行的。当 API 服务器检测到自定义资源被创建、更新或删除时,会立即通知 Operator,并且 Operator 会持续对资源进行协调,而不仅仅是在应用时进行一次处理。对于需要较长时间才能收敛、可能中途失败、创建后可能出现漂移的状态,这种持续协调机制尤为重要。
当然,这种优势也伴随着代价。Operator 是一个长期运行的进程,拥有自己的 RBAC(基于角色的访问控制)、故障模式和可观测性接口。与图表或脚本相比,Operator 的构建和运维成本更高。
如果问题仅仅是渲染一些 YAML 文件一次,那么 Helm 图表就是合适的工具。而当问题在于持续保持某事物的正确性时,Operator 才能真正发挥价值,这也是本指南后续内容所要探讨的重点。
第 2 部分:Operator 的结构
接下来,我们将探讨使这个循环真正运作起来的各个组件:自定义资源本身、用于检测变化的机制,以及负责运行所有操作的管理器,然后再深入探讨协调循环的细节。
自定义资源
在控制器能够进行任何协调之前,API 服务器需要知道它所存储对象的结构。注册 CRD(自定义资源定义)正是向 API 服务器传授这种结构的方式。
每个自定义资源都包含 Kubernetes 对象已有的身份字段(kind、name、namespace、labels 等),以及我们完全自主定义的两个字段:spec 和 status。这种字段划分并非风格选择,而是与协调循环直接对应。
- spec 表示期望状态。创建或编辑资源的用户会写入 spec,而控制器只会读取它。
- status 表示观察到的状态。它仅由控制器写入,用于记录控制器发现的内容和执行的操作。
直接向 status 写入的客户端实际上是在绕过控制器进行操作,这也是为什么 status 通常作为独立的子资源提供,并拥有单独的权限设置。
最后是注册环节。API 服务器和任何与之通信的客户端都需要一种共享且一致的方式来编码和解码我们的类型,因此我们在任何使用之前,会先将其注册到一个方案中。没有这个注册,我们的类型只是一个无人能处理的定义。有了它,API 服务器就可以像处理 Pod 或 Deployment 一样,准确地存储和提供我们的资源。
在第 3 部分中,当我们真正构建一个 Operator 时,将看到这个注册过程的具体实现。
检测变化
协调器不会通过循环轮询 API 服务器询问“是否有变化发生”。三个组件协同工作,避免了这种低效方式。
一个 informer 会向 API 服务器建立一个长期的 watch 连接,并维护一个本地内存缓存,记录特定类型的所有对象,随着 add、update 和 delete 事件的到来不断更新缓存。
一个 lister 会从这个缓存中读取数据,而不是直接访问 API 服务器,因此协调器检查“此资源是否已存在?”的成本只是一个本地映射查找,而不是一次网络调用。
工作队列位于 informer 和 reconciler 之间。当 informer 检测到变更时,不会直接调用 reconciler。相反,它会将键、命名空间和名称入队,而不是对象本身。工作线程从队列中取出键并进行协调,队列会帮我们处理去重和限速,因此对同一对象的十次快速更新会被合并为一个待处理项,而不是触发十次冗余的协调操作。
这也是为什么 reconciler 接收的是键而不是对象的原因。当工作线程从队列中取出键时,对象可能已经再次变更,因此 reconciler 总是会自行查找当前状态,而不是依赖触发它的信息。
管理器
管理器是拥有所有这些组件的进程:informer 用于填充的共享缓存、reconciler 用于读写对象的客户端,以及集群其他部分用来确认控制器是否存活的健康检查、就绪状态和指标端点。我们注册的每个 reconciler 都会在一个管理器中运行。
如果我们为了可用性运行同一控制器的多个副本,我们不希望所有副本同时协调同一对象并相互竞争。
管理器通过领导者选举来协调这一过程。副本之间竞争租约,只有一个副本持有租约并主动进行协调,其余副本会处于空闲状态,直到领导者停止续租。
我们将在第 4 部分实践中再次讨论这一点。目前只需知道管理器是实现这一功能的关键。
协调循环
这是最关键的部分。一旦我们充分理解这个循环,操作员的大部分工作都是其变体。
reconciler 的入口点仅接收命名空间和名称,没有其他信息。没有 spec、status 或差异。reconciler 必须自行获取对象,将其 spec 与实际观察到的状态进行比较,并决定采取什么行动。这个限制是有意为之,也是下文所有内容的原因。
#### 幂等性
由于 reconciler 只能获取键,并且可能被多次调用(连续、无序或在长时间间隔后),它必须无论运行多少次都产生相同的结果。一个盲目每次运行都调用 create 的 reconciler 在运行两次时就会出错,因为第二次调用会失败,因为对象已经存在。
解决方法是在采取行动前始终检查当前状态:仅在对象缺失时创建,仅在对象不同时更新,仅在对象不应存在时删除。
#### 事件驱动的协调
协调由被协调资源的 watch 事件触发,按惯例也由其拥有的或依赖的任何内容触发。此外,大多数控制器会设置定期重同步,使循环即使没有 watch 事件也会按计划运行,这在 watch 无法捕获的状态漂移发生时尤为重要。
#### 重新入队
有时一次协调遍历无法完成工作,因为等待的工作仍在其他地方进行。reconciler 可以请求在延迟后再次调用,而不会将此视为失败。这就是它轮询需要时间收敛的资源,而不是在单次调用中阻塞的方式。
#### 错误处理
返回错误会产生类似效果:它会重新入队,但使用指数退避而不是固定延迟。因此,持续失败的协调不会反复冲击它失败的目标。
需要区分哪些错误值得重试(例如超时或锁冲突)与哪些不值得重试(例如永远不会有效的规范,这类问题应作为状态条件反馈而非无限重试)。
#### 漂移修正
将上述所有内容整合后,该循环通过构造本身实现自愈。由于 reconcile 每次都会重新计算完整差异而非仅响应具体变更,因此无论漂移来源于 kubectl edit 操作、其他控制器还是资源所代表的底层系统自身状态变更,都无关紧要。下一次 reconcile 无论是由 watch 事件还是重新同步触发,都会看到相同的差距并以相同方式消除它。
第3部分:构建 Operator
到目前为止的所有内容都在为此做准备。我们现在了解了 Operator 的定义、相关术语的关系以及协调循环的组成部分。
接下来我们将实际应用这些知识,构建 VMOperator。这是一个管理 VirtualMachine 自定义资源的 Operator,该资源由模拟云提供商支持,我们还会编写一个小型 HTTP 服务作为真实云提供商的替代。
apiVersion: compute.example.com/v1
kind: VirtualMachine
spec:
image: ubuntu-22.04
cpu: 2
memory: 4Gi
status:
phase: Running
id: vm-123假设我们希望以 Kubernetes 原生方式表示虚拟机,通过 kubectl apply YAML 文件即可创建 VM,而无需接触云控制台或单独的 CLI 工具。
这就是 VMOperator 的动机:让原本完全独立于 Kubernetes 的组件成为集群现有工具(kubectl、RBAC、GitOps 管道)已知的另一种对象。
环境准备
我们需要本地集群,而 kind 是最简单的获取方式:
kind create cluster --name vmoperator除此之外,本部分将使用 Go 语言编写 Operator,kubectl 指向新集群,并使用 Python 和 Flask 实现下文的模拟提供商。
pip install flask模拟提供商
在编写控制器代码之前,我们需要一个被控制的对象。模拟提供商是一个小型 HTTP 服务,包含三个端点:
- POST /vms 创建虚拟机
- GET /vms/{id} 检查状态
- DELETE /vms/{id} 删除虚拟机
其后端仅使用内存中的字典存储数据。
每个创建的虚拟机都会从 Provisioning 状态开始,几秒后自动切换到 Running 状态,这足以迫使我们的协调器实际进行轮询而非假设成功。
我们选择用 Python 而非 Go 实现这个服务,这与 Operator 的代码无关,因为这只是模拟用途。
import random
import string
import threading
import time
from flask import Flask, jsonify, request
app = Flask(__name__)
vms = {} # 内存中的存储,以 VM ID 为键
def provision(vm):
time.sleep(5) # 模拟资源准备耗时
vm["phase"] = "Running"
@app.post("/vms")
def create_vm():
body = request.get_json()
vm_id = "vm-" + "".join(random.choices(string.digits, k=6))
vm = {"id": vm_id, "image": body["image"], "phase": "Provisioning"}
vms[vm_id] = vm
threading.Thread(target=provision, args=(vm,), daemon=True).start() # 在后台切换到 Running 状态
return jsonify(vm)
@app.get("/vms/<vm_id>")
def get_vm(vm_id):
vm = vms.get(vm_id)
if vm is None:
return "", 404
return jsonify(vm)
@app.delete("/vms/<vm_id>")
def delete_vm(vm_id):
vms.pop(vm_id, None)
return "", 204if __name__ == "__main__":
app.run(port=8080, threaded=True)
我们将独立运行这个进程,与集群并行运行,监听操作员配置调用的端口。它完全不知道Kubernetes的存在,这正是设计的初衷:它代表的是真实的云API。
### 定义VirtualMachine自定义资源
有了控制对象后,我们可以定义要控制的内容。VirtualMachine类型严格遵循第2部分中介绍的spec和status分离结构:
type VirtualMachineSpec struct { Image string json:"image" CPU int json:"cpu" Memory string json:"memory" }
type VirtualMachineStatus struct { ID string json:"id,omitempty" // 由提供商分配的ID,首次配置前为空 Phase string json:"phase,omitempty" // 镜像提供商生命周期阶段 }
type VirtualMachine struct { metav1.TypeMeta json:",inline" metav1.ObjectMeta json:"metadata,omitempty"
Spec VirtualMachineSpec json:"spec,omitempty" Status VirtualMachineStatus json:"status,omitempty" }
type VirtualMachineList struct { metav1.TypeMeta json:",inline" metav1.ListMeta json:"metadata,omitempty" Items []VirtualMachine json:"items" }
TypeMeta包含kind和apiVersion字段,这两个字段存在于所有Kubernetes对象(内置或自定义)中,用于标识对象类型。
ListMeta是列表类型的对应结构,包含resourceVersion和continue字段用于分页(替代name/namespace)。这就是为什么VirtualMachineList同时嵌入了TypeMeta和ListMeta,而VirtualMachine本身嵌入了ObjectMeta。
每个注册的类型都需要实现runtime.Object接口,即实现DeepCopyObject方法。通常这个方法会自动生成,但因为我们手动实现,下面是VirtualMachine的生成代码示例。其他类型遵循相同的机械模式:
func (in *VirtualMachine) DeepCopyObject() runtime.Object { out := VirtualMachine{ TypeMeta: in.TypeMeta, ObjectMeta: *in.ObjectMeta.DeepCopy(), // ObjectMeta已经知道如何复制自身 Spec: in.Spec, // Spec中没有指针或切片,普通复制是安全的 Status: in.Status, } return &out }
以及教API服务器认识它的CRD清单:
apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: name: virtualmachines.compute.example.com spec: group: compute.example.com scope: Namespaced names: kind: VirtualMachine listKind: VirtualMachineList plural: virtualmachines singular: virtualmachine shortNames: [vm] # 允许我们使用kubectl get vm而不是完整复数形式 versions:
- name: v1
served: true storage: true subresources: status: {} # 将status拆分为独立子资源,参见第2部分 schema: openAPIV3Schema: type: object properties: spec: type: object required: [image, cpu, memory] properties: image: { type: string } cpu: { type: integer } memory: { type: string } status: type: object properties: phase: { type: string } id: { type: string }
subresources.status 这一行至关重要。正是这一行使得 status 成为一个拥有独立更新路径的子资源。这正是我们在第 2 部分中讨论的边界:客户端可以写入的内容与只有控制器可以操作的内容之间的分界。
names 块也是 kubectl 解析的目标,kubectl get virtualmachines 能正常工作是因为复数形式的定义。shortNames 的存在也使得 kubectl get vm 能正常工作,就像 kubectl get po 用于 Pods 一样。
调和器(Reconciler)
调和器的职责在纸面上看起来很简单:查看 VirtualMachine,确保提供者中存在对应的 VM,并且其状态反映真实情况。我们将提供者的 HTTP API 封装在一个小型客户端中,使调和器本身保持可读性:
func (r *VirtualMachineReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var vm computev1.VirtualMachine
if err := r.Get(ctx, req.NamespacedName, &vm); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err) // 对象已被删除,无需处理
}
if vm.Status.ID == "" {
// 尚未创建 VM,这是第一次看到这个对象
created, err := r.Provider.Create(ctx, vm.Spec.Image)
if err != nil {
return ctrl.Result{}, err
}
vm.Status.ID = created.ID
vm.Status.Phase = created.Phase
if err := r.Status().Update(ctx, &vm); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: 2 * time.Second}, nil // 稍后重新检查,而不是在此阻塞
}
// VM 已存在,向提供者查询当前信息
current, err := r.Provider.Get(ctx, vm.Status.ID)
if err != nil {
return ctrl.Result{}, err
}
vm.Status.Phase = current.Phase
if err := r.Status().Update(ctx, &vm); err != nil {
return ctrl.Result{}, err
}
if current.Phase != "Running" {
return ctrl.Result{RequeueAfter: 2 * time.Second}, nil // 正在创建中,继续轮询
}
return ctrl.Result{}, nil
}有两个要点需要特别说明。首先,之所以能访问到这里,是因为我们已经在 VirtualMachine 上注册了监听。API 服务器会在对象创建或修改时立即通知我们,这会触发首次调用。
其次,每个分支最终都会更新 vm.Status,将提供者返回的信息映射到资源上。Kubernetes 从不直接与提供者通信。唯一能知道 VM 是否正在运行的方式,是因为我们的调和器将状态写入了 status。
故障处理与重试
请注意上面的调和器本身不会主动重试任何操作。当 r.Provider.Create 或 r.Provider.Get 失败(例如由于网络波动或模拟提供者尚未启动),它只会返回错误。这是有意为之。返回错误是我们在请求 controller-runtime 代表我们进行指数退避重试的方式。这意味着我们不需要手动实现重试循环,持续不可达的提供者也不会被大量重试请求淹没。
需要特别注意的是,不要对所有错误采取相同的处理方式。与提供者通信超时值得重试。但提供者永远不会接受的 spec.image 所对应的 VirtualMachine 则不值得重试。对这种情况无限重试只会产生一个永远无法成功的忙等待循环。
我们将通过状态条件来区分这些情况的细节留给练习部分。上面的调和器只需要考虑一种故障模式,因为模拟提供者从不会直接拒绝请求。
终止器(Finalizer)
如果现在删除一个 VirtualMachine,Kubernetes 会移除该对象,但云服务提供商仍认为该虚拟机在运行,导致出现孤立的 VM。终结器(finalizer)可以弥补这个差距:它是对象上的一个字符串,告诉 Kubernetes "在收到明确指示前不要实际删除该对象"。
const vmFinalizer = "compute.example.com/vm-cleanup"
func (r *VirtualMachineReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var vm computev1.VirtualMachine
if err := r.Get(ctx, req.NamespacedName, &vm); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if !vm.DeletionTimestamp.IsZero() {
// 正在被删除,通过提供商进行资源回收后再释放
if controllerutil.ContainsFinalizer(&vm, vmFinalizer) {
if vm.Status.ID != "" {
if err := r.Provider.Delete(ctx, vm.Status.ID); err != nil {
return ctrl.Result{}, err
}
}
controllerutil.RemoveFinalizer(&vm, vmFinalizer) // 现在可以安全地继续删除
return ctrl.Result{}, r.Update(ctx, &vm)
}
return ctrl.Result{}, nil
}
if !controllerutil.ContainsFinalizer(&vm, vmFinalizer) {
controllerutil.AddFinalizer(&vm, vmFinalizer) // 在任何资源分配之前注册终结器
if err := r.Update(ctx, &vm); err != nil {
return ctrl.Result{}, err
}
}
// ... 之前的资源分配逻辑
return ctrl.Result{}, nil
}带有终结器的 VirtualMachine 执行 kubectl delete 时不会立即被删除。而是设置 deletionTimestamp 并等待资源回收完成。
我们的协调器(reconciler)在下次调用时会检测到该状态,通过提供商进行 VM 资源回收,之后才移除终结器。此时 Kubernetes 才会真正删除该对象。如果没有终结器,就无法保证资源清理操作一定会执行。
谓词过滤
上面的协调器代码中存在一个细微的 bug。每次调用 r.Status().Update 时,该写操作本身会触发对象变更。这会激活我们自己的监听器,导致 reconcile 方法被再次调用。
如果放任不管,由于我们不断重新计算相同的状态直到稳定,不会导致无限循环。但这种因自身写入而触发的重复协调操作仍然是不必要的资源消耗。
谓词(predicate)可以在我们的代码执行前过滤掉这些事件:
func (r *VirtualMachineReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&computev1.VirtualMachine{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). // 过滤掉仅状态变更的事件
Complete(r)
}Generation 只有在 spec 发生变化时才会递增。状态更新不会影响它。GenerationChangedPredicate 利用这一特性过滤掉仅状态变更的事件,使我们自己的写入操作不再触发重复协调。这样我们就能只在有意义的变更发生时,或明确要求重新排队时才执行协调操作。
所有资源管理
一个正在运行的 VirtualMachine 单独存在时实用性有限,让我们让操作符同时创建一个 Secret 来保存 VM 的连接信息:
func (r *VirtualMachineReconciler) reconcileConnectionSecret(ctx context.Context, vm *computev1.VirtualMachine) error {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: vm.Name + "-connection",
Namespace: vm.Namespace,
},
StringData: map[string]string{"id": vm.Status.ID},
}
if err := controllerutil.SetControllerReference(vm, secret, r.Scheme); err != nil {
return err // 将 Secret 的生命周期与该 VirtualMachine 绑定
}return r.Patch(ctx, secret, client.Apply, client.ForceOwnership, client.FieldOwner("vmoperator")) // 创建或更新,任选其一 }
SetControllerReference 是让这个资源成为被拥有资源的关键,它会在 Secret 上打上一个所有者引用,指向 VirtualMachine 。
这样会带来两个免费的好处。现在删除 VirtualMachine 会触发级联删除,Kubernetes 会自动回收 Secret,且不需要 finalizer,因为它是一个集群内部对象,而非外部对象。
如果我们还在 SetupWithManager 中的 For(&computev1.VirtualMachine{}) 旁边添加 Owns(&corev1.Secret{}),那么 Secret 本身的修改或删除会重新触发其拥有者 VirtualMachine 的协调。因此如果有人手动删除它,我们会察觉并重新创建。
同样的模式,SetControllerReference 调用和 Owns() 注册会创建第二个被拥有资源:一个在集群内部为 VM 提供服务的 Service。这是同一个 VirtualMachine 所拥有的两种不同资源类型,这在实际中就是多个被拥有资源的全部含义。除了对不同类型重复调用相同模式外,没有其他需要处理的内容。
### 跨资源协调
到目前为止,每个 VirtualMachine 都只连接到一个硬编码的提供商端点。实际部署需要这个配置是可配置的,而且通常不是一次性配置:同一 AWS 账户中的五十个 VirtualMachine 共享同一个端点和凭证,而另外一百个可能位于 Azure。
我们可以将端点字段直接放在 VirtualMachineSpec 上,但当需要轮换凭证或修正拼写错误时,这意味着要逐一编辑每个使用它的 VirtualMachine。将配置提取到独立对象中,可以让多个 VirtualMachine 通过名称引用它,这样一次编辑即可传播到所有相关实例。
现在我们添加第二个小型 CRD:
type ProviderConfigSpec struct { Endpoint string json:"endpoint" }
并在 VirtualMachineSpec 上添加一个 providerRef 字段,指向一个名称对应的对象。有趣的部分不是新类型本身,而是当 ProviderConfig 发生变化时会发生什么。
VirtualMachine 不会直接监视 ProviderConfig,二者之间也没有所有者引用,因此普通的 Owns() 无法捕获其变化。相反,我们监视该类型,并将每个事件映射到所有引用它的 VirtualMachine 上:
func (r *VirtualMachineReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&computev1.VirtualMachine{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). Owns(&corev1.Secret{}). Owns(&corev1.Service{}). Watches( &computev1.ProviderConfig{}, // 不属于被拥有资源,Owns() 无法捕获其变化 handler.EnqueueRequestsFromMapFunc(r.findVirtualMachinesForProviderConfig), ). Complete(r) }
func (r *VirtualMachineReconciler) findVirtualMachinesForProviderConfig(ctx context.Context, obj client.Object) []reconcile.Request { var vms computev1.VirtualMachineList if err := r.List(ctx, &vms, client.InNamespace(obj.GetNamespace())); err != nil { return nil }
var requests []reconcile.Request for _, vm := range vms.Items { if vm.Spec.ProviderRef == obj.GetName() { // 仅重新入队实际引用此配置的 VM requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&vm)}) } } return requests }
这就是跨资源协调:一个资源的变化会触发完全不同的资源类型进行协调,二者之间仅通过字段值关联,而非所有权关系。
这也是整个项目主题的回归之处。在操作符的生产版本中,ProviderConfig 将保存 AWS、Azure 或 GCP 的真实凭证和真实端点。模拟提供者正是为了替代这一边界而存在。
### RBAC
没有权限操作这些资源,以上内容均无法生效。清单只需列出我们实际访问的内容:VirtualMachine 和 ProviderConfig 对象、VirtualMachine 状态子资源的独立规则,以及我们创建的 Secret / Service 对象:
apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: vmoperator-manager-role rules:
- apiGroups: ['compute.example.com']
resources: ['virtualmachines', 'providerconfigs'] verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete']
- apiGroups: ['compute.example.com']
resources: ['virtualmachines/status'] # 独立规则,这是单独的子资源 verbs: ['get', 'update', 'patch']
- apiGroups: ['']
resources: ['secrets', 'services'] verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete']
apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: vmoperator-manager-rolebinding roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: vmoperator-manager-role subjects:
- kind: ServiceAccount
name: vmoperator-controller-manager namespace: vmoperator-system
注意:VMOperator 通过自定义的 HTTP 客户端管理完全位于集群外部的资源,但这并非新颖的模式。Crossplane、AWS Controllers for Kubernetes、Cluster API 和 cert-manager 都通过 CRD 以相同方式协调外部或非 Kubernetes 状态。当这种模式变得熟悉后,这些资源值得一读。
再补充一点:我们有意将 VMOperator 的作用范围保持狭窄。调整正在运行的虚拟机大小、停止并重新启动虚拟机、创建快照,以及在 ProviderConfig 后支持多个真实提供者,都是对当前内容的自然扩展,也是在核心循环感觉稳固后的合理下一步。
## 第 4 部分:生产环境与部署
现在 VMOperator 已经可以工作,让我们看看如何打包和部署它,并为生产环境进行改进。
### 打包与部署
到目前为止,所有内容都是在我们自己的机器上以二进制文件运行的。通过 `go run` 针对 `kubectl` 当前指向的任意集群执行。
部署需要一个镜像,因此操作符使用了多阶段构建的 Dockerfile:一个阶段用于编译,另一个更小的镜像用于实际运行:
FROM golang:1.26 AS build WORKDIR /src COPY . . RUN CGO_ENABLED=0 go build -o /vmoperator ./cmd/manager
FROM gcr.io/distroless/static-debian12 COPY --from=build /vmoperator /vmoperator USER 65532:65532 # 非 root 用户,与下面 Deployment 的安全上下文匹配 ENTRYPOINT ["/vmoperator"]
构建阶段包含完整的 Go 工具链和所有源文件,但这些都不需要打包到最终镜像中。最终镜像仅包含编译后的二进制文件,这涵盖了容器安全上下文后续需要的大部分内容。即使在 `runAsNonRoot` 设置之前,镜像中也没有 shell 可供利用。
docker build -t registry.example.com/vmoperator:v0.1.0 . docker push registry.example.com/vmoperator:v0.1.0
该镜像正是config/manager/目录下的Deployment清单文件实际引用的镜像。只要将该镜像推送到集群可拉取的位置,就可以继续部署其余的清单文件:自定义资源定义(CRD)、RBAC、operator的Deployment,以及mock provider的Deployment和Service。我们也不再需要在本地机器上作为侧边进程运行它:
kubectl apply -f config/crd/ kubectl apply -f config/rbac/ kubectl apply -f config/manager/
在部署任何其他内容之前先安装CRD至关重要。否则,如果operator的Deployment在启动时立即尝试监视API服务器从未听说过的资源类型,就会导致Deployment进入崩溃循环。
手动编写清单文件时,模式变更会直接影响我们的操作。向VirtualMachineSpec中添加字段是安全的,但现有对象只是没有设置该字段。而重命名或重构字段则会带来问题:每个已存储的VirtualMachine都是按照旧模式序列化的。
CRD的versions列表正是为此设计的,因为可以同时提供多个版本。其中一个版本会被标记为storage,表示实际持久化的对象形状,而转换webhook会在客户端请求非存储版本时进行版本间转换。
对于VMOperator来说,今天不需要这个功能,因为v1是唯一存在的版本。但这也解释了为什么从我们最初编写的清单开始,versions字段就被设计为列表而不是单个值。
以上内容并不会取代人工执行kubectl apply的必要性。构建operator镜像、推送镜像并在合并到main分支时应用清单的CI流水线才是自然的下一步。这属于常规的CI/CD流程,只要清单本身在Git中,就不再需要任何特定于operator的处理。
### 性能与弹性
默认情况下,控制器一次只处理一个reconcile操作。当只有我们自己在测试时这没有问题,但当有数百个VirtualMachine对象时,这意味着大多数对象会停留在工作队列中等待轮到自己,尽管处理一个对象并不会阻塞处理其他对象。
MaxConcurrentReconciles参数可以提升这个限制:
func (r *VirtualMachineReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&computev1.VirtualMachine{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). Owns(&corev1.Secret{}). Owns(&corev1.Service{}). Watches(&computev1.ProviderConfig{}, handler.EnqueueRequestsFromMapFunc(r.findVirtualMachinesForProviderConfig)). WithOptions(controller.Options{MaxConcurrentReconciles: 5}). // 同时处理五个虚拟机实例,而不是仅一个 Complete(r) }
缓存只能帮助reconciler的一侧。通过r.Get从缓存中读取vm对象已经很快且本地化,因为informers会将数据保留在内存中。但r.Provider.Get每次都会进行真实的HTTP往返,且其前面没有任何缓存。
这种不对称性值得我们关注,因为它与第2部分中的情况完全相同:集群内部读取成本低廉,因为Kubernetes为我们构建了缓存层,而外部读取的成本完全取决于另一端的响应。我们可以在provider客户端前添加一个短期缓存,但这会带来实际成本:如果缓存中存储着一个刚刚失败的虚拟机的Running状态,我们的状态信息会持续重复这个错误信息,直到缓存过期。
提供者没有在其前面缓存意味着我们没有任何限制去频繁访问它。例如,集群重启后所有虚拟机同时触发同步操作,会瞬间产生大量无协调的HTTP请求。通过在客户端封装速率限制器,可以独立于工作队列现有的失败重试机制来限制请求频率:
type Client struct { baseURL string http *http.Client limiter *rate.Limiter // 被所有使用该客户端的同步操作共享 }
func (c *Client) Create(ctx context.Context, image string) (*VM, error) { if err := c.limiter.Wait(ctx); err != nil { return nil, err } // ... 现有的HTTP调用 }
领导者选举是安全运行多个副本的另一半机制。我们在第二部分将其简化为概念,但实际实现中只需要在管理器配置中添加两个字段:
mgr, err := ctrl.NewManager(cfg, ctrl.Options{ LeaderElection: true, LeaderElectionID: "vmoperator-leader", })
设置后,所有副本都会启动,但只有持有租约的副本会执行同步操作。其余副本会随时准备在租约未及时续期时接管。
并发性还暴露了第三部分中我们忽略的竞态条件。例如,协调器调用r.Provider.Create创建虚拟机后,提供者创建虚拟机并返回ID,但进程在r.Status().Update执行前崩溃。此时vm.Status.ID仍为空,下次同步会发现对象尚未创建虚拟机并再次调用Create。现在提供者会为同一个VirtualMachine创建两个虚拟机实例。
MaxConcurrentReconciles或领导者选举机制无法防止这种情况。这是创建步骤本身的缺陷,只有当外部调用和记录结果的写入之间可能发生故障时才会显现。
真正解决这个问题需要提供者接受一个幂等性密钥,该密钥在第一次Create调用前生成并存储在对象中,这样重试的创建操作会识别到该操作已执行,而不会创建第二个虚拟机实例。
### 安全性
第三部分的ClusterRole虽然有效,但权限范围过于宽泛。它在整个集群范围内授予了对secret和service的所有操作权限,而操作符只接触自己拥有的资源。
更严格的实现方式是使用Role/RoleBinding而非ClusterRole/ClusterRoleBinding,仅在虚拟机操作符仅需运行于单一命名空间时,将权限范围限定到该命名空间。同时删除我们从未调用的操作动词。我们从不列出或监视不属于自己的secret,只处理自己创建的secret。这也是前一节应用的清单文件所引用的RBAC配置。
凭证管理是另一个漏洞。当前ProviderConfig中保存的是明文端点,而真实提供者需要同时保存API密钥,该密钥不应以任何具有对象读取权限的人都能看见的方式存储在CRD规范中。它应该存储在Secret中,并通过名称引用而非内嵌:
type ProviderConfigSpec struct { Endpoint string json:"endpoint" SecretRef corev1.LocalObjectReference json:"secretRef" // 存储提供者API密钥的Secret }
协调器在构建提供者客户端时会解析SecretRef,从Secret的data字段中读取密钥,并且不会将其记录日志或写入任何具有更广泛读取权限的资源,包括VirtualMachine自身的状态。
最后一部分是操作员自身的 Pod。以非 root 用户身份运行的容器安全上下文会设置只读根文件系统,丢弃不需要的 Linux 权能,并在二进制文件本身被入侵时最大限度地缩小潜在影响。这是所有工作负载的标准实践,而非操作员特有的做法。
如果我们在本指南的任何地方添加了准入 Webhook,其证书也应放在这里。由于 VMOperator 不需要它,我们将其作为参考而非需要配置的项目。
### 可观测性
管理器无需我们编写任何代码即可暴露 Prometheus 端点。每个控制器已经自动提供了工作队列深度、重试持续时间以及重试错误计数。
关于提供者的信息不会自动出现,因此我们像任何 Go 服务一样添加指标:
var providerCallDuration = prometheus.NewHistogramVec( prometheus.HistogramOpts{ Name: "vmoperator_provider_call_duration_seconds", Help: "按操作分类的 VM 提供者调用持续时间", }, []string{"operation"}, )
func init() { metrics.Registry.MustRegister(providerCallDuration) // 共享管理器已有的 /metrics 端点 }
在每个提供者调用周围添加计时器,可以将“提供者是否缓慢”这个原本需要猜测的问题,转化为可以通过图表分析的问题。
日志记录同样受益于这种思路。如果在 SetupWithManager 中设置一次,Reconcile 内部的 log.FromContext(ctx) 会在每一行自动携带 VirtualMachine 的名称和命名空间。在设置 vm.Status.ID 后立即将其添加到日志器中,意味着后续所有该次 Reconcile 的日志行也会携带提供者为该 VM 分配的唯一标识符。这个字段使得我们可以通过 grep 在模拟提供者日志和操作员日志中同时查找同一请求的双方信息,从而定位相同的故障。
## 下一步
在本指南中,你学习了 Kubernetes 操作员的定义、如何从零构建一个操作员,以及如何为其生产环境做准备。你还在过程中了解了终结器(finalizers)、谓词(predicates)、托管资源(owned resources)以及跨资源重试(cross-resource reconciliation)等概念。
这些内容并不特定于管理虚拟机。下一个操作员,无论它管理什么资源,其结构都是一致的。
你也可以查看以下资源继续学习:
- K8s 自定义资源
- client-go
- controller-runtime
- Docker 文档
一位致力于通过技术不断进步、创新并激励他人的软件工程师。
如果你读到了这里,请感谢作者以表达你的支持。说声谢谢
免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发者。立即开始
ADVERTISEMENT