使用Kubebuilder编写Operator

Mon, Sep 20, 2021 阅读时间 4 分钟

CRD和自定义控制器

k8s的Informer机制这篇文章中我们了解了K8s的Informer机制,以及对于k8s原生资源的Informer对象的使用方式。Informer机制归根到底就是利用了kube-apiserver的ListAndWatch机制,去监听集群中特定的API对象的变更,进而去实现一些控制。而且k8s的API对象是支持拓展的,允许用户在 Kubernetes 中添加一个跟 Pod、Node 类似的、新的 API 资源类型,然后编写一个针对自定义API对象的控制器,通过Informer机制去监听这个新的API对象在集群中的变更,然后实现一些控制。这中自定义的API对象就是CRD(Custom Resource Definition)

K8s中创建一个CRD十分简单,只需要创建一个CustomResourceDefinition类型的对象即可,下面是一个例子:

# crd.yaml
---
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata: 
  name: networks.samplecrd.k8s.io
spec: 
  group: samplecrd.k8s.io 
  version: v1 
  names: 
    kind: Network 
    plural: networks 
  scope: Namespaced

关于CRD的创建这里不多赘述,关于细节可以查看k8s的官方文档

创建好CRD之后,我们就可以创建这种CRD类型的API对象了,但是这时我们创建的CRD只能说是一种存储在etcd里面的一些数据,除了能看一点用处都没有,要通过这种新API对象实现一些功能,就需要编写一个控制器。k8s集群里的原生API对象,如Pod、Deployment等都有自己的控制器,这些控制器都被集成在了kube-controller-manager这个控制面组件上。我们要编写的自定义控制器,可以以Pod的形式运行在集群上,然后在pod中和kube-apiserver进行ListAndWatch自定义的API对象,实现一些控制逻辑。

一个自定义控制器的实现方式可以如下图所示:

一个自定义控制器包含三块部分:

  • Informer

    用于从kube-apiserver中获取它关心的API对象,即我们通过CRD定义的新的API对象。

  • WorkQueue

    用来缓存API对象,ListAndWatch这个机制的主要好处就是它不是在需要的时候才去访问kube-apiserver,而是实时的保持本地缓存和kube-apiserver中的数据一致性,这样我们需要的时候可以直接从缓存中快速的获取

  • Control Loop

    上面的Informer和WorkQueue我们在k8s的Informer机制中已经讲过。Control Loop即控制循环,才是一个自定义控制器的逻辑核心。在这里我们会实现控制逻辑。从名字中就可以很好看出,Controll Loop是一个无限的循环,在我们通过Informer拿到一个API对象,这个API对象的spec字段中,会填写这种资源的期望状态,Contrl Loop通过在每一个循环周期中,对比期望状态和集群中的实际状态的差异,不断的将实际状态和期望状态靠拢,这个过程就叫做调谐(Reconcile)。

Operator

以上我们了解了一个自定义控制器的使用。而通过CRD和自定义控制器的设计,实现一种对应用的特定编排方式,就是Operator。一个Operator中可以包含一个或多个的CRD以及它们的自定义控制器,通过相互协调进而实现一些特定的应用编排方式。

在这个网址operatorhub.io有很多个别人或者公司写好的Operator,可以直接使用。比如可以在集群中部署一个etcd集群的operator,部署一个redis集群的operator等。结合Statefulset部署有状态应用是Operator最擅长实现的功能。

目前有很多工具可以帮我们自动生成Operator的代码,比如k8s官方的kubebuilder,redhat的Operator SDK等,可以帮助我们快速生成一个Operator的框架和yaml文件,Informer、Control Loop都帮我们实现完了,我们只需要编写Reconcile函数即可,实现自己的控制器逻辑即可。

通过kubebuilder编写一个Operator

设计Operator

这里我们设计一个CRD叫做MyPod,通过创建一个MyPod对象,Operator会帮我们在集群中创建一个pod出来(虽然有点脱裤子放屁,这里只是通过这个例子帮助理解哈╮(๑•́ ₃•̀๑)╭)。

我们的API对象大概长这样:

apiVersion: harryyann.github.io
kind: MyPod
metadata:
  annotations:
  labels:
  name: 
  namespace:
spec:
    podAnnotations:
    podLabels:
    podSpec:
        # 这里是原封不动的podSpec
