大数跨境
0
0

Kubernetes Operator 开发教程

Kubernetes Operator 开发教程 阿里云云栖号
2020-11-24
0
导读:实例详解。


1. 概述


我们将 CRD, Controller, Webhook 三者合起来叫 Operator。一个 Operator 工程一般必须包含 CRD 和 Controller,Admission 是可选的。如果说 Kubernetes 是 "操作系统" 的话,Operator 是 Kubernetes 的第一层应用,它部署在 Kubernetes 里,使用 Kubernetes "扩展资源" 接口的方式向更上层用户提供服务


Operator的实现方式主要包括OperatorSDK和KubeBuilder,目前KubeBuilder在阿里使用的比较多。


KubeBuilder

https://github.com/kubernetes-sigs/kubebuilder

OperatorSDK

https://github.com/operator-framework/operator-sdk


下文的接入流程中,我们主要选择KubeBuilder进行介绍。


2. 名词解释


GVKs&GVRs:


GVK = GroupVersionKind,GVR = GroupVersionResource


API Group & Versions(GV):


API Group是相关API功能的集合,每个Group拥有一或多个Versions,用于接口的演进。


Kinds & Resources:


每个GV都包含多个API类型,称为Kinds,在不同的Versions之间同一个Kind定义可能不同, Resource是Kind的对象标识

(resource type)

https://kubernetes.io/docs/reference/kubectl/overview/#resource-types,一般来说Kinds和Resources是1:1的,比如pods Resource对应Pod Kind,但是有时候相同的Kind可能对应多个Resources,比如Scale Kind可能对应很多Resources:deployments/scale,replicasets/scale,对于CRD来说,只会是1:1的关系。


每一个GVK都关联着一个package中给定的root Go type,比如apps/v1/Deployment就关联着K8s源码里面k8s.io/api/apps/v1 package里面的Deployment struct,我们提交的各类资源定义YAML文件都需要写:


  • apiVersion
    这个就是GV
  • kind
    这个就是K

根据GVK K8s就能找到你到底要创建什么类型的资源,根据你定义的Spec创建好资源之后就成为了Resource,也就是GVR。GVK/GVR就是K8s资源的坐标,是我们创建/删除/修改/读取资源的基础。

Scheme:

每一组Controllers都需要一个Scheme,提供了Kinds与对应Go types的映射,也就是说给定Go type就知道他的GVK,给定GVK就知道他的Go type,比如说我们给定一个Scheme: "tutotial.kubebuilder.io/api/v1".CronJob{}这个Go type映射到batch.tutotial.kubebuilder.io/v1的CronJob GVK,那么从Api Server获取到下面的JSON:

 
 
 
{    "kind": "CronJob",    "apiVersion": "batch.tutorial.kubebuilder.io/v1",    ...}

就能构造出对应的Go type了,通过这个Go type也能正确地获取GVR的一些信息,控制器可以通过该Go type获取到期望状态以及其他辅助信息进行调谐逻辑。

Manager:

Kubebuilder的核心组件,具有3个职责:

  • 负责运行所有的Controllers
  • 初始化共享caches,包含listAndWatch功能
  • 初始化clients用于与Api Server通信;

Cache:

Kubebuilder的核心组件,负责在Controller进程里面根据Scheme同步Api Server中所有该Controller关心GVKs的GVRs,其核心是GVK -> Informer的映射,Informer会负责监听对应GVK的GVRs的创建/删除/更新操作,以触发Controller的Reconcile逻辑。

Controller:

Kubebuidler为我们生成的脚手架文件,我们只需要实现Reconcile方法即可。

Clients:

在实现Controller的时候不可避免地需要对某些资源类型进行创建/删除/更新,就是通过该Clients实现的,其中查询功能实际查询是本地的Cache,写操作直接访问Api Server。

Index:

由于Controller经常要对Cache进行查询,Kubebuilder提供Index utility给Cache加索引提升查询效率。

Finalizer:

