Operator 初体验

Kubernetes 1.7 版本以来就引入了自定义控制器的概念,该功能可以让开发人员扩展添加新功能,更新现有的功能,并且可以自动执行一些管理任务。

Operator 是由 CoreOS 开发的,用来扩展 Kubernetes API 的控制器框架,它用来创建、配置和管理复杂的有状态应用,如数据库、缓存和监控系统。

Operator 基于 Kubernetes 的资源和控制器概念之上构建,但同时又包含了应用程序特定的领域知识。

这些自定义的控制器就像 Kubernetes 原生的组件一样,Operator 直接使用 Kubernetes API 进行开发,也就是说可以根据这些控制器内部编写的自定义规则来监控集群、更改 Pods/Services、对正在运行的应用进行扩缩容。

创建 Operator 的关键是 CRD(自定义资源)的设计。本文将通过虚拟需求,设计 CRD 并实现 CRD 的控制逻辑,以体验 Operator 的开发过程。

Operator Framework

Operator Framework 是 CoreOS 开源的一个用于快速开发 Operator 的工具包,该框架包含两个主要的部分:

  • Operator SDK: 无需了解复杂的 Kubernetes API 特性,即可让你根据你自己的专业知识构建一个 Operator 应用。
  • Operator Lifecycle Manager OLM: 帮助你安装、更新和管理跨集群的运行中的所有 Operator(以及他们的相关服务)

工作流程

Operator SDK 提供以下工作流来开发一个新的 Operator:

  1. 使用 SDK 创建一个新的 Operator 项目
  2. 通过添加自定义资源(CRD)定义新的资源 API
  3. 指定使用 SDK API 来 watch 的资源
  4. 定义 Operator 的协调(reconcile)逻辑
  5. 使用 Operator SDK 构建并生成 Operator 部署清单文件

开发一个程序

部署一个简单的 Web 服务到 Kubernetes 集群中的时候,都需要:

  • 编写一个 Deployment 的控制器;
  • 创建一个 Service 对象,通过 Pod 的 label 标签进行关联;
  • 通过 Ingress 或者 type=NodePort 类型的 Service 来暴露服务。

每次都需要这样操作,略显麻烦。

可以创建一个自定义的资源对象,通过自定义的 CRD 来描述要部署的应用信息,比如镜像、服务端口、环境变量等等。

创建自定义类型的资源对象的时候,通过控制器去创建对应的 Deployment 和 Service,是不是就方便很多了,相当于用一个资源清单去描述了 Deployment 和 Service 要做的两件事情。

CRD 的设计
1
2
3
4
5
6
7
8
9
10
11
apiVersion: paas.cmft.com/v1
kind: DemoApplication
metadata:
name: nginx-demo
spec:
replica: 2
image: nginx:latest
ports:
- port: 80
targetPort: 80
nodePort: 30002

通过这里的自定义的 DemoApplication 资源对象去创建副本数为 2 的 Pod,然后通过 nodePort=30002 的端口去暴露服务

环境准备

需要准备的环境:

  • Go 语言开发环境;
  • operator-sdk 工具;
  • Kubernetes 环境。

安装 operator-sdk:

安装命令
1
brew install operator-sdk

创建项目

执行以下命令创建项目脚手架:

生成项目框架
1
operator-sdk init --plugins=go/v4 --domain=paas.cmft.com --repo code-inc.cmft.com/CMHK/GRD-PAAS-PORTAL/demo-operator.git
  • --domain:指定 API Group
  • --repo:指定 Go 模块名

创建 API

默认新创建的项目只有一些框架文件,需要为自定义资源添加一个新的 API:

生成 API
1
operator-sdk create api --version v1beta1 --kind DemoApplication

输出如下:

调整 API

生成的 API 文件存放在 api/v1beta1/demoapplication_types.go,需要根据需求去自定义结构体 DemoApplicationSpec 的结构,比如对应上面 Demo 的结构:

实现 CRD 参数
1
2
3
4
5
6
7
8
type DemoApplicationSpec struct {
// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
// Important: Run "make" to regenerate code after modifying this file

Image string `json:"image,omitempty"`
Replica *int32 `json:"replica,omitempty"`
Ports []corev1.ServicePort `json:"ports,omitempty"`
}

修改完后,运行 make 命令重新生成一些代码。

增加业务逻辑

我们的业务逻辑需要在 internal/controller/demoapplication_controller.goReconcile 中添加:

Reconcile 的默认内容
1
2
3
4
5
6
7
func (r *DemoApplicationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
_ = log.FromContext(ctx)

// TODO(user): your logic here

return ctrl.Result{}, nil
}

Reconcile 方法

Reconcile 方法是什么?

Reconcile 方法用于实现将 CR(Custom Resource)的实际状态变更为我们的期望状态。

比如:将 Deployment 的 replicas 值从 1 修改为 2,DeploymentController 的 Reconcile 方法就创建一个新的 Pod,来满足 replicas 的描述。

Reconcile 方法什么时候被调用?

Reconcile 方法在每次 watch 的 CR 或资源变更时触发。

