Operator 是一种用来扩展 Kubernetes API 的方法,它能够将复杂的的应用程序封装成易于管理和自动化的 API 对象。假设我们要在集群中部署一个高可用 MySQL 集群,这个过程需要使用到非常多的 Kubernetes 对象,例如 StatefulSet、Service、ConfigMap、HorizontalPodAutoscaler 等等。即使有 Helm 这类工具能简化配置文件的编写过程,但也需要用户自己去管理这些对象之间的关系,更重要的是当 MySQL 集群出现问题时用户需要手动介入来解决问题,例如某个关键的对象被误删除。有没有一种方法能够让用户只需要关注 MySQL 集群本身的状态而不需要关心底层的 Kubernetes 对象呢?这就是 Operator 的用武之地,Operator 的核心思想就是将应用程序的运维逻辑封装到控制器中,这样用户只需要关注自定义资源的状态,而不需要关心底层的资源。
Operator 的架构主要由两个部分组成:自定义资源定义 CRD 和 控制器 。CRD 是 Kubernetes 的一种扩展机制,它允许用户定义自己的资源类型,然后像操作内置资源类型一样操作这些自定义资源;控制器是一个独立的程序,它会监听自定义资源的变化然后调整集群中 Kubernetes 对象的状态,这个过程称为「调谐 (Reconcile)」,实际上这个过程和我们使用 Deployment 等内置控制器创建 Pod 的过程是一样的,只不过 Operator 面向的是自定义资源。
为了更深入地理解 Operator 的概念我们来看一个「教科书」级别的 Prometheus Operator ,从名字可以看出这是一个用来管理 Prometheus 实例的 Operator,它提供了一个名为 Prometheus
的自定义资源用于描述 Prometheus 实例的状态,如下所示:
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 31 32 apiVersion: monitoring.coreos.com/v1 kind: Prometheus metadata: name: prometheus namespace: kube-prometheus-stack spec: hostNetwork: false image: quay.io/prometheus/prometheus:v2.51.0 replicas: 1 resources: limits: memory: 2Gi requests: memory: 1Gi retention: 7d retentionSize: 10GB securityContext: fsGroup: 2000 runAsGroup: 2000 runAsNonRoot: true runAsUser: 1000 seccompProfile: type: RuntimeDefault storage: volumeClaimTemplate: spec: accessModes: - ReadWriteOnce resources: requests: storage: 10Gi storageClassName: local-path
这个对象描述了 Prometheus 实例的关键信息,hostNetwork
、image
、securityContext
、resources
字段描述了 Prometheus Pod 的状态,volumeClaimTemplate
字段则描述了 PVC 对象的状态;除此之外还包含了 Prometheus 本身的配置,例如 retention
、retentionSize
等字段。当我们提交这个 Prometheus
对象后,Prometheus Operator 会根据这个对象创建 Prometheus 实例所需的 Kubernetes 对象并且在我们更新 Prometheus
对象后确保这些对象的状态与 Prometheus
对象的状态一致。从这个案例可以看出 Operator 极大地简化了应用程序的运维工作,同时也提高了应用程序的可靠性。
了解完 Operator 的概念后我们来开发一个简单的 Nginx Operator 用于在集群中快速部署 Nginx 并设计两个自定义资源:
ReverseProxy
用于部署一个 Nginx HTTP 反向代理服务TCPReverseProxy
用于部署一个 Nginx TCP 反向代理服务对于 YAML 工程师而言没有什么比亲手设计一个 Kubernetes 对象更有成就感的事情了!
kubebuilder kubebuilder 是一个用于构建 Operator 的 SDK,它提供了一系列的工具和库用于简化 Operator 的开发过程,我们可以使用 kubebuilder 来生成 Operator 的框架代码以及将 Go 语言结构体转换为 CRD。本文使用的是 kubebuilder v3.14.0 版本,相比起 v1 / v2 版本,v3 版本的 kubebuilder 更加易用生成的框架代码更加简洁。
首先需要安装 kubebuilder:
1 2 3 # download kubebuilder and install locally. curl -L -o kubebuilder "https://go.kubebuilder.io/dl/latest/$(go env GOOS)/$(go env GOARCH)" chmod +x kubebuilder && mv kubebuilder /usr/local/bin/
安装完毕后创建 Operator 项目:
1 2 3 4 5 6 7 8 9 10 # 创建项目目录 $ mkdir nginx-operator$ cd nginx-operator# 初始化项目 $ kubebuilder init --domain lin2ur.cn --repo github.com/yxwuxuanl/k8s-nginx-operator INFO Writing kustomize manifests for you to edit... INFO Writing scaffold for you to edit... ... Next: define a resource with: $ kubebuilder create api
--domain
参数用于指定自定义资源的域名,可以大胆的写上自己的域名标记自己的杰作;--repo
参数用于指定项目 Go Module 的名称,初始化完成后会在当前目录生成一个 Go 项目,根据 init 命令的提示下一步需要创建自定义资源:
1 2 3 4 5 6 7 8 $ kubebuilder create api --group nginx --version v1 --kind ReverseProxy INFO Create Resource [y/n] y INFO Create Controller [y/n] y INFO Writing kustomize manifests for you to edit... INFO Writing scaffold for you to edit... ...
--group
参数用于指定自定义资源组名,结合初始化项目时的 --domain
参数可以得到完整名称 nginx.lin2ur.cn
;--version
参数用于指定自定义资源版本;--kind
参数用于指定自定义资源的具体名称。以上三个参数组成了自定义资源的 GVK 信息:
1 2 apiVersion: nginx.lin2ur.cn/v1 kind: ReverseProxy
TCPReverseProxy
自定义资源的创建过程与 ReverseProxy
类似这里就不赘述了。
设计自定义资源 如同业务开发第一步是设计数据库表结构一样,开发 Operator 的第一步是设计自定义资源,调用 create api
命令后 kubebuilder 会在 api/v1/xxx_types.go
文件中生成自定义资源对应的结构体代码,我们要做的是修改这个结构体,随后调用 kubebuilder 生成安装到集群的 CRD,下面来看 ReverseProxy
资源的设计:
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 package v1import ( "github.com/yxwuxuanl/k8s-nginx-operator/internal/nginx" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type ReverseProxy struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` Spec ReverseProxySpec `json:"spec,omitempty"` Status ReverseProxyStatus `json:"status,omitempty"` } type ReverseProxySpec struct { NginxSpec `json:",inline"` nginx.CommonConfig `json:",inline"` nginx.ReverseProxyConfig `json:",inline"` } type NginxSpec struct { Image string `json:"image,omitempty"` Replicas int32 `json:"replicas,omitempty"` ServicePort int32 `json:"servicePort,omitempty"` Resources corev1.ResourceRequirements `json:"resources,omitempty"` NodeSelector map [string ]string `json:"nodeSelector,omitempty"` PodLabels map [string ]string `json:"podLabels,omitempty"` } type ReverseProxyStatus struct { Conditions []metav1.Condition `json:"conditions"` }
ReverseProxy
是自定义资源的根结构体,和大多数 Kubernetes 对象一样 ReverseProxy
包含有 metadata
、spec
、status
字段,先来看描述用户期望状态的 spec
字段对应的的 ReverseProxySpec
结构,内嵌了三个结构体:
NginxSpec
配置 Nginx 的 Pod、Service 等 Kubernetes 对象nginx.CommonConfig
配置 Nginx 的通用配置,例如 workerProcesses
、workerConnections
等nginx.ReverseProxyConfig
配置 Nginx 的 HTTP 反向代理配置这样的设计有助于代码复用,ReverseProxy
和 TCPReverseProxy
的差异主要在于 nginx.ReverseProxyConfig
和 nginx.TCPReverseProxyConfig
结构,而其它部分都是一样的。继续来看内嵌的结构:
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 31 32 33 34 35 36 37 38 39 40 package nginxtype CommonConfig struct { Resolvers []string `json:"resolver,omitempty"` WorkerProcesses *int `json:"workerProcesses,omitempty"` WorkerConnections int `json:"workerConnections,omitempty"` } type ReverseProxyConfig struct { ProxyPass string `json:"proxyPass"` Logfmt *string `json:"logFormat,omitempty"` Rewrite []RewriteConfig `json:"rewrite,omitempty"` HideHeaders []string `json:"proxyHideHeader,omitempty"` ProxyHeaders map [string ]string `json:"proxySetHeader,omitempty"` ReadTimeout string `json:"proxyReadTimeout,omitempty"` SendTimeout string `json:"proxySendTimeout,omitempty"` ConnectTimeout string `json:"proxyConnectTimeout,omitempty"` }
在设计时有几个注意事项:
每个字段都必须要有 json tag,包括内嵌字段 默认情况下 kubebuilder 生成 CRD 时会将每个字段都设置为必填的,选填的字段需要加上 omitempty
标记 描述 Kubernetes 对象信息的字段尽量与原字段名保持一致,例如 image
、replicas
、resources
等,减少用户的学习成本 尽量复用 Kubernetes 中已定义的结构体,例如 resources
字段使用 corev1.ResourceRequirements
结构体 需要区分 缺省 和 零值 的情况的字段可以使用指针类型,例如 logFormat
字段,用户不设置该字段时使用默认日志格式;用户传入空字符串时禁用日志 我们注意到在结构体和字段上面都一些以 // +kubebuilder:
开头的注释,这些注释是用于代码生成和 CRD 生成的标记,这些标记非常重要,来看 ReverseProxy
结构体上的标记:
1 2 3 4 5 6 7 type ReverseProxy struct {}
//+kubebuilder:resource:shortName
用于指定自定义资源的简称,可以在 kubectl get
命令使用这个简称;//+kubebuilder:printcolumn
用于指定自定义资源在 kubectl get
时展示的列,将关键的字段展示出来可以让使用者更方便地查看对象的状态;这些标记会在生成 CRD 时发挥作用,当 CRD 安装到集群后,我们可以使用 kubectl get ngxpxy
命令查看自定义资源的状态:
1 2 3 $ kubectl get ngxpxy NAME RECONCILED PROXYPASS NGINXIMAGE REPLICAS whoami True https://whoami.dev.lin2ur.cn nginx:1.25.1 1
再来看字段上的一些标记:
// +kubebuilder:default
用于指定字段的缺省值// +kubebuilder:validation
用于指定字段的验证规则,例如正则表达式、枚举值等其余的标记这里就不一一列出了 官方文档 中有详细的说明,合理地使用生成标记能提高程序的正确性,减少用户的输入错误的概率。
需要特别说明 nginx.CommonConfig
结构的 // +kubebuilder:object:generate=true
标记,这个标记的作用是告诉 kubebuilder 需要为该结构体生成 DeepCopy
代码,所有内嵌到根结构体中的结构都 必需 实现 DeepCopy
接口,生成的代码在同级目录的 zz_generated.deepcopy.go
文件中,忽略这一步会导致程序编译时报错。
设计完成后调用 kubebuilder 生成代码:
1 2 3 4 5 # 生成 CRD $ make manifests # 生成 DeepCopy 代码 $ make generate
生成的 CRD 对象在 config/crd/bases/nginx.lin2ur.cn_reverseproxies.yaml
文件中,将其安装到集群:
1 2 3 4 5 6 $ kubeclt apply -f config/crd/bases/nginx.lin2ur.cn_reverseproxies.yaml customresourcedefinition.apiextensions.k8s.io/reverseproxies.nginx.lin2ur.cn created # 验证 $ kubectl get ngxpxy No resources found in default namespace.
到这里自定义资源就设计好了,在修改自定义资源结构后需要重复以上步骤。
实现控制器 在创建自定义资源时 kubebuilder 会一并生成资源控制器的框架代码,ReverseProxy
资源的控制器在 internal/controller/reverseproxy_controller.go
文件中:
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 package controllertype ReverseProxyReconciler struct { client.Client Scheme *runtime.Scheme } func (r *ReverseProxyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error ) { _ = log.FromContext(ctx) return ctrl.Result{}, nil } func (r *ReverseProxyReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&nginxv1.ReverseProxy{}). Complete(r) }
Reconcile
是控制器的核心方法,我们需要在这个方法中实现调谐逻辑;SetupWithManager
方法用于构建控制器并将控制器注册到 Manager 中,在这个过程中我们可以对控制器进行一些配置。
当资源发生变化时 Reconcile
方法会被调用,req
参数对应的 ctrl.Request
类型包含了需要调谐对象的名称以及命名空间,除此之外没有其它信息,需要通过名称从集群中获取到对象的详细信息,然后根据对象描述的期望状态来调整集群的其它资源的状态。其它资源指的是哪些资源呢?这时候就需要梳理一下在没有 Operator 的情况下搭建一个 Nginx 反向代理实例需要哪些 Kubernetes 资源:
ConfigMap
保存 Nginx 配置文件Deployment
运行 Nginx PodsService
暴露 Nginx 服务简单梳理之后思路就清晰了,Reconcile
方法要做的就是根据 ReverseProxy
对象的状态创建、更新、删除这些资源,下面来看 Reconcile
方法的实现:
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 31 32 33 34 func (r *ReverseProxyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error ) { reverseProxy := &nginxv1.ReverseProxy{} if err := r.Get(ctx, req.NamespacedName, reverseProxy); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } err := updateObject(ctx, r.Client, r.Scheme, reverseProxy) defer updateStatus(ctx, r.Client, reverseProxy, &err, func (n *nginxv1.ReverseProxy, condition metav1.Condition) { meta.SetStatusCondition(&n.Status.Conditions, condition) }) if err != nil { r.Recorder.Event(reverseProxy, "Warning" , "ReconcileFailed" , err.Error()) return ctrl.Result{}, err } return ctrl.Result{}, nil }
可以看到 Reconcile
方法上面有一些 //+kubebuilder:rbac
开头的标记,这些标记是用于生成 RBAC 规则的,我们可以在这里加上控制器运行时需要的 RBAC 权限,调用 make manifests
命令时 kubebuilder 会在 config/rbac
目录下生成对应的 Role 和 RoleBinding 等对象。
Reconcile
首先调用 r.Get
方法获取需要调谐的 ReverseProxy
对象,然后调用 updateObject
方法调整集群中的资源,最后调用 updateStatus
方法更新 ReverseProxy
对象的状态。不难看出调谐的核心逻辑在 updateObject
方法中,在讲解这个方法之前先来了解一下 Reconcile
方法的两个返回值,这两个返回值决定调谐失败时应该如何处理:当返回 error
不为空时,调谐请求将使用指数退避重新进入队列等待下次调谐;如果想要控制下次调谐的间隔时间,可以通过返回 ctrl.Result
的 RequeueAfter
字段指定下次调谐的时间。下面来看 updateObject
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 func updateObject (ctx context.Context, cli client.Client, scheme *runtime.Scheme, ngxObject NgxObject) error { objects, err := buildObjects(ngxObject) if err != nil { log.FromContext(ctx).Error(err, "failed to build objects" ) return err } for _, object := range objects { if err := controllerutil.SetControllerReference(ngxObject, object, scheme); err != nil { return err } if err := createOrUpdate(ctx, cli, object); err != nil { return err } } return nil }
updateObject
方法主要做了两件事:
调用 buildObjects
方法构建需要 Nginx 实例的 Kubernetes 对象 调用 createOrUpdate
方法依次创建或更新这些对象 继续来看 buildObjects
方法,这个方法会调用其它方法来构建 Kubernetes 对象,这系列方法接收的是 NgxObject
接口类型而不是具体的自定义类型资源,这个接口对自定义资源进行了抽象,确保了这部分代码可以适用于 ReverseProxy
和 TCPReverseProxy
两种资源:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 type NgxObject interface { client.Object GetNginxSpec() v1.NginxSpec GetNginxConfig() nginx.Config GetNamePrefix() string } func buildObjects (ngx NgxObject) (objects []client.Object, err error ) {}func buildService (ngx NgxObject, selector map [string ]string ) *corev1.Service {}func buildConfigMap (ngx NgxObject) (configMap *corev1.ConfigMap, configsum string , err error ) {}func buildDeployment (ngx NgxObject) *appsv1.Deployment {}
createOrUpdate
方法的实现也很简单:
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 func createOrUpdate (ctx context.Context, cli client.Client, object client.Object) error { existing := object.DeepCopyObject().(client.Object) if err := cli.Get(ctx, client.ObjectKeyFromObject(object), existing); err != nil { if !errors.IsNotFound(err) { return err } if err := cli.Create(ctx, object); err != nil { return err } return nil } if err := cli.Update(ctx, object); err != nil { return err } return nil }
最后来看 updateStatus
方法,这个方法用于更新自定义资源的状态,也就是 status
状态:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 func updateStatus [T client .Object ]( ctx context.Context, cli client.Client, object T, reconcileErr *error , mutateFn func (T, metav1.Condition) ,) { newObj := object.DeepCopyObject().(T) mutateFn(newObj, condition) if err := cli.Status().Update(ctx, newObj); err != nil { log.FromContext(ctx).Error(err, "failed to update status" ) } }
细心的同学可能会问 updateStatus
方法调用了 Update
更新对象的状态,会触发对象再次调谐吗?答案是会的,那有什么方法可以避免这个现象呢?我们来回头看 SetupWithManager
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 var createOrUpdatePred = builder.WithPredicates(predicate.Funcs{ UpdateFunc: func (e event.UpdateEvent) bool { return e.ObjectOld.GetGeneration() != e.ObjectNew.GetGeneration() }, CreateFunc: func (e event.CreateEvent) bool { return true }, DeleteFunc: func (e event.DeleteEvent) bool { return false }, GenericFunc: func (e event.GenericEvent) bool { return false }, }) func (r *ReverseProxyReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&nginxv1.ReverseProxy{}, createOrUpdatePred). Complete(r) }
和原来 kubebuilder 生成的代码相比,我们给 For
方法传入了第二个参数,这个参数是一个 predicate.Predicate
类型用于过滤事件,而传入的 createOrUpdatePred
只有对象创建以及对象的 metadata.generation
字段发生变化时才会触发调谐,官方文档对这个字段的解释非常简洁:表示 期望状态 的特定生成的序列号。也就是说只有当对象的 spec
字段发生变化时这个字段才会发生变化,这样就避免了在更新 status
字段时意外触发调谐。需要注意的是如果调谐逻辑依赖对象 labels
或 annotations
字段,那就不能只判断 metadata.generation
字段是否发生变化。
调谐逻辑中并没有包含删除对象的逻辑,正常来说当自定义资源对象被删除时,由它创建的其它对象也应该一并被删除。在 updateObject
方法中我们调用 SetControllerReference
方法将这些受控对象和它的属主对象关联起来,SetControllerReference
方法的定义如下:
1 func SetControllerReference (owner, controlled metav1.Object, scheme *runtime.Scheme) error {}
owner
参数指定属主对象,controlled
指定受控对象。属主关系体现在受控对象的 metadata.ownerReferences
字段中:
1 2 3 4 5 6 7 8 9 10 11 12 kind: Service apiVersion: v1 metadata: name: ngx-proxy-whoami ownerReferences: - apiVersion: nginx.lin2ur.cn/v1 kind: ReverseProxy name: whoami uid: 19f00878-ff6f-46a1-8897-376aaeb346ea controller: true blockOwnerDeletion: true
官方文档中对 ownerReferences
字段的解释是:此对象所依赖的对象列表,如果列表中的所有对象都已被删除,则该对象将被垃圾回收。因此当自定义资源对象被删除时,由它创建的其它对象也会被垃圾回收掉。
Finalizer 虽然 ownerReferences
机制为我们提供了一种快捷的删除关联对象方法,但这个过程我们是无法控制的,在一些复杂的场景下可能会带来一些问题:例如我们需要执行一些清理或者备份工作。虽然我们也可以在接收到 Delete
事件后进行,但用户还是无法感知这个过程。终结器 (Finalizer) 机制为我们提供了另外一种删除流程。
相信不少同学遇到过调用 kubectl delete
删除某个资源时一直处于 Terminating
状态无法完成,网上大多数解决方案都是移除对象的 metadata.finalizers
字段,大多数情况下都能解决问题,Why?当删除一个拥有 Finalizer
的对象 (metadata.finalizers
不为空) 时 Kubernetes 并不会马上删除这个对象,而是将对象的 metadata.deletionTimestamp
字段设置为当前时间后就返回,对象进入「软删除」状态,无法获取也无法修改;此时为对象设置 Finalizer
的控制器应该执行清理工作,清理工作结束后移除对象的 Finalizer
,当对象的 metadata.finalizers
字段为空时 Kubernetes 会将对象从集群中删除。
TCPReverseProxyReconciler
展示了如何使用 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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 func (r *TCPReverseProxyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error ) { tcpReverseProxy := &nginxv1.TCPReverseProxy{} if !tcpReverseProxy.DeletionTimestamp.IsZero() { if err := deleteObject(ctx, r.Client, tcpReverseProxy); err != nil { return ctrl.Result{}, err } return ctrl.Result{}, nil } if err := setFinalizer(ctx, r.Client, tcpReverseProxy, false ); err != nil { return ctrl.Result{}, err } err := updateObject(ctx, r.Client, r.Scheme, tcpReverseProxy) } func deleteObject (ctx context.Context, cli client.Client, ngxObject NgxObject) error { objects, err := buildObjects(ngxObject) if err != nil { return err } for _, object := range objects { if err := cli.Delete(ctx, object); err != nil { if errors.IsNotFound(err) { continue } return fmt.Errorf("failed to delete object: %w" , err) } } return setFinalizer(ctx, cli, ngxObject, true ) }
与 ReverseProxyReconciler
不同的是 TCPReverseProxyReconciler
在获取到对象后会通过 deletionTimestamp
字段判断对象是否处于删除阶段,是则调用 deleteObject
方法执行清理工作,deleteObject
方法在将关联对象完全删除后调用 setFinalizer
方法移除对象的 Finalizer
字段。前面说到对象进入「软删除」状态后不能进行修改,但 metadata.finalizers
字段除外,因此 setFinalizer
方法采用了 Patch 精确更新对象而不是使用 Update,因为使用 Update 可能会修改其它字段导致请求报错:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 func setFinalizer (ctx context.Context, cli client.Client, object client.Object, remove bool ) error { var patches []jsonpatch.JsonPatchOperation patches = append (patches, jsonpatch.JsonPatchOperation{ Operation: "replace" , Path: "/metadata/finalizers" , Value: object.GetFinalizers(), }) jsonPatches, _ := json.Marshal(patches) return cli.Patch( ctx, object, client.RawPatch(types.JSONPatchType, jsonPatches), ) }
Webhook 基本的调谐功能完成后我们可以考虑如何让 Operator 能更「正确」地工作,在设计自定义资源结构的时候我们用到了一些 // +kubebuilder:
标记来生成字段的验证规则以及设置默认值,但这些规则只能对字段进行一些简单的验证,例如字段类型、数值范围、正则校验等。对于 Nginx Operator 的自定义资源,这些验证方式显然是不太够用的,例如 ReverseProxy
中的 spec.proxySetHeader
字段,该字段用于设置反向代理需要向上游服务传递的 Headers,Nginx 允许我们使用内置变量,例如:
1 2 3 4 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for ;proxy_set_header X-Forwarded-Proto $scheme ;proxy_set_header X-Forwarded-Host $host ;proxy_set_header X-Forwarded-Port $server_port ;
但如果我们使用一个不存在的变量启动 Nginx 时就会报错,这是我们不希望看到的,错误前置是非常重要的开发原则,我们希望在用户提交错误的 ReverseProxy
对象时就能够得到反馈。看过我的另一篇关于 动态准入控制 的同学应该知道可以用 ValidatingWebhook 来解决这个问题,好消息是 kubebuilder 能为我们生成 Webhook 的框架代码:
1 2 3 4 5 $ kubebuilder create webhook \ --group nginx \ --version v1 \ --kind ReverseProxy \ --defaulting --programmatic-validation
GVK 参数和 create api
命令中的一样这里就不赘述了,--defaulting
参数用于生成默认值 Webhook,可以给自定义资源对象设置默认值;--programmatic-validation
参数用于生成程序化验证 Webhook。运行命令后会在 api/v1/reverseproxy_webhook.go
文件中生成 Webhook 框架代码,下面直接来看 ReverseProxy
资源的 Webhook 的代码:
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 func (r *ReverseProxy) Default() { reverseproxylog.Info("default" , "name" , r.Name) if headers["host" ] == "" { if u, err := url.Parse(r.Spec.ProxyPass); err == nil { headers["host" ] = u.Host } } if r.Spec.Image == "" { r.Spec.Image = os.Getenv("DEFAULT_NGINX_IMAGE" ) } if r.Spec.Logfmt == nil { r.Spec.Logfmt = ptr.To(nginx.LogFmtCombined) } } func (r *ReverseProxy) ValidateCreate() (admission.Warnings, error ) { reverseproxylog.Info("validate create" , "name" , r.Name) if r.Spec.Image == "" { return nil , field.Invalid( field.NewPath("spec" ).Child("image" ), "" , "not be empty" , ) } config, err := nginx.BuildConfig(r.GetNginxConfig()) if err != nil { return nil , fmt.Errorf("failed to build nginx config: %w" , err) } if err := nginx.TestConfig(config); err != nil { return nil , fmt.Errorf("bad nginx config: %w" , err) } return nil , nil } func (r *ReverseProxy) ValidateUpdate(old runtime.Object) (admission.Warnings, error ) { reverseproxylog.Info("validate update" , "name" , r.Name) return r.ValidateCreate() }
当提交包含错误配置的 ReverseProxy
对象时,Webhook 会拒绝请求并返回错误信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 $ cat bad_reverse_proxy.yamlapiVersion: nginx.lin2ur.cn/v1 kind: ReverseProxy metadata: name: noexists spec: # ... proxySetHeader: x-noexists: $noexists # <-- 不存在的变量 $ kubectl apply -f bad_reverse_proxy.yaml Resource: "nginx.lin2ur.cn/v1, Resource=reverseproxies", GroupVersionKind: "nginx.lin2ur.cn/v1, Kind=ReverseProxy" Name: "noexists", Namespace: "default" for: "bad_reverse_proxy.yaml": error when patching "nginx/bad_reverse_proxy.yaml": admission webhook "vreverseproxy.kb.io" denied the request: bad nginx config: 2024/04/05 09:02:51 [emerg] 16#16: unknown "noexists" variable nginx: [emerg] unknown "noexists" variable nginx: configuration file /tmp/nginx-1520620110.conf test failed
Watch 目前控制器只能在自定义资源发生变化时自动调谐受控对象的状态,并不能确保受控对象的状态始终与自定义资源描述的保持一致,例如用户可能误删除了 Service 对象或者误修改了 ConfigMap 的配置,这些操作都会破坏 Nginx 实例的状态,想要修复状态必须手动介入触发一次调谐。对于一些关键的服务来说这是不可接受的,我们希望控制器在受控对象的状态发生预期外的变化时能够「自愈」。
controller-runtime
为开发者提供了一种监控机制,在构建控制器时允许开发者指定需要监控的对象,当对象发生变化时控制器会生成对应的调谐请求来触发调谐。下面来看 ReverseProxyReconciler
控制器的实现:
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 31 var deleteOnlyPred = builder.WithPredicates(predicate.Funcs{ DeleteFunc: func (event event.DeleteEvent) bool { return true }, CreateFunc: func (createEvent event.CreateEvent) bool { return false }, UpdateFunc: func (updateEvent event.UpdateEvent) bool { return false }, GenericFunc: func (genericEvent event.GenericEvent) bool { return false }, }) func (r *ReverseProxyReconciler) SetupWithManager(mgr ctrl.Manager) error { enqueueForOwner := handler.EnqueueRequestForOwner( mgr.GetScheme(), mgr.GetRESTMapper(), &nginxv1.ReverseProxy{}, ) return ctrl.NewControllerManagedBy(mgr). For(&nginxv1.ReverseProxy{}, createOrUpdatePred). Watches(&corev1.ConfigMap{}, enqueueForOwner, deleteOnlyPred). Watches(&corev1.Service{}, enqueueForOwner, deleteOnlyPred). Watches(&appsv1.Deployment{}, enqueueForOwner, deleteOnlyPred). Complete(r) }
Watches
方法就是监控机制的入口,第一个参数是需要监控的对象;第二个参数指定对象发生变化时应该如何处理,handler.EnqueueRequestForOwner
方法会根据对象的 metadata.ownerReferences
字段获取到对象的属主对象,然后生成一个针对属主对象的调谐请求,也可以使用 handler.EnqueueRequestsFromMapFunc
自定义调谐请求的生成逻辑;第三个参数是一个 predicate.Predicate
类型用于过滤事件,这里我们只监听了对象的删除事件。需要注意的是监控对象需要有对应的 RBAC 权限,可以用上文提到的 //+kubebuilder:rbac
标记来生成。
1 2 3 4 5 6 $ kubectl delete svc/ngx-proxy-whoami service "ngx-proxy-whoami" deleted $ kubectl get svc/ngx-proxy-whoami NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE ngx-proxy-whoami ClusterIP 10.43.202.139 <none> 80/TCP 5s
可以看到手动删除 Service 对象后控制器自动创建了一个新的 Service 对象,这个过程完全无需人工干预。
部署 当我们调用 make manifests
后 kubebuilder 会在 config/
目录下生成用于部署的 manifests 文件,包含有 crd
、rbac
、webhook
等资源,虽然很方便但不得不承认我从来没有试过直接使用这些 manifests 文件部署 Operator,因为这些 manifests 文件都是使用 kustomize 组织的,虽然 kustomize 很强大但还是不如 Helm 方便,因此我选择将 manifests 封装成使用 Helm Chart 来部署:
1 2 3 4 5 git clone https://github.com/yxwuxuanl/k8s-nginx-operator.git cd k8s-nginx-operator/deploy helm install nginx-operator . -n nginx-operator --create-namespace --set=image.tag=main
部署完成后来测试一下 Nginx Operator 的功能:
1 2 3 4 5 6 7 8 9 10 $ kubectl apply -f test /whoami.yaml reverseproxy.nginx.lin2ur.cn/whoami created $ kubectl get ngxpxy -n nginx-operator NAME RECONCILED PROXYPASS NGINXIMAGE REPLICAS whoami True https://whoami.dev.lin2ur.cn nginx:1.25 1 $ kubectl port-forward svc/ngx-proxy-whoami 8080:80 -n nginx-operator Forwarding from 127.0.0.1:8080 -> 8080 Forwarding from [::1]:8080 -> 8080
新打开一个终端:
1 2 3 4 5 6 7 8 $ curl 127.0.0.1:8080/foo/hello GET /bar/hello HTTP/1.1 Host: whoami.dev.lin2ur.cn User-Agent: curl/8.6.0 Accept: */* Accept-Encoding: gzip X-Powered-By: nginx-operator X-Real-Ip: 120.235.164.65
可以看到 Nginx 反向代理实例正常工作,到此 Nginx Operator 的开发就告一段落了。