声明式API
我们来探讨下,当我把一个 YAML 文件提交给 Kubernetes 之后,它究竟是如何创建出一个 API 对象的呢。
在 Kubernetes 项目中,一个 API 对象在 Etcd 里的完整资源路径,是由:
Group
(API 组)、Version
(API 版本)和Resource
(API 资源类型)三个部分组成的。
通过这样的结构,整个 Kubernetes 里的所有 API 对象,实际上就可以用如下的树形结构表示出来:
比如,现在我要声明要创建一个 CronJob 对象,那么我的 YAML 文件的开始部分会这么写:
apiVersion: batch/v2alpha1
kind: CronJob
...
在这个 YAML 文件中,“CronJob”就是这个 API 对象的资源类型(Resource),“batch”就是它的组(Group),v2alpha1 就是它的版本(Version)。
当我们提交了这个 YAML 文件之后,Kubernetes 就会把这个 YAML 文件里描述的内容,转换成 Kubernetes 里的一个 CronJob 对象。
找到API对象
那么,Kubernetes 是如何对 Resource、Group 和 Version 进行解析,从而在 Kubernetes 项目里找到 CronJob 对象的定义呢?
匹配 API 对象的组
需要明确的是,对于 Kubernetes 里的核心 API 对象,比如:Pod、Node 等,是不需要 Group 的(即:它们的 Group 是“”)。所以,对于这些 API 对象来说,Kubernetes
会直接在 /api 这个层级进行下一步的匹配过程。
而对于 CronJob 等非核心 API 对象来说,Kubernetes 就必须在 /apis 这个层级里查找它对应的 Group,进而根据“batch”
这个 Group 的名字,找到 /apis/batch
。
匹配到 API 对象的版本号
对于 CronJob 这个 API 对象来说,Kubernetes 在 batch 这个 Group 下,匹配到的版本号就是v2alpha1
。
在 Kubernetes 中,同一种 API 对象可以有多个版本,这正是 Kubernetes 进行 API 版本化管理的重要手段。这样,比如在 CronJob 的开发过程中,对于会影响到用户的变更就可以通过升级新版本来处理,从而保证了向后兼容。
Kubernetes 会匹配 API 对象的资源类型
在前面匹配到正确的版本之后,Kubernetes 就知道,我要创建的原来是一个 /apis/batch/v2alpha1
下的 CronJob 对象。
整体流程
为了方便理解,我为你总结了一个如下所示流程图来阐述一个API对象的创建过程(上一步骤的CronJob):
1,首先,当我们发起了创建 CronJob 的 POST 请求之后,我们编写的 YAML 的信息就被提交给了 APIServer。
而 APIServer 的第一个功能,就是过滤这个请求,并完成一些前置性的工作,比如授权、超时处理、审计等。
2,然后,请求会进入 MUX 和 Routes 流程。如果你编写过 Web Server 的话就会知道,MUX 和 Routes 是 APIServer 完成 URL 和 Handler 绑定的场所。而 APIServer 的 Handler 要做的事情,就是按照我刚刚介绍的匹配过程,找到对应的 CronJob 类型定义。
3,APIServer 最重要的职责就来了:根据这个 CronJob
类型定义,使用用户提交的 YAML 文件里的字段,创建一个 CronJob 对象。
而在这个过程中,APIServer
会进行一个 Convert
工作,即:把用户提交的 YAML 文件,转换成一个叫作 Super Version
的对象,它正是该 API 资源类型所有版本的字段全集。这样用户提交的不同版本的 YAML
文件,就都可以用这个 Super Version
对象来进行处理了。
4,APIServer
会先后进行 Admission()
和 Validation()
操作。比如, Admission Controller
和 Initializer
,就都属于 Admission 的内容。
而 Validation,则负责验证这个对象里的各个字段是否合法。这个被验证过的 API 对象,都保存在了 APIServer 里一个叫作 Registry 的数据结构中。也就是说,只要一个 API 对象的定义能在 Registry 里查到,它就是一个有效的 Kubernetes API 对象。
5,APIServer 会把验证过的 API 对象转换成用户最初提交的版本,进行序列化操作,并调用 Etcd 的 API 把它保存起来。
由此可见,
声明式 API
对于 Kubernetes 来说非常重要。所以,APIServer 成了 Kubernetes 项目的重中之重。它不仅是 Google Borg 设计思想的集中体现。
CRD
在过去很长一段时间里,在这样一个极其“复杂”的 APIServer 中,添加一个 Kubernetes 风格的 API 资源类型,是一个非常困难的工作。
不过,在 Kubernetes v1.7 之后,这个工作就变得轻松得多了。这,当然得益于一个全新的 API 插件机制:CRD
。
CRD 的全称是 Custom Resource Definition
。顾名思义,它指的就是,允许用户在 Kubernetes 中添加一个跟 Pod、Node 类似的、新的 API 资源类型,即:自定义 API 资源
。
自定义 API 资源
举个例子,我现在要为 Kubernetes 添加一个名叫 Network 的 API 资源类型。
它的作用是,一旦用户创建一个 Network 对象,那么 Kubernetes 就应该使用这个对象定义的网络参数,调用真实的网络插件,比如 Neutron 项目,为用户创建一个真正的“网络”。这样,将来用户创建的 Pod,就可以声明使用这个“网络”了。
这个 Network 对象的 YAML 文件,名叫 example-network.yaml,它的内容如下所示:
apiVersion: samplecrd.k8s.io/v1
kind: Network
metadata:
name: example-network
spec:
cidr: "192.168.0.0/16"
gateway: "192.168.0.1"
可以看到,我想要描述“网络”的 API 资源类型是 Network;API
组是samplecrd.k8s.io
;API 版本是 v1
。
其实,上面的这个 YAML 文件,就是一个具体的“自定义 API 资源”实例,也叫 CR(Custom Resource)。而为了能够让 Kubernetes 认识这个 CR,你就需要让 Kubernetes 明白这个 CR 的宏观定义是什么,也就是 CRD(Custom Resource Definition)。
所以,接下来,我就先编写一个 CRD 的 YAML 文件,它的名字叫作 network.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 中,我指定了“group: samplecrd.k8s.io”“version: v1”
这样的 API 信息,也指定了这个 CR 的资源类型叫作 Network
,复数(plural)是 networks
。
然后,我还声明了它的 scope
是 Namespaced
,即:我们定义的这个 Network 是一个属于 Namespace 的对象,类似于 Pod。这就是一个 Network API 资源类型的 API 部分的宏观定义了。
接下来,我还需要让 Kubernetes“认识”这种 YAML 文件里描述的“网络”部分,比如“cidr”(网段),“gateway”(网关)这些字段的含义。可以认为是具体实现了。
字段定义
接下来,我就需要稍微做些代码工作了。
首先,我要在 GOPATH 下,创建一个结构如下的项目:
$ tree $GOPATH/src/github.com/<your-name>/k8s-controller-custom-resource
.
├── controller.go
├── crd
│ └── network.yaml
├── example
│ └── example-network.yaml
├── main.go
└── pkg
└── apis
└── samplecrd
├── register.go
└── v1
├── doc.go
├── register.go
└── types.go
其中,pkg/apis/samplecrd 就是 API 组的名字,v1 是版本,而 v1 下面的 types.go 文件里,则定义了 Network 对象的完整描述。
然后,我在 pkg/apis/samplecrd 目录下创建了一个 register.go 文件,用来放置后面要用到的全局变量。这个文件的内容如下所示:
package samplecrd
const (
GroupName = "samplecrd.k8s.io"
Version = "v1"
)
接着,我需要在 pkg/apis/samplecrd 目录下添加一个 doc.go 文件(Golang 的文档源文件)。这个文件里的内容如下所示:
// +k8s:deepcopy-gen=package
// +groupName=samplecrd.k8s.io
package v1
在这个文件中,你会看到 +<tag_name>[=value]
格式的注释,这就是 Kubernetes 进行代码生成要用的 Annotation
风格的注释。
其中,+k8s:deepcopy-gen=package
意思是,请为整个 v1 包里的所有类型定义自动生成 DeepCopy 方法;而+groupName=samplecrd.k8s.io
,则定义了这个包对应的 API 组的名字。
可以看到,这些定义在 doc.go
文件的注释,起到的是全局的代码生成控制的作用,所以也被称为 Global Tags
。
接下来,我需要添加 types.go
文件。顾名思义,它的作用就是定义一个 Network
类型到底有哪些字段(比如,spec 字段里的内容)。这个文件的主要内容如下所示:
package v1
...
// +genclient
// +genclient:noStatus
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// Network describes a Network resource
type Network struct {
// TypeMeta is the metadata for the resource, like kind and apiversion
metav1.TypeMeta `json:",inline"`
// ObjectMeta contains the metadata for the particular object, including
// things like...
// - name
// - namespace
// - self link
// - labels
// - ... etc ...
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec networkspec `json:"spec"`
}
// networkspec is the spec for a Network resource
type networkspec struct {
Cidr string `json:"cidr"`
Gateway string `json:"gateway"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// NetworkList is a list of Network resources
type NetworkList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata"`
Items []Network `json:"items"`
}
在上面这部分代码里,你可以看到Network
类型定义方法跟标准的 Kubernetes 对象一样,都包括了 TypeMeta
(API 元数据)和 ObjectMeta
(对象元数据)字段。
而其中的 Spec
字段,就是需要我们自己定义的部分。所以,在 networkspec
里,我定义了 Cidr 和 Gateway 两个字段。其中,每个字段最后面的部分比如json:"cidr"
,指的就是这个字段被转换成 JSON 格式之后的名字,也就是 YAML 文件里的字段名字。
此外,除了定义
Network
类型,你还需要定义一个NetworkList
类型,用来描述一组Network
对象应该包括哪些字段。之所以需要这样一个类型,是因为在 Kubernetes 中,获取所有 X 对象的 List() 方法,返回值都是List 类型,而不是 X 类型的数组。这是不一样的。
其中,+genclient
的意思是:请为下面这个 API 资源类型生成对应的 Client 代码(这个 Client,我马上会讲到)。而 +genclient:noStatus
的意思是:这个 API 资源类型定义里,没有 Status 字段。否则,生成的 Client 就会自动带上 UpdateStatus
方法
如果你的类型定义包括了 Status 字段的话,就不需要这句 +genclient:noStatus
注释了。比如下面这个例子:
// +genclient
// Network is a specification for a Network resource
type Network struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec NetworkSpec `json:"spec"`
Status NetworkStatus `json:"status"`
}
需要注意的是,+genclient
只需要写在 Network 类型上,而不用写在 NetworkList 上。因为 NetworkList 只是一个返回值类型,Network 才是“主类型”
。
而由于我在 Global Tags 里已经定义了为所有类型生成 DeepCopy
方法,所以这里就不需要再显式地加上 +k8s:deepcopy-gen=true
了。当然,这也就意味着你可以用 +k8s:deepcopy-gen=false
来阻止为某些类型生成 DeepCopy。
最后,我需要再编写一个 pkg/apis/samplecrd/v1/register.go
文件。
在前面对 APIServer 工作原理的讲解中,我已经提到,“registry”
的作用就是注册一个类型(Type)给 APIServer。其中,Network 资源类型在服务器端注册的工作,APIServer 会自动帮我们完成。但与之对应的,我们还需要让客户端也能“知道”Network 资源类型的定义。这就需要我们在项目里添加一个 register.go 文件。它最主要的功能,就是定义了如下所示的
addKnownTypes() `方法:
package v1
...
// addKnownTypes adds our types to the API scheme by registering
// Network and NetworkList
func addKnownTypes(scheme *runtime.Scheme) error {
scheme.AddKnownTypes(
SchemeGroupVersion,
&Network{},
&NetworkList{},
)
// register the type in the scheme
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
return nil
}
有了这个方法,Kubernetes 就能够在后面生成客户端的时候,“知道”Network 以及 NetworkList 类型的定义了。
像上面这种register.go
文件里的内容其实是非常固定的,你以后可以直接使用我提供的这部分代码做模板,然后把其中的资源类型、GroupName 和 Version 替换成你自己的定义即可。
这样,Network
对象的定义工作就全部完成了。可以看到,它其实定义了两部分内容:
第一部分是,自定义资源类型的 API 描述,包括:
组(Group)
、版本(Version)
、资源类型(Resource)
等。
第二部分是,自定义资源类型的对象描述,包括:
Spec
、Status
等。这相当于告诉了计算机:兔子有长耳朵和三瓣嘴。
接下来,我就要使用 Kubernetes 提供的代码生成工具,为上面定义的 Network
资源类型自动生成 clientset
、informer
和 lister
。其中,clientset
就是操作 Network 对象所需要使用的客户端,而 informer
和lister
这两个包的主要功能,放在后面在重点讲解。
代码生成
这个代码生成工具名叫k8s.io/code-generator
,使用方法如下所示:
# 代码生成的工作目录,也就是我们的项目路径
$ ROOT_PACKAGE="github.com/resouer/k8s-controller-custom-resource"
# API Group
$ CUSTOM_RESOURCE_NAME="samplecrd"
# API Version
$ CUSTOM_RESOURCE_VERSION="v1"
# 安装k8s.io/code-generator
$ go get -u k8s.io/code-generator/...
$ cd $GOPATH/src/k8s.io/code-generator
# 执行代码自动生成,其中pkg/client是生成目标目录,pkg/apis是类型定义目录
$ ./generate-groups.sh all "$ROOT_PACKAGE/pkg/client" "$ROOT_PACKAGE/pkg/apis" "$CUSTOM_RESOURCE_NAME:$CUSTOM_RESOURCE_VERSION"
代码生成工作完成之后,我们再查看一下这个项目的目录结构:
$ tree
.
├── controller.go
├── crd
│ └── network.yaml
├── example
│ └── example-network.yaml
├── main.go
└── pkg
├── apis
│ └── samplecrd
│ ├── constants.go
│ └── v1
│ ├── doc.go
│ ├── register.go
│ ├── types.go
│ └── zz_generated.deepcopy.go
└── client
├── clientset
├── informers
└── listers
其中,pkg/apis/samplecrd/v1
下面的 zz_generated.deepcopy.go
文件,就是自动生成的 DeepCopy 代码文件。
而整个 client 目录,以及下面的三个包(clientset、informers、 listers)
,都是 Kubernetes 为 Network 类型生成的客户端库,这些库会在后面编写自定义控制器的时候用到。
现在你就可以在 Kubernetes 集群里创建一个 Network 类型的 API 对象了。我们不妨一起来试验下。
创建自定义对象
首先,使用 network.yaml 文件,在 Kubernetes 中创建 Network 对象的 CRD(Custom Resource Definition):
$ kubectl apply -f crd/network.yaml
customresourcedefinition.apiextensions.k8s.io/networks.samplecrd.k8s.io created
这个操作,就告诉了 Kubernetes,我现在要添加一个自定义的 API 对象。而这个对象的 API 信息,正是 network.yaml
里定义的内容。我们可以通过 kubectl get
命令,查看这个 CRD:
$ kubectl get crd
NAME CREATED AT
networks.samplecrd.k8s.io 2018-09-15T10:57:12Z
然后,我们就可以创建一个 Network 对象了,这里用到的是 example-network.yaml:
$ kubectl apply -f example/example-network.yaml
network.samplecrd.k8s.io/example-network created
通过这个操作,你就在 Kubernetes 集群里创建了一个 Network 对象。它的 API 资源路径是samplecrd.k8s.io/v1/networks
。
这时候,你就可以通过 kubectl get 命令,查看到新创建的 Network 对象:
$ kubectl get network
NAME AGE
example-network 8s
你还可以通过 kubectl describe 命令,看到这个 Network 对象的细节:
$ kubectl describe network example-network
Name: example-network
Namespace: default
Labels: <none>
...API Version: samplecrd.k8s.io/v1
Kind: Network
Metadata:
...
Generation: 1
Resource Version: 468239
...
Spec:
Cidr: 192.168.0.0/16
Gateway: 192.168.0.1
当然 ,你也可以编写更多的 YAML 文件来创建更多的 Network 对象,这和创建 Pod、Deployment 的操作,没有任何区别。
上面我们创建出这样一个自定义 API 对象,我们只是完成了 Kubernetes 声明式 API 的一半工作。
接下来的另一半工作是:为这个 API 对象编写一个自定义控制器(Custom Controller
)。
这样, Kubernetes 才能根据 Network API
对象的“增、删、改”操作,在真实环境中做出相应的响应。比如,“创建、删除、修改”真正的 Neutron 网络。
Custom Controller
接下来继续和你一起完成剩下一半的工作,即:为 Network 这个自定义 API 对象编写一个自定义控制器(Custom Controller
)。
正如之前提到的一些概念,“
声明式 API
”并不像“命令式 API
”那样有着明显的执行逻辑。这就使得基于声明式 API
的业务功能实现,往往需要通过控制器模式来“监视”API
对象的变化(比如,创建或者删除 Network),然后以此来决定实际要执行的具体工作。
总得来说,编写自定义控制器代码的过程包括:编写 main 函数
、编写自定义控制器的定义
,以及编写控制器里的业务逻辑
三个部分。
编写 main 函数
首先,我们来编写这个自定义控制器的 main 函数。
main 函数的主要工作就是,定义并初始化一个自定义控制器(Custom Controller
),然后启动它。这部分代码的主要内容如下所示:
func main() {
...
cfg, err := clientcmd.BuildConfigFromFlags(masterURL, kubeconfig)
...
kubeClient, err := kubernetes.NewForConfig(cfg)
...
networkClient, err := clientset.NewForConfig(cfg)
...
networkInformerFactory := informers.NewSharedInformerFactory(networkClient, ...)
controller := NewController(kubeClient, networkClient,
networkInformerFactory.Samplecrd().V1().Networks())
go networkInformerFactory.Start(stopCh)
if err = controller.Run(2, stopCh); err != nil {
glog.Fatalf("Error running controller: %s", err.Error())
}
}
可以看到,这个 main 函数主要通过三步
完成了初始化并启动一个自定义控制器的工作。
1,main
函数根据我提供的 Master
配置(APIServer 的地址端口和 kubeconfig 的路径),创建一个 Kubernetes 的 client(kubeClient)和 Network 对象的 client(networkClient)。但是,如果我没有提供 Master 配置呢?
这时,main 函数会直接使用一种名叫 InClusterConfig 的方式来创建这个 client。这个方式,会假设你的自定义控制器是以 Pod 的方式运行在 Kubernetes 集群里的。
Kubernetes 里所有的 Pod 都会以
Volume
的方式自动挂载 Kubernetes 的默认ServiceAccount
。所以,这个控制器就会直接使用默认ServiceAccount
数据卷里的授权信息,来访问 APIServer。
2,main 函数为 Network 对象创建一个叫作 InformerFactory
(即:networkInformerFactory
)的工厂,并使用它生成一个 Network 对象的 Informer
,传递给控制器。
3,main 函数启动上述的 Informer
,然后执行 controller.Run
,启动自定义控制器。
控制器的工作原理
详细解释一下这个自定义控制器的工作原理。
在 Kubernetes 项目中,一个自定义控制器的工作原理,可以用下面这样一幅流程图来表示(在后面的叙述中,我会用“示意图”来指代它):
这个控制器要做的第一件事,是从 Kubernetes 的 APIServer 里获取它所关心的对象,也就是我定义的 Network 对象。
Informer
这个操作,依靠的是一个叫作 Informer
(可以翻译为:通知器)的代码库完成的。Informer 与 API 对象是一一对应的,所以我传递给自定义控制器的,正是一个 Network 对象的 Informer(Network Informer)
。
不知你是否已经注意到,我在创建这个 Informer
工厂的时候,需要给它传递一个 networkClient
。
事实上,Network Informer
正是使用这个 networkClient
,跟 APIServer 建立了连接。不过,真正负责维护这个连接的,则是 Informer
所使用的 Reflector
包。
Reflector
使用的是一种叫作ListAndWatch
的方法,来“获取”并“监听”这些Network
对象实例的变化。
在 ListAndWatch
机制下,一旦 APIServer
端有新的 Network 实例被创建、删除或者更新,Reflector
都会收到“事件通知”
。这时,该事件及它对应的 API 对象这个组合,就被称为增量(Delta),它会被放进一个 Delta FIFO Queue
(即:增量先进先出队列)中。
而另一方面,Informe
会不断地从这个 Delta FIFO Queue
里读取(Pop)增量。每拿到一个增量,Informer
就会判断这个增量里的事件类型,然后创建或者更新本地对象的缓存。这个缓存,在 Kubernetes 里一般被叫作 Store
。
比如,如果事件类型是 Added
(添加对象),那么 Informer
就会通过一个叫作 Indexer
的库把这个增量里的 API 对象保存在本地缓存中,并为它创建索引。相反,如果增量的事件类型是 Deleted(删除对象)
,那么 Informer
就会从本地缓存中删除这个对象。
这个同步本地缓存的工作,是 Informer
的第一个职责,也是它最重要的职责。
而 Informer
的第二个职责,则是根据这些事件的类型,触发事先注册好的 ResourceEventHandler
。这些 Handler,需要在创建控制器的时候注册给它对应的 Informer
。
接下来,我们就来编写这个控制器的定义,它的主要内容如下所示:
func NewController(
kubeclientset kubernetes.Interface,
networkclientset clientset.Interface,
networkInformer informers.NetworkInformer) *Controller {
...
controller := &Controller{
kubeclientset: kubeclientset,
networkclientset: networkclientset,
networksLister: networkInformer.Lister(),
networksSynced: networkInformer.Informer().HasSynced,
workqueue: workqueue.NewNamedRateLimitingQueue(..., "Networks"),
...
}
networkInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: controller.enqueueNetwork,
UpdateFunc: func(old, new interface{}) {
oldNetwork := old.(*samplecrdv1.Network)
newNetwork := new.(*samplecrdv1.Network)
if oldNetwork.ResourceVersion == newNetwork.ResourceVersion {
return
}
controller.enqueueNetwork(new)
},
DeleteFunc: controller.enqueueNetworkForDelete,
return controller
}
我前面在 main 函数里创建了两个 client(
kubeclientset
和networkclientset
),然后在这段代码里,使用这两个 client 和前面创建的Informer
,初始化了自定义控制器。
在这个自定义控制器里,我还设置了一个工作队列(work queue
),它正是处于示意图中间位置的 WorkQueue
。这个工作队列的作用是,负责同步 Informer
和控制循环之间的数据。
然后,我为 networkInformer
注册了三个 Handler(AddFunc
、UpdateFunc
和 DeleteFunc
),分别对应 API 对象的“添加”“更新”和“删除”事件。而具体的处理操作,都是将该事件对应的 API 对象加入到工作队列中。
需要注意的是,实际入队的并不是 API 对象本身,而是它们的 Key,即:该 API 对象的/。
Informer
通过一种叫作ListAndWatch
的方法,把 APIServer 中的 API 对象缓存在了本地,并负责更新和维护这个缓存。
其中,ListAndWatch
方法的含义是:首先,通过 APIServer 的 LIST API“获取”所有最新版本的 API 对象;然后,再通过 WATCH API
来“监听”所有这些 API 对象的变化。
而通过监听到的事件变化,Informer
就可以实时地更新本地缓存,并且调用这些事件对应的 EventHandler
了。
此外,在这个过程中,每经过 resyncPeriod
指定的时间,Informer
维护的本地缓存,都会使用最近一次 LIST
返回的结果强制更新一次,从而保证缓存的有效性。在 Kubernetes 中,这个缓存强制更新的操作就叫作:resync
。
需要注意的是,这个定时 resync
操作,也会触发 Informer
注册的“更新”事件。但此时,这个“更新”事件对应的 Network 对象实际上并没有发生变化,即:新、旧两个 Network
对象的 ResourceVersion
是一样的。在这种情况下,Informer
就不需要对这个更新事件再做进一步的处理了。
Controle Loop
接下来,我们就来到了示意图中最后面的控制循环(Control Loop)部分,也正是我在 main 函数最后调用 controller.Run() 启动的“控制循环”。它的主要内容如下所示:
func (c *Controller) Run(threadiness int, stopCh <-chan struct{}) error {
...
if ok := cache.WaitForCacheSync(stopCh, c.networksSynced); !ok {
return fmt.Errorf("failed to wait for caches to sync")
}
...
for i := 0; i < threadiness; i++ {
go wait.Until(c.runWorker, time.Second, stopCh)
}
...
return nil
}
可以看到,启动控制循环的逻辑非常简单:
1,首先,等待 Informer
完成一次本地缓存的数据同步操作;
2,然后,直接通过 goroutine
启动一个(或者并发启动多个)“无限循环”的任务。
而这个“无限循环”任务的每一个循环周期,执行的正是我们真正关心的业务逻辑。
所以接下来,我们就来编写这个自定义控制器的业务逻辑
,它的主要内容如下所示:
func (c *Controller) runWorker() {
for c.processNextWorkItem() {
}
}
func (c *Controller) processNextWorkItem() bool {
obj, shutdown := c.workqueue.Get()
...
err := func(obj interface{}) error {
...
if err := c.syncHandler(key); err != nil {
return fmt.Errorf("error syncing '%s': %s", key, err.Error())
}
c.workqueue.Forget(obj)
...
return nil
}(obj)
...
return true
}
func (c *Controller) syncHandler(key string) error {
namespace, name, err := cache.SplitMetaNamespaceKey(key)
...
network, err := c.networksLister.Networks(namespace).Get(name)
if err != nil {
if errors.IsNotFound(err) {
glog.Warningf("Network does not exist in local cache: %s/%s, will delete it from Neutron ...",
namespace, name)
glog.Warningf("Network: %s/%s does not exist in local cache, will delete it from Neutron ...",
namespace, name)
// FIX ME: call Neutron API to delete this network by name.
//
// neutron.Delete(namespace, name)
return nil
}
...
return err
}
glog.Infof("[Neutron] Try to process network: %#v ...", network)
// FIX ME: Do diff().
//
// actualNetwork, exists := neutron.Get(namespace, name)
//
// if !exists {
// neutron.Create(namespace, name)
// } else if !reflect.DeepEqual(actualNetwork, network) {
// neutron.Update(namespace, name)
// }
return nil
}
可以看到,在这个执行周期里(processNextWorkItem
),我们首先从工作队列里出队(workqueue.Get
)了一个成员,也就是一个 Key(Network 对象的:namespace/name
)。
然后,在 syncHandler
方法中,我使用这个 Key,尝试从 Informer
维护的缓存中拿到了它所对应的 Network
对象。
可以看到,在这里,我使用了 networksLister
来尝试获取这个 Key 对应的 Network
对象。这个操作,其实就是在访问本地缓存的索引。实际上,在 Kubernetes
的源码中,你会经常看到控制器从各种 Lister
里获取对象,比如:podLister
、nodeLister
等等,它们使用的都是 Informer
和缓存机制。
而如果控制循环从缓存中拿不到这个对象(即:networkLister
返回了 IsNotFound
错误),那就意味着这个 Network 对象的 Key 是通过前面的“删除”事件添加进工作队列的。所以,尽管队列里有这个 Key,但是对应的 Network
对象已经被删除了。
这时候,我就需要调用 Neutron 的 API,把这个 Key 对应的 Neutron
网络从真实的集群里删除掉。
而如果能够获取到对应的
Network
对象,我就可以执行控制器模式里的对比“期望状态”和“实际状态”的逻辑了。
其中,自定义控制器“千辛万苦”拿到的这个 Network
对象,正是 APIServer
里保存的“期望状态”,即:用户通过 YAML 文件提交到 APIServer 里的信息。当然,在我们的例子里,它已经被 Informer
缓存在了本地。
对于“实际状态”
的获取,我们的控制循环需要通过 Neutron API
来查询实际的网络情况。
比如,我可以先通过 Neutron 来查询这个 Network 对象对应的真实网络是否存在。
1,如果不存在,这就是一个典型的“期望状态”与“实际状态”不一致的情形。这时,我就需要使用这个 Network 对象里的信息(比如:CIDR 和 Gateway),调用 Neutron API 来创建真实的网络。
2,如果存在,那么,我就要读取这个真实网络的信息,判断它是否跟 Network 对象里的信息一致,从而决定我是否要通过 Neutron 来更新这个已经存在的真实网络。
这样,我就通过对比“期望状态”
和“实际状态”
的差异,完成了一次调协(Reconcile
)的过程。
至此,一个完整的自定义 API 对象和它所对应的自定义控制器,就编写完毕了。
接下来,我们就一起来把这个项目运行起来,查看一下它的工作情况。
运行项目
你可以自己编译这个项目。编译并启动这个项目的具体流程如下所示:
# Clone repo
$ git clone https://github.com/resouer/k8s-controller-custom-resource$ cd k8s-controller-custom-resource
### Skip this part if you don't want to build
# Install dependency
$ go get github.com/tools/godep
$ godep restore
# Build
$ go build -o samplecrd-controller .
$ ./samplecrd-controller -kubeconfig=$HOME/.kube/config -alsologtostderr=true
I0915 12:50:29.051349 27159 controller.go:84] Setting up event handlers
I0915 12:50:29.051615 27159 controller.go:113] Starting Network control loop
I0915 12:50:29.051630 27159 controller.go:116] Waiting for informer caches to sync
E0915 12:50:29.066745 27159 reflector.go:134] github.com/resouer/k8s-controller-custom-resource/pkg/client/informers/externalversions/factory.go:117: Failed to list *v1.Network: the server could not find the requested resource (get networks.samplecrd.k8s.io)
...
你可以看到,自定义控制器被启动后,一开始会报错。
这是因为,此时 Network 对象的 CRD 还没有被创建出来,所以 Informer
去 APIServer 里“获取”(List)Network
对象时,并不能找到 Network
这个 API 资源类型的定义,即:
Failed to list *v1.Network: the server could not find the requested resource (get networks.samplecrd.k8s.io)
所以,接下来我就需要创建 Network 对象的 CRD,这个操作在上一篇文章里已经介绍过了。
在另一个 shell 窗口里执行:
$ kubectl apply -f crd/network.yaml
这时候,你就会看到控制器的日志恢复了正常,控制循环启动成功:
...
I0915 12:50:29.051630 27159 controller.go:116] Waiting for informer caches to sync
...
I0915 12:52:54.346854 25245 controller.go:121] Starting workers
I0915 12:52:54.346914 25245 controller.go:127] Started workers
接下来,我就可以进行 Network 对象的增删改查操作了。
首先,创建一个 Network 对象:
$ cat example/example-network.yaml
apiVersion: samplecrd.k8s.io/v1
kind: Network
metadata:
name: example-network
spec:
cidr: "192.168.0.0/16"
gateway: "192.168.0.1"
$ kubectl apply -f example/example-network.yaml
network.samplecrd.k8s.io/example-network created
这时候,查看一下控制器的输出:
...
I0915 12:50:29.051349 27159 controller.go:84] Setting up event handlers
I0915 12:50:29.051615 27159 controller.go:113] Starting Network control loop
I0915 12:50:29.051630 27159 controller.go:116] Waiting for informer caches to sync
...
I0915 12:52:54.346854 25245 controller.go:121] Starting workers
I0915 12:52:54.346914 25245 controller.go:127] Started workers
I0915 12:53:18.064409 25245 controller.go:229] [Neutron] Try to process network: &v1.Network{TypeMeta:v1.TypeMeta{Kind:"", APIVersion:""}, ObjectMeta:v1.ObjectMeta{Name:"example-network", GenerateName:"", Namespace:"default", ... ResourceVersion:"479015", ... Spec:v1.NetworkSpec{Cidr:"192.168.0.0/16", Gateway:"192.168.0.1"}} ...
I0915 12:53:18.064650 25245 controller.go:183] Successfully synced 'default/example-network'
...
可以看到,我们上面创建 example-network
的操作,触发了 EventHandler
的“添加”事件,从而被放进了工作队列。紧接着,控制循环就从队列里拿到了这个对象,并且打印出了正在“处理”这个 Network 对象的日志。
可以看到,这个 Network
的 ResourceVersion
,也就是 API 对象的版本号,是 479015
,而它的 Spec
字段的内容,跟我提交的 YAML 文件一摸一样,比如,它的 CIDR 网段是:192.168.0.0/16。
这时候,我来修改一下这个 YAML 文件的内容,如下所示:
$ cat example/example-network.yaml
apiVersion: samplecrd.k8s.io/v1
kind: Network
metadata:
name: example-network
spec:
cidr: "192.168.1.0/16"
gateway: "192.168.1.1"
然后,我们执行了 kubectl apply 命令来提交这次更新,如下所示:
$ kubectl apply -f example/example-network.yaml
network.samplecrd.k8s.io/example-network configured
这时候,我们就可以观察一下控制器的输出:
...
I0915 12:53:51.126029 25245 controller.go:229] [Neutron] Try to process network: &v1.Network{TypeMeta:v1.TypeMeta{Kind:"", APIVersion:""}, ObjectMeta:v1.ObjectMeta{Name:"example-network", GenerateName:"", Namespace:"default", ... ResourceVersion:"479062", ... Spec:v1.NetworkSpec{Cidr:"192.168.1.0/16", Gateway:"192.168.1.1"}} ...
I0915 12:53:51.126348 25245 controller.go:183] Successfully synced 'default/example-network'
可以看到,这一次,Informer 注册的“更新”事件被触发,更新后的 Network 对象的 Key 被添加到了工作队列之中。
所以,接下来控制循环从工作队列里拿到的 Network 对象,与前一个对象是不同的:它的 ResourceVersion
的值变成了 479062;而 Spec 里的字段,则变成了 192.168.1.0/16 网段。
最后,我再把这个对象删除掉:
$ kubectl delete -f example/example-network.yaml
这一次,在控制器的输出里,我们就可以看到,Informer
注册的“删除”事件被触发,并且控制循环“调用”Neutron API“
删除”了真实环境里的网络。这个输出如下所示:
W0915 12:54:09.738464 25245 controller.go:212] Network: default/example-network does not exist in local cache, will delete it from Neutron ...
I0915 12:54:09.738832 25245 controller.go:215] [Neutron] Deleting network: default/example-network ...
I0915 12:54:09.738854 25245 controller.go:183] Successfully synced 'default/example-network'
以上,就是编写和使用自定义控制器的全部流程了。
实际上,这套流程不仅可以用在自定义 API 资源上,也完全可以用在 Kubernetes 原生的默认 API 对象上。
比如,我们在 main 函数里,除了创建一个 Network Informer 外,还可以初始化一个 Kubernetes 默认 API 对象的 Informer
工厂,比如 Deployment
对象的 Informer。这个具体做法如下所示:
func main() {
...
kubeInformerFactory := kubeinformers.NewSharedInformerFactory(kubeClient, time.Second*30)
controller := NewController(kubeClient, exampleClient,
kubeInformerFactory.Apps().V1().Deployments(),
networkInformerFactory.Samplecrd().V1().Networks())
go kubeInformerFactory.Start(stopCh)
...
}
在这段代码中,我们首先
使用 Kubernetes 的 client(kubeClient)
创建了一个工厂;
然后
,我用跟 Network 类似的处理方法,生成了一个 Deployment Informer
;
接着
,我把 Deployment Informer
传递给了自定义控制器;当然,我也要调用 Start
方法来启动这个 Deployment Informer
。
而有了这个 Deployment Informer
后,这个控制器也就持有了所有 Deployment
对象的信息
这就使得在这个自定义控制器里面,我可以通过对自定义 API 对象和默认 API 对象进行协同,从而实现更加复杂的编排功能。
比如:用户每创建一个新的
Deployment
,这个自定义控制器,就可以为它创建一个对应的Network
供它使用。