由《Kubernetes in Action》这本书自定义Controller章节启发,原作里面是作者自己hack了一个Controller(代码在这里),而我想通过纯正的Controller方式实现出来。

CRD是什么

全称Custom Resource Definition,顾名思义就是「自定义资源定义」,也就是按照官方Scheme来定义官方没有的资源struct,即创建你自己的“Pod”,Kubernetes提供了这样的入口就是方便用户扩展Kubernetes,适应更多使用场景。由于是官方配置,所以CRD有它自己的特点或者叫约束:

  • 强类型
  • 能够被订阅更新事件,本质上是让api server能够识别

Controller是什么

也就是「控制器」,控制Kubernetes的资源实体。怎么控制呢?通过监听资源变化事件。这个事件可能是用户发起的(他希望把资源从A状态更新到B状态),Controller就会获取这个事件并处理事件,即更新目标资源。Kubernetes默认有很多控制器,他们控制着Kubernetes默认资源,如Pod、Deployment、Service等,他们都包含在Controller Manager中。但如果你的资源是个CRD,因为没有对应的控制器,你就得为它自己写Controller了。

改造「Kubernetes In Action」例子的计划

正如引文里面提到的,作为第一次写Controller,我想使用正确的方式去实现,有意不去用像Operator等成熟框架去写,使用更原生的方式更能帮助理解工作原理。Kubernetes官方提供了一个样例,里面提供的方式很原生,也够简单,所以准备“以瓢画葫”了。

“只要一个git repo就可以创建一个网站”,是这次代码实现的目标。开发计划如下:

  • 创建CRD叫website,接受git repo地址、deployment name、replicas
  • 定义Controller行为
    • 创建Deployment ,一个pod包含两个容器服务,nginx 和 git-sync。git-sync用来同步git repo的代码
    • 创建NodePort Service,提供对外访问能力
  • 部署Controller服务

开始动手,定义Website CRD

以下是我的例子:

type Website struct {
        metav1.TypeMeta   `json:",inline"`
        metav1.ObjectMeta `json:"metadata,omitempty"`
        Spec   WebsiteSpec   `json:"spec"`
        Status WebsiteStatus `json:"status"`
   }

// 这里是CRD的主体,面向用户的日常使用
type WebsiteSpec struct {
        GitRepo        string `json:"gitRepo"`
        DeploymentName string `json:"deploymentName"`
        Replicas       *int32 `json:"replicas"`
    }

type WebsiteStatus struct {
        AvailableReplicas int32 `json:"availableReplicas"`
    }

type WebsiteList struct {
        metav1.TypeMeta `json:",inline"`
        metav1.ListMeta `json:"metadata,omitempty"`

        Items []Website `json:"items"`
    }

自动生成Controller基础代码

跟官方例子一样,我也使用「code generator」这个工具,基于已经定义好的CRD,自动生成Controller基础代码。先来看下你需要准备的代码框架:

如上图,首先需要定义好4个文件,其中:

  • doc.go,这个文件的内容很简单,主要就是占位
// 下面两行是用来帮助生成Controller代码的
// +k8s:deepcopy-gen=package
// +groupName=mycontroller.nevermosby.io

// Package v1alpha1 是定义Controller的v1alpha1版本
// 所以你可以定义多个版本的Controller
package v1alpha1
  • types.go,这个文件就是上文CRD的定义
  • register.go(内层),这个文件有点复杂,个人理解主要是为CRD作CustomResourceValidation,本次入门就不展开讨论了
  • register.go(外层),这个文件也很简单,主要是给CRD取个groupname
package mycontroller
// 定义CRD的GroupName
const (
	GroupName = "mycontroller.nevermosby.io"
)

大家可以去我的GitHub上看到完整代码:

https://github.com/nevermosby/my-crd-controller

接下来就是使用「generate-groups.sh」这个文件,开始代码生成工作,以下是帮助文档:

./generate-groups.sh -h
Usage: generate-groups.sh <generators> <output-package> <apis-package> <groups-versions> ...

  <generators>        the generators comma separated to run (deepcopy,defaulter,client,lister,informer) or "all".
  <output-package>    the output package name (e.g. github.com/example/project/pkg/generated).
  <apis-package>      the external types dir (e.g. github.com/example/api or github.com/example/project/pkg/apis).
  <groups-versions>   the groups and their versions in the format "groupA:v1,v2 groupB:v1 groupC:v2", relative
                      to <api-package>.
  ...                 arbitrary flags passed to all generator binaries.

Examples:
  generate-groups.sh all             github.com/example/project/pkg/client github.com/example/project/pkg/apis "foo:v1 bar:v1alpha1,v1beta1"
  generate-groups.sh deepcopy,client github.com/example/project/pkg/client github.com/example/project/pkg/apis "foo:v1 bar:v1alpha1,v1beta1"

以下是我自己使用的命令:

./vendor/k8s.io/code-generator/generate-groups.sh \
    # 期望生成的函数列表
	"deepcopy,client,informer,lister" \
    # 生成代码的目标目录
	github.com/nevermosby/my-crd-controller/pkg/client \
    # CRD所在目录
 	github.com/nevermosby/my-crd-controller/pkg/apis \ 
    # CRD的group name和version
    "mycontroller:v1alpha1" \
    # 这个文件里面其实是开源授权说明,但如果没有这个入参,该命令无法执行
    --go-header-file /Users/davidli/gh/myk8scrd/src/github.com/nevermosby/my-crd-controller/hack/custom-boilerplate.go.txt

执行完成后,在原来定义CRD的目录下,你会得到下面的文件结构:

如上图,多出了「zzgenerated.deepcopy.go」这个文件,里面都是一堆「deepcopy」方法,实现CRD的复制功能。

在期望生成代码的目标目录下,你会得到如下的文件结构:

主要包含了「clientset」、「lister」和「informers」三个组件:「clientset」是使用CRD的SDK,对外暴露接口。「lister」和「informers」这里面的代码就是监听CRD变化事件并留出写handler的入口。

至此为止,新Controller的框架已经搭起来了。

编写Controller业务逻辑代码

Controller业务逻辑代码是无法自动生成的。通常的编码逻辑是,写一个入口用来初始化这个Controller,并填充上文提到的handler入口。

1.初始化Controller

在项目的main方法里,添加初始化Controller的核心代码如下:

controller := NewController(kubeClient, exampleClient,
	kubeInformerFactory.Apps().V1().Deployments(),
	kubeInformerFactory.Core().V1().Services(),
	exampleInformerFactory.Mycontroller().V1alpha1().Websites())

// notice that there is no need to run Start methods in a separate goroutine. (i.e. go kubeInformerFactory.Start(stopCh)
// Start method is non-blocking and runs all registered informers in a dedicated goroutine.
kubeInformerFactory.Start(stopCh)
exampleInformerFactory.Start(stopCh)

if err = controller.Run(2, stopCh); err != nil {
	klog.Fatalf("Error running controller: %s", err.Error())
}

详细初始化逻辑可以参照这里:https://github.com/nevermosby/my-crd-controller/blob/master/controller.go#L91

2.针对不同事件,实现处理事件的handler

针对「新增」、「更新」和「删除」这3种事件,实现了不同的处理行为,最终都是「syncHandler」这个方法来统一处理,核心逻辑就是:首先查找目标资源有没有,没有就创建。目标资源包括deployment和service。然后对比已经创建的资源状态是否符合期待,如副本(replica)数量是否满足。创建资源的核心代码参考如下:

// 创建service
func newService(website *myv1alpha1.Website) *v1core.Service {
	deploymentName := website.Spec.DeploymentName
	// use deploynment name for service name
	serviceName := fmt.Sprintf("%s-%s", deploymentName, "npsvc")
	labels := map[string]string{
		"app":        "website",
		"controller": website.Name,
	}
	selectLabels := map[string]string{
		"app":        "website-nginx",
		"controller": website.Name,
	}
	return &v1core.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:      serviceName,
			Namespace: website.Namespace,
			Labels:    labels,
			OwnerReferences: []metav1.OwnerReference{
				*metav1.NewControllerRef(website, myv1alpha1.SchemeGroupVersion.WithKind("Website")),
			},
		},
		Spec: v1core.ServiceSpec{
			Ports: []v1core.ServicePort{
				{
					Port:     80,
					Protocol: v1core.ProtocolTCP,
				}},
			Type:     v1core.ServiceTypeNodePort,
			Selector: selectLabels,
		},
	}
}
// 创建deployment
func newDeployment(website *myv1alpha1.Website) *appsv1.Deployment {
	labels := map[string]string{
		"app":        "website-nginx",
		"controller": website.Name,
	}
	return &appsv1.Deployment{
		ObjectMeta: metav1.ObjectMeta{
			Name:      website.Spec.DeploymentName,
			Namespace: website.Namespace,
			OwnerReferences: []metav1.OwnerReference{
				*metav1.NewControllerRef(website, myv1alpha1.SchemeGroupVersion.WithKind("Website")),
			},
		},
		Spec: appsv1.DeploymentSpec{
			Replicas: website.Spec.Replicas,
			Selector: &metav1.LabelSelector{
				MatchLabels: labels,
			},
			Template: corev1.PodTemplateSpec{
				ObjectMeta: metav1.ObjectMeta{
					Labels: labels,
				},
				Spec: corev1.PodSpec{
				Containers:[]corev1.Container{
				{
		// nginx container for hosting website
				Name:  "nginx",
				Image: "nginx",
				VolumeMounts: []corev1.VolumeMount{
					{
						Name:      "html",
						MountPath: "/usr/share/nginx/html",
						ReadOnly:  true,
					},
				},
				Ports: []corev1.ContainerPort{
					{
						ContainerPort: 80,
						Protocol:      "TCP",
					},
				},
				},
				{
					// git sync container for fetching code
					Name:  "git-sync",
					Image: "openweb/git-sync",
					Env: []corev1.EnvVar{
						{
							Name:  "GIT_SYNC_REPO",
							Value: website.Spec.GitRepo,
						},
						{
							Name:  "GIT_SYNC_DEST",
							Value: "/gitrepo",
						},
						{
							Name:  "GIT_SYNC_BRANCH",
							Value: "master",
						},
						{
							Name:  "GIT_SYNC_REV",
							Value: "FETCH_HEAD",
						},
						{
							Name:  "GIT_SYNC_WAIT",
							Value: "3600",
						},
					},
					VolumeMounts: []corev1.VolumeMount{
						{
							Name:      "html",
							MountPath: "/gitrepo",
						},
					},
				},
            },
					Volumes: []corev1.Volume{
						{
							Name: "html",
							VolumeSource: corev1.VolumeSource{
								EmptyDir: &corev1.EmptyDirVolumeSource{
									Medium: "",
								},
							},
						},
					},
				},
			},
		},
	}
}

