CRD集成Helm实践

2,985 阅读8分钟

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…

K8s系统架构(图片来自网络)
  • 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上的资源事件,然后进入到自身的处理逻辑中。

K8s controller结构(图片来自网络)

controller本身的实现比较复杂,可以简单理解为,controller会直接和apiserver进行交互,监听资源变化产生的事件,将事件入本地队列后controller中用户定义的处理逻辑从本地队列中取事件进行处理。事实上,不仅仅是CRD资源会使用controller,原生资源的事件处理也依赖于controller,两者在工作机制上是完全一致的。

List-Watch

List-Watch是保证消息可靠性核心技术,避免因消息丢失而造成数据状态不一致。请求端先是通过list获取资源期望状态到本地缓存中,然后通过http长链接监控资源数据变化。如果监听的资源有更新,则将变化实时更新到本地缓存中,同时生成一个资源变化的事件。CRD资源作为K8s扩展资源的一种,其变化也可以通过List-Watch被控制器获取到。

pod创建过程中的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通过注册仓库地址的方式去仓库中拉取所有软件包列表
  • 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交互。

controller框架图

新建一个微服务对象实际上等同于新建一个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 := &microv1alpha1.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 := &microv1alpha1.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。

参考链接