1. 概述
我们将 CRD, Controller, Webhook 三者合起来叫 Operator。一个 Operator 工程一般必须包含 CRD 和 Controller,Admission 是可选的。如果说 Kubernetes 是 "操作系统" 的话,Operator 是 Kubernetes 的第一层应用,它部署在 Kubernetes 里,使用 Kubernetes "扩展资源" 接口的方式向更上层用户提供服务。
Operator的实现方式主要包括OperatorSDK和KubeBuilder,目前KubeBuilder在阿里使用的比较多。
OperatorSDK
下文的接入流程中,我们主要选择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),一般来说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 命名规范
CRD 的全名是必须符合如下的命名规范: ${Kind}.${Group}.${Organization}.kubeone.alibaba-inc.com
。其中:
${Organization}
为仓库的 git Group,即 团队名英文简称。${Group}
必须是一种功能类别,如ops
,apps
,auth
等。尽量用精简的单个英语单词的方式传达你的 CRD 属于的“类别”。组成的字母必须都是小写${Kind}
即为 CRD 真正的短名字,用精简的单个或多个英文单词的拼接来命名真正的 CRD 短名字。如AdvancedDeployment
,NoteBook
等。使用大驼峰命名法(首字母也是大写,即 UpperCamelCase)。- alipay.com 是固定的,即 Company Name Domain。
- 目前对于 CRD 版本转化不太友好,统一使用
v1
。
4.2 Spec, Status 规范
- 用命令在
apis
包下生成 CRD Types 之后,请不要随意修改apis
里的结构体、命名规则、以及注释。 - 只能、也只需要修改
${Kind}_types.go
文件里 Spec 和 StatusSpec 结构体里的内容。 - Spec 和 StatusSpec 里的字段都必须是 Public 的,也就是字段名首字母是大写。
- 每个字段,都应该写上 JSON Tag,JSON Tag 必须使用 小驼峰命名法,即 LowerCamelCase。
- 如果字段允许为空,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 1.13+
- docker 17.03+
kubectl 1.11.3+:
brew install kubectl
helm 3.2.2+:
```dockerfile
brew install helm
controller-tools 0.3.0+
go get https://github.com/kubernetes-sigs/controller-tools.git
kubebuilder 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://goproxy.io,不推荐,有些包无法下载
开发工具推荐:
- goland:推荐,但是收费
- idea:可以装go插件,但是可能没有goland适配的好
- vscode:比较轻量级,但是开发不太方便
5.2 创建工程
创建脚手架工程
mkdir myapp-operator cd myapp-operator/ kubebuilder init --domain kubeone.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 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 搭建K8S
5.5 调试工程
配置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.6 打包构建
- 配置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.7 上传镜像
下面以阿里云镜像服务为例。
创建命名空间
- 创建镜像仓库
上传镜像
$ 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.8 Helm部署
如果你的资源比较少,可以不使用Helm,如果比较多,可以尝试用helm做包管理工具。
拿一个完整的中间件举例:
如ZooKeeper的例子,可分为产品-->应用(可能有子应用)-->chart和定义6. 源码解读
6.1 KubeBuilder对Controller的逻辑封装
想说明一下KubeBuilder实际上是提供了对ClientGo进行封装的Library(准确来说是Runtime Controller),更加便利我们来开发K8S的Operator。
我上面提到的workQueue的例子已经实现了一个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个事件,只不过处理的阶段不同而已。接下来我们来看看推导流程。
事件入队流程:
- 初始化share_informer后,会注册eventHandler(pkg/kubelet/kubelietconfig/watch.go)
- 接下来开始执行shared_informer的run方法
- 调用handler中的OnUpdate方法
- 在OnUpdate方法中会根据predicate过滤器来进行事件的过滤。此时事件类型还是event.UpdateEvent
- 接下来会将该事件入队,此时事件类型转换为了reconcile.Request(handler/enqueue.go)
事件处理流程:
- controller启动后(internal/controller/controller.go),会启动worker goroutine
- 不停的从队列里面取事件,进行扔到reconcileHandler中进行处理
- 将事件传递到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
}
}
}