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 实例的状态,如下所示:
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:
# 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 项目:
# 创建项目目录
$ 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 命令的提示下一步需要创建自定义资源:
$ 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 信息:
apiVersion: nginx.lin2ur.cn/v1
kind: ReverseProxy
TCPReverseProxy
自定义资源的创建过程与 ReverseProxy
类似这里就不赘述了。
设计自定义资源
如同业务开发第一步是设计数据库表结构一样,开发 Operator 的第一步是设计自定义资源,调用 create api
命令后 kubebuilder 会在 api/v1/xxx_types.go
文件中生成自定义资源对应的结构体代码,我们要做的是修改这个结构体,随后调用 kubebuilder 生成安装到集群的 CRD,下面来看 ReverseProxy
资源的设计:
// api/v1/reverseproxy_types.go
package v1
import (
"github.com/yxwuxuanl/k8s-nginx-operator/internal/nginx"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
//+kubebuilder:resource:shortName="ngxpxy"
//+kubebuilder:printcolumn:name="Reconciled",type="string",JSONPath=".status.conditions[?(@.type == 'Reconciled')].status"
//+kubebuilder:printcolumn:name="ProxyPass",type="string",JSONPath=".spec.proxyPass"
//+kubebuilder:printcolumn:name="NginxImage",type="string",JSONPath=".spec.image"
//+kubebuilder:printcolumn:name="Replicas",type="number",JSONPath=".spec.replicas"
// ReverseProxy is the Schema for the reverseproxies API
type ReverseProxy struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ReverseProxySpec `json:"spec,omitempty"`
Status ReverseProxyStatus `json:"status,omitempty"`
}
// ReverseProxySpec defines the desired state of ReverseProxy
type ReverseProxySpec struct {
NginxSpec `json:",inline"`
nginx.CommonConfig `json:",inline"`
nginx.ReverseProxyConfig `json:",inline"`
}
type NginxSpec struct {
Image string `json:"image,omitempty"`
// +kubebuilder:default:=1
Replicas int32 `json:"replicas,omitempty"`
// +kubebuilder:default:=80
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"`
}
// ReverseProxyStatus defines the observed state of ReverseProxy
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
结构,而其它部分都是一样的。继续来看内嵌的结构:
// internal/nginx/types.go
package nginx
// +kubebuilder:object:generate=true
type CommonConfig struct {
Resolvers []string `json:"resolver,omitempty"`
WorkerProcesses *int `json:"workerProcesses,omitempty"`
// +kubebuilder:default:=1024
WorkerConnections int `json:"workerConnections,omitempty"`
}
// +kubebuilder:object:generate=true
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"`
// +kubebuilder:default:="15s"
// +kubebuilder:validation:Pattern:=^\d+s$
ReadTimeout string `json:"proxyReadTimeout,omitempty"`
// +kubebuilder:default:="15s"
// +kubebuilder:validation:Pattern:=^\d+s$
SendTimeout string `json:"proxySendTimeout,omitempty"`
// +kubebuilder:default:="15s"
// +kubebuilder:validation:Pattern:=^\d+s$
ConnectTimeout string `json:"proxyConnectTimeout,omitempty"`
}
// ...
在设计时有几个注意事项:
- 每个字段都必须要有 json tag,包括内嵌字段
- 默认情况下 kubebuilder 生成 CRD 时会将每个字段都设置为必填的,选填的字段需要加上
omitempty
标记 - 描述 Kubernetes 对象信息的字段尽量与原字段名保持一致,例如
image
、replicas
、resources
等,减少用户的学习成本 - 尽量复用 Kubernetes 中已定义的结构体,例如
resources
字段使用corev1.ResourceRequirements
结构体 - 需要区分 缺省 和 零值 的情况的字段可以使用指针类型,例如
logFormat
字段,用户不设置该字段时使用默认日志格式;用户传入空字符串时禁用日志
我们注意到在结构体和字段上面都一些以 // +kubebuilder:
开头的注释,这些注释是用于代码生成和 CRD 生成的标记,这些标记非常重要,来看 ReverseProxy
结构体上的标记:
//+kubebuilder:resource:shortName="ngxpxy"
//+kubebuilder:printcolumn:name="Reconciled",type="string",JSONPath=".status.conditions[?(@.type == 'Reconciled')].status"
//+kubebuilder:printcolumn:name="ProxyPass",type="string",JSONPath=".spec.proxyPass"
//+kubebuilder:printcolumn:name="NginxImage",type="string",JSONPath=".spec.image"
//+kubebuilder:printcolumn:name="Replicas",type="number",JSONPath=".spec.replicas"
type ReverseProxy struct {}
//+kubebuilder:resource:shortName
用于指定自定义资源的简称,可以在 kubectl get
命令使用这个简称;//+kubebuilder:printcolumn
用于指定自定义资源在 kubectl get
时展示的列,将关键的字段展示出来可以让使用者更方便地查看对象的状态;这些标记会在生成 CRD 时发挥作用,当 CRD 安装到集群后,我们可以使用 kubectl get ngxpxy
命令查看自定义资源的状态:
$ 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 生成代码:
# 生成 CRD
$ make manifests
# 生成 DeepCopy 代码
$ make generate
生成的 CRD 对象在 config/crd/bases/nginx.lin2ur.cn_reverseproxies.yaml
文件中,将其安装到集群:
$ 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
文件中:
// internal/controller/reverseproxy_controller.go
package controller
// ReverseProxyReconciler reconciles a ReverseProxy object
type ReverseProxyReconciler struct {
client.Client
Scheme *runtime.Scheme
}
//+kubebuilder:rbac:groups=nginx.lin2ur.cn,resources=reverseproxies,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=nginx.lin2ur.cn,resources=reverseproxies/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=nginx.lin2ur.cn,resources=reverseproxies/finalizers,verbs=update
func (r *ReverseProxyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
_ = log.FromContext(ctx)
// TODO(user): your logic here
return ctrl.Result{}, nil
}
// SetupWithManager sets up the controller with the Manager.
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
方法的实现:
// internal/controller/reverseproxy_controller.go
//+kubebuilder:rbac:groups=nginx.lin2ur.cn,resources=reverseproxies,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=nginx.lin2ur.cn,resources=reverseproxies/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=nginx.lin2ur.cn,resources=reverseproxies/finalizers,verbs=update
//+kubebuilder:rbac:groups=core,resources=configmaps;services,verbs=*
//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=*
//+kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
func (r *ReverseProxyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
reverseProxy := &nginxv1.ReverseProxy{}
// 通过名称获取 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)
// 更新 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
方法:
// internal/controller/object.go
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
两种资源:
// internal/controller/object.go
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
方法的实现也很简单:
// internal/controller/object.go
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
状态:
func updateStatus[T client.Object](
ctx context.Context,
cli client.Client,
object T,
reconcileErr *error,
mutateFn func(T, metav1.Condition),
) {
// build 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
方法:
// internal/controller/reverseproxy_controller.go
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
方法的定义如下:
func SetControllerReference(owner, controlled metav1.Object, scheme *runtime.Scheme) error {}
owner
参数指定属主对象,controlled
指定受控对象。属主关系体现在受控对象的 metadata.ownerReferences
字段中:
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
机制:
// internal/controller/tcpreverseproxy_controller.go
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
}
// 设置 Finalizer
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)
}
}
// 移除 Finalizer
return setFinalizer(ctx, cli, ngxObject, true)
}
与 ReverseProxyReconciler
不同的是 TCPReverseProxyReconciler
在获取到对象后会通过 deletionTimestamp
字段判断对象是否处于删除阶段,是则调用 deleteObject
方法执行清理工作,deleteObject
方法在将关联对象完全删除后调用 setFinalizer
方法移除对象的 Finalizer
字段。前面说到对象进入「软删除」状态后不能进行修改,但 metadata.finalizers
字段除外,因此 setFinalizer
方法采用了 Patch 精确更新对象而不是使用 Update,因为使用 Update 可能会修改其它字段导致请求报错:
// internal/controller/reconcile.go
func setFinalizer(ctx context.Context, cli client.Client, object client.Object, remove bool) error {
// set & remove finalizer...
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 允许我们使用内置变量,例如:
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 的框架代码:
$ 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 的代码:
// api/v1/reverseproxy_webhook.go
func (r *ReverseProxy) Default() {
reverseproxylog.Info("default", "name", r.Name)
// 设置默认 $Host 头
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)
}
}
// ValidateCreate implements webhook.Validator so a webhook will be registered for the type
func (r *ReverseProxy) ValidateCreate() (admission.Warnings, error) {
reverseproxylog.Info("validate create", "name", r.Name)
// 未指定 Image 字段时拒绝请求
if r.Spec.Image == "" {
return nil, field.Invalid(
field.NewPath("spec").Child("image"),
"",
"not be empty",
)
}
// 构建 Nginx 配置
config, err := nginx.BuildConfig(r.GetNginxConfig())
if err != nil {
return nil, fmt.Errorf("failed to build nginx config: %w", err)
}
// 调用 Nginx 测试配置
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 会拒绝请求并返回错误信息:
$ cat bad_reverse_proxy.yaml
apiVersion: 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
控制器的实现:
// internal/controller/reverseproxy_controller.go
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
标记来生成。
$ 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 来部署:
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 的功能:
$ 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
新打开一个终端:
$ 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 的开发就告一段落了。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。