前言

如果你想要对 K8S 做二次开发或者说在原有的基础上封装一些功能让开发者更加好用,那么 Operator 的用法你可必须掌握。

什么是 Operator

我觉得 Operator 真的是 K8S 扩展设计的非常巧妙的一点,它好像一个插件系统,你有了它就好像有了 k8s 的一个扩展操作权,能扩展出各种各样的用法。那什么是 Operator 呢?这需要从 CRD 说起。

CRD

首先我们需要知道第一个概念就是 CRD(Custom Resource Define),自定义资源定义,顾名思义就是使用者可以通过 CRD 来创建自定义的资源。我们知道在 K8S 中有各种各样的资源 PodDeploymentStatefulSet… 在编写 yaml 文件的时候会指定对应的资源类型。

官方文档:Create a CustomResourceDefinition 其中有一个实际的 CustomResourceDefinition 案例

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
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
# name must match the spec fields below, and be in the form: <plural>.<group>
name: crontabs.stable.example.com
spec:
# group name to use for REST API: /apis/<group>/<version>
group: stable.example.com
# list of versions supported by this CustomResourceDefinition
versions:
- name: v1
# Each version can be enabled/disabled by Served flag.
served: true
# One and only one version must be marked as the storage version.
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
cronSpec:
type: string
image:
type: string
replicas:
type: integer
# either Namespaced or Cluster
scope: Namespaced
names:
# plural name to be used in the URL: /apis/<group>/<version>/<plural>
plural: crontabs
# singular name to be used as an alias on the CLI and for display
singular: crontab
# kind is normally the CamelCased singular type. Your resource manifests use this.
kind: CronTab
# shortNames allow shorter string to match your resource on the CLI
shortNames:
- ct

---- 下面是具体的 object ----

apiVersion: "stable.example.com/v1"
kind: CronTab
metadata:
name: my-new-cron-object
spec:
cronSpec: "* * * * */5"
image: my-awesome-cron-image

然后,有了它,你就可以像操作一个 pod 一样操作这个你定义的对象了,你还可以为它定义一些必要的属性(properties)。那么有了 CRD 之后,我们就有了一个非常强大的能力来扩展 k8s 已有的功能了。但是只有这样还是不够的。因为它仅仅定义了你所需要的资源,但是这个资源如何被操作呢?

Controller

有了资源没有人管肯定也不行,那么我们就需要一个 Controller 来控制它的行为和动作了。其实 Controller 本质是一个控制循环。我们知道,k8s 的控制模式其实是基于一个状态模型的,它将监控所有资源的状态,当现在的资源状态不满足用户定义的资源状态的时候,它就会做出调整,想办法让资源调整状态到预期值。

1
2
3
4
5
6
7
8
9
for {
实际状态 := 获取集群中对象 X 的实际状态(Actual State)
期望状态 := 获取集群中对象 X 的期望状态(Expectation State)
if 实际状态 == 期望状态{
什么都不做
} else {
执行编排动作,将实际状态调整为期望状态
}
}

当 Controller Manager 发现资源的实际状态和期望状态有偏差之后,会触发相应 Controller 注册的 Event Handler,让它们去根据资源本身的特点进行调整。

Operator

所以,我们可以简单的理解为 Operator = CRD + Controller 也就是说自定义资源加自定义控制器就是 Operator,使用它我们不仅可以自定义我们想要的资源,还可以通过我们想要的逻辑和方式对它进行操作。

那此时你就可以想象的到它是有多万能了。比如:有了自定义资源,你可以定义你想要的各种属性,原来 deployment 只有那些属性,现在你就可以扩展各种你想要的属性了,并且你可以组合一些现有的资源。同时有了自定义控制器,你就可以任意的进行操作了,最重要的是,你能在出现各种情况(重启、异常退出等等)需要调度的时候第一时间知道,并且可以控制如何去调度,调度之后应该配置什么属性等等。

那本文下面就带你来快速制作一个 Demo 来体验一下 Operator,当然前提是你需要有一个可以操作的 k8s 环境。

使用 kubebuilder 创建 Operator

开发 Operator 并不一定要用 kubebuilder 还可以使用 https://github.com/operator-framework/operator-sdk 我更习惯用 kubebuilder 而已

安装

安装文档见:installation