status:
    podPhase:
    podIp:
    nodeIp:
    createdTime:

metadata里面填的是MyPod对象的基础信息,spec里是期望状态,就是pod的annotation、label和podSpec。status保存和MyPod对象关联的pod的一些信息,如果phase、podIp、nodeIp等。

要实现这个需求,我们要做的事情有三个:

  • 创建一个MyPod的CRD出来。
  • 编写一个Operator监听集群中MyPod对象的创建,每当创建一个MyPod对象,我们就通过clien创建一个pod出来。
  • operator还要监控创建的pod,并把这个pod的一些信息,实时更新到MyPod的status中,且在删除MyPod时,对应的pod也要删除

安装kubebuilder

这个地址下载好需要的二进制文件,把kubebuilder放到PATH目录下,检查一下kubebuilder是否可用:

# kubebuilder version
Version: main.version{KubeBuilderVersion:"3.3.0", KubernetesVendor:"1.23.1", GitCommit:"47859bf2ebf96a64db69a2f7074ffdec7f15c1ec", BuildDate:"2022-01-18T17:03:29Z", GoOs:"linux", GoArch:"amd64"}

可以正确显示版本就说明安装成功了。

初始化项目

  1. 首先创建一个空目录,然后进入这个目录:

    # mkdir mypod-operator
    # cd mypod-operator
    
  2. 执行kubebuilder init命令初始化项目:

    # kubebuilder init --domain harryyann.github.io --repo github.com/harryyann/mypod-operator
    

    我这里指定了--domain--repo两个参数,domain是我们项目的域名,他会成为我们创建的API对象的组名,repo则是仓库地址,他会成为go mod init时指定的项目名。

  3. 然后我们创建API:

    # kubebuilder create api --namespaced --version v1 --kind MyPod --plural mypods
    

    这里我们会创建API对象的代码,--namespaced参数指定这个API对象是命名空间级的,–version指定了API版本,--kind指定了API对象的类型,即yaml文件的kind字段, --plural指定了API对象的复数形式。这里还可以指定--group组信息,如果不指定会使用--domain作为组名,指定了就会和domain进行拼接作为组名。

在执行完成以上命令后,我们当前的目录结构就是这样的:

├── Dockerfile
├── Makefile
├── PROJECT
├── api/
├── bin/
├── config
│   ├── crd/
│   ├── default/
│   ├── manager/
│   ├── prometheus/
│   ├── rbac/
│   └── samples/
├── controllers/
├── go.mod
├── go.sum
├── hack/
└── main.go

里面一些主要目录的作用如下:

  • Makefile:定义了很多脚本,需要什么就执行make的具体步骤即可
  • config:一些yaml文件,比如rbac目录下就是一些权限认证的yaml,manager里面就是部署这个operator的yaml,crd里面就是crd的yaml,包括webhook等
  • bin:是我们生成代码要用到的二进制文件
  • api:比较关键,我们的CRD资源的定义和register、deepcopy等方法也在这。
  • controllers:比较关键,我们的控制器的reconcile逻辑就在这里写
  • main.go:项目的入口,就是operator的主体逻辑,我们一般情况下不需要改这块

修改API对象的字段

生成了整个框架之后,定义一下我们的自定义资源的字段,位于api/v1/mypod_types.go中,在这里定义一下spec字段,和status字段。MyPodSpec中默认会带有一个Foo的string类型字段,把他删掉。然后添加我们真正需要的字段,如下:

// MyPodSpec defines the desired state of MyPod
type MyPodSpec struct {
   // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
   // Important: Run "make" to regenerate code after modifying this file

   PodAnnotations map[string]string `json:"podAnnotations,omitempty"`
   PodLabels map[string]string `json:"podLabels,omitempty"`
   PodSpec  v1.PodSpec   `json:"podSpec"`
}


// MyPodStatus defines the observed state of MyPod
type MyPodStatus struct {
   // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
   // Important: Run "make" to regenerate code after modifying this file
   PodPhase string `json:"podPhase,omitempty"`
   PodIp string `json:"podIp,omitEmpty"`
   NodeIp string `json:"nodeIp,omitEmpty"`
   CreatedTimestamp int64 `json:"createdTimestamp,omitEmpty"`
}

我们还可以添加这样的注释,让我们可以在通过kubectl查看时展示更多内容:

//+kubebuilder:printcolumn:JSONPath=".status.phase",name=Phase,type=string
//+kubebuilder:printcolumn:JSONPath=".status.podIp",name=PodIp,type=string

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

改完字段后,我们用命令make manifests generate,针对修改过的资源,生成需要的yaml文件

# `make manifests generate`

然后我们会发现config/crd目录下多了一个bases目录,里面有CRD的定义,我们可以用这个文件创建出CRD。

在集群中运行一下operator

我们先不添加Reconcile的代码在集群中跑一下,我跑的过程遇到了一些坑,具体就不说了,总之我做了下面这些修改后,才顺利跑起来:

  1. 首先要修改main.go的代码,修改kubeconfig的获取方式,因为我们的operator是在pod里跑的,所以改为InClusterConfig的方式获取kubeconfig,否则pod运行时会因为无法获取kubeconfig而error。

    config, err := rest.InClusterConfig()
    if err != nil {
       setupLog.Error(err, "get in cluster config error")
       os.Exit(1)
    }
    mgr, err := ctrl.NewManager(config, ctrl.Options{
    
  2. 然后还要在manager/manager.yaml中做下修改:添加这一行,要不然InClusterConfig函数会提示找不到token文件:

    automountServiceAccountToken: true
    
  3. 最后还要在config/rbac/role.yaml中加些权限:

    - apiGroups:
        - "coordination.k8s.io"
      resources:
        - leases
      verbs:
        - create
        - delete
        - get
        - list
        - patch
        - update
        - watch
    - apiGroups:
        - ""
      resources:
        - pods
        - configmaps
        - events
      verbs:
        - create
        - delete
        - get
        - list
        - patch
        - update
        - watch
    

完成上述操作后,我们在集群中创建出CRD、role、roleBinding、serviceAccount:

# kubectl apply -f config/crd/bases/harryyann.github.io_mypods.yaml
# kubectl apply -f config/rbac/role.yaml
# kubectl apply -f config/rbac/role_binding.yaml
# kubectl apply -f config/rbac/serviceAccount.yaml

然后我们通过make构建一下Operator的镜像,并push到镜像中心:

# make docker-build
# make docker-push

镜像打好并push到远程仓库之后,我们在集群中创建namespace和operator的deployment,我这里给ns起名为test-mypod:

# kubectl apply -f config/manager/manager.yaml

然后就可以看到ns下有pod再跑了:

# kubectl get pod -n test-mypod
NAME                             READY   STATUS    RESTARTS   AGE
mypod-manager-56d6687f75-pqt6v   1/1     Running   0          18m

查看一下它的日志:

# kubectl logs -f -n test-mypod mypod-manager-56d6687f75-pqt6v
1.6475719323468134e+09  INFO    controller-runtime.metrics      Metrics server is starting to listen    {"addr": ":8080"}
1.6475719323471029e+09  INFO    setup   starting manager
1.6475719323472993e+09  INFO    Starting server {"path": "/metrics", "kind": "metrics", "addr": "[::]:8080"}
1.6475719323473573e+09  INFO    Starting server {"kind": "health probe", "addr": "[::]:8081"}
I0318 02:52:12.347376       1 leaderelection.go:248] attempting to acquire leader lease test-mypod/9ef2ee97.harryyann.github.io...
I0318 02:52:29.431037       1 leaderelection.go:258] successfully acquired lease test-mypod/9ef2ee97.harryyann.github.io
1.647571949431093e+09   DEBUG   events  Normal  {"object": {"kind":"ConfigMap","namespace":"test-mypod","name":"9ef2ee97.harryyann.github.io","uid":"65e4bba0-1dde-4dae-b592-6279d1953b9e","apiVersion":"v1","resourceVersion":"313330506"}, "reason": "LeaderElection", "message": "mypod-manager-56d6687f75-pqt6v_a7556a76-14a3-4c90-8fef-f74bb7144b5d became leader"}
1.6475719494311852e+09  DEBUG   events  Normal  {"object": {"kind":"Lease","namespace":"test-mypod","name":"9ef2ee97.harryyann.github.io","uid":"b3abbcb8-5d68-450c-b104-4c8a9d542e44","apiVersion":"coordination.k8s.io/v1","resourceVersion":"313330507"}, "reason": "LeaderElection", "message": "mypod-manager-56d6687f75-pqt6v_a7556a76-14a3-4c90-8fef-f74bb7144b5d became leader"}
1.647571949431183e+09   INFO    controller.mypod        Starting EventSource    {"reconciler group": "harryyann.github.io", "reconciler kind": "MyPod", "source": "kind source: *v1.MyPod"}
1.647571949431227e+09   INFO    controller.mypod        Starting Controller     {"reconciler group": "harryyann.github.io", "reconciler kind": "MyPod"}
1.6475719495316322e+09  INFO    controller.mypod        Starting workers        {"reconciler group": "harryyann.github.io", "reconciler kind": "MyPod", "worker count": 1}