在一般情况下,如果资源被删除之后,我们虽然能够被触发删除事件,但是这个时候从Cache里面无法读取任何被删除对象的信息,这样一来导致很多垃圾清理工作因为信息不足无法进行,K8s的Finalizer字段用于处理这种情况。在K8s中,只要对象ObjectMeta里面的Finalizers不为空,对该对象的delete操作就会转变为update操作,具体说就是update  deletionTimestamp 字段,其意义就是告诉K8s的GC“在deletionTimestamp这个时刻之后,只要Finalizers为空,就立马删除掉该对象”。所以一般的使用姿势就是在创建对象时把Finalizers设置好(任意string),然后处理DeletionTimestamp不为空的update操作(实际是delete),根据Finalizers的值执行完所有的pre-delete hook(此时可以在Cache里面读取到被删除对象的任何信息)之后将Finalizers置为空即可。

OwnerReference:

K8s GC在删除一个对象时,任何ownerReference是该对象的对象都会被清除,与此同时,Kubebuidler支持所有对象的变更都会触发Owner对象controller的Reconcile方法。

3. List-Watch机制



这里我把代码分为通用的Common part和Special Part。前者是ClientGo的基本流程,而后者部分是controller自身逻辑部分。

为了让ClientGo 更快地返回List/Get请求的结果、减少对 Kubenetes API的直接调用,Informer 被设计实现为一个依赖Kubernetes List/Watch API、可监听事件并触发回调函数的二级缓存工具包。

  • 更快地返回 List/Get 请求,减少对 Kubenetes API 的直接调用

使用Informer实例的Lister()方法,List/Get Kubernetes 中的 Object时,Informer不会去请求Kubernetes API,而是直接查找缓存在本地内存中的数据(这份数据由Informer自己维护)。通过这种方式,Informer既可以更快地返回结果,又能减少对 Kubernetes API 的直接调用。

  • 依赖 Kubernetes List/Watch API

Informer 只会调用Kubernetes List 和 Watch两种类型的 API。Informer在初始化的时,先调用Kubernetes List API 获得某种 resource的全部Object,缓存在内存中; 然后,调用 Watch API 去watch这种resource,去维护这份缓存; 最后,Informer就不再调用Kubernetes的任何 API。

用List/Watch去维护缓存、保持一致性是非常典型的做法,但令人费解的是,Informer 只在初始化时调用一次List API,之后完全依赖 Watch API去维护缓存,没有任何resync机制。

笔者在阅读Informer代码时候,对这种做法十分不解。按照多数人思路,通过 resync机制,重新List一遍 resource下的所有Object,可以更好的保证 Informer 缓存和 Kubernetes 中数据的一致性。

咨询过Google 内部 Kubernetes开发人员之后,得到的回复是:

在 Informer 设计之初,确实存在一个relist无法去执 resync操作, 但后来被取消了。原因是现有的这种 List/Watch 机制,完全能够保证永远不会漏掉任何事件,因此完全没有必要再添加relist方法去resync informer的缓存。这种做法也说明了Kubernetes完全信任etcd。

  • 可监听事件并触发回调函数

Informer通过Kubernetes Watch API监听某种 resource下的所有事件。而且,Informer可以添加自定义的回调函数,这个回调函数实例(即 ResourceEventHandler 实例)只需实现 OnAdd(obj interface{}) OnUpdate(oldObj, newObj interface{}) 和OnDelete(obj interface{}) 三个方法,这三个方法分别对应informer监听到创建、更新和删除这三种事件类型。

在Controller的设计实现中,会经常用到 informer的这个功能。

  • 二级缓存

二级缓存属于 Informer的底层缓存机制,这两级缓存分别是DeltaFIFO和 LocalStore。

这两级缓存的用途各不相同。DeltaFIFO用来存储Watch API返回的各种事件 ,LocalStore 只会被Lister的List/Get方法访问 。

虽然Informer和 Kubernetes 之间没有resync机制,但Informer内部的这两级缓存之间存在resync 机制。

4. CRD规范


