如何构建Kubernetes操作器:开发人员手册
Kubernetes自带了一些控制器,用于管理一组内置资源,例如部署对象、服务、节点等等。 而“操作符”则将这种机制扩展到那些Kubernetes本身不识别的资源类型,使你能够以与管理系统中其他资源相同的方式来管理这些自定义的、通常是外部系统。 本指南分为四个部分:首先介绍什么是操作符,然后讲解操作符的内部结构,接着指导如何从零开始构建一个操作符,最后说明如何将其准备好投入生产环境使用。 目录 第1部分:简介 什么是操作符? 操作符、控制器与CRD的区别 为什么不直接使用Helm Chart、CronJob或脚本呢? 第2部分:操作符的内部结构 自定义资源 监控变化 管理器 协调循环 第3部分
Kubernetes自带了一些控制器,用于管理一组内置资源,例如部署对象、服务、节点等等。
而“操作符”则将这种机制扩展到那些Kubernetes本身不识别的资源类型,使你能够以与管理系统中其他资源相同的方式来管理这些自定义的、通常是外部系统。
本指南分为四个部分:首先介绍什么是操作符,然后讲解操作符的内部结构,接着指导如何从零开始构建一个操作符,最后说明如何将其准备好投入生产环境使用。
目录
第1部分:简介
什么是操作符?
Kubernetes通过将我们描述的状态与实际状态进行对比来运行。控制器的作用就是消除这种差异——比如当某个Pod被删除时,部署控制器会负责替换它;或者当我们不再需要某些资源时,控制器会负责缩减它们的规模。
这种观察、比较并采取行动的循环被称为协调机制。它的含义是:查找某种资源的期望状态与实际状态,决定接下来该采取什么行动,并且在每次运行时都会重新计算这个决策结果,无论其间发生了哪些变化。
正是这种机制使得这一循环具有弹性:它无需确保自己已经观察到了所有事件的发生,只需要确保循环能够被再次执行即可。
操作员会将同样的循环机制应用于Kubernetes本身并不原生支持的资源类型。我们定义一种自定义资源,在其中描述该资源的期望状态,然后编写相应的控制器来负责实现这种状态的协调机制。
整个概念就是这样的:本指南中提到的其他所有内容(如信息提供者、工作队列、终结器以及状态条件等),都是为了让Kubernetes仅通过我们的定义就能可靠地管理这些自定义资源类型。
操作员、控制器与自定义资源定义
这三个术语经常被交替使用,但它们实际上描述的是同一系统中的不同层次。
自定义资源定义(CRD):这是一种我们向Kubernetes API服务器注册的规范,用于告知它新资源的存在方式。单独来看,CRD本身并不会执行任何操作;它只是为API服务器提供了存储、验证和提供这些资源数据的结构。
控制器:任何针对某种资源类型运行协调机制的软件组件,无论是内置的部署控制器,还是用于管理
PostgresCluster这样的自定义控制器。操作员:一种专门用于管理自定义资源的控制器,或者由一组控制器组成的系统。这类操作员包含了足够的领域特定知识,能够完全自动化地管理这些资源的整个生命周期,包括资源配置、升级、故障恢复等环节。
所有的操作员都属于控制器的范畴,但并不是所有的控制器都是操作员。如果没有相应的控制器支撑,仅仅是一个CRD也是毫无实际作用的。
为什么不直接使用Helm图表、CronJob或脚本呢?
Helm图表会将一组配置信息转换成YAML格式并仅执行一次;之后它就无法继续监控这些资源的状态了。如果它创建的资源被删除或者状态发生了变化,除非我们手动再次运行helm upgrade命令,否则Helm是无法察觉到的。
CronJob虽然可以实现持续的监控机制,但其监控的粒度较低,且两次执行之间不会保留任何状态信息;此外,一个CronJob也无法对另一个CronJob所引发的状态变化做出反应。
一次性脚本则只有在被手动触发或通过持续集成管道启动时才会执行相应的操作,在两次执行之间的间隔期内它不会进行任何自动处理。而且,这类脚本很少会将重试机制和幂等性作为设计重点来考虑。
为什么操作符在这里能够发挥作用:
操作符采用事件驱动机制,且运行过程是连续不断的。每当自定义资源被创建、更新或删除时,API服务器会立即通知操作符,而操作符会在该资源的整个生命周期内持续进行状态同步,而不仅仅是在应用资源变更的那一刻。对于那些需要一定时间才能达到稳定状态、在处理过程中可能会出现错误、或者在初次创建后其状态可能会发生偏差的资源来说,这种机制尤为重要。
然而,这种方式也带来了一定的成本。操作符是一个长期运行的进程,它拥有自己的基于角色的访问控制机制、故障处理机制以及监控系统。与简单的图表或脚本相比,构建和运行操作符需要投入更多的资源和精力。
如果真正需要的只是偶尔将某些YAML数据渲染一次,那么Helm图表才是合适的工具。而当问题在于需要持续保持某些数据的正确性时,操作符才显得真正有用——而这正是本指南后续内容所要重点讲解的内容。
第2部分:操作符的构成原理
接下来,我们将详细探讨那些使得这一机制能够正常运行的组成部分:自定义资源本身、用于检测数据变化的机制,以及负责整体运行的管理组件。
自定义资源
在控制器开始进行状态同步之前,API服务器首先需要了解所存储数据的结构。通过注册自定义资源定义文件,API服务器才能掌握这种结构信息。
每个自定义资源都会包含Kubernetes对象中已有的那些标识字段(如kind、name、namespace、labels等),此外还会额外包含两个由我们自行定义的字段:`spec`和`status`。这种划分方式并非随意选择,而是与状态同步机制直接相关。
Spec代表期望的状态。这个字段由创建或编辑资源的用户来填写,而控制器仅负责读取这些信息。
Status表示实际观察到的状态。这个字段仅由控制器写入,用于记录控制器检测到的数据以及它所执行的操作。
如果客户端直接修改`status`字段,那就意味着它在绕过控制器进行操作,因此通常会将`status`作为独立的子资源来管理,并为其设置不同的权限。
最后一个关键步骤是注册自定义资源。API服务器以及任何与之交互的客户端都需要一种统一的编码方式来处理这种类型的数据,因此我们必须在正式使用之前先按照规定的格式进行注册。如果没有这个注册过程,我们的自定义资源就只是一些无法被有效处理的定义而已;而通过注册,API服务器就可以像处理普通Pod或Deployment对象一样来存储和提供这些资源了。
在第3部分中,我们将具体了解这种注册操作的详细流程。
检测数据变化
状态同步机制并不会通过循环不断向API服务器询问“是否有新的变化发生”,而是依靠三个不同的组件来协同工作以避免这种情况的发生。
一个监控组件会长期监视API服务器的状态,并在内存中维护一个针对特定类型资源的缓存列表,每当有添加、更新或删除操作发生时,就会及时更新这个缓存。
列表器会从缓存中读取数据,而不是通过API服务器获取信息,因此当协调器检查“这个资源是否已经存在”时,它只需要在本地缓存中查找相应的数据,而无需进行网络请求。
工作队列位于通知器和协调器之间。当通知器检测到数据发生变化时,它并不会直接调用协调器,而是会将相关的键、命名空间和名称放入队列中。随后,工作进程会从队列中取出这些信息并进行协调处理;队列还会自动消除重复项并控制请求的频率,因此,如果同一个对象发生了十次快速更新,系统也只会将这十次操作合并为一次待处理的请求,而不会进行十次多余的协调操作。
这也是为什么协调器接收的是键而不是对象本身的原因。当工作进程从队列中取出键时,对象的状态可能已经发生了变化,因此协调器总是会查询当前的实际状态,而不会依赖最初触发更新的信息。
管理器
管理器是负责协调所有这些流程的进程:它包含了通知器所填充的共享缓存、协调器用于读写对象的客户端机制,以及集群中其他组件用来判断控制器是否正常运行的健康检查接口等。我们注册的每一个协调器都会在同一个管理器内部运行。
如果我们为了提高可用性而运行多个相同控制器的副本,我们就不能让这些副本同时对同一个对象进行协调操作,否则它们会相互干扰、导致错误的结果。
管理器通过领导者选举机制来协调这些流程。各个副本会竞争获得领导者的权限,只有其中一个副本能够持有该权限并主动执行协调操作,而其他副本则处于待机状态,直到领导者停止更新权限为止。
我们将在第4部分中详细讨论这一机制的实际应用方式。目前,只需要知道管理器是实现这些流程的关键组件就可以了。
协调循环
这是整个系统中最为关键的部分。一旦我们真正理解了这个协调循环的工作原理,就会发现操作员所执行的绝大多数操作实际上都是对这个循环的变体而已。
协调器的入口函数只需要接收命名空间和名称作为参数,其他任何信息都不需要。协调器必须自行获取对象的实际数据,将其规格描述与观察到的实际状态进行对比,然后决定应该采取什么行动。这种设计是经过刻意考虑的,也是后面所有逻辑的基础所在。
幂等性
由于协调器每次接收到的只是键的信息,并且对于同一个对象,它可以被调用任意多次(无论是连续调用还是间隔一段时间后调用),因此无论它被执行多少次,最终都应该得到相同的结果。如果一个协调器在每次执行时都会盲目地创建对象,那么当它被执行第二次时,就会因为尝试操作已经存在的对象而失败。
<解决办法是在采取任何行动之前始终先检查当前的状态:只有在数据缺失时才进行创建操作;只有当数据与现有内容不同时才进行更新;而只有在这种数据本就不应该存在的情况下,才执行删除操作。>事件驱动的协调机制
协调操作的触发通常源于被协调资源上发生的监控事件,按照惯例,该资源的所有子资源或它所依赖的其他组件也会触发此类事件。此外,大多数控制器还会设置定期同步机制,因此即使没有监控事件发生,这个协调循环也会按计划执行——这一点非常重要,因为有时状态的变化可能由于某些原因而无法被监控机制及时发现。
重排任务
有时候,单次协调操作无法完成所有工作,因为某些依赖操作仍在其他地方进行中。协调器可以请求在一段时间后再次执行该操作,而不会将这种情况视为失败。通过这种方式,它可以持续检测那些需要较长时间才能完成的任务,而不会在一次调用中就陷入等待状态。
错误处理
返回错误时也会采用类似的处理方式:即重新安排任务执行时间,但会使用指数级延迟策略而非固定间隔。这样一来,即使某个协调操作持续失败,也不会对相关系统造成过大的压力。
需要区分那些可以重试的错误(比如超时或锁冲突)和那些无法重试的错误(比如某些永远无效的条件,这类错误应该通过状态提示来显示,而不是无限次地尝试重试)。
偏差校正
将以上所有机制结合起来,这个协调循环就具备了自我修复的能力。由于每次协调操作都会重新计算所有的差异信息,而不仅仅针对具体发生了哪些变化,因此无论状态偏差是由谁引起的——无论是有人使用了kubectl edit命令,还是其他控制器,又或者是该资源所代表的底层系统自身发生了状态变化——下一次协调操作都能检测到这种偏差,并以相同的方式将其消除。
第三部分:构建操作器
到目前为止,我们所做的一切都是为构建这个操作器做准备。现在我们已经了解了什么是操作器,它相关的各个概念是如何相互关联的,以及一个协调循环是由哪些组件构成的。
接下来,我们将把这些知识应用到实际中,来构建VMOperator这个操作器。它是一个用于管理VirtualMachine自定义资源的操作器,而这个资源实际上是由一个模拟的云服务提供的;我们还会编写一个小型的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配置文件,就能创建出一台虚拟机——而这一切都不需要通过云控制台或单独的CLI工具来完成。
这就是开发VMOperator的初衷:让那些完全独立于Kubernetes之外的资源,也能成为集群内置工具(如kubectl、RBAC机制以及GitOps管道)能够正常处理的对象。

设置准备
我们需要搭建一个本地集群,而kind是实现这一目标的最简单工具:
kind create cluster --name vmoperator
除此之外,在这一部分中,我们将使用Go语言来编写操作器的代码,使用指向新创建的集群的kubectl命令,同时还会使用Python和Flask来实现模拟提供者的功能。
pip install flask
模拟提供者
在编写控制器代码之前,我们首先需要一个可以被控制的对象。这个模拟提供者实际上就是一个拥有三个端点的简单HTTP服务:
POST /vms:用于创建虚拟机GET /vms/{id}:用于查询虚拟机的状态DELETE /vms/{id}:用于删除虚拟机
这个模拟提供者的实现其实仅仅依赖于内存中存储的一个字典而已。
它创建的每个虚拟机在最初都会处于Provisioning状态,几秒钟后就会自动切换到Running状态。这样的设计能够确保我们的协调器会真正地去检查这些虚拟机的状态,而不会错误地认为它们已经成功创建。
我们使用Python而不是Go来编写这个模拟提供者,因为它的作用仅仅是用于模拟测试,并且与操作器的代码没有直接关系。
import random
import string
import threading
import time
from flask import Flask, jsonify, request
app = Flask(__name__)
vms = {} # 用于存储虚拟机信息的字典,键为虚拟机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() # 在后台启动虚拟机的创建过程
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 "", 204
if __name__ == "__main__":
app.run(port=8080, threaded=True)
我们会将这个模拟提供者作为一个独立的进程来运行,让它与集群同时运行,并且它会监听操作器配置中指定的端口。这个模拟提供者根本不知道Kubernetes的存在,这正是它的设计目的——它就是用来替代真实的云服务API的。
定义VirtualMachine CRD
既然我们有了可以控制的对象,那么就可以开始定义我们要控制的具体内容了。VirtualMachine类型的CRD完全遵循了第二部分中介绍的规范和状态模型:
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: {} # 将“状态”信息单独定义为子资源,具体细节请参见第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这一行的设置非常重要。正是这一设置使得“状态”信息成为了一个独立的子资源,并且拥有自己专门的更新路径。这正好符合我们在第2部分中讨论过的原则:哪些信息可以由客户端直接修改,哪些信息只能由控制器来处理。
names块也是kubectl命令进行解析的依据。因此,`kubectl get virtualmachines`这一命令能够正常工作,就是因为配置文件中指定了plural值为“virtualmachines”。同样地,`kubectl get vm`也能正常使用,因为配置文件中指定了shortNames值为“vm”;对于Pods来说,`kubectl get po`这一命令也是基于同样的原理工作的。
协调器
从表面上看,协调器的职责似乎很简单:只需查看一个VirtualMachine对象,然后确认提供商系统中确实存在与之对应的虚拟机,并且该虚拟机的状态与实际情况相符即可。我们会将提供商提供的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 == "" {
// 尚不存在虚拟机,这是我们第一次看到这个对象
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 // 稍后再次检查,而不是在这里阻塞
}
// 虚拟机已经存在,向提供者询问当前的状态信息
_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失败时(例如由于网络问题或模拟提供者尚未启动),它只会返回错误信息。这种设计是故意的——通过返回错误,我们可以让控制器运行时系统代为执行指数级退步的重试策略。这样一来,我们就无需自己编写重试逻辑,而且那些始终无法连接的提供者也不会被过多的重试请求所困扰。
唯一需要注意的是:不能对所有类型的错误都采取相同的处理方式。与提供者的通信超时是可以尝试重试的;但如果提供者永远不会接受某个VirtualMachine对象的配置信息,那么持续重试也是没有意义的,因为这样只会导致无限循环而无法成功。
关于如何区分这两种情况,我们会在后续练习中通过状态条件来详细说明。由于模拟提供者从不会直接拒绝请求,因此上述协调器只需要考虑一种失败情况即可。
终结器
如果我们现在删除一个VirtualMachine对象,Kubernetes会立即移除该对象,但提供者仍然会认为这个虚拟机处于运行状态,从而导致数据不一致。终结器的作用就是解决这个问题:它会在对象上添加一条字符串信息,告诉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命令时,Kubernetes实际上并不会立即删除该对象。相反,它会设置deletionTimestamp>字段,然后等待相应的删除操作完成。
我们的协调器会在下一次调用时通过相应的提供者来释放该虚拟机的资源,只有在这个步骤之后,最终删除标记才会被移除,此时Kubernetes才会真正删除该对象。如果不存在这样的最终删除标记,就无法保证资源清理操作会被执行。
谓词
在上述协调器代码中存在一个隐蔽的错误:每次调用r.Status().Update时,这种写操作本身就会导致对象状态的改变,而这又会触发我们的监控机制,进而使得协调器再次被调用。
如果不对这个现象进行处理,虽然这种循环不会无限持续下去,因为最终状态会稳定下来,但这样的重复协调操作仍然属于浪费资源的行为。
func (r *VirtualMachineReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&computev1.VirtualMachine{}, builder.WithPredicates(predicateGenerationChangedPredicate{})). // 只保留状态发生变化时的事件
Complete(r)
}
generation字段只有当spec配置发生改变时才会增加;状态更新不会影响这个字段的值。GenerationChangedPredicate谓词的作用就是过滤掉那些仅仅是状态信息发生变化的事件,这样我们就不会再因为自己进行的写操作而触发不必要的协调过程了。只有当有真正重要的信息发生变化,或者我们明确要求重新执行协调操作时,才会再次触发协调流程。
所属资源
一个正在运行的VirtualMachine本身并没有太大的实用价值,因此我们可以让操作者同时创建一个Secret>对象,用来存储该虚拟机在集群内部的连接信息:
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, rScheme); err != nil {
return err // 将这个Secret的生命周期与这台VirtualMachine关联起来
}
return r.Patch(ctx, secret, client.Apply, client.ForceOwnership, client.FieldOwner("vmoperator")) // 无论是创建还是更新,都会执行这个操作
}
SetControllerReference这一操作使得Secret成为一种“被某台虚拟机所拥有的资源”;它会在Secret中添加一个引用,指向对应的VirtualMachine。
由此会产生两个好处:首先,删除VirtualMachine时,Kubernetes会自动回收与之关联的Secret;其次,由于这种资源是集群内部的对象,因此不需要额外的终结器机制。
如果在SetupWithManager方法中同时使用For(&computev1.VirtualMachine{})和Owns(&corev1.Secret{}),那么当人们直接删除Secret时,系统会自动重新触发对其所关联的VirtualMachine的同步操作。因此,即使有人手动删除了Secret,系统也会自动重新创建它。
同样的机制——通过调用SetControllerReference并使用Owns()——还可以用来创建另一种“被某台虚拟机所拥有的资源”,也就是用于在集群内部为该虚拟机提供服务的Service对象。实际上,这意味着一台VirtualMachine可以拥有多种不同的资源类型。
跨资源同步
目前,每一台VirtualMachine都只与一个固定的提供者端点进行通信。但在实际部署中,这种配置应该是可定制的;而且,这种情况也并不罕见:例如,在同一个AWS账户中,可能会有50台VirtualMachine>共享相同的端点和认证信息,而在Azure环境中,则可能有另外100台虚拟机使用相同的配置。
我们可以在VirtualMachineSpec结构体中直接添加Endpoint字段,但这样一来,每当需要更新认证信息或修正拼写错误时,就必须逐一修改所有使用该字段的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(predicateGenerationChangedPredicate{})).
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() { // 只将那些确实引用了该配置文件的虚拟机重新加入处理队列
requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&vm)})
}
}
return requests
}
这就是跨资源协调机制:某种资源的变更会引发另一种完全不同类型的资源也需要进行协调处理,而这两种资源之间仅通过某个字段的值联系在一起,并不存在所有权关系。
这也是整个项目主题的核心所在。在这个操作器的生产环境中,ProviderConfig会用来存储与AWS、Azure或GCP相关的真实凭证和接口信息;而模拟提供的组件则正好起到了划分这些资源范围的作用。
RBAC
如果没有相应的执行权限,上述所有机制都无法正常运行。配置文件中只需要列出我们实际需要操作的资源类型:VirtualMachine和ProviderConfig对象,以及VirtualMachine的状态子资源,还有我们创建的Secret/Service对象:
apiVersion: rbacauthorization.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: rbacauthorization.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相同。当您熟悉这种设计模式后,这些相关资料值得一读。
另外一点需要说明:我们有意将VMOperator的功能范围控制在有限的范围内。调整正在运行的虚拟机的大小、停止并重新启动它、创建快照,以及通过ProviderConfig支持多个真实的资源提供者,这些都是对现有功能的自然扩展,也是在核心功能稳定运行之后的合理发展方向。
第4部分:生产环境与部署
既然VMOperator已经可以正常使用了,接下来我们就来看看如何将其打包、部署,并对其进行优化以便在生产环境中使用。
打包与部署
到目前为止,所有的操作都是在我们的本地机器上以二进制文件的形式运行的。我们只需要执行go run命令,就可以让程序在当前指定的Kubernetes集群中运行。
然而,对于Deployment>来说,需要使用镜像来进行部署,因此开发者会编写一个多阶段的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用户权限,与后续部署中的安全上下文相匹配
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规则、使用VMOperator的Deployment配置,以及为模拟资源提供者创建相应的Deployment和Service。因此,我们也不再需要在本地机器上单独运行这个程序来进行部署了:
kubectl apply -f config/crd/
kubectl apply -f config/rbac/
kubectl apply -f config/manager/
在开始其他任何部署步骤之前,首先安装CRD是至关重要的。否则,如果VMOperator的Deployment>尝试去监控API服务器根本不知道存在的资源类型,那么程序就会陷入无限循环中。
模式的变化正是那些需要我们手动操作配置文件时才能直接感受到的部分。向VirtualMachineSpec中添加一个字段并不会造成任何问题,但现有的对象并不会包含这个新字段;而重新命名或调整某个字段的结构则不同:因为所有已存储的VirtualMachine对象都是根据旧的结构被序列化存储的。
正是为了应对这种情况,CRD才设计了versions列表——因为可以同时提供多个版本。其中有一个版本会被标记为storage,用来说明哪些对象实际上是以哪种结构被保存下来的;而当客户端请求一个与已存储版本不同的版本时,转换Webhook会负责在这些版本之间进行转换。
对于当前的VMOperator来说,我们并不需要这种机制,因为目前只有v1这个版本存在。但正因为如此,从我们编写的第一份配置文件开始,versions字段就被设计成了一个列表,而不是一个单一的值。
以上这些措施并不会永远取代人们手动执行kubectl apply>命令的做法。接下来自然而然的步骤应该是:使用CI管道来构建操作器的镜像、将其推送上去,并在主代码库合并时应用相应的配置文件。一旦这些配置文件被存入了Git仓库,这个过程就完全属于普通的CI/CD流程了,与操作器本身并无特殊关联。
性能与弹性
默认情况下,控制器一次只能处理一个协调任务。当我们只是自己在进行测试时,这种设计并没有问题;但当存在数百个VirtualMachine对象时,这意味着大多数对象都会停留在工作队列中等待执行,尽管处理其中一个对象的流程并不会影响到其他对象的协调过程。
MaxConcurrentReconciles这个参数正是为了解决这个问题而设计的:
func (r *VirtualMachineReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&&computev1.VirtualMachine{}, builder.WithPredicates(predicateGenerationChangedPredicate{})).
Owns(&&corev1.Secret{}).
Owns(&&corev1.Service{}).
Watches(&&computev1.ProviderConfig{}, handler.EnqueueRequestsFromMapFunc(r.findVirtualMachinesForProviderConfig)).
WithOptions(controller.Options{MaxConcurrentReconciles: 5}). // 同时处理5个VM对象的协调任务
Complete(r)
}
缓存机制只对这种协调流程的某一方有帮助:r.Get方法用于读取vm>对象的信息,由于通知器会将这些信息保留在内存中,因此这个操作的速度已经很快了,而且也是本地进行的;但r.Provider.Get方法每次都需要进行一次完整的HTTP请求,目前并没有缓存机制来优化这一过程。
这种不对称性其实是可以接受的,因为这与我们在第二部分讨论过的内容是一样的:在Kubernetes集群内部进行的读操作成本很低,因为Kubernetes已经为我们构建好了缓存层;而外部请求的成本则完全取决于网络另一端的系统性能。我们可以在提供者客户端前面添加一个短期缓存的机制,但这样做也会带来一定的代价——例如,如果某个VM对象刚刚发生了故障,但其状态仍然被显示为“Running”,那么这个错误信息就会一直被保留下来,直到缓存失效为止。
如果提供者没有使用缓存机制,那么也就没有任何东西能够阻止我们频繁地向它发送请求。例如,在集群重启后,如果所有的VirtualMachine对象都被同时访问,那么这些请求就会转化为一系列没有协调机制的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>机制确实有效,但它的权限范围过于宽泛。它为secrets和services>相关的所有操作都赋予了全局权限,而实际上操作者只需要处理自己拥有的那些资源而已。
一个更严格的权限设置方式是使用Role/RoleBinding来限定权限范围,只针对特定的命名空间。因为VMOperator>通常只在某个特定的命名空间内运行,所以只需要为该命名空间配置相应的权限规则即可;同时,也可以删除那些我们根本不会使用的操作权限。我们从不会列出或监控不属于自己的任意Secret>对象,只关注自己创建的那些秘密。
凭证管理也是另一个需要解决的问题。ProviderConfig>目前包含一个明文端点地址,而一个真正的提供者应该同时使用API密钥进行身份验证。这种密钥不应该被放在任何任何人都可以访问的CRD配置文件中,而应该存储在Secret>对象中,并通过名称来引用这个密钥:
type ProviderConfigSpec struct {
Endpoint string `json:"endpoint"`
SecretRef corev1.LocalObjectReference `json:"secretRef"` // 存储提供者API密钥的Secret对象
}
在构建提供者客户端时,协调器会读取SecretRef中存储的密钥值,但不会将这个密钥记录到任何具有更广泛访问权限的地方,包括VirtualMachine自身的状态信息。
最后还有一个组成部分,那就是操作员自己的Pod。以非root用户身份运行的容器安全环境会设置只读的root文件系统,移除不需要的Linux功能,并限制那些可能被恶意利用的功能。这种做法对于任何类型的工作负载都是标准操作,并不是操作员特有的措施。
如果我们在本指南中的任何地方添加了admission webhook,它的证书也应该放在这里。不过对于VMOperator来说,并不需要这样的机制,因此我们只是将其作为参考信息列出,而不需要进行具体配置。
可观测性
管理者会自动提供Prometheus接口,我们无需额外编写任何代码即可使用这些数据。每个控制器都会显示工作队列的深度、协调操作的耗时以及错误统计信息。
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,就可以确保后续的所有日志记录都能包含该虚拟机的唯一标识符。正是这个字段的存在,才使得我们能够同时分析模拟提供的日志和操作员的日志,从而找出同一请求所引发的问题。下一步计划
通过本指南,您了解了Kubernetes操作员的概念、如何从零开始构建一个操作员以及如何使其准备好投入生产环境。同时,您还学习了关于终结器、谓词、受管理的资源以及跨资源协调等相关知识。
相关文章
演讲主题:利用CHERI技术实现内存安全性与细粒度的隔离机制
David Chisnall阐述了CHERI硬件架构是如何重新定义指针安全性,从而解决数据隔离与共享所带来的问题的。他解释了CHERI如何为C/C++语言提供空间上的和时间上的内存安全保障,如何通过CHERIoT技术将其应用到微控制器中,以及如何用轻量级且可审计的隔离机制取代那些成本高昂的操作系统级别的远程过程调用机制——而所有这些都不需要对现有代码进行大规模的修改。 作者:David Chisnall
阅读全文
JetBrains详细介绍了其为控制这一快速增长的人工智能相关支出所采取的措施。
JetBrains解释了为何在与开发相关的支出在六个月内增加了大约十倍之后,它开始集中管理人工智能技术的使用情况。该公司并没有限制工程师只能使用少数被批准的工具,而是建立了一个共享访问及成本核算系统,这样既能保证各团队仍可以自由选择所需的工具,同时也能让这些团队更清楚地了解自身的资源消耗情况,并对资源的利用进行更好的控制。 作者:Matt Foster
阅读全文
Pinterest是如何通过集中式的Terraform管道来大规模保护其AWS基础设施的?
Pinterest公布了其自主研发的Terraform执行引擎——资源供应管道系统(RPP)。该系统能够确保最小权限访问机制,并需要经过双重审核流程。对于Pinterest的AWS基础设施而言,这一系统至关重要,因为它为GitHub Actions工作流程提供了严格的防护机制。 作者:Claudio Masolo
阅读全文
项目“英灵殿”的首次预览:JEP 401重新定义了Java对象中的“==”运算符
JEP 401被集成到了JDK 28中,它引入了“值对象”这一概念。这些新的类实例具有不可变的字段、经过修改的相等性判断逻辑,以及更为严格的构造函数使用规则和同步机制。其目的在于提升程序运行效率并降低内存分配所带来的开销。不过,默认情况下这些新功能是处于禁用状态的,需要用户在编译或运行时进行相应的配置才能启用。 作者:A N M Bazlur Rahman
阅读全文