code-generator 和 controller-tool

371 阅读17分钟

code-generator

之前有介绍使用Kubebuilder框架开发Kubernetes控制器。kubebuilder根据types.go里结构体定义,帮助开发人员自动生成代码脚手架,开发人员只需要在Reconcile里添加自己控制器的业务逻辑,非常方便。今天介绍通过code-generator生成代码,开发控制器的方法。

code-generator提供了以下代码生成器工具为kubernetes中的资源生成代码:

  • deepcopy-gen: 生成深度拷贝方法,避免性能开销
  • client-gen:为资源生成标准的操作方法(get,list,create,update,patch,delete,deleteCollection,watch)
  • informer-gen: 生成informer,提供事件机制来响应kubernetes对应的CRD资源改变
  • lister-gen: 为get和list方法提供只读缓存层

• Kubebuilder不会生成informers、listers、clientsets,而code-generator会。

• Kubebuilder会生成Controller、Admission Webhooks,而code-generator不会。

• Kubebuilder会生成manifests yaml,而code-generator不会。

• Kubebuilder还带有一些其他便利性设施。

k8s.io/client-go 提供了对k8s原生资源的informer和clientset等等,但对于自定义资源的操作则相对低效,需要使用 rest api 和 dynamic client 来操作,并自己实现反序列化等功能。

code-generator 提供了以下工具用于为k8s中的资源生成相关代码,可以更加方便的操作自定义资源

deepcopy-gen: 生成深度拷贝方法,为每个 T 类型生成 func (t* T) DeepCopy() *T 方法,API 类型都需要实现深拷贝

client-gen: 为资源生成标准的 clientset

informer-gen: 生成 informer,提供事件机制来响应资源的事件

lister-gen: 生成 Lister,为 get 和 list 请求提供只读缓存层(通过 indexer 获取)

Informer 和 Lister 是构建控制器的基础,使用这4个代码生成器可以创建全功能的、和 Kubernetes 上游控制器工作机制相同的 production-ready 的控制器。

code-generator 还包含一些其它的代码生成器,例如 Conversion-gen 负责产生内外部类型的转换函数、Defaulter-gen 负责处理字段默认值。大部分的生成器支持--input-dirs参数来读取一系列输入包,处理其中的每个类型,然后生成代码:

1、部分代码生成到输入包所在目录,例如 deepcopy-gen 生成器,也可以使用参数--output-file-base "zz_generated.deepcopy" 来定义输出文件名

2、其它代码生成到 --output-package 指定的目录,例如 client-gen、informer-gen、lister-gen 等生成器

参考: code-generator简单介绍

  • deepcopy-gen: 生成深度拷贝对象方法

    使用方法:

    • 在文件中添加注释// +k8s:deepcopy-gen=package
    • 为单个类型添加自动生成// +k8s:deepcopy-gen=true
    • 为单个类型关闭自动生成// +k8s:deepcopy-gen=false
  • client-gen: 为资源生成标准的操作方法(get;list;watch;create;update;patch;delete)

    在pkg/apis/GROUP/{GROUP}/GROUP/{VERSION}/types.go中使用,使用// +genclient标记对应类型生成的客户端, 如果与该类型相关联的资源不是命名空间范围的(例如PersistentVolume), 则还需要附加// + genclient:nonNamespaced标记,

    • // +genclient - 生成默认的客户端动作函数(create, update, delete, get, list, update, patch, watch以及 是否生成updateStatus取决于.Status字段是否存在)。
    • // +genclient:nonNamespaced - 所有动作函数都是在没有名称空间的情况下生成
    • // +genclient:onlyVerbs=create,get - 指定的动作函数被生成.
    • // +genclient:skipVerbs=watch - 生成watch以外所有的动作函数.
    • // +genclient:noStatus - 即使.Status字段存在也不生成updateStatus动作函数
  • informer-gen: 生成informer,提供事件机制(AddFunc,UpdateFunc,DeleteFunc)来响应kubernetes的event

  • lister-gen: 为get和list方法提供只读缓存层

  • conversion-gen是用于自动生成在内部和外部类型之间转换的函数的工具

    一般的转换代码生成任务涉及三套程序包:

    • 一套包含内部类型的程序包,
    • 一套包含外部类型的程序包
    • 单个目标程序包(即,生成的转换函数所在的位置,以及开发人员授权的转换功能所在的位置)。包含内部类型的包在Kubernetes的常规代码生成框架中扮演着称为peer package的角色。

    使用方法:

    • 标记转换内部软件包 // +k8s:conversion-gen=<import-path-of-internal-package>
    • 标记转换外部软件包// +k8s:conversion-gen-external-types=<import-path-of-external-package>
    • 标记不转换对应注释或结构 // +k8s:conversion-gen=false
  • defaulter-gen 用于生产Defaulter函数

    • 为包含字段的所有类型创建defaulters,// +k8s:defaulter-gen=<field-name-to-flag>
    • 所有都生成// +k8s:defaulter-gen=true|false
  • go-to-protobuf 通过go struct生成pb idl

  • import-boss 在给定存储库中强制执行导入限制

  • openapi-gen 生成openAPI定义

    使用方法:

    • +k8s:openapi-gen=true 为指定包或方法开启
    • +k8s:openapi-gen=false 指定包关闭
  • register-gen 生成register

  • set-gen

code-generator整合了这些gen,使用脚本generate-groups.shgenerate-internal-groups.sh可以为自定义资源生产相关代码。

参考: code-generator使用

示例

接来下我们使用code-generator进行实战演示:

首先我们将项目拉到本地:

$ git clone https://github.com/kubernetes/code-generator.git
$ git checkout 0.23.3

然后我们进入到cmd目录下,就会看到我们上面介绍的工具:

image-20220511155636385.png

接着我们对client-gen,deepcopy-gen,infromer-gen,lister-gen进行安装,会安装到GOPATH的bin目录下:

# 进行安装
$ go install ./cmd/{client-gen,deepcopy-gen,informer-gen,lister-gen}

# 获取GOPATH路径
$ go env | grep GOPATH
GOPATH="/Users/Christian/go"

# 查看
ls /Users/Christian/go/bin
client-gen     deepcopy-gen   goimports      lister-gen
controller-gen defaulter-gen  informer-gen   type-scaffold
复制代码

发现我们已经成功的安装了,这时候我们就可以直接使用这些工具了,比如我们可以使用--help命令来查看如何使用client-gen:

image-20220511160606711.png

当然通常情况下我们不会去单独的使用某一个工具。

接下来我们来创建我们的项目,此处我们可以仿照sample controller项目进行编写:

$ mkdir operator-test && cd operator-test
$ go mod init operator-test
$ mkdir -p pkg/apis/example.com/v1