1
2
$ curl -L -o kubebuilder "https://go.kubebuilder.io/dl/latest/$(go env GOOS)/$(go env GOARCH)"  
$ chmod +x kubebuilder && mv kubebuilder /usr/local/bin/

创建项目

1
2
3
$ mkdir opex
$ cd opex
$ kubebuilder init --domain linkinstars.com --repo linkinstars.com/op-ex

创建 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ kubebuilder create api --group example --version v1 --kind ExampleA
# 然后输入两次 y
Create Resource [y/n]
y
Create Controller [y/n]
y
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
api/v1/examplea_types.go
api/v1/groupversion_info.go
internal/controller/suite_test.go
internal/controller/examplea_controller.go
...
...

此时项目结构已经创建好了,kubebuilder 也为我们创建了对应的 CRD 模板和 Controller 模板。你可以先大致浏览一下项目结构。下面我们就会开始编码的工作。

编码

首先明确一下我们的目标,我们的目标是创建一个 CRD 和 Controller 来体验一下 Operator。我们这次创建的 CRD 扮演一个监察的角色,当整个集群中出现带有指定名称的标签(Label)的对象时,监察就会改变自己的状态,变成监控中。

修改 CRD 的定义

修改 api/v1/examplea_types.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
29
30
31
32
33
34
35
36
37
38
39
40
package v1

import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// ExampleASpec defines the desired state of ExampleA
type ExampleASpec struct {
GroupName string `json:"groupName,omitempty"`
}

// ExampleAStatus defines the observed state of ExampleA
type ExampleAStatus struct {
UnderControl bool `json:"underControl,omitempty"`
}

//+kubebuilder:object:root=true
//+kubebuilder:subresource:status

