1. 背景
K8s (Kubernetes)通过CRD对外提供了自定义扩展资源的方式,既可以通过组合K8s中现有资源生成新的资源类型,也可以引入外部组件生成自有资源。如果想要在原生K8s的基础上进行其他的功能开发,又尽可能和K8s原生资源的使用方式保持一致,那么就可以使用CRD的方式将自有功能集成,实现方便且非常灵活,是扩展K8s功能的重要工具。
2. 名词
K8s
K8s是容器集群管理系统,是一个开源的平台,可以实现容器集群的自动化部署、自动扩缩容、维护等功能。它在容器技术的基础上,为容器化应用提供部署运行、资源调度、服务发现和动态伸缩等功能。K8s目前已成为容器编排事实上的标准,得到了广泛应用。
K8s的资源调度大部分依赖于声明式API,调度方式则采用水平触发,即用户向K8s声明资源后,K8s会保证系统达到用户预期的状态。
常见的资源介绍:
pod:资源最小调度单元,pod中可以有一个或者多个容器。
service:K8s中对软负载的抽象实现,访问service的请求会被转发到关联的pod上。
deployment:间接对pod进行管理,可以设置pod副本数量,受纳管的每个pod在功能上地位等同,对外提供相同的服务。还可以实现pod的滚动更新,回滚等。
statefulset:相当于有状态服务的deployment,每个pod有唯一的名称,并且重启后保持不变。
pvc:持久化存储,可用来对接不同的存储类型。
K8s资源详细介绍,请参考官方文档 kubernetes.io/docs/concep…
- Etcd是K8s的核心数据库,用来存储集群中数据。
- Master节点组件:
- kube-apiserver: K8s对外接受请求的服务,其他组件获取集群状态,资源等信息都需要和它交互。
- kube-scheduler: 负责pod的调度工作,包括节点预选和优选操作。
- kube-controller-manager: 资源控制器,保证资源达到预期状态。
- Node节点组件:
- kubelet: 每个node节点都会运行,负责pod中容器的生命周期管理。
- kube-proxy: 负责根据service生成转发规则。
CRD
CRD全称是CustomResourceDefinition ,字面意思是自定义资源。CRD本身也是K8s的一种资源,创建一个CRD即在K8s中定义了一种新的资源类型,这个资源类型可以像K8s中的原生资源一样,既可以通过kubectl命令行,也可以通过访问apiserver来进行操作。
controller
CRD控制器,如果仅在K8s中注册一个CRD资源,那么该资源只能被看成一个简单的存储对象。真正想通过CRD实现程序控制逻辑的话,需要实现CRD对应的controller。controller会捕获该CRD上的资源事件,然后进入到自身的处理逻辑中。
controller本身的实现比较复杂,可以简单理解为,controller会直接和apiserver进行交互,监听资源变化产生的事件,将事件入本地队列后controller中用户定义的处理逻辑从本地队列中取事件进行处理。事实上,不仅仅是CRD资源会使用controller,原生资源的事件处理也依赖于controller,两者在工作机制上是完全一致的。
List-Watch
List-Watch是保证消息可靠性核心技术,避免因消息丢失而造成数据状态不一致。请求端先是通过list获取资源期望状态到本地缓存中,然后通过http长链接监控资源数据变化。如果监听的资源有更新,则将变化实时更新到本地缓存中,同时生成一个资源变化的事件。CRD资源作为K8s扩展资源的一种,其变化也可以通过List-Watch被控制器获取到。
从pod的创建流程中可以看到,controller-manager,scheduler,kubelet等组件都会和apiserver建立list-watch连接,从而保证资源被处理时是最新的。
Helm
Helm是K8s生态中的一种软件包(chart)管理软件。软件包本质上是K8s中原生资源的组合,表现形式是yaml文件。比如一个包中可以包含有一个service和一个deployment。部署“包”生成实例(release)的过程,是将这个service和deployment部署到K8s的过程。通过helm,使用者可以自由组合软件,将自由软件部署以一种模板的形式固化。并且helm提供了升级和回滚的功能,是目前K8s生态中最被广泛使用的组件之一。
- 仓库:存储软件包的地方,helm通过注册仓库地址的方式去仓库中拉取所有软件包列表
- helm:二进制命令文件,可以在本地注册仓库地址,管理实例。
- tiller:服务端,负责接受helm请求,在K8s中管理实例中定义的资源。
3. Kubebuilder
kubebuilder框架对controller进行了封装,从上文介绍中可以看到,controller在结构和功能上具有一致性,controller的不同主要体现在对不同资源的处理流程上。kubebuilder将controller中涉及的资源List-Watch,事件队列缓存等共性步骤抽象出来,在框架中进行实现,使用者只需要实现核心函数Reconciler,便可以生成一个基本的controller。下文中也将以kubebuilder框架为基础,介绍CRD controller实践。
4. CRD + Helm
Helm是一个C/S架构的程序,client和server之间通过gRPC进行接口调用。通过在CRD中引用helm中的对象,可以将helm功能无缝引入到K8s中。与直接使用helm相比,优点是不必将资源编排文件(即“包”)实体化存储,减轻了外部依赖,包中资源的结构化操作在controller中完成。
新建的CRD资源可被定义为任意名称,这里以微服务(microservice)来命名。CRD在向K8s中注册该资源后,由controller负责和helm服务端即tiller交互。
新建一个微服务对象实际上等同于新建一个helm release,可以通过kubectl直接操作微服务对象,比如CRUD,每次操作后对应的时间被controller捕获后进入到controller的处理逻辑中,该处理逻辑是由用户完全自定义的。
5. 实践
5.1 安装kubebuilder
有两种方式来安装kubebuilder,一种是安装kubebuilder的release版本,另一种则是拉取社区源码,编译得到最新的kubebuilder二进制文件。这里采用第一种方法,获取release版本,到本文为止,最新版本为v2.0.1。
环境准备:
kubernetes集群(v1.15)
tiller,需要将Helm安装到集群中,安装参考文档helm。tiller服务对外暴露服务地IP:PORT。
开发环境:
CentOS 7.3
Go 1.12
安装Kubebuilder
$ wget https://github.com/kubernetes-sigs/kubebuilder/releases/download/v2.0.1/kubebuilder_2.0.1_linux_amd64.tar.gz
$ tar -zxf kubebuilder_2.0.1_linux_amd64.tar.gz -C /tmp
$ mv /tmp/kubebuilder_2.0.1_linux_amd64/ /usr/local/kubebuilder
$ export PATH=$PATH:/usr/local/kubebuilder/bin
安装kustomize
$ cd /usr/local/kubebuilder/bin
$ wget https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv3.2.2/kustomize_kustomize.v3.2.2_linux_amd64
$ mv kustomize_kustomize.v3.2.2_linux_amd64 kustomize
$ chmod +x kustomize
kustomize是一个定制yaml文件的小工具,主要会用在生成CRD定义文件上。在有kustomize之前,需要根据资源手动编写CRD定义文件,借助这个yaml生成工具,用户只需定义结构体,CRD便可以自动生成。
5.2 创建项目和API
在$GOPATH路径下新建一个项目目录
$ mkdir $GOPATH/src/microservice-controller
$ cd $GOPATH/src/microservice-controller
执行init命令,进行项目初始化,domain参数可自由设置。这个步骤kubebuilder会访问网络下载必要的go依赖包,因此要保证网络畅通。
$ export GO111MODULE=on
$ kubebuilder init --domain hello.com
执行成功后,目录中出现如下文件:
$ ls
Dockerfile Makefile PROJECT bin config go.mod go.sum hack main.go
创建一个API框架,API即K8s中group/version的概念,设置为"micro/v1alpha1", kind为资源名称,设置为"Microservice"。
$ kubebuilder create api --group micro --version v1alpha1 --kind Microservice
在执行命令的过程中,会出现"Create Resource [y/n]"以及"Create Controller [y/n] "的交互,都输入"y"即可,表示创建所有必要的文件。
可以看到目录中新增了"controllers", "api"以及"config" 文件夹,其中controller和api是核心文件夹,后面所做的修改都在这两个文件夹中。
$ ls
Dockerfile PROJECT bin controllers go.sum main.go Makefile api config go.mod hack
将K8s集群中位于"/root/.kube/config"路径下的文件拷贝到开发环境中,该配置文件将会安装CRD和运行controller时用来指向集群。
5.3 定义CRD types
microservice这个CRD资源中的属性用户可自定义,根据前期介绍,每个microservice对象会对应一个helm的release,因此这里将使用一个deployment和一个service的组合作为每个release的默认编排的资源类型,如果有其他资源编排需求,也可以从这里扩展。microservice中定义这两种资源。
修改MicroserviceSpec属性。MicroserviceStatus属性被用来记录对象的状态,这里只解释核心逻辑,因此不做介绍。
$ vim api/v1alpha1/microservice_types.go
...
type MicroserviceSpec struct {
// 存储将被helm解析的资源
Resources string `json:"resources,omitempty"`
}
...
执行
$ make install
在集群中安装CRD。"install"这个target先是由kustomize根据microservice_types.go中定义的结构体生成了CRD定义文件(资源类型为CustomResourceDefinition),然后将该CRD注册到K8s中。这里便用到了前文中提到的kubeconfig文件。
在K8s集群中执行
$ kubectl get crd |grep microservice
microservices.micro.hello.com 2019-10-23T08:11:55Z
5.4 编写Reconciler
按照前文约束,microservice的resources属性被用来存储K8s资源。因此在reconciler中,将把该属性资源序列化为helm中chart结构体,该结构体是进行release安装的核心对象。
$ vim controllers/microservice_controller.go
...
func (r *MicroserviceReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
_ = context.Background()
_ = r.Log.WithValues("microservice", req.NamespacedName)
//这里将编写处理microservice资源被CRUD时,controller如何针对不同类型的事件进行处理
// your logic here
return ctrl.Result{}, nil
}
...
为简洁起见,这里只展示microservice在新建和删除时,reconcile如何进行处理。 处理资源被删除时的逻辑
name := req.Name
ns := req.Namespace
msvc := µv1alpha1.Microservice{}
// microservice被新建或者删除时,会触发一次事件操作,该事件操作会被controller捕获。
// 因为K8s被设计为只关注资源的最新状态,所以req中只保留了资源的name和namespace。
// 资源的当前状态需要controller去获取。
err := r.Get(context.TODO(), req.NamespacedName, msvc)
if err != nil {
// 如果资源已经无法被找到,意味着资源已经被删除,需要清理helm的release。
if errors.IsNotFound(err) {
// 删除对应的helm release
if err := r.HelmClient.UninstallRelease(hrlsname); err != nil {
// 删除出现错误,返回requeue=true,表示该事件未被正确处理,需要再次进入reconcile
// 处理
return reconcile.Result{Requeue: true}, err
}
// 删除成功,该事件被正确处理,流程结束
return reconcile.Result{}, nil
}
// 并非NotFound类型错误,同样需要重新入队
return reconcile.Result{Requeue: true}, err
}
处理新建资源时的逻辑
// Chart是helm中用于创建release的结构体
hchart := &chart.Chart{
Metadata: &chart.Metadata{
Name: name,
Version: "0.0.0",
},
Templates: []*chart.Template{
&chart.Template{
// 按照template的格式组装数据,在软件包种,yaml会以文件方式存储,
// 这里遵从这种格式
Name: name + ".yaml",
// 只需要将resource字段的值传递到data中,后续helm会对该值进行反序列化
Data: []byte(msvc.Spec.Resources),
},
},
}
emptyRawVals, _ := yaml.Marshal(map[string]interface{}{})
// 安装release
_, e := r.HelmClient.InstallRelease(
name,
ns,
hchart,
emptyRawVals,
)
完整函数如下
func (r *MicroserviceReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
name := req.Name
ns := req.Namespace
msvc := µv1alpha1.Microservice{}
err := r.Get(context.TODO(), req.NamespacedName, msvc)
if err != nil {
if errors.IsNotFound(err) {
if err := r.HelmClient.UninstallRelease(name); err != nil {
return reconcile.Result{Requeue: true}, err
}
return reconcile.Result{}, nil
}
return reconcile.Result{Requeue: true}, err
}
hchart := &chart.Chart{
Metadata: &chart.Metadata{
Name: name,
Version: "0.0.0",
},
Templates: []*chart.Template{
&chart.Template{
Name: name + ".yaml",
Data: []byte(msvc.Spec.Resources),
},
},
}
emptyRawVals, _ := yaml.Marshal(map[string]interface{}{})
// 安装release
_, e := r.HelmClient.InstallRelease(
name,
ns,
hchart,
emptyRawVals,
)
if e != nil {
return reconcile.Result{Requeue: true}, err
}
return ctrl.Result{}, nil
}
代码中调用了HelmClient.UninstallRelease和HelmClient.InstallRelease两个方法,这两个方法属于HelmClient,即是对helm提供的原生接口的简单封装。代码和microservice_controller.go位于同一目录。
type HelmClient struct {
*helm.Client
}
func NewHelmClient(tillerhost string) *HelmClient {
// 初始化HelmClient时需要显式指明tiller的地址,否则helm无法感知tiller
options := []helm.Option{helm.Host(tillerhost), helm.ConnectTimeout(10)}
return &HelmClient{Client: helm.NewClient(options...)}
}
func (client *HelmClient) InstallRelease(name, namespace string, chart *chart.Chart, values []byte) (*release.Release, error) {
resp, e := client.InstallReleaseFromChart(
chart,
namespace,
helm.ReleaseName(name),
helm.ValueOverrides(values),
)
if e != nil {
return nil, e
}
return resp.Release, nil
}
func (client *HelmClient) UninstallRelease(name string) error {
opts := []helm.DeleteOption{helm.DeletePurge(true), helm.DeleteTimeout(300)}
res, err := client.DeleteRelease(name, opts...)
if res != nil && err == nil {
return nil
}
if strings.Contains(fmt.Sprintf("%s", err), "not found") {
return nil
}
return err
}
修改MicroserviceReconciler结构体,增加client
type MicroserviceReconciler struct {
client.Client
Log logr.Logger
// 新增属性
HelmClient *HelmClient
}
修改项目目录下main.go文件main函数,引入tillerhost参数并初始化Reconciler。
// 在Parse函数之前定义tillerHost启动时参数
var tillerHost string
flag.StringVar(&tillerHost, "tiller-host", ":8080", "tillerhost")
flag.Parse()
if err = (&controllers.MicroserviceReconciler{
Client: mgr.GetClient(),
Log: ctrl.Log.WithName("controllers").WithName("Microservice"),
// 在Reconciler中注入HelmClient
HelmClient: controllers.NewHelmClient(tillerHost),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "Microservice")
os.Exit(1)
}
到此关于Reconciler的修改全部完成。
修改Makefile中run这个target,添加tiller-host启动参数
run: generate fmt vet manifests
go run ./main.go -tiller-host=<IP:PORT>
执行
make run
可以看到controller运行成功。
5.5 测试controller
在K8s集群新建一个microservice的资源文件msvc.yaml
apiVersion: micro.hello.com/v1alpha1
kind: Microservice
metadata:
name: test
spec:
//注意resources属性的格式,是字符串类型。
resources: |
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: test
spec:
replicas: 1
selector:
matchLabels:
app: test
template:
metadata:
labels:
app: test
spec:
containers:
- image: nginx
name: nginx
---
apiVersion: v1
kind: Service
metadata:
labels:
app: test
name: test
namespace: test
spec:
ports:
- name: nginx
port: 80
protocol: TCP
targetPort: 80
selector:
app: test
type: ClusterIP
执行
$kubectl create ns test
$kubectl apply -f msvc.yaml
验证test这个microservice是否被新建成功
$ helm ls
NAME ... STATUS
test ... DEPLOYED
$ kubectl -n test get pod |grep test
test-d4df74fc9-k7gvj 1/1 Running 0 7m18s
$ kubectl -n test get service |grep test
test ClusterIP 10.96.242.146 <none> 80/TCP 7m59s
$curl http://10.96.242.146:80
...
Welcome to nginx
..
如果helm release 状态为“DEPLOYED”,pod和service都已被新建成功,表明在microservice被新建时触发的事件被controller捕获,并进入到release的创建阶段。curl命令执行后出现“Welcome”字样表明pod中容器运行正常。
下面验证一下controller中的删除逻辑
$ kubectl -n test delete -f msvc.yaml
$ helm ls
$ kubectl -n test get pod |grep test
$ kubectl -n test get service |grep test
$ curl http://10.96.242.146:80 //hang在这个地方
从执行结果看,test这个release和对应的pod与service均已经被删除,表明删除成功。
6. 总结
本文大体实现了编写一个controller的流程,验证了CRD和Helm集成的可能性。其中并未过多涉及List-Watch,原因是kubebuilder已经对这部分进行了较好的封装,但是List-Watch机制是K8s消息传递的核心,感兴趣的同学可以继续了解。另外controller中的Reconciler函数逻辑并不仅仅那么简单,文中只简单展示了如何处理创建和删除时的事件处理逻辑,其中并未涉及到helm中的实例回滚,microservice对象的状态更新等更为复杂的流程。写好一个controller的核心是写好一个reconciler。