➜  operator-test tree
.
├── go.mod
├── go.sum
└── pkg
    └── apis
        └── example.com
            └── v1
                ├── doc.go
                ├── register.go
                └── types.go

4 directories, 5 files

复制代码

接下来我们对v1下面的三个go文件进行填充(可以直接复制sample-controller,对其进行做简单修改):

doc.go主要是用来声明要使用deepconpy-gen以及groupName。

// pkg/crd.example.com/v1/doc.go

// +k8s:deepcopy-gen=package
// +groupName=example.com

package v1
复制代码

types.go主要是定义crd资源对应的go中的结构。

// pkg/crd.example.com/v1/types.go

package v1

import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

// Bar is a specification for a Bar resource
type Bar struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec BarSpec `json:"spec"`
    // Status BarStatus `json:"status"`
}

// BarSpec is the spec for a Bar resource
type BarSpec struct {
    DeploymentName string `json:"deploymentName"`
    Image          string `json:"image"`
    Replicas       *int32 `json:"replicas"`
}

// BarStatus is the status for a Bar resource
type BarStatus struct {
    AvailableReplicas int32 `json:"availableReplicas"`
}

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

// BarList is a list of Bar resources
type BarList struct {
    metav1.TypeMeta `json:",inline" :"metav1.TypeMeta"`
    metav1.ListMeta `json:"metadata" :"metav1.ListMeta"`

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

复制代码

register.go顾名思义,就是注册资源。

package v1

import (
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/apimachinery/pkg/runtime/schema"
)

// SchemeGroupVersion is group version used to register these objects
var SchemeGroupVersion = schema.GroupVersion{Group: "example.com", Version: "v1"}

// Kind takes an unqualified kind and returns back a Group qualified GroupKind
func Kind(kind string) schema.GroupKind {
    return SchemeGroupVersion.WithKind(kind).GroupKind()
}

// Resource takes an unqualified resource and returns a Group qualified GroupResource
func Resource(resource string) schema.GroupResource {
    return SchemeGroupVersion.WithResource(resource).GroupResource()
}

var (
    // SchemeBuilder initializes a scheme builder
    SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
    // AddToScheme is a global function that registers this API group & version to a scheme
    AddToScheme = SchemeBuilder.AddToScheme
)

// Adds the list of known types to Scheme.
func addKnownTypes(scheme *runtime.Scheme) error {
    scheme.AddKnownTypes(SchemeGroupVersion,
        &Bar{},
        &BarList{},
    )
    metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
    return nil
}
复制代码

这时候会发现&Bar{},&BarLis{}会报错,这是因为我们还没有为其实现deepcopy方法。

由于在自动生成代码的时候,需要指定header的信息,所以我们为了方便,可以将code-generator项目下的hack包直接拷贝到我们当前项目根目录下。

接下来我们使用code-generator来为我们自动生成代码:

# 运行 code-generator/generate-group.sh
./../../github/code-generator/generate-groups.sh all \
# 指定 group 和 version,生成deeplycopy以及client
operator-test/pkg/client operator-test/pkg/apis crd.example.com:v1 \
# 指定头文件
--go-header-file=./hack/boilerplate.go.txt \
# 指定输出位置,默认为GOPATH
--output-base ../

Generating deepcopy funcs
Generating clientset for crd.example.com:v1 at operator-test/pkg/client/clientset
Generating listers for crd.example.com:v1 at operator-test/pkg/client/listers
Generating informers for crd.example.com:v1 at operator-test/pkg/client/informers
复制代码

这时候我们再来查看项目结构:

➜  operator-test tree
.
├── go.mod
├── go.sum
├── hack
│   └── boilerplate.go.txt
└── pkg
    ├── apis
    │   └── crd.example.com
    │       └── v1
    │           ├── doc.go
    │           ├── register.go
    │           ├── types.go
    │           └── zz_generated.deepcopy.go
    └── client
        ├── clientset
        │   └── versioned
        │       ├── clientset.go
        │       ├── doc.go
        │       ├── fake
        │       │   ├── clientset_generated.go
        │       │   ├── doc.go
        │       │   └── register.go
        │       ├── scheme
        │       │   ├── doc.go
        │       │   └── register.go
        │       └── typed
        │           └── crd.example.com
        │               └── v1
        │                   ├── bar.go
        │                   ├── crd.example.com_client.go
        │                   ├── doc.go
        │                   ├── fake
        │                   │   ├── doc.go
        │                   │   ├── fake_bar.go
        │                   │   └── fake_crd.example.com_client.go
        │                   └── generated_expansion.go
        ├── informers
        │   └── externalversions
        │       ├── crd.example.com
        │       │   ├── interface.go
        │       │   └── v1
        │       │       ├── bar.go
        │       │       └── interface.go
        │       ├── factory.go
        │       ├── generic.go
        │       └── internalinterfaces
        │           └── factory_interfaces.go
        └── listers
            └── crd.example.com
                └── v1
                    ├── bar.go
                    └── expansion_generated.go

22 directories, 29 files
复制代码

这时候我们就可以像操作内置资源一样,操作我们的自定义资源了。

我们先准备crd以及对应的cr,这边也是可以直接从sample-controller项目进行拷贝,做简单的修改即可。

# manifests/example.com_bars.yaml

---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  annotations:
    controller-gen.kubebuilder.io/version: (devel)
  creationTimestamp: null
  name: bars.crd.example.com
spec:
  group: crd.example.com
  names:
    kind: Bar
    listKind: BarList
    plural: bars
    singular: bar
  scope: Namespaced
  versions:
  - name: v1
    schema:
      openAPIV3Schema:
        description: Bar is a specification for a Bar resource
        properties:
          apiVersion:
            description: 'APIVersion defines the versioned schema of this representation
              of an object. Servers should convert recognized schemas to the latest
              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
            type: string
          kind:
            description: 'Kind is a string value representing the REST resource this
              object represents. Servers may infer this from the endpoint the generated
              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
            type: string
          metadata:
            type: object
          spec:
            description: BarSpec is the spec for a Bar resource
            properties:
              deploymentName:
                type: string
              image:
                type: string
              replicas:
                format: int32
                type: integer
            required:
            - deploymentName
            - image
            - replicas
            type: object
        required:
        - spec
        type: object
    served: true
    storage: true


# manifests/cr.yaml
---
apiVersion: crd.example.com/v1
kind: Bar
metadata:
  name: bar-demo
  namespace: default
spec:
  image: "nginx:1.17.1"
  deploymentName: example-bar
  replicas: 2
复制代码

接下来我们来编写main函数,这时候我们就可以使用client-go像操作我们内置资源一样,操作crd资源了。

package main

import (
    "context"
    "fmt"
    v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/tools/cache"
    "k8s.io/client-go/tools/clientcmd"
    "log"
    clientSet "operator-test/pkg/client/clientset/versioned"
    "operator-test/pkg/client/informers/externalversions"
)

func main() {
    config, err := clientcmd.BuildConfigFromFlags("", clientcmd.RecommendedHomeFile)
    if err != nil {
        log.Fatalln(err)
    }

    clientset, err := clientSet.NewForConfig(config)
    if err != nil {
        log.Fatalln(err)
    }

    list, err := clientset.CrdV1().Bars("default").List(context.TODO(), v1.ListOptions{})
    if err != nil {
        log.Fatalln(err)
    }

    for _, bar := range list.Items {
        fmt.Println(bar.Name)
    }

    factory := externalversions.NewSharedInformerFactory(clientset, 0)
    factory.Crd().V1().Bars().Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
        AddFunc:    nil,
        UpdateFunc: nil,
        DeleteFunc: nil,
    })
    // todo
}


// ====
// 程序输出结果:
bar-demo
复制代码

代码生成tag

在我们上面的示例中,我们在源码中添加了很多tag,我们使用这些tag来标记一些供生成器使用的属性。这些tag主要分为两类:

  • doc.go的package语句智商提供的全局tag
  • 在需要被处理的类型上提供局部tag

tag的使用方法如下所示:

// +tag-name
// 或者
// +tag-name=value
复制代码

我们可以看到 tag 是通过注释的形式存在的,另外需要注意的是 tag 的位置非常重要,很多 tag 必须直接位于 type 或 package 语句的上一行,另外一些则必须和 go 语句隔开至少一行空白。

全局tag

必须在目标包的doc.go文件中声明,一般路径为pkg/apis/<apigroup>/<version>/doc.go,如下所示:

// 为包中任何类型生成深拷贝方法,可以在局部 tag 覆盖此默认行为
// +k8s:deepcopy-gen=package
 
// groupName 指定 API 组的全限定名
// 此 API 组的 v1 版本,放在同一个包中
// +groupName=crd.example.com
package v1
复制代码

注意:空行不能省略

局部tag

局部tag要么直接声明在类型之前,要么位于类型之前的第二个注释块中。下面的 types.go 中声明了 CR 对应的类型:

// 为当前类型生成客户端,如果不加此注解则无法生成 lister、informer 等包
// +genclient
 
// 提示此类型不基于 /status 子资源来实现 spec-status 分离,产生的客户端不具有 UpdateStatus 方法
// 否则,只要类型具有 Status 字段,就会生成 UpdateStatus 方法
// +genclient:noStatus
 
// 为每个顶级 API 类型添加,自动生成 DeepCopy 相关代码
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
 
// K8S 资源,数据库
type Database struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`
 
    Spec DatabaseSpec `json:"spec"`
}
 
// 不为此类型生成深拷贝方法
// +k8s:deepcopy-gen=false

// 数据库的规范
type DatabaseSpec struct {
    User     string `json:"user"`
    Password string `json:"password"`
    Encoding string `json:"encoding,omitempty"`
}
 
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
 
// 数据库列表,因为 list 获取的是列表,所以需要定义该结构
type DatabaseList struct {
    metav1.TypeMeta `json:",inline"`
    metav1.ListMeta `json:"metadata"`
 
    Items []Database `json:"items"`
}
复制代码

在上面 CR 的定义上面就通过 tag 来添加了自动生成相关代码的一些注释。此外对于集群级别的资源,我们还需要提供如下所示的注释:

// +genclient:nonNamespaced
 
// 下面的 Tag 不能少
// +genclient
复制代码

另外我们还可以控制客户端提供哪些 HTTP 方法:

// +genclient:noVerbs
// +genclient:onlyVerbs=create,delete
// +genclient:skipVerbs=get,list,create,update,patch,delete,deleteCollection,watch
// 仅仅返回 Status 而非整个资源
// +genclient:method=Create,verb=create,result=k8s.io/apimachinery/pkg/apis/meta/v1.Status
 
// 下面的 Tag 不能少
// +genclient
复制代码

使用 tag 定义完需要生成的代码规则后,执行上面提供的代码生成脚本即可自动生成对应的代码了。

补充

除了上面介绍的代码生成方式,我们还可以直接使用sample-controller项目提供的hack/update-condegen.sh脚本。

#!/usr/bin/env bash

set -o errexit
set -o nounset
set -o pipefail

SCRIPT_ROOT=$(dirname "${BASH_SOURCE[0]}")/..
# 代码生成器包的位置
CODEGEN_PKG=${CODEGEN_PKG:-$(cd "${SCRIPT_ROOT}"; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null || echo ../code-generator)}

# generate-groups.sh <generators> <output-package> <apis-package> <groups-versions>
#                    使用哪些生成器,可选值 deepcopy,defaulter,client,lister,informer,逗号分隔,all表示全部使用
#                    输出包的导入路径  
#                    CR 定义所在路径
#                    API 组和版本
bash "${CODEGEN_PKG}"/generate-groups.sh "deepcopy,client,informer,lister" \
  k8s.io/sample-controller/pkg/generated k8s.io/sample-controller/pkg/apis \
  samplecontroller:v1alpha1 \
  --output-base "$(dirname "${BASH_SOURCE[0]}")/../../.." \
  --go-header-file "${SCRIPT_ROOT}"/hack/boilerplate.go.txt

# 自动生成的源码头部附加的内容:
#   --go-header-file "${SCRIPT_ROOT}"/hack/custom-boilerplate.go.txt
复制代码

执行上面的脚本后,所有 API 代码会生成在 pkg/apis 目录下,clientsets、informers、listers 则生成在 pkg/generated 目录下。不过从脚本可以看出需要将 code-generator 的包放置到 vendor 目录下面,现在我们都是使用 go modules 来管理依赖保,我们可以通过执行 go mod vendor 命令将依赖包放置到 vendor 目录下面来。

我们还可以进一步提供 hack/verify-codegen.sh 脚本,用于判断生成的代码是否 up-to-date:

#!/usr/bin/env bash

set -o errexit
set -o nounset
set -o pipefail

# 先调用 update-codegen.sh 生成一份新代码
# 然后对比新老代码是否一样

SCRIPT_ROOT=$(dirname "${BASH_SOURCE[0]}")/..

DIFFROOT="${SCRIPT_ROOT}/pkg"
TMP_DIFFROOT="${SCRIPT_ROOT}/_tmp/pkg"
_tmp="${SCRIPT_ROOT}/_tmp"

cleanup() {
  rm -rf "${_tmp}"
}
trap "cleanup" EXIT SIGINT

cleanup

mkdir -p "${TMP_DIFFROOT}"
cp -a "${DIFFROOT}"/* "${TMP_DIFFROOT}"

"${SCRIPT_ROOT}/hack/update-codegen.sh"
echo "diffing ${DIFFROOT} against freshly generated codegen"
ret=0
diff -Naupr "${DIFFROOT}" "${TMP_DIFFROOT}" || ret=$?
cp -a "${TMP_DIFFROOT}"/* "${DIFFROOT}"
if [[ $ret -eq 0 ]]
then
  echo "${DIFFROOT} up to date."
else
  echo "${DIFFROOT} is out of date. Please run hack/update-codegen.sh"
  exit 1
fi
复制代码

controller-tools

在上面code-generator简单介绍中重点介绍了如何使用code-generator来自动生成代码,通过自动生成的代码可以帮助我们像访问k8s内置资源那样来操作我们的CRD,其实就是帮助我们生成ClientSet、Informer、Lister等工具包。

但是我们需要自己定义types.go文件以及需要自己去编写crd文件。工作量其实也是很大的,那么有没有工具像code-generator那样帮助我们生成代码呢?答案是肯定的,那就是接下来要介绍的controller-tools

示例

controller-tools主要可以帮我们自动生成types.go所需要的内容以及自动帮我们生成crd。

同样首先将其clone到本地:

$ git clone https://github.com/kubernetes-sigs/controller-tools.git

在项目的cmd目录下,我们可以看到有controller-genhelpgentype-scaffold三个工具。

其中type-scaffold可以用来生成我们需要的types.go文件,controller-gen可以生成zz_xxx.deepcopy.go文件以及crd文件。

我们使用go install进行安装:

$ cd controller-gen
$ go install ./cmd/{controller-gen,type-scaffold}
复制代码

安装完成后我们可以去GOPATH下的bin目录下查看。

image-20220512100751889.png

接着我们就可以新建一个项目,来使用controller-tools提供的工具为我们自动生成代码了。

$ mkdir controller-test && cd controller-test
$ go mod init controller-test
$ mkdir -p pkg/apis/example.com/v1
$ tree
.
├── go.mod
└── pkg
    └── apis
        └── example.com
            └── v1

4 directories, 1 file
复制代码

接下来我们就可以使用工具来生成我们所需要的代码了,首先我们生成types.go所需要的内容,由于type-scaffold不支持导入文本,所以生成后我们需要复制到types.go文件中:

$ type-scaffold --kind Foo
// FooSpec defines the desired state of Foo
type FooSpec struct {
        // INSERT ADDITIONAL SPEC FIELDS -- desired state of cluster
}

// FooStatus defines the observed state of Foo.
// It should always be reconstructable from the state of the cluster and/or outside world.
type FooStatus struct {
        // INSERT ADDITIONAL STATUS FIELDS -- observed state of cluster
}

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

// Foo is the Schema for the foos API
// +k8s:openapi-gen=true
type Foo struct {
        metav1.TypeMeta   `json:",inline"`
        metav1.ObjectMeta `json:"metadata,omitempty"`

        Spec   FooSpec   `json:"spec,omitempty"`
        Status FooStatus `json:"status,omitempty"`
}

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

// FooList contains a list of Foo
type FooList struct {
        metav1.TypeMeta `json:",inline"`
        metav1.ListMeta `json:"metadata,omitempty"`
        Items           []Foo `json:"items"`
}

复制代码

然后在types.go文件中将import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"添加上就行。

当然自动生成只是一个模版,里面的具体细节还是需要我们自己去填写,比如我们填充FooSpec

资源类型定义好了,那么如何能让client-go识别我们的资源呢,这里就需要其注册进去。我们可以在register.go中定义GV(Group Version),以及通过标签指定groupName。

// register.go

// +groupName=example.com

package v1

import (
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/runtime/serializer"
)

var (
	Scheme       = runtime.NewScheme()
	GroupVersion = schema.GroupVersion{
		Group:   "example.com",
		Version: "v1",
	}
	Codec = serializer.NewCodecFactory(Scheme)
)
复制代码

types.go中调用Scheme.AddKnownTypes方法即可:

// types.go

package v1

import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

// FooSpec defines the desired state of Foo
type FooSpec struct {
	// INSERT ADDITIONAL SPEC FIELDS -- desired state of cluster
	Name     string `json:"name"`
	Replicas int32  `json:"replicas"`
}

// FooStatus defines the observed state of Foo.
// It should always be reconstructable from the state of the cluster and/or outside world.
type FooStatus struct {
	// INSERT ADDITIONAL STATUS FIELDS -- observed state of cluster
}

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

// Foo is the Schema for the foos API
// +k8s:openapi-gen=true
type Foo struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   FooSpec   `json:"spec,omitempty"`
	Status FooStatus `json:"status,omitempty"`
}

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

// FooList contains a list of Foo
type FooList struct {
	metav1.TypeMeta `json:",inline"`
	metav1.ListMeta `json:"metadata,omitempty"`
	Items           []Foo `json:"items"`
}

func init() {
	Scheme.AddKnownTypes(GroupVersion, &Foo{}, &FooList{})
}
复制代码

接下来就需要生成deepcopy.go文件了:

$ controller-gen object paths=./pkg/apis/example.com/v1/types.go
复制代码

同样,我们使用controller-gen生成crd:

$ mkdir config
$ go mod tidy
$ controller-gen crd paths=./... output:crd:dir=config/crd
复制代码

这时候我们查看项目结构:

.
├── config
│   └── crd
│       └── example.com_foos.yaml
├── go.mod
├── go.sum
└── pkg
    └── apis
        └── example.com
            └── v1
                ├── register.go
                ├── types.go
                └── zz_generated.deepcopy.go

6 directories, 6 files
复制代码

最后我们来进行验证,首先创建一个cr:

apiVersion: example.com/v1
kind: Foo
metadata:
  name: crd-test
spec:
  name: test
  replicas: 2
复制代码

将crd和cr添加到集群后,我们来编写main.go文件来进行验证:

package main

import (
	"context"
	v1 "controller-test/pkg/apis/example.com/v1"
	"fmt"
	"k8s.io/client-go/rest"
	"k8s.io/client-go/tools/clientcmd"
	"log"
)

func main() {
	config, err := clientcmd.BuildConfigFromFlags("", clientcmd.RecommendedHomeFile)
	if err != nil {
		log.Fatalln(err)
	}
	// 这边需要使用原始的 RESTClient

	config.APIPath = "/apis/"
	config.NegotiatedSerializer = v1.Codec
	config.GroupVersion = &v1.GroupVersion

	client, err := rest.RESTClientFor(config)
	if err != nil {
		log.Fatalln(err)
	}

	foo := &v1.Foo{}
	err = client.Get().Namespace("default").Resource("foos").Name("crd-test").Do(context.TODO()).Into(foo)
	if err != nil {
		log.Fatalln(err)
	}

	newObj := foo.DeepCopy()
	newObj.Spec.Name = "test2"
	fmt.Println(foo.Spec.Name)
	fmt.Println(newObj.Spec.Name)
}

//=======
// 输出结果
test
test2

参考: controller-tools简单使用

kubebuilder

Kubebuilder是用于使用 自定义资源定义(CRD)构建Kubernetes API的框架。

类似于Ruby on RailsSpringBoot之类的Web开发框架,Kubebuilder可以提高速度并降低开发人员管理的复杂性,以便在Go中快速构建和发布Kubernetes API。它建立在用于构建核心Kubernetes API的规范技术的基础之上,以提供减少样板和麻烦的简单抽象。

Resource + Controller = Operator,可以利用Kubebuilder编写自定义资源的Operator。

示例

介绍

假设一个Nginx的QPS(服务器一秒内处理的请求数)上限为500,如果外部访问的QPS达到了600,为了保证服务质量,必须扩容一个Nginx来分摊请求。

在Kubernetes环境中,如果外部请求超过了单个Pod的处理极限,我们则可以增加Pod数量来达到横向扩容的目的。

假设我们的服务是无状态服务,我们来利用kubebuilder来开发一个operator,来模拟我们已上所述的场景。

项目初始化

在开发 Operator 之前我们需要先提前想好我们的 CRD 资源对象,比如我们想要通过下面的 CR 资源来创建我们的Operator :

apiVersion: elasticweb.example.com/v1
kind: ElasticWeb
metadata:
  name: elasticweb-sample
  namespace: dev
spec:
  image: nginx:1.17.1  # 镜像
  port: 30003          # 外部访问的端口
  singlePodsQPS: 800   # 单个 Pod 的 QPS
  totalQPS: 2400       # 总 QPS
复制代码

首先初始化项目,这里使用kubebuilder来构建我们的脚手架:

$ mkdir app-operator && cd app-operator
$ go mod init app-operator
$ kubebuilder init --domain example.com
kubebuilder init --domain example.com
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
...
复制代码

脚手架创建完成后,然后定义资源API:

$ kubebuilder create api --group elasticweb --version v1 --kind El
asticWeb
Create Resource [y/n]
y
Create Controller [y/n]
y
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
...
复制代码

这样我们的项目初始化就完成了,整体的代码结构如下:

$ tree -L 2
.
├── Dockerfile
├── Makefile
├── PROJECT
├── api
│   └── v1
├── bin
│   └── controller-gen
├── config
│   ├── crd
│   ├── default
│   ├── manager
│   ├── prometheus
│   ├── rbac
│   └── samples
├── controllers
│   ├── elasticweb_controller.go
│   └── suite_test.go
├── go.mod
├── go.sum
├── hack
│   └── boilerplate.go.txt
└── main.go

12 directories, 10 files
复制代码

然后根据我们上面设计的 ElasticWeb 这个对象来编辑 Operator 的结构体即可,修改文件 api/v1/elasticweb_types.go 中的 ElasticWebSpec 结构体以及ElasticWebStatus结构体,ElasticWebStatus结构体主要用来记录当前集群实际支持的总QPS:

// api/v1/elasticweb_types.go

type ElasticWebSpec struct {
	Image string `json:"image"`
	Port  *int32 `json:"port"`
	// 单个pod的QPS上限
	SinglePodsQPS *int32 `json:"singlePodsQPS"`
	// 当前整个业务的QPS
	TotalQPS *int32 `json:"totalQPS,omitempty"`
}

type ElasticWebStatus struct {
    // 当前 Kubernetes 集群实际支持的总QPS
    RealQPS *int32 `json:"realQPS"`
}
复制代码

同样,为了打印的日志方便我们阅读,我们给ElasticWeb添加一个String方法:

// api/v1/elasticweb_types.go

func (e *ElasticWeb) String() string {
	var realQPS string
	if nil == e.Status.RealQPS {
		realQPS = ""
	} else {
		realQPS = strconv.Itoa(int(*e.Status.RealQPS))
	}

	return fmt.Sprintf("Image [%s], Port [%d], SinglePodQPS [%d], TotalQPS [%d], RealQPS [%s]",
		e.Spec.Image,
		*e.Spec.Port,
		*e.Spec.SinglePodsQPS,
		*e.Spec.TotalQPS,
		realQPS)
}
复制代码

要注意每次修改完成需要执行make命令重新生成代码:

$ make
 make
/Users/Christian/Documents/code/negan/app-operator/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
api/v1/elasticweb_types.go
go vet ./...
go build -o bin/manager main.go
复制代码

接下来我们就可以去控制器的 Reconcile 函数中来实现我们自己的业务逻辑了。

业务逻辑

首先在目录 controllers 下面创建一个 resource.go文件,用来根据我们的ElasticWeb对象生成对应的deploymentservice以及更新状态。

// controllers/resource.go

package controllers

import (
	v1 "app-operator/api/v1"
	"context"
	"fmt"
	appsv1 "k8s.io/api/apps/v1"
	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/api/resource"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/utils/pointer"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
	"sigs.k8s.io/controller-runtime/pkg/log"
)

var (
	ElasticWebCommonLabelKey = "app"
)

const (
	// APP_NAME deployment 中 App 标签名
	APP_NAME = "elastic-app"
	// CONTAINER_PORT 容器的端口号
	CONTAINER_PORT = 8080
	// CPU_REQUEST 单个POD的CPU资源申请
	CPU_REQUEST = "100m"
	// CPU_LIMIT 单个POD的CPU资源上限
	CPU_LIMIT = "100m"
	// MEM_REQUEST 单个POD的内存资源申请
	MEM_REQUEST = "512Mi"
	// MEM_LIMIT 单个POD的内存资源上限
	MEM_LIMIT = "512Mi"
)

// 根据总QPS以及单个POD的QPS,计算需要多少个Pod
func getExpectReplicas(elasticWeb *v1.ElasticWeb) int32 {
	// 单个pod的QPS
	singlePodQPS := *elasticWeb.Spec.SinglePodsQPS
	// 期望的总QPS
	totalQPS := *elasticWeb.Spec.TotalQPS
	// 需要创建的副本数
	replicas := totalQPS / singlePodQPS

	if totalQPS%singlePodQPS != 0 {
		replicas += 1
	}
	return replicas
}

// CreateServiceIfNotExists  创建service
func CreateServiceIfNotExists(ctx context.Context, r *ElasticWebReconciler, elasticWeb *v1.ElasticWeb, req ctrl.Request) error {
	logger := log.FromContext(ctx)
	logger.WithValues("func", "createService")
	svc := &corev1.Service{}

	svc.Name = elasticWeb.Name
	svc.Namespace = elasticWeb.Namespace

	svc.Spec = corev1.ServiceSpec{
		Ports: []corev1.ServicePort{
			{
				Name:     "http",
				Port:     CONTAINER_PORT,
				NodePort: *elasticWeb.Spec.Port,
			},
		},
		Type: corev1.ServiceTypeNodePort,
		Selector: map[string]string{
			ElasticWebCommonLabelKey: APP_NAME,
		},
	}

	// 设置关联关系
	logger.Info("set reference")
	if err := controllerutil.SetControllerReference(elasticWeb, svc, r.Scheme); err != nil {
		logger.Error(err, "SetControllerReference error")
		return err
	}

	logger.Info("start create service")
	if err := r.Create(ctx, svc); err != nil {
		logger.Error(err, "create service error")
		return err
	}

	return nil
}

// CreateDeployment 创建deployment
func CreateDeployment(ctx context.Context, r *ElasticWebReconciler, elasticWeb *v1.ElasticWeb) error {
	logger := log.FromContext(ctx)
	logger.WithValues("func", "createDeploy")

	// 计算期待pod的数量
	expectReplicas := getExpectReplicas(elasticWeb)
	logger.Info(fmt.Sprintf("expectReplicas [%d]", expectReplicas))

	deploy := &appsv1.Deployment{}

	deploy.Labels = map[string]string{
		ElasticWebCommonLabelKey: APP_NAME,
	}

	deploy.Name = elasticWeb.Name
	deploy.Namespace = elasticWeb.Namespace

	deploy.Spec = appsv1.DeploymentSpec{
		Replicas: pointer.Int32Ptr(expectReplicas),
		Selector: &metav1.LabelSelector{
			MatchLabels: map[string]string{
				ElasticWebCommonLabelKey: APP_NAME,
			},
		},
		Template: corev1.PodTemplateSpec{
			ObjectMeta: metav1.ObjectMeta{
				Labels: map[string]string{
					ElasticWebCommonLabelKey: APP_NAME,
				},
			},
			Spec: corev1.PodSpec{
				Containers: []corev1.Container{
					{
						Name:  APP_NAME,
						Image: elasticWeb.Spec.Image,
						Ports: []corev1.ContainerPort{
							{
								Name:          "http",
								ContainerPort: CONTAINER_PORT,
								Protocol:      corev1.ProtocolSCTP,
							},
						},
						Resources: corev1.ResourceRequirements{
							Limits: corev1.ResourceList{
								corev1.ResourceCPU:    resource.MustParse(CPU_LIMIT),
								corev1.ResourceMemory: resource.MustParse(MEM_LIMIT),
							},
							Requests: corev1.ResourceList{
								corev1.ResourceCPU:    resource.MustParse(CPU_REQUEST),
								corev1.ResourceMemory: resource.MustParse(MEM_REQUEST),
							},
						},
					},
				},
			},
		},
	}

	// 建立关联,删除web后会将deploy一起删除
	logger.Info("set reference")
	if err := controllerutil.SetControllerReference(elasticWeb, deploy, r.Scheme); err != nil {
		logger.Error(err, "SetControllerReference error")
		return err
	}

	// 创建Deployment
	logger.Info("start create deploy")
	if err := r.Create(ctx, deploy); err != nil {
		logger.Error(err, "create deploy error")
		return err
	}

	logger.Info("create deploy success")
	return nil
}

func UpdateStatus(ctx context.Context, r *ElasticWebReconciler, elasticWeb *v1.ElasticWeb) error {
	logger := log.FromContext(ctx)
	logger.WithValues("func", "updateStatus")

	// 单个pod的QPS
	singlePodQPS := *elasticWeb.Spec.SinglePodsQPS

	// pod 总数
	replicas := getExpectReplicas(elasticWeb)

	// 当pod创建完成后,当前系统的QPS为: 单个pod的QPS * pod总数
	// 如果没有初始化,则需要先初始化
	if nil == elasticWeb.Status.RealQPS {
		elasticWeb.Status.RealQPS = new(int32)
	}

	*elasticWeb.Status.RealQPS = singlePodQPS * replicas
	logger.Info(fmt.Sprintf("singlePodQPS [%d],replicas [%d],realQPS[%d]", singlePodQPS, replicas, *elasticWeb.Status.RealQPS))

	if err := r.Update(ctx, elasticWeb); err != nil {
		logger.Error(err, "update instance error")
		return err
	}
	return nil
}
复制代码

上面的代码虽然很多,但逻辑很简单,就是根据我们的 ElasticWeb 去构造 deployservice资源对象,构造完成后,当我们创建 ElasticWeb 的时候就可以在控制器的 Reconcile 函数中去进行逻辑处理了。

同时,我们需要在Reconcile函数注释中添加 deployservice的RBAC声明。

// controllers/elasticweb_controller.go

//+kubebuilder:rbac:groups=elasticweb.example.com,resources=elasticwebs,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=elasticweb.example.com,resources=elasticwebs/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=elasticweb.example.com,resources=elasticwebs/finalizers,verbs=update
//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch;delete

func (r *ElasticWebReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    logger := log.FromContext(ctx)

    instance := &elasticwebv1.ElasticWeb{}

    if err := r.Get(ctx, req.NamespacedName, instance); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    logger.Info(fmt.Sprintf("instance:%s", instance.String()))

    // 获取deployment
    deploy := &appsv1.Deployment{}
    if err := r.Get(ctx, req.NamespacedName, deploy); err != nil {
        if errors.IsNotFound(err) {
            // 如果没有查找到,则需要创建
            logger.Info("deploy not exists")
            // 判断qps的需求,如果qps没有需求,则啥都不做
            if *instance.Spec.TotalQPS < 1 {
                logger.Info("not need deployment")
                return ctrl.Result{}, nil
            }

            // 创建service
            if err = CreateServiceIfNotExists(ctx, r, instance, req); err != nil {
                return ctrl.Result{}, err
            }

            // 创建Deploy
            if err := CreateDeployment(ctx, r, instance); err != nil {
                return ctrl.Result{}, err
            }

            // 更新状态
            if err := UpdateStatus(ctx, r, instance); err != nil {
                return ctrl.Result{}, err
            }

            return ctrl.Result{}, nil
        }
        logger.Error(err, "failed to get deploy")
        return ctrl.Result{}, err
    }

    // 根据单个Pod的QPS计算期望pod的副本
    expectReplicas := getExpectReplicas(instance)

    // 获取当前deployment实际的pod副本
    realReplicas := deploy.Spec.Replicas

    if expectReplicas == *realReplicas {
        logger.Info("not need to reconcile")
        return ctrl.Result{}, nil
    }

    // 重新赋值
    deploy.Spec.Replicas = &expectReplicas
    // 更新 deploy
    if err := r.Update(ctx, deploy); err != nil {
        logger.Error(err, "update deploy replicas error")
        return ctrl.Result{}, err
    }

    // 更新状态
    if err := UpdateStatus(ctx, r, instance); err != nil {
        logger.Error(err, "update status error")
        return ctrl.Result{}, err
    }

    return ctrl.Result{}, nil
}
复制代码

调试

接下来我们首先安装我们的 CRD 对象,让我们的 Kubernetes 系统识别我们的 ElasitcWeb 对象:

$ make install
/Users/Christian/Documents/code/negan/app-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
/Users/Christian/Documents/code/negan/app-operator/bin/kustomize build config/crd | kubectl apply -f -
customresourcedefinition.apiextensions.k8s.io/elasticwebs.elasticweb.example.com configured
复制代码

接着运行控制器:

$ make install
/Users/Christian/Documents/code/negan/app-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
/Users/Christian/Documents/code/negan/app-operator/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
controllers/elasticweb_controller.go
go vet ./...
go run ./main.go
1.652941435373431e+09   INFO    controller-runtime.metrics      Metrics server is starting to listen    {"addr": ":8080"}
1.6529414353737469e+09  INFO    setup   starting manager
1.6529414353739378e+09  INFO    Starting server {"path": "/metrics", "kind": "metrics", "addr": "[::]:8080"}
1.652941435373951e+09   INFO    Starting server {"kind": "health probe", "addr": "[::]:8081"}
1.6529414353741682e+09  INFO    controller.elasticweb   Starting EventSource    {"reconciler group": "elasticweb.example.com", "reconciler kind": "ElasticWeb", "source": "kind source: *v1.ElasticWeb"}
1.652941435374196e+09   INFO    controller.elasticweb   Starting EventSource    {"reconciler group": "elasticweb.example.com", "reconciler kind": "ElasticWeb", "source": "kind source: *v1.Deployment"}
1.652941435374202e+09   INFO    controller.elasticweb   Starting Controller     {"reconciler group": "elasticweb.example.com", "reconciler kind": "ElasticWeb"}
1.65294143547575e+09    INFO    controller.elasticweb   Starting workers        {"reconciler group": "elasticweb.example.com", "reconciler kind": "ElasticWeb", "worker count": 1}

复制代码

控制器启动成功后我们就可以去创建我们的CR了,将示例 CR 资源清单修改成下面的 YAML:

apiVersion: elasticweb.example.com/v1
kind: ElasticWeb
metadata:
  name: elasticweb-sample
spec:
  image: nginx:1.17.1
  port: 30003
  singlePodsQPS: 800
  totalQPS: 2400
复制代码

另外开启一个终端创建上面的资源对象:

$ kubectl apply -f config/samples/elasticweb_v1_elasticweb.yaml
elasticweb.elasticweb.example.com/elasticweb-sample created
复制代码

创建完成后我们可以查看对应的 ElasticWeb对象:

$ kubectl get ElasticWeb
NAME                AGE
elasticweb-sample   40s
复制代码

对应也会自动创建我们的 Deployment 和 Service 资源清单:

$ kubectl get all  
NAME                                     READY   STATUS    RESTARTS   AGE
pod/elasticweb-sample-6879bdfcf4-42jtc   1/1     Running   0          2m40s
pod/elasticweb-sample-6879bdfcf4-sdmbp   1/1     Running   0          2m40s
pod/elasticweb-sample-6879bdfcf4-w87tj   1/1     Running   0          2m40s

NAME                        TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)          AGE
service/elasticweb-sample   NodePort    10.100.200.7   <none>        8080:30003/TCP   2m40s
service/kubernetes          ClusterIP   10.96.0.1      <none>        443/TCP          14d

NAME                                READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/elasticweb-sample   3/3     3            3           2m40s

NAME                                           DESIRED   CURRENT   READY   AGE
replicaset.apps/elasticweb-sample-6879bdfcf4   3         3         3       2m40s
复制代码

优化

现在我们需要对Deploy进行Watch,Service是的创建包含在创建Deploy的逻辑里,所以Deploy出现变化,我们需要重新进行调谐。当然我们只需要Watch被ElasticWeb控制的这部分独享即可。在elasticweb_controller.go文件中更新SetupWithManager函数即可:

func (r *ElasticWebReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&elasticwebv1.ElasticWeb{}).
		Owns(&appsv1.Deployment{}).
		Complete(r)
}
复制代码

而且我们发现在终端打印的日志中,worker count 为1,这时候我们同样可以更新SetupWithManager函数:

func (r *ElasticWebReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        WithOptions(controller.Options{MaxConcurrentReconciles: 5}).
        For(&elasticwebv1.ElasticWeb{}).
        Owns(&appsv1.Deployment{}).
        Complete(r)
}
复制代码

同样我们发现输出的日志是时间戳格式,不够直观。 在 main 函数中有个zap的Options,我们可以在这里面进行设置:

opts := zap.Options{
		Development: true,
		TimeEncoder: zapcore.ISO8601TimeEncoder,
	}
复制代码

自定义输出列

我们这里的 Elastic 实例,我们可以使用 kubectl 命令列出这个对象:

$ kubectl get ElasticWeb
NAME                AGE
elasticweb-sample   40s
复制代码

但是这个信息太过于简单,如果我们想要查看这个对象使用了什么镜像,部署了多少个副本,我们可能还需要通过 kubectl describe 命令去查看,这样就太过于麻烦了。这个时候我们就可以在 CRD 定义的结构体类型中使用 +kubebuilder:printcolumn 这个注释来告诉 kubebuilder 将我们所需的信息添加到 CRD 中,比如我们想要打印使用的镜像,在 +kubebuilder:object:root=true 注释下面添加一列新的注释,如下所示:

//+kubebuilder:object:root=true
// +kubebuilder:printcolumn:name="Image",type="string",JSONPath=".spec.image",description="The Docker Image of MyAPP"
//+kubebuilder:subresource:status

// ElasticWeb is the Schema for the elasticwebs API
type ElasticWeb struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   ElasticWebSpec   `json:"spec,omitempty"`
	Status ElasticWebStatus `json:"status,omitempty"`
}
复制代码

printcolumn 注释有几个不同的选项,在这里我们只使用了其中一部分:

  • name:这是我们新增的列的标题,由 kubectl 打印在标题中
  • type:要打印的值的数据类型,有效类型为 integer、number、string、boolean 和 date
  • JSONPath:这是要打印数据的路径,在我们的例子中,镜像 image 属于 spec 下面的属性,所以我们使用 .spec.image。需要注意的是 JSONPath 属性引用的是生成的 JSON CRD,而不是引用本地 Go 类。
  • description:描述列的可读字符串,目前暂未发现该属性的作用...

新增了注释后,我们需要运行 make install 命令重新生成 CRD 并安装,然后我们再次尝试列出 CRD。

$ kubectl get ElasticWeb                                       
NAME                IMAGE
elasticweb-sample   nginx:1.17.1
复制代码

可以看到现在列出来的数据有一列 IMAGE 的数据了,不过却没有了之前列出来的 AGE这一列了。这是因为当我们添加自定义列的时候,就不会再显示其他默认的列了(NAME 除外),所以如果我们还想出现 AGE这一列,我们还需要在 ElasticWeb 的结构体上面添加对应的注释信息,如下所示:

// +kubebuilder:object:root=true
// +kubebuilder:printcolumn:name="Image",type="string",JSONPath=".spec.image",description="The Docker Image of Etcd"
// +kubebuilder:printcolumn:name="Port",type="integer",JSONPath=".spec.port",description="container port"
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
// +kubebuilder:subresource:status

// ElasticWeb is the Schema for the elasticwebs API
type ElasticWeb struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   ElasticWebSpec   `json:"spec,omitempty"`
    Status ElasticWebStatus `json:"status,omitempty"`
}
复制代码

运行 make install命令行,再次查看 CRD 数据:

$ kubectl get ElasticWeb
NAME                IMAGE          PORT    AGE
elasticweb-sample   nginx:1.17.1   30003   37m
复制代码

如果我们还想获取当前应用的状态,同样也可以通过 +kubebuilder:printcolumn 来添加对应的信息,只是状态的数据是通过 .status 在 JSONPath 属性中去获取了。

如果你觉得这里添加了太多的信息,如果我们想隐藏某个字段并只在需要时显示该字段怎么办?

这个时候就需要使用 priority 这个属性了,如果没有配置这个属性,默认值为0,也就是默认情况下列出显示的数据是 priority=0 的列,如果将 priority 设置为大于1的数字,那么则只会当我们使用 -o wide 参数的时候才会显示,比如我们给 Port 这一列添加一个 priority=1 的属性:

// +kubebuilder:printcolumn:name="Port",type="string",priority=1,JSONPath=".spec.image",description="The Docker Image of Etcd"
复制代码

同样重新运行make install命令后,再次查看CRD:

$ kubectl get ElasticWeb
NAME                IMAGE          AGE
elasticweb-sample   nginx:1.17.1   41m

$ kubectl get ElasticWeb -o wide
NAME                IMAGE          PORT    AGE
elasticweb-sample   nginx:1.17.1   30003   41m
复制代码

了解更多详细信息请查看 CRD 文档上的 AdditionalPrinterColumns 字段

部署

现在我们已经完成了开发工作,并在本地完成了测试工作,这时候我们就需要把我们的operator部署到kubernetes环境中。

首先我们需要修改Dockerfile文件,需要添加上go mod的代理配置:

# Build the manager binary
FROM golang:1.17 as builder

WORKDIR /workspace
# Copy the Go Modules manifests
COPY go.mod go.mod
COPY go.sum go.sum
# cache deps before building and copying source so that we don't need to re-download as much
# and so that source changes don't invalidate our downloaded layer
ENV GOPROXY https://goproxy.cn

RUN go mod download

# Copy the go source
COPY main.go main.go
COPY api/ api/
COPY controllers/ controllers/

# Build
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o manager main.go

# Use distroless as minimal base image to package the manager binary
# Refer to https://github.com/GoogleContainerTools/distroless for more details
FROM gcr.io/distroless/static:nonroot
WORKDIR /
COPY --from=builder /workspace/manager .
USER 65532:65532

ENTRYPOINT ["/manager"]
复制代码

接下来就是登陆docker了,我这边使用的docker hub,直接在命令行登陆即可。

$ docker login
Authenticating with existing credentials...
Login Succeeded

Logging in with your password grants your terminal complete access to your account. 
For better security, log in with a limited-privilege personal access token. Learn more at https://docs.docker.com/go/access-tokens/
复制代码

登陆成功后,就可以构建镜像了。

注意如果你用的是Mac M1的电脑,那么需要对Makefile做一小点修改,具体可见issues

.PHONY: test
test: manifests generate fmt vet envtest ## Run tests.
	#KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) -p path)" go test ./... -coverprofile cover.out
	KUBEBUILDER_ASSETS="$(shell $(ENVTEST) --arch=amd64 use $(ENVTEST_K8S_VERSION) -p path)" go test ./... -coverprofile cover.out
复制代码

接下来就是构建并将镜像推送到镜像仓库:

$ make docker-build docker-push IMG=<some-registry>/<project-name>:tag

$ make docker-build docker-push IMG=huiyichanmian/elasitcweb:v0.0.1
复制代码

等待推送成功后,就可以根据IMG指定的镜像将控制器部署到集群中:

$ make deploy IMG=<some-registry>/<project-name>:tag

$ make deploy IMG=huiyichanmian/elasticweb:v0.0.1
复制代码

同样,这里可能会遇到镜像gcr.io/kubebuilder/kube-rbac-proxy:v0.8.0这个镜像拉不下来的情况,这里可以使用kubesphere/kube-rbac-proxy:v0.8.0进行替代。

可以直接修改config/default/manager_auth_proxy_patch.yaml或者使用docker tag进行改名。

部署完成后,系统会自动创建项目名- system的命名空间,我们的控制器所有东西都在这个namespace下。

最后如果要从集群中卸载operator也很简单:

$ make undeploy

kubesphere

kubesphere 中融合了三家之长,可以通过

  • 通过 code-generator 生成 informers、listers、clientsets
  • 通过 kubebuilder 生成 Controller、Admission Webhooks 以及部署所需的 manifests yaml 文件(也可以通过 controller-tools 工具生成 crd 文件)

主要的生成逻辑都在 hack 下的 shell 脚本中,非常方便。

总结

code-generator 可以生成informers、listers、clientsets,但是需要自行编写type.go和crd的文件

  • deepcopy-gen: 生成深度拷贝方法,避免性能开销
  • client-gen:为资源生成标准的操作方法(get,list,create,update,patch,delete,deleteCollection,watch)
  • informer-gen: 生成informer,提供事件机制来响应kubernetes对应的CRD资源改变
  • lister-gen: 为get和list方法提供只读缓存层

controller-tools 可以帮我们自动生成types.go所需要的内容以及自动帮我们生成crd

  • type-scaffold可以用来生成我们需要的types.go文件
  • controller-gen可以生成zz_xxx.deepcopy.go文件以及crd文件
  • helpgen

Kubebuilder会生成Controller、Admission Webhooks

Kubebuilder会生成部署所需的manifests yaml(生成 crd 时也是调用 controller-tools 库实现)

  • Controller
  • Admission Webhooks
  • manifests yaml

参考:

Kubernetes代码生成器code-generator的使用

kubebuilder和code-generator使用分享