这次这篇文章的初衷其实也只是为了记录一下 clientset 的最小化配置方法,但是在资料汇总的过程中发现了 controller-runtime 这种方法,作为 operator 的开发者最后选择使用 controller-runtime,因为生成 clientset 需要改动的东西实在是太多了,而且很容易出错。controller-runtime 在易用性和通用性都有不错的表现。
kubebuilder 能否生成类似 clie 在去年写的系列文章[1]中,我们完整的实现了 operator 开发过程中涉及到的绝大部分要素,但是在实际的生产应用中我们定义的 CR(CustomResource[2]) 就像 k8s 自带的 deployment、pod 等资源一样,会存在其他服务直接调用 api-server 接口进行创建更新的需求,而不仅仅只是通过 kubectl 编辑yaml。
那么 k8s 自带的对象我们可以通过 client-go 进行调用,我们自己设计的 CR 能否直接生成类似的 SDK 呢?
这个问题在 kubebuilder 社区从 v1 - v2 版本都有用户在提,但是 kubebuilder 官方似乎不太赞同生成 sdk 的这种做法。
https://github.com/kubernetes-sigs/kubebuilder/issues/403 [3]。 https://github.com/kubernetes-sigs/kubebuilder/issues/1152 [4] 目前找到以下几种方案。
方案
优点
缺点
通过 client-gen [5] 生成对应的 sdk
调用方使用起来会更加的方便,毕竟是静态代码,不容易出错
对于 operator 的开发者来说比较麻烦,因为要通过这个工具生成对应的代码还需要做很多其他的事情,甚至需要调整 kubebuiler 生成的代码结构
客制化较强,通用性较弱,每个 CR 都需要单独生成
controller-runtime/pkg/client [6]
调用也比较方便
通用性强,只需要将 kubebuilder 生成好的 CR 定义暴露出去即可
相对于通过 client-gen 来说静态代码检查的能力相对较弱
client-go/dynamic [7]
通用性极强,甚至可以不用 Operator 开发中提供对应的 CR 定义代码
调用方来说极其不方便,需要自定义很多东西,并且需要反复进行序列化操作
接下来我们就自定义一个简单的 CR,这个 CR 没有任何的逻辑,只是为了用来验证客户端调用,关于 kubebuilder 生成 CR 如果不是特别清楚,可以阅读之前的这篇文章: kubebuilder 简明教程[8]。
复制 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
1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 如上所示这个 CR 只有一个 foo 字段,也就是 kubebuilder 初始化的一个字段,除此之外什么也没有。
接下来我都以 get 数据为例来分别说明这三种方式的基本使用方法,下面的示例代码可以在 operator-kubebuilder-clientset[9] 项目中找到。
通过 client-go 调用 如下所示可以看到,代码整体来说相对比较复杂,dynamic 包生成的 client 是一个通用的 client,所以他只能获取到 k8s 的一些通用的 metadata 数据,如果想要获取到 CR 的结构化数据就只能通过 json 来进行转换。
复制 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. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. 15. 16. 17. 18. 19. 20. 21. 22. 23. 24. 25. 执行代码可以获取到正确的结果。
复制 ❯ go run client-example/client-go/main.go
2022/11/15 23:16:23 foo: test
简单看一下源码,可以看到实际上 Resource 方法就是返回了 NamespaceableResourceInterface 接口,这个接口支持了 Namespace 以及非 Namespace 级别的资源的 CURD 等访问方法。
复制 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
}
1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. 15. 16. 17. 18. 19. 上面的这些方法返回的都是 *unstructured.Unstructured 类型的数据,这个类型本质上就是把 object 通过 map 保存了下来,然后提供了 GetNamespace 等便捷的方法给用户使用。
复制 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 数据也可以直接使用生成好的数据。
复制 func main () {
cfg , err := config .GetConfigWithContext ("kind-kind" )
fatalf (err , "get config fail" )
scheme , err := v1 .SchemeBuilder .Build ()
fatalf (err , "get scheme fail" )
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. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. 15. 16. 17. 18. 19. 执行测试一下。
复制 ❯ go run client - example / controller - runtime / main .go
2022 / 11 / 15 23 :34 :45 foo : test
同样简单看下接口,controller-runtime 的 client 是多个接口组合而来的,合并在一起之后其实和上面 client-go 的接口大差不差。
复制 // 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
}
1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. 15. 16. 17. 18. 19. 20. 生成 clientset 调用 生成 clientset 我们使用 code-generator[10] 的 client-gen 子项目来生成客户端的调用,使用这个方法我们需要对代码做很多的调整。
项目结构调整,kubebuilder 生成的 api 目录是api/v1,但是 client-gen 要求的目录结构是 api/${group}/${version} 。 所以我们需要将目录结构调整为api/job/v1,调整后记得修改原有代码的依赖路径。 修改PROJECT 文件,这个文件用于 kubebuilder 记录,修改里面的 path 路径。 复制 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 前面即可。 复制 //+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。 复制 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 那我们执行。 复制 go get k8s .io / code - generator @v0 .25 .0
由于我们的项目内实际上并没有依赖 code-generator ,所以我们需要添加一个文件依赖这个项目,我们新建一个 hack/code_generator.go 文件,我们加上 go:build tools 标签确保在编译应用的时候不会将这个依赖编译进去。 复制 //go:build tools
// +build tools
package hack
import _ "k8s.io/code-generator"
然后我们执行 go mod tidy。 编写代码生成脚本,会将 clientset 放到 pkg 目录下。 复制 # !/ 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)
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. 执行 bash hack/gen-client.sh 生成代码,生成的目录结构如下: 复制 ❯ 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. 20. 21. 22. 23. 生成的客户端接口如下所示,我们可以看到和上面两种方式的主要区别就是指定了类型。 复制 // 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
}
1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. 15. 16. 17. 18. 调用 clientset 可以看到 clientset 的代码是最简洁的。
复制 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 )
}
执行:
复制 ❯ 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 在易用性和通用性都有不错的表现。
参考资料 [1] 系列文章: https://lailin.xyz/post/operator-11-summary.html。
[2] CustomResource: https://kubernetes.io/zh-cn/docs/concepts/extend-kubernetes/api-extension/custom-resources/。
[3] https://github.com/kubernetes-sigs/kubebuilder/issues/403: https://github.com/kubernetes-sigs/kubebuilder/issues/403。
[4] https://github.com/kubernetes-sigs/kubebuilder/issues/1152: https://github.com/kubernetes-sigs/kubebuilder/issues/1152。
[5] client-gen: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-api-machinery/generating-clientset.md。
[6] controller-runtime/pkg/client: https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/client?utm_source=godoc#example-Client-Update。
[7] client-go/dynamic: https://pkg.go.dev/k8s.io/client-go@v0.25.4/dynamic。
[8] kubebuilder 简明教程: https://lailin.xyz/post/operator-03-kubebuilder-tutorial.html。
[9] operator-kubebuilder-clientset: https://github.com/mohuishou/blog-code/tree/main/02-k8s-operator/operator-kubebuilder-clientset/client-example。
[10] code-generator: https://github.com/kubernetes/code-generator。
本文转载自微信公众号「mohuishou」,可以通过以下二维码关注。转载本文请联系mohuishou公众号。