4.1 命名规范


  1. CRD 的全名一般要符合如下的命名规范: {Kind}.{Group}.{Organization}.{Domain}。其中:
    1. {Kind} 即为 CRD 真正的短名字,用精简的单个或多个英文单词的拼接来命名真正的 CRD 短名字。如 AdvancedDeployment,NoteBook 等。使用大驼峰命名法(首字母也是大写,UpperCamelCase)。
    2. {Group} 必须是一种功能类别,如 ops, apps, auth 等。尽量用精简的单个英语单词的方式传达你的 CRD 属于的“类别”。组成的字母必须都是小写
    3. {Organization} 为仓库的 git Group,即 团队名英文简称。
    4. {Domain},即 Company Name Domain,例如alibaba-inc.com。
    5. 目前对于 CRD 版本转化不太友好,统一使用 v1


4.2 Spec, Status 规范


  1. 在上一节用命令在  apis 包下生成 CRD Types 之后,请不要随意修改 apis 里的结构体、命名规则、以及注释。
  2. 只能、也只需要修改 {Kind}_types.go 文件里 Spec 和 StatusSpec 结构体里的内容。
  3. Spec 和 StatusSpec 里的字段都必须是 Public 的,也就是字段名首字母是大写。
  4. 每个字段,都应该写上 JSON Tag,JSON Tag 必须使用 小驼峰命名法,即 LowerCamelCase。
  5. 如果字段允许为空,JSON Tag 记得带上 omitempty。StatusSpec 的字段一般都是允许为空的!例子:

 
 
 
type MySpec struct {    // FiledA 允许为空    FieldA string `json:"fieldA,omitempty"`
// FiledB 不允许为空 FieldB string `json:"fieldB"`}

5. 快速接入


5.1 安装环境


由于云原生部分组件没有windows版本,建议尽量用MAC进行开发,下面的例子也是主要以MAC为主:

  • go
    https://golang.org/dl/
    1.13+
  • docker

    https://docs.docker.com/install/

    17.03+
  • kubectl

    https://kubernetes.io/docs/tasks/tools/install-kubectl/

    1.11.3+:


 
 
 
brew install kubectl

  • helm

    https://github.com/helm/helm

    3.2.2+:


 
 
 
brew install helm

  • kustomize

    https://sigs.k8s.io/kustomize/docs/INSTALL.md

    3.1.0+



 
 
 
如果kubectl版本为1.14+,则无需安装,使用kubectl kustomize即可

  • controller-tools

    https://github.com/kubernetes-sigs/controller-tools/releases

    0.3.0+



 
 
 
go get https://github.com/kubernetes-sigs/controller-tools.git

  • kubebuilder

    https://book.kubebuilder.io/quick-start.html#installation

    2.3.1+



 
 
 
下载压缩包:kubebuilder_2.3.1_${os}_${arch}解压并复制到:/usr/local/kubebuilder添加环境变量:export PATH=$PATH:/usr/local/kubebuilder/bin

  • 设置环境变量



 
 
 
打开配置文件:open ~/.bash_profile添加环境变量:export GOBIN=$GOPATH/binexport PATH=$PATH:$GOBINexport GOPATH=/Users/yunzheng/goexport GOROOT=/usr/local/goexport GO111MODULE=onexport GOPROXY=https://goproxy.cn执行修改的文件:source ~/.bash_profile

注意:

  • 必须开启go mod,GO111MODULE=on
  • 代理地址推荐:
    • 七牛云:https://goproxy.cn 推荐,可解决依赖问题
    • 阿里云:https://mirrors.aliyun.com/goproxy/,不推荐,某些包依然无法下载
    • 腾讯:https://goproxy.io,不推荐,有些包无法下载
  • 开发工具推荐:
    • goland:推荐,但是收费
    • idea:可以装go插件,但是可能没有goland适配的好
    • vscode:比较轻量级,但是开发不太方便


5.2 创建工程


  • 创建脚手架工程
 
 
 
mkdir myapp-operatorcd myapp-operator/kubebuilder init --domain my.alibaba-inc.com

domain参数是group的后缀(域的概念,不填写默认my.domain)
本步骤创建了 Go module 工程的模板文件,引入了必要的依赖。

  • 创建 API,生成CRD和Controller
 
 
 
kubebuilder create api --group apps --version v1alpha1 --kind Myapp注意:1)group参数表示组的概念2)version定义版本3)kind定义自定义资源类型4)以上参数组成 自定义yaml 的 apiVersion和kind

  • 如果需要在Myapp CRUD 时进行合法性检查, 可以生成webhook:

 
 
 
