29

Kubernetes Operator 开发教程

 3 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzI0NTE4NjA0OQ%3D%3D&%3Bmid=2658371211&%3Bidx=1&%3Bsn=c15c7096157649169d9de5d71994782a
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

vIv2eq6.jpg!mobile

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机制

FfYbErz.png!mobile

这里我把代码分为通用的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/bin
export PATH=$PATH:$GOBIN
export GOPATH=/Users/yunzheng/go
export GOROOT=/usr/local/go
export GO111MODULE=on
export 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-operator
cd 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

最后工程结构如下:

JZ73yeb.png!mobile

5.3 编写代码

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

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

vmiA7rI.png!mobile

5.3.1 定义 CRD

在myapp_type.go中定义 Spec 和 Status

// MyappSpec defines the desired state of Myapp
type 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 Myapp
type 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 API
type 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) error
func (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 binary
FROM golang:1.12.5 as builder


WORKDIR /workspace
# Copy the Go Modules manifests
COPY go.mod go.mod
COPY 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 source
COPY main.go main.go
COPY api/ api/
COPY controllers/ controllers/


# Build
RUN 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:latest
WORKDIR /
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-operator
make docker-build

5.6 上传镜像

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

  • 创建命名空间

FruaAfV.png!mobile

  • 创建镜像仓库

Bv2Qvqq.png!mobile

  • 上传镜像

$ 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和定义

Evuq2iA.png!mobile

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:

QFBR3qr.png!mobile

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方法

Vru2ErM.png!mobile

  1. 调用handler中的OnUpdate方法

22mIzaN.png!mobile

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

    是event.UpdateEvent

Znmq2ar.png!mobile

  1. 接下来会将该事件入队,此时事件类型转换为了

    reconcile.Request(handler/enqueue.go)

r2a2eq.png!mobile

事件处理流程:

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

vI7vUfi.png!mobile

u2uA7rI.png!mobile

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

j2iER3U.png!mobile

  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 API
type 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

更多精彩

QV7viyr.png!mobile

识别二维码观看直播

zQZbqiJ.jpg!mobile

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

fuUnEzJ.jpg!mobile

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

M7nAzyj.png!mobile


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK