第三方应用如何调用我们 kubebuilder 生成的自定义资源?

注:本文已发布超过一年,请注意您所使用工具的相关版本是否适用

注:本文所有示例代码都可以在 blog-code 仓库中找到

kubebuilder 能否生成类似 client-go 的 sdk?

在去年写的系列文章中,我们完整的实现了 operator 开发过程中涉及到的绝大部分要素,但是在实际的生产应用中我们定义的 CR(CustomResource) 就像 k8s 自带的 deployment、pod 等资源一样,会存在其他服务直接调用 api-server 接口进行创建更新的需求,而不仅仅只是通过 kubectl 编辑yaml

那么 k8s 自带的对象我们可以通过 client-go 进行调用,我们自己设计的 CR 能否直接生成类似的 SDK 呢?

这个问题在 kubebuilder 社区从 v1 - v2 版本都有用户在提,但是 kubebuilder 官方似乎不太赞同生成 sdk 的这种做法

目前找到以下几种方案

方案优点缺点
通过 client-gen 生成对应的 sdk调用方使用起来会更加的方便,毕竟是静态代码,不容易出错
对于 operator 的开发者来说比较麻烦,因为要通过这个工具生成对应的代码还需要做很多其他的事情,甚至需要调整 kubebuiler 生成的代码结构
客制化较强,通用性较弱,每个 CR 都需要单独生成
controller-runtime/pkg/client调用也比较方便
通用性强,只需要将 kubebuilder 生成好的 CR 定义暴露出去即可
相对于通过 client-gen 来说静态代码检查的能力相对较弱
client-go/dynamic通用性极强,甚至可以不用 Operator 开发中提供对应的 CR 定义代码调用方来说极其不方便,需要自定义很多东西,并且需要反复进行序列化操作

接下来我们就自定义一个简单的 CR,这个 CR 没有任何的逻辑,只是为了用来验证客户端调用,关于 kubebuilder 生成 CR 如果不是特别清楚,可以阅读之前的这篇文章: kubebuilder 简明教程

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: job.lailin.xyz/v1
kind: Test
metadata:
labels:
app.kuberentes.io/managed-by: kustomize
app.kubernetes.io/created-by: operator-kubebuilder-clientset
app.kubernetes.io/instance: test-sample
app.kubernetes.io/name: test
app.kubernetes.io/part-of: operator-kubebuilder-clientset
name: test-sample
namespace: default
spec:
foo: test

如上所示这个 CR 只有一个 foo 字段,也就是 kubebuilder 初始化的一个字段,除此之外什么也没有

接下来我都以 get 数据为例来分别说明这三种方式的基本使用方法,下面的示例代码可以在 operator-kubebuilder-clientset 项目中找到

通过 client-go 调用

如下所示可以看到,代码整体来说相对比较复杂,dynamic​ 包生成的 client 是一个通用的 client,所以他只能获取到 k8s 的一些通用的 metadata 数据,如果想要获取到 CR 的结构化数据就只能通过 json 来进行转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func main() {
cfg, err := clientcmd.BuildConfigFromFlags("", os.Getenv("HOME")+"/.kube/config")
fatalf(err, "get kube config fail")

// 获取 client
gvr := schema.GroupVersionResource{
Group: jobv1.GroupVersion.Group,
Version: jobv1.GroupVersion.Version,
Resource: "tests",
}
client := dynamic.NewForConfigOrDie(cfg).Resource(gvr)

ctx := context.Background()
res, err := client.Namespace("default").Get(ctx, "test-sample", v1.GetOptions{})
fatalf(err, "get resource fail")

b, err := res.MarshalJSON()
fatalf(err, "get json byte fail")

test := jobv1.Test{}
err = json.Unmarshal(b, &test)
fatalf(err, "get json byte fail")

log.Printf("foo: %s", test.Spec.Foo)
}

执行代码可以获取到正确的结果

1
2
go run client-example/client-go/main.go
2022/11/15 23:16:23 foo: test

简单看一下源码,可以看到实际上 Resource​ 方法就是返回了 NamespaceableResourceInterface​ 接口,这个接口支持了 Namespace 以及非 Namespace 级别的资源的 CURD​ 等访问方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type ResourceInterface interface {
Create(ctx context.Context, obj *unstructured.Unstructured, options metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error)
Update(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error)
UpdateStatus(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions) (*unstructured.Unstructured, error)
Delete(ctx context.Context, name string, options metav1.DeleteOptions, subresources ...string) error
DeleteCollection(ctx context.Context, options metav1.DeleteOptions, listOptions metav1.ListOptions) error
Get(ctx context.Context, name string, options metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error)
List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error)
Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error)
Patch(ctx context.Context, name string, pt types.PatchType, data []byte, options metav1.PatchOptions, subresources ...string) (*unstructured.Unstructured, error)
Apply(ctx context.Context, name string, obj *unstructured.Unstructured, options metav1.ApplyOptions, subresources ...string) (*unstructured.Unstructured, error)
ApplyStatus(ctx context.Context, name string, obj *unstructured.Unstructured, options metav1.ApplyOptions) (*unstructured.Unstructured, error)
}

// dynamic.NewForConfigOrDie(cfg).Resource(gvr) 返回的接口
type NamespaceableResourceInterface interface {
Namespace(string) ResourceInterface
ResourceInterface
}

上面的这些方法返回的都是 *unstructured.Unstructured​ 类型的数据,这个类型本质上就是把 object 通过 map 保存了下来,然后提供了 GetNamespace​ 等便捷的方法给用户使用

1
2
3
4
5
6
type Unstructured struct {
// Object is a JSON compatible map with string, float, int, bool, []interface{}, or
// map[string]interface{}
// children.
Object map[string]interface{}
}

通过 controller-runtime 调用

如下所示,可以发现 controller-runtime 的代码明显要比上一种方式要简洁一些,不需要手动去 json 编码解码了,基础的 scheme 数据也可以直接使用生成好的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func main() {
cfg, err := config.GetConfigWithContext("kind-kind")
fatalf(err, "get config fail")

scheme := runtime.NewScheme()
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(v2.AddToScheme(scheme))

c, err := client.New(cfg, client.Options{Scheme: scheme})
fatalf(err, "new client fail")

test := v1.Test{}
err = c.Get(context.Background(), types.NamespacedName{
Namespace: "default",
Name: "test-sample",
}, &test)
fatalf(err, "get resource fail")

log.Printf("foo: %s", test.Spec.Foo)
}

执行测试一下

1
2
go run client-example/controller-runtime/main.go
2022/11/15 23:34:45 foo: test

同样简单看下接口,controller-runtime 的 client 是多个接口组合而来的,合并在一起之后其实和上面 client-go 的接口大差不差

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Client knows how to perform CRUD operations on Kubernetes objects.
type Client interface {
Reader
Writer
StatusClient

Scheme() *runtime.Scheme
RESTMapper() meta.RESTMapper
}

type Reader interface {
Get(ctx context.Context, key ObjectKey, obj Object, opts ...GetOption) error
List(ctx context.Context, list ObjectList, opts ...ListOption) error
}

type Writer interface {
Create(ctx context.Context, obj Object, opts ...CreateOption) error
Delete(ctx context.Context, obj Object, opts ...DeleteOption) error
Update(ctx context.Context, obj Object, opts ...UpdateOption) error
Patch(ctx context.Context, obj Object, patch Patch, opts ...PatchOption) error
DeleteAllOf(ctx context.Context, obj Object, opts ...DeleteAllOfOption) error
}

生成 clientset 调用

生成 clientset

我们使用 code-generator 的 client-gen 子项目来生成客户端的调用,使用这个方法我们需要对代码做很多的调整

  • 项目结构调整,kubebuilder 生成的 api 目录是 api/v1​​,但是 client-gen 要求的目录结构是 api/${group}/${version}​​ 。

    • 所以我们需要将目录结构调整为 api/job/v1​​,调整后记得修改原有代码的依赖路径

    • 修改 PROJECT​​ 文件,这个文件用于 kubebuilder 记录,修改里面的 path 路径

      1
      2
      3
      4
      5
      6
      resources:
      # ... 删除掉不需要关注的部分
      - path: github.com/mohuishou/blog-code/02-k8s-operator/operator-kubebuilder-clientset/api/v1
      + path: github.com/mohuishou/blog-code/02-k8s-operator/operator-kubebuilder-clientset/api/job/v1
      version: v1
      version: "3"
  • 给需要生成 sdk 的资源加上 // +genclient​​ 注释,如下所示,放在 //+kubebuilder:object:root=true​​ 前面即可

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13

    //+genclient
    //+kubebuilder:object:root=true
    //+kubebuilder:subresource:status

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

    Spec TestSpec `json:"spec,omitempty"`
    Status TestStatus `json:"status,omitempty"`
    }
  • api 新增 SchemeGroupVersion​​ 全局变量,修改 api/job/v1/groupversion_info.go​​

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    var (

    // GroupVersion is group version used to register these objects
    GroupVersion = schema.GroupVersion{Group: "job.lailin.xyz", Version: "v1"}

    // SchemeGroupVersion for clien-gen
    SchemeGroupVersion = GroupVersion

    // SchemeBuilder is used to add go types to the GroupVersionKind scheme
    SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}

    // AddToScheme adds the types in this group-version to the given scheme.
    AddToScheme = SchemeBuilder.AddToScheme
    )
  • 添加 code-generator​​ 依赖,注意 code-generator​ 版本一定要和你的 client-go​ 版本一致

    • 例如在我们的测试项目里面 client-go​ 的版本是 v0.25.0 那我们执行

      1
      go get k8s.io/code-generator@v0.25.0
    • 由于我们的项目内实际上并没有依赖 code-generator​ ,所以我们需要添加一个文件依赖这个项目,我们新建一个 hack/code_generator.go​ 文件,我们加上 go:build tools​ 标签确保在编译应用的时候不会将这个依赖编译进去

      1
      2
      3
      4
      5
      6
      //go:build tools
      // +build tools

      package hack

      import _ "k8s.io/code-generator"
    • 然后我们执行 go mod tidy

  • 编写代码生成脚本,会将 clientset 放到 pkg 目录下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    #!/bin/bash
    set -e
    set -x

    # 生成 clientset 代码

    # 获取 go module name
    go_module=$(go list -m)
    # crd group
    group=${GROUP:-"job"}
    # api 版本
    api_version=${API_VERSION:-"v1"}

    project_dir=$(cd $(dirname ${BASH_SOURCE[0]})/..; pwd) # 项目根目录

    # check generate-groups.sh is exist
    # 直接下载 generate-groups.sh 脚本,这个脚本还可以生成其他类型的代码,但是我们这里只用来生成 client 的代码
    if [ ! -f "$project_dir/hack/generate-groups.sh" ]; then
    echo "hack/generate-groups.sh is not exist, download"

    wget -O "$project_dir/hack/generate-groups.sh" https://raw.githubusercontent.com/kubernetes/code-generator/master/generate-groups.sh
    chmod +x $project_dir/hack/generate-groups.sh
    fi

    # 生成 clientset
    # 脚本文档可以查看 https://raw.githubusercontent.com/kubernetes/code-generator/master/generate-groups.sh
    CLIENTSET_NAME_VERSIONED="$api_version" \
    $project_dir/hack/generate-groups.sh client \
    $go_module/pkg $go_module/api "$group:$api_version" --output-base $project_dir/


    if [ ! -d "$project_dir/pkg" ];then
    mkdir $project_dir/pkg
    fi

    # 生成的 clientset 的文件夹路径会包含 $go_module/pkg 所以我们需要把这个文件夹复制出来
    rm -rf $project_dir/pkg/clientset
    mv -f $project_dir/$go_module/pkg/* $project_dir/pkg/

    # 删除不需要的目录
    rm -rf $project_dir/$(echo $go_module | cut -d '/' -f 1)
  • 执行 bash hack/gen-client.sh​ 生成代码,生成的目录结构如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    ❯ tree pkg/clientset 
    pkg/clientset
    └── v1
    ├── clientset.go
    ├── doc.go
    ├── fake
    │ ├── clientset_generated.go
    │ ├── doc.go
    │ └── register.go
    ├── scheme
    │ ├── doc.go
    │ └── register.go
    └── typed
    └── job
    └── v1
    ├── doc.go
    ├── fake
    │ ├── doc.go
    │ ├── fake_job_client.go
    │ └── fake_test.go
    ├── generated_expansion.go
    ├── job_client.go
    └── test.go
  • 生成的客户端接口如下所示,我们可以看到和上面两种方式的主要区别就是指定了类型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // TestsGetter has a method to return a TestInterface.
    // A group's client should implement this interface.
    type TestsGetter interface {
    Tests(namespace string) TestInterface
    }

    // TestInterface has methods to work with Test resources.
    type TestInterface interface {
    Create(ctx context.Context, test *v1.Test, opts metav1.CreateOptions) (*v1.Test, error)
    Update(ctx context.Context, test *v1.Test, opts metav1.UpdateOptions) (*v1.Test, error)
    UpdateStatus(ctx context.Context, test *v1.Test, opts metav1.UpdateOptions) (*v1.Test, error)
    Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error
    DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error
    Get(ctx context.Context, name string, opts metav1.GetOptions) (*v1.Test, error)
    List(ctx context.Context, opts metav1.ListOptions) (*v1.TestList, error)
    Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error)
    Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (result *v1.Test, err error)
    TestExpansion
    }

调用 clientset

可以看到 clientset 的代码是最简洁的

1
2
3
4
5
6
7
8
9
10
11
func main() {
cfg, err := config.GetConfigWithContext("kind-kind")
fatalf(err, "get config fail")

client := clientv1.NewForConfigOrDie(cfg)

test, err := client.Tests("default").Get(context.Background(), "test-sample", v1.GetOptions{})
fatalf(err, "new client fail")

log.Printf("foo: %s", test.Spec.Foo)
}

执行结果如下

1
2
❯ go run client-example/clientset/main.go 
2022/11/16 10:26:50 foo: test

总结

这三种调用方式其实各有优劣,kubebuilder 官方比较推荐直接使用 controller-runtime,但是另外两种方式也有各自的使用场景,client-go 这种方式通用性最强,不用依赖 operator 开发者的代码,clientset 的定制性最强,对于使用方来说也最方便

对于我而言其实最开始只了解到 client-go 和 clientset 这两种方式,所以之前一直都是使用的 clientset 这种方式,这次这篇文章的初衷其实也只是为了记录一下 clientset 的最小化配置方法,但是在资料汇总的过程中发现了 controller-runtime 这种方法,作为 operator 的开发者最后选择使用 controller-runtime,因为生成 clientset 需要改动的东西实在是太多了,而且很容易出错。controller-runtime 在易用性和通用性都有不错的表现

参考文献

关注我获取更新

wechat
知乎
github

猜你喜欢