看起来输出正常,我们的Operator可以运行了,接下来就是修改Reconcile的代码,让它实现该有的功能即可。

编写Reconcile函数

Reconcile函数位于controllers目录下。它的定义是这样的:

func (r *MyPodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error)

参数req中会包含当前所变更对象的namespace和name信息,通过这两个值,就可以获取到我们创建、修改、删除的MyPod对象,进而完成一些操作。

这部分主要是写逻辑,我就不细说了,代码我放在这个仓库里了。

在集群上调试

改完代码后,执行make docker-build构建镜像,执行make docker-push推送镜像。

然后在集群中通过上设置deployment的imagePullPolicy=Always,然后删除manager的pod,他就会自动重建,并拉取最新的镜像。

观察到日志输出是正常的:

1.6476801020824914e+09  INFO    controller.mypod        Starting EventSource    {"reconciler group": "harryyann.github.io", "reconciler kind": "MyPod", "source": "kind source: *v1.MyPod"}
1.6476801020826638e+09  INFO    controller.mypod        Starting Controller     {"reconciler group": "harryyann.github.io", "reconciler kind": "MyPod"}
1.647680102183413e+09   INFO    controller.mypod        Starting workers        {"reconciler group": "harryyann.github.io", "reconciler kind": "MyPod", "worker count": 1}

然后我们用如下yaml创建一个MyPod对象:

apiVersion: harryyann.github.io/v1
kind: MyPod
metadata:
  name: mypod-sample
  namespace: test-mypod
spec:
  podAnnotations:
    app: mypod
  podLabels:
    app: mypod
  podSpec:
    containers:
    - image: gcr.io/google-containers/pause:3.1
      imagePullPolicy: IfNotPresent
      name: main
      resources:
        limits:
          cpu: 50m
          memory: 50Mi
    nodeSelector:
      project: test

集群中就可以看到这个MyPod对象已经存在了:

# kubectl get mypod -n test-mypod
NAME           AGE
mypod-sample   4m33s

然后我们观察manager的日志,发现pod创建成功了:

1.6476801088302636e+09  INFO    controller.mypod        create pod mypod-sample success {"reconciler group": "harryyann.github.io", "reconciler kind": "MyPod", "name": "mypod-sample", "namespace": "test-mypod"}

在集群上查看,mypod-sample这个pod确实已经创建起来了:

# kubectl get po -n test-mypod
NAME                             READY   STATUS    RESTARTS   AGE
mypod-manager-56d6687f75-h8cwd   1/1     Running   0          6m20s
mypod-sample                     1/1     Running   0          5m54s

查看MyPod对象的Status字段也被填充好了:

# kubectl get mypod -n test-mypod mypod-sample  -oyaml
apiVersion: harryyann.github.io/v1
kind: MyPod
metadata:
  creationTimestamp: "2022-03-19T10:12:31Z"
  generation: 1
  name: mypod-sample
  namespace: test-mypod
  resourceVersion: "314990422"
  selfLink: /apis/harryyann.github.io/v1/namespaces/test-mypod/mypods/mypod-sample
  uid: ec645507-23c0-4158-9d3f-cfe01f8d52db
spec:
  podLabels:
    sym-app: mypod
  podSpec:
    containers:
    - image: ncr.nie.netease.com/gcr.io/google-containers/pause:3.1
      imagePullPolicy: IfNotPresent
      name: main
      resources:
        limits:
          cpu: 50m
          memory: 50Mi
    nodeSelector:
      project: test
status:
  createdTimestamp: 0
  nodeIp: 7.34.19.17
  podIp: 7.53.69.18
  podPhase: Running

这就证明我们的Operator生效了,如果有问题,就是调谐里的逻辑没写对,修改代码然后重复执行构建镜像,重新创建manager的pod的过程反复调试即可。