kubebuilder create webhook

  • 初始化基础的依赖包信息

 
 
 
go mod init

最后工程结构如下:


5.3 编写代码


下面主要以Deployment为例,核心逻辑是把自定义CR(Myapp)当做终态,把Deployment当做运行态,通过比对属性的不一致,编写相关的Reconcile逻辑。

一张图解释各种资源和 Controller 的关系:



5.3.1 定义 CRD


在myapp_type.go中定义 Spec 和 Status

 
 
 
// MyappSpec defines the desired state of Myapptype MyappSpec struct {    // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster    // Important: Run "make" to regenerate code after modifying this file
// Foo is an example field of Myapp. Edit Myapp_types.go to remove/update Foo string `json:"foo,omitempty"` appsv1.DeploymentSpec `json:",inline"`}
// MyappStatus defines the observed state of Myapptype MyappStatus struct { // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster // Important: Run "make" to regenerate code after modifying this file appsv1.DeploymentStatus `json:",inline"` Phase MyappPhase `json:"phase"`}
// +kubebuilder:object:root=true// +kubebuilder:subresource:status
// Myapp is the Schema for the myapps APItype Myapp struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"`
Spec MyappSpec `json:"spec,omitempty"` Status MyappStatus `json:"status,omitempty"`}

注意+kubebuilder并非普通注释,不能随意删除。

5.3.2 编写Reconcile逻辑


