首页
/ Kubernetes CRD 自定义资源客户端生成实战:基于 code-generator 的 CustomResource Example 深度解析

Kubernetes CRD 自定义资源客户端生成实战:基于 code-generator 的 CustomResource Example 深度解析

2026-09-06 19:24:30作者:谭伦延

本文聚焦 Kubernetes 主仓库 staging 目录下 apiextensions-apiserver/examples/client-go 这一经典示例:它演示了如何借助 k8s.io/code-generator 为一套自研 CustomResource(自定义资源)一键生成类型化客户端(typed client)、Informer、Lister 与 DeepCopy 代码。读完本文,你将理解 code-generator 各子生成器的职责分工、生成脚本的参数含义与运行方式、类型定义所需的标记(tag)写法,以及如何在集群中把自定义资源当作原生 API 使用——这些知识正是自行编写 CRD 控制器(Operator)前的必备基建。

背景:从 ThirdPartyResource 到 CustomResourceDefinition

示例 README 开篇就给出了一条重要历史注记:

CustomResourceDefinition 是已被废弃的 ThirdPartyResource 的继任者。

在 Kubernetes 早期,扩展 API 的机制称为 ThirdPartyResource(TPR),它实现简陋、缺乏 schema 校验与版本管理能力,后被 CustomResourceDefinition(CRD)正式取代。当前集群中扩展资源的标准途径就是 CRD:自定义资源类型一经注册,就与 Pod、Service 等内置资源几乎等同,能够通过 kubectl apply 等原生工作流创建、查询与更新。本示例正是建立在这一机制之上,示范"如何为自己的 CRD 生成高质量的 Go 客户端库"。

示例的定位与产物

该示例的核心主张是:不需要手写访问 CRD 的 REST 客户端代码,而应通过 ./hack/update-codegen.sh 脚本一键生成。脚本自动产出如下文件与目录:

  • pkg/apis/cr/v1/zz_generated.deepcopy.go
  • pkg/client/

在示例仓库中,对应实际路径为 hack/update-codegen.shzz_generated.deepcopy.go 以及 pkg/client。其中 pkg/client 下包含四个生成子目录,每个对应一类生成器产物:

pkg/client/
├── applyconfiguration/   # applyconfiguration-gen 产物(声明式配置,用于 server-side apply)
├── clientset/            # client-gen 产物(typed clientset,含 fake 与 scheme)
├── informers/            # informer-gen 产物(事件驱动缓存,供控制器 watch 资源变化)
└── listers/              # lister-gen 产物(只读缓存层,加速 GET/LIST)

可以看出,README 中列出的 pkg/client/ 实际是上述多个生成器输出的汇集目录。

四个代码生成器各司其职

README 明确指出示例使用如下 code-generators,各自职责如下:

  • deepcopy-gen —— 为每个类型 T 生成方法 func (t *T) DeepCopy() *T(以及 DeepCopyIntoDeepCopyObject 等配套方法)。DeepCopy 是 runtime.Object 协议的一部分,Kubernetes API 对象在序列化、缓存与并发场景中必须能够安全深拷贝,因此所有 APIServer 侧类型都依赖它。
  • client-gen —— 为自定义资源的 API Group 生成类型化 clientset。相比直接使用 dynamic.Interface 并自行组装 unstructured 数据,类型化客户端在编译期即可保证字段类型正确。
  • informer-gen —— 生成自定义资源的 Informer。Informer 通过 List/Watch 与 API Server 保持长连接,在本地维护一份一致性缓存,并以事件(Add/Update/Delete)驱动控制器逻辑,是 controller 模式的标准组件。
  • lister-gen —— 生成 Lister。Lister 提供针对 Informer 共享缓存的只读访问层,供同一进程内多个消费者并发读取,避免各自重复发起 GET/LIST 请求。

代码生成并非运行时依赖的库,而是编译期工具链。示例的 hack/tools.go 通过空的 import 语句将 k8s.io/code-generator 钉进 go.mod 依赖图(该文件带 //go:build tools 构建标签,注释明确说明"此包导入构建脚本所需的依赖,以强制 go mod 将其视为依赖"),保证生成脚本可重复、可复现地拉取到正确版本的 code-generator。

生成脚本逐行拆解

update-codegen.sh 是整套流程的入口,其中关键逻辑如下:

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)}

