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 实例的关键信息,hostNetworkimagesecurityContextresources 字段描述了 Prometheus Pod 的状态,volumeClaimTemplate 字段则描述了 PVC 对象的状态;除此之外还包含了 Prometheus 本身的配置,例如 retentionretentionSize 等字段。当我们提交这个 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
// 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 包含有 metadataspecstatus 字段,先来看描述用户期望状态的 spec 字段对应的的 ReverseProxySpec 结构,内嵌了三个结构体:

  • NginxSpec 配置 Nginx 的 Pod、Service 等 Kubernetes 对象
  • nginx.CommonConfig 配置 Nginx 的通用配置,例如 workerProcessesworkerConnections
  • nginx.ReverseProxyConfig 配置 Nginx 的 HTTP 反向代理配置

这样的设计有助于代码复用,ReverseProxyTCPReverseProxy 的差异主要在于 nginx.ReverseProxyConfignginx.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
// 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 对象信息的字段尽量与原字段名保持一致,例如 imagereplicasresources 等,减少用户的学习成本
  • 尽量复用 Kubernetes 中已定义的结构体,例如 resources 字段使用 corev1.ResourceRequirements 结构体
  • 需要区分 缺省零值 的情况的字段可以使用指针类型,例如 logFormat 字段,用户不设置该字段时使用默认日志格式;用户传入空字符串时禁用日志

我们注意到在结构体和字段上面都一些以 // +kubebuilder: 开头的注释,这些注释是用于代码生成和 CRD 生成的标记,这些标记非常重要,来看 ReverseProxy 结构体上的标记:

1
2
3
4
5
6
7
//+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 命令查看自定义资源的状态:

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
// 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 Pods
  • Service 暴露 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
// 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.ResultRequeueAfter 字段指定下次调谐的时间。下面来看 updateObject 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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 接口类型而不是具体的自定义类型资源,这个接口对自定义资源进行了抽象,确保了这部分代码可以适用于 ReverseProxyTCPReverseProxy 两种资源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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 方法的实现也很简单:

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
// 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 状态:

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),
) {
// 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 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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 字段时意外触发调谐。需要注意的是如果调谐逻辑依赖对象 labelsannotations 字段,那就不能只判断 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
// 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 可能会修改其它字段导致请求报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 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 允许我们使用内置变量,例如:

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
// 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 会拒绝请求并返回错误信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ 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 控制器的实现:

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
// 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 标记来生成。

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 文件,包含有 crdrbacwebhook 等资源,虽然很方便但不得不承认我从来没有试过直接使用这些 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 的开发就告一段落了。