在myapp_controller.go中实现 Reconcile 逻辑

 
 
 
func (r *MyappReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {    ctx := context.Background()    log := r.Log.WithValues("myapp", req.NamespacedName)
// your logic here log.Info("start Reconcile" + req.Name) ...}

5.3.3 编写断言


在myapp_predicate.go实现断言,并拷贝新旧Spec、ObjectMeta等

 
 
 
var oldSpec interface{}    var oldObjectMeta metav1.ObjectMeta    var newSpec interface{}    var newObjectMeta metav1.ObjectMeta    switch e.MetaOld.(type) {    case *appsv1alpha1.Myapp:        oldSpec = e.MetaOld.(*appsv1alpha1.Myapp).Spec        oldObjectMeta = e.MetaOld.(*appsv1alpha1.Myapp).ObjectMeta        newSpec = e.MetaNew.(*appsv1alpha1.Myapp).Spec        newObjectMeta = e.MetaNew.(*appsv1alpha1.Myapp).ObjectMeta    case *appsv1.Deployment:        oldSpec = e.MetaOld.(*appsv1.Deployment).Spec        oldObjectMeta = e.MetaOld.(*appsv1.Deployment).ObjectMeta        newSpec = e.MetaNew.(*appsv1.Deployment).Spec        newObjectMeta = e.MetaNew.(*appsv1.Deployment).ObjectMeta    case *corev1.Pod:        oldSpec = e.MetaOld.(*corev1.Pod).Spec        oldObjectMeta = e.MetaOld.(*corev1.Pod).ObjectMeta        newSpec = e.MetaNew.(*corev1.Pod).Spec        newObjectMeta = e.MetaNew.(*corev1.Pod).ObjectMeta    }    if !reflect.DeepEqual(oldSpec, newSpec) ||        oldObjectMeta.DeletionTimestamp != nil ||        newObjectMeta.DeletionTimestamp != nil {        log.Info("Update event has new metadata", "event")        return true    }

5.3.4 修改Webhook


 
 
 
func (r *Myapp) ValidateCreate() error func (r *Myapp) ValidateUpdate(old runtime.Object) errorfunc (r *Myapp) Default()

比如我们在这里对myapp.Spec.Abstract的内容进行检查,如果replicas大于100,我们就进行进行报错。

5.3.5 修改main入口


添加唯一的锁“myapp-operator”和监听的namespace

 
 
 

  mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{        Scheme:             scheme,        MetricsBindAddress: metricsAddr,        LeaderElection:     enableLeaderElection,        LeaderElectionID:   "myapp-operator",        Port:               9443,        LeaderElectionNamespace: os.Getenv("POD_NAMESPACE"),        Namespace:               os.Getenv("POD_NAMESPACE"),    })

  • 更多详情查看demo例子


5.4 调试工程


  • 配置Makefile
 
 
 

 注意 kustomize build都改为kubectl kustomize

  • 生产CRD:make manifests
  • 编译工程:make
  • 部署CRD:make install
  • 部署RBAC相关Yaml
  • 运行CR:make run

可以前台运行manager,我们经过上面的编辑,manager中注入了一个controller和一个webhook。

  • 部署CR

kubectl apply -f config/samples/apps_v1alpha1_myapp.yaml

  • 在idea或者goland中打断点调试


5.5 打包构建


  • 配置Dockerfile
 
 
 

# Build the manager binaryFROM golang:1.12.5 as builder
WORKDIR /workspace# Copy the Go Modules manifestsCOPY go.mod go.modCOPY go.sum go.sum# cache deps before building and copying source so that we don't need to re-download as much# and so that source changes don't invalidate our downloaded layer# 设置代理RUN GO111MODULE=on GOPROXY=https://goproxy.cn go mod download
# Copy the go sourceCOPY main.go main.goCOPY api/ api/COPY controllers/ controllers/
# BuildRUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GO111MODULE=on go build -a -o manager main.go
# Use distroless as minimal base image to package the manager binary# Refer to https://github.com/GoogleContainerTools/distroless for more details# 设置基础镜像FROM reg.docker.alibaba-inc.com/alibase/alios7u2-min:latestWORKDIR /COPY --from=builder /workspace/manager .USER admin:admin
ENTRYPOINT ["/manager"]

  • 设置代理
 
 
 

RUN GO111MODULE=on GOPROXY=https://goproxy.cn go mod download

  • 设置基础镜像
 
 
 

FROM reg.docker.alibaba-inc.com/alibase/alios7u2-min:latest

  • 打镜像
 
 
 

cd myapp-operatormake docker-build

5.6 上传镜像


下面以阿里云镜像服务为例。

  • 创建命名空间


  • 创建镜像仓库


  • 上传镜像
 
 
 

$ sudo docker login --username=[username] registry.cn-hangzhou.aliyuncs.com$ sudo docker tag [ImageId] registry.cn-hangzhou.aliyuncs.com/amwp/myapp-operator:[镜像版本号]$ sudo docker push registry.cn-hangzhou.aliyuncs.com/amwp/myapp-operator:[镜像版本号]

5.7 Helm部署


如果你的资源比较少,可以不使用Helm,如果比较多,可以尝试用helm做包管理工具。

拿一个完整的中间件举例:

如ZooKeeper的例子,可分为产品-->应用(可能有子应用)-->chart和定义


6. 源码解读


6.1 KubeBuilder对Controller的逻辑封装


想说明一下KubeBuilder实际上是提供了对ClientGo进行封装的Library(准确来说是Runtime Controller),更加便利我们来开发K8S的Operator。

我上面提到的workQueue
https://links.jianshu.com/go?to=https%3A%2F%2Fgithub.com%2Fkubernetes%2Fclient-go%2Fblob%2Fmaster%2Fexamples%2Fworkqueue%2Fmain.go
的例子已经实现了一个Controller的逻辑。而KubeBuilder还帮我们做了以下的额外工作:

  • KubeBuilder引入了Manager这个概念,一个Manager可以管理多个Controller,而这些Controller会共享Manager的Client;
  • 如果manager挂掉或者停止了,所有的controller也会随之停止;
  • kubebuilder使用一个map[GroupVersionKind]informer来管理这些controller,所以每个controller还是拥有其独立的workQueue,deltaFIFO,并且kubebuilder也已经帮我们实现了这部分代码;
  • 我们主要需要做的开发,就是写Reconcile中的逻辑。
  • Manager通过map[GroupVersionKind]informer启动所有Controller:


Controller处理event的逻辑都在
https://github.com/kubernetes-sigs/controller-runtime/blob/master/pkg/internal/controller/controller.go这个文件里面,其实它就是实现了workqueue这个例子的大部分代码,推荐先看懂这个例子再来分析这个文件。

6.2 Predicate和Controller中接收事件的区别


结论:本质上是同1个事件,只不过处理的阶段不同
事件入队流程:

  1. 初始化share_informer后,会注册
    eventHandler(pkg/kubelet/kubelietconfig/watch.go)
  2. 接下来开始执行shared_informer的run方法


  1. 调用handler中的OnUpdate方法


  1. 在OnUpdate方法中会根据predicate过滤器来进行事件的过滤。此时事件类型还
    是event.UpdateEvent


  1. 接下来会将该事件入队,此时事件类型转换为了
    reconcile.Request(handler/enqueue.go)


事件处理流程:

  1. controller启动后(internal/controller/controller.go),会启动worker goroutine


  1. 不停的从队列里面取事件,进行扔到reconcileHandler中进行处理


  1. 将事件传递到reconcile逻辑中,此时reconcile入参类型ctrl.Request,该类型是和reconcile.Request是同一个东西


7. 常见问题


7.1 the server could not find the requested resource


如果出现:the server could not find the requested resource 这个错误,那么在CRD结构体上需要加个注释 // +kubebuilder:subresource:status

 
 
 
// +kubebuilder:object:root=true// +kubebuilder:subresource:status
// Myapp is the Schema for the myapps APItype Myapp struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"`
Spec MyappSpec `json:"spec,omitempty"` Status MyappStatus `json:"status,omitempty"`}