source "${CODEGEN_PKG}/kube_codegen.sh"

THIS_PKG="k8s.io/apiextensions-apiserver/examples/client-go"

kube::codegen::gen_helpers \
    --boilerplate "${SCRIPT_ROOT}/hack/boilerplate.go.txt" \
    --lint-rules known-tags-only,require-explicit-disablement \
    "${SCRIPT_ROOT}/pkg/apis"

kube::codegen::gen_client \
    --with-watch \
    --with-applyconfig \
    --output-dir "${SCRIPT_ROOT}/pkg/client" \
    --output-pkg "${THIS_PKG}/pkg/client" \
    --boilerplate "${SCRIPT_ROOT}/hack/boilerplate.go.txt" \
    --lint-rules known-tags-only,require-explicit-disablement \
    "${SCRIPT_ROOT}/pkg/apis"

要点解读如下。

CODEGEN_PKG 定位逻辑:优先在 ./vendor/k8s.io/code-generator 中寻找 code-generator;找不到时回退到 ../../../code-generator。从本示例所在目录(staging/src/k8s.io/apiextensions-apiserver/examples/client-go)向上三级,恰好指向仓库内实际存在的 staging/src/k8s.io/code-generator/kube_codegen.sh,说明在 Kubernetes 主仓库的 staging 布局下,该脚本可不经任何配置直接运行。

kube::codegen::gen_helpers 负责对 pkg/apis 下的类型做辅助生成,产物即 zz_generated.deepcopy.go--lint-rules known-tags-only,require-explicit-disablement 用于校验源码标记的规范性,--boilerplate 指定每个生成文件头部必须携带的版权样板(仓库中的 hack/boilerplate.go.txt)。

kube::codegen::gen_client 是核心调用:

  • --with-watch:让 Informer 启用 Watch 能力,实现事件的增量推送;
  • --with-applyconfig:额外生成 applyconfiguration 类型,配合 client.Apply() 使用 server-side apply;
  • --output-dir / --output-pkg:分别指定产物落盘目录与 Go 导入路径,二者必须一致,否则包内相互引用的 import 路径会错位;
  • 末尾的 pkg/apis 参数是待扫描的类型源码根目录。

由此可以推断,新版 code-generator 已经将过去分散的 deepcopy-gen / client-gen / informer-gen / lister-gen / applyconfiguration-gen 统一封装进 gen_helpersgen_client 两个高层入口,README 中"生成了 pkg/client/ 与 zz_generated.deepcopy.go"的描述正是这两个函数的分工结果。

与它配套的 verify-codegen.sh 实现"生成结果漂移检测":先把当前 pkg 拷入临时目录,重新执行一次 update-codegen.sh,再用 diff 比对两份产物。任何差异都会让脚本以非零码退出并提示 "is out of date. Please run hack/update-codegen.sh"。CI 中通常同时运行 update 与 verify 脚本,从机制上杜绝"改了类型定义却忘了重新生成"的提交。

定义自定义资源类型

README 的 "Defining types" 一节指出,自定义资源的每个实例都挂载一个 Spec,应以 Go struct{} 定义来提供数据格式校验。实践中该 Spec 是任意键值数据,描述资源的目标配置与行为。文档给出的数据库场景示例为:

type DatabaseSpec struct {
	Databases []string `json:"databases"`
	Users     []User   `json:"users"`
	Version   string   `json:"version"`
}

type User struct {
	Name     string `json:"name"`
	Password string `json:"password"`
}

注意:这里的 json tag 不只是序列化别名,更是 CRD schema 推导与客户端编解码的事实来源——字段名、嵌套结构、必填性最终都体现在 OpenAPI schema 中,字段缺失会直接导致序列化错误。

把视线转向示例仓库本身,pkg/apis/cr/v1/types.go 定义了一套完整的、可被 code-generator 识别的 API 类型:

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

// Example is a specification for an Example resource
type Example struct {
	metav1.TypeMeta   `json:""`
	metav1.ObjectMeta `json:"metadata"`

	Spec   ExampleSpec   `json:"spec"`
	Status ExampleStatus `json:"status,omitempty"`
}

// ExampleSpec is the spec for an Example resource
type ExampleSpec struct {
	Foo string `json:"foo"`
	Bar bool   `json:"bar"`
}