// ExampleA is the Schema for the examplea API
type ExampleA struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`

Spec ExampleASpec `json:"spec,omitempty"`
Status ExampleAStatus `json:"status,omitempty"`
}

//+kubebuilder:object:root=true

// ExampleAList contains a list of ExampleA
type ExampleAList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []ExampleA `json:"items"`
}

func init() {
SchemeBuilder.Register(&ExampleA{}, &ExampleAList{})
}

可以看到这里我们主要是定义了 ExampleASpec,也就是我们常常在 yaml 文件中写的 spec 属性,其中我们添加了 GroupName 也就是一个组名。

修改 Controller 的定义

修改 internal/controller/examplea_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
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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
package controller

import (
"context"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
examplev1 "linkinstars.com/op-ex/api/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)

// ExampleAReconciler reconciles a ExampleA object
type ExampleAReconciler struct {
client.Client
Scheme *runtime.Scheme
}

//+kubebuilder:rbac:groups=example.linkinstars.com,resources=examplea,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=example.linkinstars.com,resources=examplea/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=example.linkinstars.com,resources=examplea/finalizers,verbs=update

// Reconcile
func (r *ExampleAReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
logger.Info("开始调用Reconcile方法")

var exp examplev1.ExampleA
if err := r.Get(ctx, req.NamespacedName, &exp); err != nil {
logger.Error(err, "未找到对应的CRD资源")
return ctrl.Result{}, client.IgnoreNotFound(err)
}

exp.Status.UnderControl = false

var podList corev1.PodList
if err := r.List(ctx, &podList); err != nil {
logger.Error(err, "无法获取pod列表")
} else {
for _, item := range podList.Items {
if item.GetLabels()["group"] == exp.Spec.GroupName {
logger.Info("找到对应的pod资源", "name", item.GetName())
exp.Status.UnderControl = true
}
}
}

if err := r.Status().Update(ctx, &exp); err != nil {
logger.Error(err, "无法更新CRD资源状态")
return ctrl.Result{}, err
}
logger.Info("已更新CRD资源状态", "status", exp.Status.UnderControl)
return ctrl.Result{}, nil
}

// SetupWithManager sets up the controller with the Manager.
func (r *ExampleAReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&examplev1.ExampleA{}).
Watches(
&corev1.Pod{},
handler.EnqueueRequestsFromMapFunc(r.podChangeHandler),
).
Complete(r)
}

func (r *ExampleAReconciler) podChangeHandler(ctx context.Context, obj client.Object) []reconcile.Request {
logger := log.FromContext(ctx)

var req []reconcile.Request
var list examplev1.ExampleAList
if err := r.Client.List(ctx, &list); err != nil {
logger.Error(err, "无法获取到资源")
} else {
for _, item := range list.Items {
if item.Spec.GroupName == obj.GetLabels()["group"] {
req = append(req, reconcile.Request{
NamespacedName: types.NamespacedName{Name: item.Name, Namespace: item.Namespace},
})
}
}
}
return req
}

核心逻辑非常简单,就是遍历所有的 pod,如果发现 label 中带有对应 groupName 的 pod 就修改当前 crd 的 UnderControl 状态为 true

1
2
3
4
if item.GetLabels()["group"] == exp.Spec.GroupName {
logger.Info("找到对应的pod资源", "name", item.GetName())
exp.Status.UnderControl = true
}

其中有几个要点

  • Reconcile:注意📢,这个方法就是我们的重中之重。当我们的资源发生变动这个方法就会被调用。包括对象创建,spec 变动,状态变动等。
  • SetupWithManager:添加了一个监控 Watches 方法的第一个参数就是监控的对象类型,第二个参数就是 handler。
  • podChangeHandler:就是监控了所有的 pod,当 pod 资源变动时(比如 pod 倍创建)就会在队列中加入一个 reconcile.Request 请求事件

调试

安装

使用 kubebuilder 的方便就是部署和调试很方便,模板都有,执行下面的命令生成并将 CRD 安装到 k8s 集群中。

1
2
$ make manifests
$ make install

安装成功后,查看一下

1
2
$ kubectl get crds |grep linkin
exampleas.example.linkinstars.com 2023-08-03T23:02:39Z

意外错误

1
error: accumulating resources: accumulation err='accumulating resources from 'bases/example.linkinstars.com_examplea.yaml'

如果出现这样类似的错误,通常是由于生成文件名 s 的问题导致的,修改 config/crd/kustomization.yaml 文件中的 resources: - bases/example.linkinstars.com_exampleas.yaml 对应的正确名称即可

启动

建议新开一个终端窗口来启动,它会前终端中运行并输入对应的日志,方便后续查看

1
$ make run
1
2
3
4
5
6
7
8
9
10
2023-08-03T23:07:21+08:00  INFO  controller-runtime.metrics  Metrics server is starting to listen  {"addr": ":8080"}
2023-08-03T23:07:21+08:00 INFO setup starting manager
2023-08-03T23:07:21+08:00 INFO starting server {"path": "/metrics", "kind": "metrics", "addr": "[::]:8080"}
2023-08-03T23:07:21+08:00 INFO Starting server {"kind": "health probe", "addr": "[::]:8081"}
2023-08-03T23:07:21+08:00 INFO Starting EventSource {"controller": "examplea", "controllerGroup": "example.linkinstars.com", "controllerKind": "ExampleA", "source": "kind source: *v1.ExampleA"}
2023-08-03T23:07:21+08:00 INFO Starting EventSource {"controller": "examplea", "controllerGroup": "example.linkinstars.com", "controllerKind": "ExampleA", "source": "kind source: *v1.Pod"}
2023-08-03T23:07:21+08:00 INFO Starting Controller {"controller": "examplea", "controllerGroup": "example.linkinstars.com", "controllerKind": "ExampleA"}
2023-08-03T23:07:21+08:00 INFO Starting workers {"controller": "examplea", "controllerGroup": "example.linkinstars.com", "controllerKind": "ExampleA", "worker count": 1}
2023-08-03T23:08:46+08:00 INFO 开始调用Reconcile方法 {"controller": "examplea", "controllerGroup": "example.linkinstars.com", "controllerKind": "ExampleA", "ExampleA": {"name":"my-opex","namespace":"default"}, "namespace": "default", "name": "my-opex", "reconcileID": "9a03fe8e-8461-4500-b32f-140161095f8b"}
2023-08-03T23:08:46+08:00 INFO 已更新CRD资源状态 {"controller": "examplea", "controllerGroup": "example.linkinstars.com", "controllerKind": "ExampleA", "ExampleA": {"name":"my-opex","namespace":"default"}, "namespace": "default", "name": "my-opex", "reconcileID": "9a03fe8e-8461-4500-b32f-140161095f8b", "status": false}

创建 CRD

创建一个 CRD config/samples/example_v1_examplea.yaml 内容如下,指定 groupName 为 business 也就是当出现 business 的 pod 这个 crd 就开始认真监察了。

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: example.linkinstars.com/v1  
kind: ExampleA
metadata:
labels:
app.kubernetes.io/name: examplea
app.kubernetes.io/instance: examplea-sample
app.kubernetes.io/part-of: opex
app.kubernetes.io/managed-by: kustomize
app.kubernetes.io/created-by: opex
name: my-opex
spec:
groupName: business
1
kubectl apply -f config/samples/example_v1_examplea.yaml

然后,我们查看一下当前的 CRD 状态,可以看到现在状态应该是空的

1
$ kubectl describe ExampleA my-opex

然后新建一个文件 example_v1_examplea 1.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: Pod
metadata:
name: busybox
labels:
group: "business"
spec:
containers:
- name: busybox
image: busybox:latest
command:
- sleep
- "3600"

然后再次查看 CRD 状态

1
2
3
4
5
6
7
8
$ kubectl describe ExampleA my-opex
...
Spec:
Group Name: business
Status:
Under Control: true
Events: <none>
...

可以看到控制状态已经变成了 true 了,同时你也可以在控制台的日志中看到资源状态变更的日志

1
2
3
4
2023-08-03T23:28:21+08:00  INFO  开始调用Reconcile方法  {"controller": "examplea", "controllerGroup": "example.linkinstars.com", "controllerKind": "ExampleA", "ExampleA": {"name":"my-opex","namespace":"default"}, "namespace": "default", "name": "my-opex", "reconcileID": "7578ca2f-2bfe-4e4b-ba1c-3d43ff366ddf"}
2023-08-03T23:28:21+08:00 INFO 已更新CRD资源状态 {"controller": "examplea", "controllerGroup": "example.linkinstars.com", "controllerKind": "ExampleA", "ExampleA": {"name":"my-opex","namespace":"default"}, "namespace": "default", "name": "my-opex", "reconcileID": "7578ca2f-2bfe-4e4b-ba1c-3d43ff366ddf", "status": false}
2023-08-03T23:28:21+08:00 INFO 开始调用Reconcile方法 {"controller": "examplea", "controllerGroup": "example.linkinstars.com", "controllerKind": "ExampleA", "ExampleA": {"name":"my-opex","namespace":"default"}, "namespace": "default", "name": "my-opex", "reconcileID": "e37ac2a6-a769-491c-9d73-e89ea8e43f23"}
2023-08-03T23:28:21+08:00 INFO 已更新CRD资源状态 {"controller": "examplea", "controllerGroup": "example.linkinstars.com", "controllerKind": "ExampleA", "ExampleA": {"name":"my-opex","namespace":"default"}, "namespace": "default", "name": "my-opex", "reconcileID": "e37ac2a6-a769-491c-9d73-e89ea8e43f23", "status": false}

至此,我们的上手工作就已经完成了。之后你就可以摸索更加高级的各种操作了,根据具体的实际业务场景需求来满足不同的需要。

如果需要回收删除对应的资源先使用 kubectl delete -f 删除所有创建的测试。然后直接执行 make uninstall 就可以了。

对于 helm

网上有很多对于 helm 和 Operator 的类比,其实我觉得二者方向就不同。helm 是将所有需要部署的资源统一打包在一起,方便打包和部署。当然 CRD 也可以实现类似的功能并且更加强大。但 helm 是对于已有资源的集合,大多数部署情况 k8s 提供的 deploy/service/… 等等已经足够用了。最最最关键的一点,helm 没法控制循环,Controller 才是 Operator 的灵魂。

总结

我觉得很多人会认为 Operator 复杂或者很难上手,多数情况是不理解 k8s 内部原理导致的。如果你非常清楚他的 Controller Manager 的原理和行为,直到控制循环,其实 Operator 已经封装的非常好了。这样的设计我觉得巧妙的原因是扩展起来真的非常方便。

kubebuilder 官方还有一个 CronJob 的教程,让你快速使用 Operator 实现一个 CronJob 的功能。我觉得对于新手可能还是稍微复杂了一点点,当然你看完本文并且实践之后建议你跑一把玩一玩会更容易理解。