这里有个麻烦的地方,相信写过Controller的同学都会遇到——原来通过yaml创建资源是件相对简单直接的事情,现在换成用go语言,变得到处都是大括号,还得找struct的key叫什么。。。一地鸡毛+反人类!

终于,拯救程序员还得靠程序员自己。强烈推荐下面的工具,绝对save the day:http://structify.carsonoid.net/

部署Controller服务到Kubernetes集群中

像用go语言写的Controller,一般只要写完编译出二进制文件,塞进容器镜像就完事儿了,随时随地docker run启动即可。为了管理方便,都是把它们部署到Kubernetes集群中去。一般两种模式:

  1. 使用deployment方式,跟管理应用一样,简单并能保证高可用,一般就部署一个pod
  2. 纯pod方式,类似Kubernetes核心基础组件,这种方式用的不多,如果是定位成很底层的Controller会这么玩

大功告成,来看效果

用户提交一个website资源,自动创建相关deployment和service;删除website资源时,也会自动删除相关资源。下面的demo里还用到了kubewatch和mattermost(下面是个gif动图,请耐心等待加载完毕):

一旦deployment和service创建完毕后,就能对外服务了:

简述Controller工作原理

要完全说清Controller工作原理其实整一篇文章都不够,主要是涉及太多基础知识,所以我这边就给个简述版,算是给自己做个笔记。

参照下图,主要使用到 Informer和workqueue两个核心组件。Controller可以有一个或多个informer来跟踪某一个resource。Informter跟API server保持通讯获取资源的最新状态并更新到本地的cache中,一旦跟踪的资源有变化,informer就会调用callback。把关心的变更的Object放到workqueue里面。然后woker执行真正的业务逻辑,计算和比较workerqueue里items的当前状态和期望状态的差别,然后通过client-go向API server发送请求,直到驱动这个集群向用户要求的状态演化。

当然有框架专门写Controller

大家也看到了,写一套CRD和Controller绝对不是简单的事情,因此社区提供了相关框架去尽量简化复杂度,让使用者能够更方便地专注业务,甚至连业务都不用写,以下是几个主流框架:

  • Kubebuilder,是Kubernetes社区官方推荐的方案,用下来比较顺手,其本质就是本次文章的内容
  • Operator Framework,是红帽主导的专门用来开发Kubernetes Operator的框架,那Operator又是什么呢?引用别人的一句定义:

Operator 是一种特殊的自定义 Controller,它的编写者,一定是某个“能力”对应的领域专家比如 TiDB 的开发人员,因为它包含了对这个领域的所有运维能力,如安装、升级、备份、恢复等。

  • Kudo,全称「The Kubernetes Universal Declarative Operator」,定位和Operator很像,官方叫「Automate Day-2 Operations」。主要社区维护方是D2iQ,也就是Mesosphere(为什么我突然泪目了)。这款我已经做了深度体验,感觉很不错,期待和大家分享。

这三个框架之间的关系有点「本是同根生相煎何太急」,将来有机会给大家整理下。

参考材料

1 对 “Kubernetes CRD&Controller入门实践(附PPT)”的想法;

发表评论

电子邮件地址不会被公开。 必填项已用*标注