Reconcile 方法每次调用时都会传入 Request 变量,该变量由 Namespace/Name 组成,这个值用于从缓存中查询我们的对象。

Reconcile 方法根据处理结果不同可以返回不同的结果:

Reconcile 方法响应的含意
1
2
3
4
5
6
7
8
9
10
11
// 存在错误
return ctrl.Result{}, err

// 无错误,但重入队列
return ctrl.Result{Requeue: true}, nil

// 处理完成
return ctrl.Result{}, nil

// XX 时间后重新执行
return ctrl.Result{RequeueAfter: nextRun.Sub(r.Now())}, nil

业务逻辑

业务逻辑主要是:

  1. Watch 我们的自定义资源 DemoApplication
  2. DemoApplication 不存在时直接返回;
  3. DemoApplication 存在时,判断 Deployment 是否存在,不存在就创建;
  4. 如果已经存在就根据 spec 有没变化更新 DeploymentService

获取自定义资源

获取自定义资源
1
2
3
4
5
6
7
8
9
application := &paascmftcomv1beta1.DemoApplication{}
if err := r.Get(ctx, req.NamespacedName, application); err != nil {
if apierrors.IsNotFound(err) {
log.Info("DemoApplication resource not found. Ignoring since it must be deleted")
return ctrl.Result{}, nil
}
log.Error(err, "Failed to get DemoApplication")
return ctrl.Result{}, err
}

维护 Deployment 和 Service

代码框架如下:

获取管理的 Deployment
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
found := &appsv1.Deployment{}
err := r.Get(ctx, req.NamespacedName, found)
if err != nil && apierrors.IsNotFound(err) {
// TODO 不存在,创建 Deployment 和 Service
} else if err != nil {
log.Error(err, "Failed to get Deployment")
return ctrl.Result{}, err
}

oldSpec := paascmftcomv1beta1.DemoApplicationSpec{}
if application.Annotations[AnnotationLastAppliedConfig] != "" {
if err = json.Unmarshal([]byte(application.Annotations[AnnotationLastAppliedConfig]), &oldSpec); err != nil {
return ctrl.Result{}, err
}
}

if !reflect.DeepEqual(application.Spec, oldSpec) {
// TODO 存在且有变化,更新 Deployment 和 Service
}

return ctrl.Result{}, nil

一般 Operator 都是用于管理特定业务的部署,Deployment 可以根据我们需要提供一个业务默认模板,这样只需要在 DemoApplication 开放一些可配置参数既可。

根据示例,我们创建 Deployment 的方法如下:

构造 Deployment 对接
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
func (r *DemoApplicationReconciler) deploymentForDemoApplication(application *paascmftcomv1beta1.DemoApplication) *appsv1.Deployment {
labels := map[string]string{
"app": application.Name,
}

containerPorts := []corev1.ContainerPort
for _, port := range application.Spec.Ports {
containerPorts = append(containerPorts, corev1.ContainerPort{
ContainerPort: port.TargetPort.IntVal,
})
}

return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: application.Name,
Namespace: application.Namespace,
},
Spec: appsv1.DeploymentSpec{
Replicas: application.Spec.Replica,
Selector: &metav1.LabelSelector{
MatchLabels: labels,
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: labels,
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: application.Name,
Image: application.Spec.Image,
Ports: containerPorts,
},
},
},
},
},
}
}

类似的,Service 的创建逻辑如下:

构造 Service 对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (r *DemoApplicationReconciler) serviceForDemoApplication(application *paascmftcomv1beta1.DemoApplication) *corev1.Service {
return &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: application.Name,
Namespace: application.Namespace,
},
Spec: corev1.ServiceSpec{
Ports: application.Spec.Ports,
Selector: map[string]string{
"app": application.Name,
},
},
}
}

整合后,创建 DeploymentService 的代码如下:

创建 Deployment 和 Service
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
dep := r.deploymentForDemoApplication(application)

log.Info("Creating a new Deployment", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name)
if err := r.Create(ctx, dep); err != nil {
log.Error(err, "Failed to create new Deployment", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name)
return ctrl.Result{}, err
}

svc := r.serviceForDemoApplication(application)

log.Info("Creating a new Service", "Service.Namespace", svc.Namespace, "Service.Name", svc.Name)
if err := r.Create(ctx, svc); err != nil {
log.Error(err, "Failed to create new Service", "Service.Namespace", svc.Namespace, "Service.Name", svc.Name)
return ctrl.Result{}, err
}

cfg, _ := json.Marshal(application.Spec)
if application.Annotations != nil {
application.Annotations[AnnotationLastAppliedConfig] = string(cfg)
} else {
application.Annotations = map[string]string{
AnnotationLastAppliedConfig: string(cfg),
}
}

if err := r.Update(ctx, application); err != nil {
log.Error(err, "Failed to update DemoApplication", "DemoApplication.Namespace", application.Namespace, "DemoApplication.Name", application.Name)
return ctrl.Result{}, nil
}

// Requeue the request to ensure the Deployment is created
return ctrl.Result{RequeueAfter: time.Minute}, nil

修改时和创建略有不同:

更新 Deployment 和 Service
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
newDep := r.deploymentForDemoApplication(application)
oldDep := &appsv1.Deployment{}
if err := r.Get(ctx, req.NamespacedName, oldDep); err != nil {
return ctrl.Result{}, err
}

oldDep.Spec = newDep.Spec
if err := r.Update(ctx, oldDep); err != nil {
return ctrl.Result{}, err
}

newSvc := r.serviceForDemoApplication(application)
oldSvc := &corev1.Service{}
if err := r.Get(ctx, req.NamespacedName, oldSvc); err != nil {
return ctrl.Result{}, err
}

oldSvc.Spec = newSvc.Spec
if err := r.Update(ctx, oldSvc); err != nil {
return ctrl.Result{}, err
}

cfg, _ := json.Marshal(application.Spec)
if application.Annotations != nil {
application.Annotations[AnnotationLastAppliedConfig] = string(cfg)
} else {
application.Annotations = map[string]string{
AnnotationLastAppliedConfig: string(cfg),
}
}

if err := r.Update(ctx, application); err != nil {
log.Error(err, "Failed to update DemoApplication", "DemoApplication.Namespace", application.Namespace, "DemoApplication.Name", application.Name)
return ctrl.Result{}, nil
}

运行

本地运行
1
make install run

应用配置:

增加实验数据
1
kubectl apply -f config/samples/v1beta1_demoapplication.yaml

优化

现存的问题:

  1. DemoApplication 删除时,对应的 DeploymentService 不会跟着删除;
  2. 如果 DeploymentService 被修改时不会自动恢复。

问题一

如果DemoApplication 删除时,对应的 DeploymentService 不会跟着删除,那会导致DeploymentService 资源变得无人管理。

就像 Deployment 和 Pod 的关系一样,如果 Deployment 被删除,但是 Pod 没被删除会非常难管理。

手动设置 OwnerReference
1
2
3
4
5
6
7
8
9
10
11
ObjectMeta: metav1.ObjectMeta{
Name: application.Name,
Namespace: application.Namespace,
OwnerReferences: []metav1.OwnerReference{
*metav1.NewControllerRef(application, schema.GroupVersionKind{
Group: paascmftcomv1beta1.GroupVersion.Group,
Version: paascmftcomv1beta1.GroupVersion.Version,
Kind: "DemoApplication",
}),
},
},

更好的方法

controller-runtimecontrollerutil 包提供了一个工具方法用于设置 Owner 引用。

使用工具方法
1
ctrl.SetControllerReference(application, dep, r.Scheme)

问题二

我们可以在 Reconcile 这个方法里对查询的到 Deployment 进行处理:

处理 Replicas 不一致问题
1
2
3
4
5
6
7
8
9
10
replicas := application.Spec.Replica
if *replicas != *found.Spec.Replicas {
found.Spec.Replicas = replicas
if err := r.Update(ctx, found); err != nil {
log.Error(err, "Failed to update Deployment", "Deployment.Namespace", found.Namespace, "Deployment.Name", found.Name)
return ctrl.Result{}, err
}

return ctrl.Result{Requeue: true}, nil
}

不过如果只是这样写,并不会有效果。为什么?

Operator 里的资源

在 controller-runtime 包里,将资源区分为了两种类型:Primary Resources 和 Secondary Resources。

  • Primary Resources:主要资源,指的是控制器负责管理的资源,这里是 DemoApplication
  • Secondary Resources:次要资源,控制器也可能管理的资源,但主要是为了支持 DemoApplication 的实现,这里是 DeploymentService

次要资源的修改会直接影响主要资源,所以我们的控制器必须要监控并保证次要资源和主要资源的一致。

这里相当于少了对资源的监控(Watch)动作:

注册 Reconciler
1
2
3
4
5
6
func (r *DemoApplicationReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&paascmftcomv1beta1.DemoApplication{}).
Owns(&appsv1.Deployment{}).
Complete(r)
}

同时在 Reconcile 方法上加个以下注释:

配置权限注释
1
2
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch;delete

执行 make manifests 生成 RBAC 授权信息。

注意点

如果不配置这个,部署到集群中后会报权限不足的错误。本地启动因为是读取的 ~/.kube/config 配置,大多是集群管理员身份,可能不会发现这个问题。

总结

使用 Operator SDK 开发一个新的 Operator 可以按以下流程:

  1. 使用 SDK 创建一个新的 Operator 项目
  2. 通过添加自定义资源(CRD)定义新的资源 API
  3. 指定使用 SDK API 来 watch 的资源
  4. 定义 Operator 的协调(reconcile)逻辑
  5. 使用 Operator SDK 构建并生成 Operator 部署清单文件

当 CRD 需要创建其它资源来实现功能时,需要配置 OwnerReferences,可以使用 ctrl.SetControllerReference 进行配置。

当需要 Watch 次要资源时,在 SetupWithManager 中设置 Owns

引用

  1. Kubernetes Operator 快速入门教程
  2. k8s operator controller 区别
  3. Watching Secondary Resources Owned by the Controller
  4. Watching resources
作者

Jakes Lee

发布于

2025-03-20

更新于

2025-03-27

许可协议

评论