7.2 删除回收器Finalizer


 
 
 
time.Sleep(time.Second * 10)if err := r.Delete(ctx, vm); err != nil {    log.Error(err, "unable to delete vm ", "vm", vm)}

如果不使用Finalizers,kubectl delete 时直接就删了etcd数据,controller再想去拿CRD时已经拿不到了。所以在创建时我们需要给CRD加上Finalizer:

 
 
 
vm.ObjectMeta.Finalizers = append(vm.ObjectMeta.Finalizers, "app.kubeone.alibaba-inc.com")

然后删除时就只会给CRD打上一个删除时间戳,供我们做后续处理, 处理完了我们删除掉Finalizers:

 
 
 
如果 DeleteionTimestamp不存在    如果没有Finalizers        加上Finalizers,并更新CRD要不然,说明是要被删除的    如果存在Finalizers,删除Finalizers,并更新CRD

看个完整的代码示例:

 
 
 
if cronJob.ObjectMeta.DeletionTimestamp.IsZero() {        if !containsString(cronJob.ObjectMeta.Finalizers, myFinalizerName) {            cronJob.ObjectMeta.Finalizers = append(cronJob.ObjectMeta.Finalizers, myFinalizerName)            if err := r.Update(context.Background(), cronJob); err != nil {                return ctrl.Result{}, err            }        }    } else {        if containsString(cronJob.ObjectMeta.Finalizers, myFinalizerName) {            if err := r.deleteExternalResources(cronJob); err != nil {                return ctrl.Result{}, err            }
cronJob.ObjectMeta.Finalizers = removeString(cronJob.ObjectMeta.Finalizers, myFinalizerName) if err := r.Update(context.Background(), cronJob); err != nil { return ctrl.Result{}, err } } }

8. 参考资料


  • kubebuilder2.0学习笔记——搭建和使用
    https://segmentfault.com/a/1190000020338350
  • kubebuilder2.0学习笔记——进阶使用
    https://segmentfault.com/a/1190000020359577
  • Operator开发规范
    https://zhuanlan.zhihu.com/p/73036278
  • Metacontroller 入门指南
    https://zhuanlan.zhihu.com/p/73036278


更多精彩


识别二维码观看直播


走出舒适圈,从来都不简单


Bilibili资深运维工程师:DCDN在游戏应用加速中的实践



【声明】内容源于网络
0
0
阿里云云栖号
云栖官方内容平台,汇聚云栖365优质内容。
内容 3553
粉丝 0
阿里云云栖号 云栖官方内容平台,汇聚云栖365优质内容。
总阅读144
粉丝0
内容3.6k