参考:
- https://time.geekbang.org/column/article/42076 张磊 – 深入剖析kubernetes
- https://github.com/kubernetes/sample-controller
这篇文章主要介绍一个operator的工作原理,以及如何自己用代码实现一个operator。
在kubernetes项目中,一个API对象在ETCD中的完整资源路径是由Group(API组)
、Version(API版本)
、Resource(API资源类型)
组成
现在我要声明要创建一个 CronJob 对象,那么我的 YAML 文件的开始部分会这么写:
apiVersion: batch/v2alpha1
kind: CronJob
...
在这个yaml中,CronJob就是这个 API 对象的资源类型(Resource),batch就是它的组(Group),v2alpha1 就是它的版本(Version)
CRD
在Kubernetes v1.7 之后,出现了一个新的API插件机制:CRD。 CRD的全称是Custom Resource Definition。它指的就是,允许用户在 Kubernetes 中添加一个跟 Pod、Node 类似的、新的 API 资源类型,即:自定义 API 资源。
例如我们要添加一个名字为Nginx的API资源类:
apiVersion: samplecrd.k8s.io/v1
kind: Nginx
metadata:
name: nginx-sample
spec:
size: 3
image: nginx:1.7.9
ports:
- name: http
port: 80
targetPort: 80
从这个 yaml 可以看到,API 资源类型是 Nginx ,API 组是 samplecrd.k8s.io ,API 版本是 v1。上面的 YAML 文件,就是一个具体的自定义API资源实例,也叫 CR,这个 CR 就类似于我们创建的 deployment。samplecrd.k8s.io/v1 对应的就是 apps/v1 , Nginx 对应的就是 Deployment。我们之所以能创建 deployment 的前提是有 apps/v1 这个版本,或者其他能够识别 Deployment 资源类型的版本,那么我们要创建 Nginx 这个资源类型实例的时候,也需要认识这个 Nginx 资源类型,那么我们就需要通过 CRD 把这个 Resource 创建出来:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: nginxes.samplecrd.k8s.io
spec:
group: samplecrd.k8s.io
version: v1
names:
kind: Nginx
plural: nginxes
通过上面的 yaml 我们创建了一个 group 为 apiextensions.k8s.io ,版本为 v1 , resource 为 Nginx 的资源,也就是说我们可以使用下面这种形式创建资源了。
apiVersion: samplecrd.k8s.io/v1
kind: Nginx
自定义控制器(Controller)
那么像 Deployment 我们可以指定什么 metadata , spec, name, labels 等等,那这个 Nginx 我们可以使用什么字段呢?这时候我们就要写代码了。 从这里开始就属于写operator了,这里有两种办法, 一是通过 client-go 来写,第二个就是通过 operator-sdk 来写。 这里我们通过 client-go 来写,了解一下其中的原理, 第二种方法,我们之后再说。首先我们创建一个代码的框架。
$ tree $GOPATH/src/sample-custom-controller
.
├── controller.go
├── go.mod
├── main.go
└── pkg
├── apis
│ └── samplecontroller
│ ├── register.go
│ └── v1
│ ├── docs.go
│ ├── register.go
│ └── types.go
└── signals
├── signal.go
└── signal_posix.go
在 pkg/apis/sample-controller 目录下创建一个 register.go 的文件,用来防止后面要用到的全局变量
package samplecontroller
const (
GroupName = "samplecontroller.k8s.io"
Version = "v1"
)
然后在 pkg/apis/sample-controller/v1 下创建三个文件 docs.go, register.go, types.go ,我们看下 docs.go 文件:
// +k8s:deepcopy-gen=package
// +groupName=samplecontroller.k8s.io
package v1
在这个文件中,你会看到 +[=value]格式的注释,这就是 Kubernetes 进行代码生成要用的 Annotation 风格的注释。其中,+k8s:deepcopy-gen=package 意思是,请为整个 v1 包里的所有类型定义自动生成 DeepCopy 方法;而+groupName=samplecontroller.k8s.io,则定义了这个包对应的 API 组的名字。
下面看一下 types.go 文件,这个文件定义自定义资源的字段以及字段类型,我们可以在yaml使用什么字段。
// +k8s:deepcopy-gen=package
// +groupName=samplecontroller.k8s.io
package v1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
corev1 "k8s.io/api/core/v1"
)
type NginxSpec struct {
Size int32 `json:"size"`
Image string `json:"image"`
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
Envs []corev1.EnvVar `json:"envs,omitempty"`
Ports []corev1.ServicePort `json:"ports,omitempty"`
}
type NginxStatus struct {
AvailableReplicas int32 `json:"availablereplicas "`
}
// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type Nginx struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec NginxSpec `json:"spec,omitempty"`
Status NginxStatus `json:"status,omitempty"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type NginxList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata"`
Items []Nginx `json:"items"`
}
+genclient
的意思是为这个 API 资源类型生成对应的 Client 代码,+k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
意思是在生成 DeepCopy 的时候,实现 Kubernetes 提供的 runtime.Object 接口。否则,在某些版本的 Kubernetes 里,你的这个类型定义会出现编译错误。
下面看一下 register.go 文件:
func Kind(kind string) schema.GroupKind {
return SchemeGroupVersion.WithKind(kind).GroupKind()
}
func addKnowTypes(scheme *runtime.Scheme) error {
scheme.AddKnownTypes(
SchemeGroupVersion ,
&Nginx{},
&NginxList{},
)
metav1.AddToGroupVersion(scheme, SchemeGroupVersion )
return nil
}
func Resource(resource string) schema.GroupResource {
return SchemeGroupVersion.WithResource(resource).GroupResource()
}
这个文件的主要功能就是让客户端知道 Nginx 资源类型的定义, 有了这个文件,kubernetes 在生成客户端的时候就知道 Nginx类型和 NginxList 类型了。
有了这些东西,我们就可以通过kubernetes 的代码生成工具为 Nginx 资源类型生成 clientset,informer 和 lister 代码了。
$ export CUSTOM_RESOURCE_NAME="samplecontroller"
$ export CUSTOM_RESOURCE_VERSION="v1"
$ export ROOT_PACKAGE="sample-custom-controller"
$ go get -u k8s.io/code-generator/...
$ /bin/bash $GOPATH/src/pkg/mod/k8s.io/code-generator@v0.18.6/generate-groups.sh all "$ROOT_PACKAGE/pkg/client" "$ROOT_PACKAGE/pkg/apis" "$CUSTOM_RESOURCE_NAME:$CUSTOM_RESOURCE_VERSION"
当最后一条命令报这个错误的时候:
Failed loading boilerplate: open $GOPATH/src/k8s.io/code-generator/hack/boilerplate.go.txt: no such file or directory
可以在 G O P A T H / s r c / 下 创 建 一 个 k 8 s . i o 的 目 录 , 然 后 将 GOPATH/src/ 下创建一个 k8s.io 的目录, 然后将 GOPATH/src/下创建一个k8s.io的目录,然后将GOPATH/src/pkg/mod/k8s.io/code-generator@v0.x 目录拷贝为 $GOPATH/src/k8s.io/code-generator,然后在执行最后一条命令。
生成了代码之后,我们需要写自定义控制器了。自定义控制器简单来说就是定义对 Nginx 资源的操作,类似于 Controller 对 Deployment 的操作,我在创建一个 Deployment 的时候,会有什么操作,删除的时候需要做什么操作,以及更新等。自定义控制器主要包含 main函数,控制器定义和业务逻辑
三个部分。 main函数的主要作用就是初始化自定义控制器,然后启动它。下面看一下 main 函数定义:
func main() {
flag.Parse()
stopCh := signals.SetupSignalHandler()
cfg, err := clientcmd.BuildConfigFromFlags(masterURL, kubeconfig)
kubeClient, err := kubernetes.NewForConfig(cfg)
nginxClient, err := clientset.NewForConfig(cfg)
nginxInformerFactory := informers.NewSharedInformerFactory(nginxClient, time.Second * 30)
controller := NewController(kubeClient, nginxClient, nginxInformerFactory.Samplecontroller().V1().Nginxes())
go nginxInformerFactory.Start(stopCh)
if err = controller.Run(2, stopCh); err != nil {
glog.Fatalf("Error running controller: %s", err.Error())
}
}
func init() {
flag.StringVar(&kubeconfig, "kubeconfig", "", "Path to a kubeconfig. Only required if out-of-cluster.")
flag.StringVar(&masterURL, "master", "", "The address of the Kubernetes API server. Overrides any value in kubeconfig. Only required if out-of-cluster.")
}
- 首先通过 apiserver 的url 和 kubeconfig 文件创建一个 kubernetes 的 client(kubeClient)和 Nginx 对象的 client(nginxClient)
- 然后创建了一个 nginxInformerFactory ,并使用他生成一个 Nginx 对象的 Informer,传递给控制器
- 最后main 函数启动上述的 Informer,然后执行 controller.Run,启动自定义控制器。
这个 Informer 是什么?
Informer,其实就是一个带有本地缓存和索引机制的、可以注册 EventHandler 的 client。
我们启动控制器,需要从 APIServer 里获取它 Nginx 对象,这个操作是是通过 Informer 库完成的。Informer 与 API 对象是一一对应的,这里传递给控制器的,就是一个 Nginx 对象的 Informer,如果要对 Deployment 进行操作, 那么就需要传递一个 Deployment 的 Informer,这个后面会讲怎么操作一个 Deployment。在创建 Nginx 这个 Informer 的时候,需要用到 nginxClient,因为通过 nginxClient 和 APIserver 建立的连接,但是维护这个连接的是 Reflector 包,Reflector 包通过 ListAndWatch 机制,来获取和监听这些 Nginx 对象实例的变化。
Informer 作用是什么?
Informer 作用主要是有两个:
- 同步本地缓存:当 APIServer 端有新的实例被创建、删除或者更新, Reflector 都会收到事件通知,此时改事件以及它对应的 API 对象组合就会被放进一个队列中,这个队列叫做 Delta FIFO Queue。Informer 会不断从这个队列中读取元素,判断他的事件类型,然后创建或更新本地对象的缓存。
- 触发 在 ResourceEventHandler 中注册的事件
下面看一下自定义控制器的代码:
func NewController(
kubeclientset kubernetes.Interface,
nginxclientset clientset.Interface,
nginxInformer informer.NginxInformer,
) *Controller {
utilruntime.Must(nginxscheme.AddToScheme(scheme.Scheme))
klog.Infof("Create Event broadcaster")
eventBroadcaster := record.NewBroadcaster()
eventBroadcaster.StartLogging(klog.Infof)
eventBroadcaster.StartRecordingToSink(&typedcorev1.EventSinkImpl{Interface: kubeclientset.CoreV1().Events("")})
recorder := eventBroadcaster.NewRecorder(scheme.Scheme, corev1.EventSource{Component:controllerAgentName})
controller := &Controller {
kubeclientset: kubeclientset,
nginxclientset: nginxclientset,
nginxLister: nginxInformer.Lister(),
nginxSynced: nginxInformer.Informer().HasSynced,
workerqueue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "Nginx"),
recorder: recorder,
}
nginxInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: controller.enqueueNginx,
UpdateFunc: func(old, new interface{}) {
oldNginx := old.(*samplecontrollerv1.Nginx)
newNginx := new.(*samplecontrollerv1.Nginx)
if oldNginx.ResourceVersion == newNginx.ResourceVersion{
return
}
controller.enqueueNginx(new)
},
DeleteFunc: controller.enqueueNginxForDelete,
})
return controller
}
我们在 main 函数中创建了两个 client(kubeClient 和 nginxClient),和一个 Informer , 这里我们初始化 controller 的时候会用到这三个对象。在自定义控制器中,还设置了一个队列。上面说到 APIServer 有新的 Nginx 实例被创建、删除或者更新的时候,会把事件和对象的一个组合放在一个队列中,说的就是这个队列,但是实际加入队列的不是API对象本身,而是他们的key(<namespace>/<name>)
初始化 Controller 后,又给 nginxInformer 绑定了对应的事件,分别对应新增,更新和删除操作。
最后,也是图中的最后一个部分,控制循环,也就是 controller.Run() 启动的控制循环:
func (c *Controller) Run(threadiness int, stopCh <-chan struct{}) error {
defer utilruntime.HandleCrash()
defer c.workerqueue.ShutDown()
if ok := cache.WaitForCacheSync(stopCh, c.nginxSynced); !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
}
很容易看出,先等待 Informer 完成一次本地缓存的数据同步,然后通过 goroutine 启动无限循环的任务,而这个无限循环中的逻辑就是我们的业务逻辑。那我们来看一下我们的业务逻辑:
func (c *Controller) runWorker() {
for c.processNextWorkItem() {
}
}
func (c *Controller) processNextWorkItem() bool {
obj, shutdown := c.workerqueue.Get()
...
err := func(obj interface{}) error {
...
if key, ok = obj.(string); !ok {
c.workerqueue.Forget(obj)
return nil
}
if err := c.syncHandler(key); err != nil {
c.workerqueue.AddRateLimited(key)
return fmt.Errorf("error syncing '%s': %s, requeuing", key, err.Error())
}
c.workerqueue.Forget(obj)
return nil
}(obj)
...
}
func (c *Controller) syncHandler(key string) error {
namespace, name, err := cache.SplitMetaNamespaceKey(key)
if err != nil {
utilruntime.HandleError(fmt.Errorf("invalid resource key: %s", key))
return nil
}
nginx, err := c.nginxLister.Nginxes(namespace).Get(name)
if err != nil {
if errors.IsNotFound(err) {
utilruntime.HandleError(fmt.Errorf("Nginx '%s' in work queue no longer exists", key))
return nil
}
return err
}
c.recorder.Event(nginx, corev1.EventTypeNormal, SuccessSynced, MessageResourceSynced)
// 这里补充你的业务逻辑
// 例如你要创建一个deployment
// 自定义控制器代码可以参考:https://github.com/kubernetes/sample-controller/blob/master/controller.go
// 在sample中您可以看到controller中更详细的代码
return nil
}
首先在队列中获取一个元素,也就是一个key,上面说了这个key并不是一个对象,而是 namespce/name 的一个组合,然后我们在 syncHandler 方法中通过 nginxLister 获取到对应的 Nginx 的对象。获取到 Nginx 对象后,Nginx 对象进行一些操作了。比如 Nginx 对象控制的是一个 deployment, 那我们就可以看这个deployment 是不是存在, 是不是期望的状态,如果不是则进行一些操作。 到此为止,一个简单的自定义控制器就完成了, 我们就可以直接运行了。
$ go run . -kubeconfig=/root/.kube/config -master='http://127.0.0.1:8080'