// ExampleStatus is the status for an Example resource
type ExampleStatus struct {
	State   ExampleState `json:"state,omitempty"`
	Message string       `json:"message,omitempty"`
}

type ExampleState string

const (
	ExampleStateCreated   ExampleState = "Created"
	ExampleStateProcessed ExampleState = "Processed"
)

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

// ExampleList is a list of Example resources
type ExampleList struct {
	metav1.TypeMeta `json:""`
	metav1.ListMeta `json:"metadata"`

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

这段代码是理解生成规则的最佳范本,值得逐点对照:

  • // +genclient 标记告诉 client-gen:为该类型生成 REST 客户端方法;紧随其后的 // +genclient:noStatus 声明该资源不参与 status 子资源更新(虽然定义了 Status 字段,但控制面不提供 /status 端点语义)。
  • // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object 要求 deepcopy-gen 为该类型额外实现 runtime.Object 接口(即 DeepCopyObject()),使其能进入 scheme、被序列化框架统一处理。ExampleExampleList 都带此标记,二者恰好构成 scheme 注册时的"单对象 + 列表对象"标配。
  • Example 内嵌 metav1.TypeMetametav1.ObjectMeta:这是 Kubernetes API 对象的通用骨架。前者承载 kind/apiVersion,后者承载名称、命名空间、标签、注解、UID 等元数据。
  • Status 字段带 omitempty、其子字段同样可省略,而 Spec 无 omitempty,符合"spec 必须有值、status 由控制器回填"的惯例。
  • ExampleState 以字符串常量枚举 Created/Processed:这类状态机字段在真实控制器中非常常见,控制器根据当前 Status.State 决定下一步动作。

Group 与 Scheme 注册

类型定义之外,还必须有 Group 与 scheme 注册代码,它们同样是生成器的输入。

pkg/apis/cr/register.go 用常量声明了 API Group 名称:

const (
	GroupName = "cr.example.apiextensions.k8s.io"
)

pkg/apis/cr/v1/doc.go 中的 package 级标记将 Group 与 version 绑定:

// +k8s:deepcopy-gen=package
// +groupName=cr.example.apiextensions.k8s.io

// Package v1 is the v1 version of the API.
package v1

其中 // +k8s:deepcopy-gen=package 开启整包 deepcopy 生成,// +groupName=... 显式声明包所属 Group。而 pkg/apis/cr/v1/register.go 则以手写代码完成 scheme 注册:定义 SchemeGroupVersion = schema.GroupVersion{Group: cr.GroupName, Version: "v1"},通过 runtime.NewSchemeBuilder(addKnownTypes) 构建 SchemeBuilder,并在 addKnownTypes 中将 &Example{}&ExampleList{} 加入已知类型列表,同时调用 metav1.AddToGroupVersion 补全元数据类型。

生成产物:从 typed client 到 apply 配置

运行 ./hack/update-codegen.sh 后,代码生成器会依据上述类型与标记输出完整可用的客户端层。以 example.go(typed client) 为例,其生成的 ExampleInterface 提供了一套与内置资源客户端完全对齐的方法集:

type ExampleInterface interface {
	Create(ctx context.Context, example *crv1.Example, opts metav1.CreateOptions) (*crv1.Example, error)
	Update(ctx context.Context, example *crv1.Example, opts metav1.UpdateOptions) (*crv1.Example, 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) (*crv1.Example, error)
	List(ctx context.Context, opts metav1.ListOptions) (*crv1.ExampleList, 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 *crv1.Example, err error)
	Apply(ctx context.Context, example *applyconfigurationcrv1.ExampleApplyConfiguration, opts metav1.ApplyOptions) (result *crv1.Example, err error)
	ExampleExpansion
}

值得注意的实现细节是,新版客户端基于 gentype.ClientWithListAndApply[...] 泛型骨架构造(该文件通过类型参数把 Example/ExampleList/ExampleApplyConfiguration 三件套注入统一实现),ExampleExpansion 接口则预留了人工扩展钩子——你可以给客户端追加自定义方法而不会被下一次代码生成覆盖(对应目录中的 generated_expansion.go)。

与此同时,--with-applyconfig 生成的 example.go(applyconfiguration) 提供了服务端应用(server-side apply)所需的声明式配置类型:字段全部变为指针,通过 WithNameWithLabelsWithSpec 等链式方法构造变更意图,最终交给 client.Apply(ctx, cfg, opts)。例如构造一个名为 example1、位于 default 命名空间的资源可以写为:

v1.Example("example1", "default").
    WithSpec(v1.ExampleSpec().WithFoo("bar").WithBar(true))

声明式配置天然区分"本次要管理的字段"与"未声明的字段",在多控制器协作、避免互相踩踏的场景下是官方推荐的更新方式。这套机制在示例仓库中还有配套的测试与辅助文件(example_test.goexamplestatus.go 等),fake 目录(fake)则为单元测试提供了免集群的内存版客户端。

此外 informers/listers/ 目录(如 informers/externalversions/cr/v1/example.golisters/cr/v1/example.go)分别生成了带事件回调的 Informer 工厂与只读 Lister,二者配合正是控制器 watch-reconcile 循环的标准数据通路。

使用场景:为什么需要 CRD + 生成客户端

README 明确指出 CRD 让自定义资源"像 Kubernetes 中绝大多数资源一样工作,可以被 kubectl apply 等操作"。它给出的两类典型使用场景:

  • 外部数据存储/数据库的供给与管理(例如 CloudSQL / RDS 实例):把"创建数据库"这一运维动作建模为声明式资源,由控制器负责调用云厂商 API 收敛实际状态,用户只需 apply 一个 YAML。
  • 对 Kubernetes 原生原语的更高层抽象(例如用一个资源描述整个 etcd 集群,底层由 Service 与 ReplicationController/StatefulSet 支撑):将"多资源编排"封装成单一 CRD,向用户暴露更贴合业务语义的 API。

这两类场景的共性在于:控制逻辑(控制器)需要频繁、类型安全地与自定义资源交互。手写 REST 调用极易出错且难以维护,而 code-generator 产出的 typed client、Informer、Lister 与内置资源使用完全一致的心智模型,让开发者可以集中精力编写业务 reconcile 逻辑。如需参考一个完整的"CRD 类型 + 生成客户端 + 控制器"端到端示例,可阅读 Kubernetes 官方的 sample-controller 项目——本 README 特别提示读者前往查看它(该示例与本例的产物结构、控制器接入方式一脉相承,但控制器代码位于独立仓库,不在本仓库范围内)。

注意事项与最佳实践

README 以明确的口吻给出了两条对自研控制器开发者至关重要的提醒:

  1. 绝不要手工修改生成文件zz_generated.*.gopkg/client/ 下的所有文件顶部均带有 "Code generated by ... DO NOT EDIT." 警告,任何手工改动都会在下次运行时被覆盖丢失,属于无效劳动。
  2. 不要复制生成文件到自己的项目里。当你基于本实现编写自己的控制器时,正确做法是拿到属于自己的类型定义后,运行 update-codegen 脚本现场生成。这是因为生成文件与你的 THIS_PKG 导入路径、API Group、类型结构深度绑定,直接拷贝会导致 import 路径错乱或类型不匹配——复制粘贴看似省事,实则是后续排错的隐患。

结合 verify-codegen.sh 的 diff 机制,推荐的工程化闭环是:类型变更 → 运行 update-codegen.sh 重新生成 → 在 CI 中跑 verify-codegen.sh 防止产物漂移 → 提交类型源码与生成产物。

小结

通过 examples/client-go/README.md 及示例仓库代码可以看到一条清晰的链路:用 Go struct 定义 API 类型并书写生成标记 → 通过 hack/update-codegen.sh(底层调用 kube_codegen.shgen_helpers / gen_client)→ 产出 deepcopy、typed clientset、Informer、Lister 与 applyconfiguration → 供控制器与客户端类型安全地消费。这套流程至今仍是 Kubernetes 生态中编写 CRD 控制器与 Operator 的标配脚手架:理解了本示例,就等于掌握了通往 k8s.io/sample-controller 及各类生产级 Operator 工程的钥匙。下一步建议你在本地集群中实际注册一个 CRD,亲自动手跑一遍生成脚本,观察每个目录的产物与类型定义、标记之间的对应关系。

登录后查看全文
热门项目推荐
相关项目推荐