Kubernetes 编程 / Operator 专题【左扬精讲】—— Kubernetes 自定义资源的内部版本与外部版本:从源码看版本定义机制
Kubernetes 的自定义资源(Custom Resource, CR)是扩展 Kubernetes API 的核心机制。但你是否好奇过:当你在 YAML 里写下 apiVersion: apiextensions.k8s.io/v1 时,Kubernetes 内部到底是怎么处理这个版本的?为什么 kube-apiserver 内部使用的是一种"看不见"的内部版本,而对外暴露的却是 v1、v1beta1 这样的外部版本?这两者之间又是如何转换的?
很多初学者在阅读 Kubernetes 源码时,会被 pkg/apis/ 和 staging/src/k8s.io/api/ 下"长得一样但又不完全一样"的类型定义搞得一头雾水。本文将从 Kubernetes 1.36.1 源码出发,深入剖析内部版本(Internal Version)与外部版本(External Version)的定义方式、差异所在以及它们之间的转换机制。
Kubernetes 1.36.1 Go 1.24+ CRD apiextensions-apiserver
🔓 学习重点提示 — 建议先通读全文,再重点回顾标注内容
★ 重点掌握(必须)
• 内部版本与外部版本的核心区别:内部版本无 JSON tag、Version 为 __internal;外部版本有 JSON/protobuf tag、Version 为具体版本号
• 版本转换机制:通过 runtime.Scheme 注册转换函数,实现内部 ↔ 外部的双向转换
• CRD 的版本演进:v1beta1 → v1 的结构变化(如顶层字段下沉到 per-version)
☆ 次重点(了解即可)
• conversion-gen 代码生成器的工作原理
• SetVersionPriority 版本优先级设置
• CRD 示例项目 examples/client-go 的组织方式
目录
- 为什么要区分内部版本和外部版本?
- 内部版本定义详解
- 外部版本定义详解(v1)
- 内部版本与外部版本的核心差异对比
- 版本注册与 Scheme 机制
- 版本转换:内部 ↔ 外部的桥梁
- CRD 的 v1beta1 → v1 版本演进
- 实战:如何为自定义资源定义内部版本和外部版本
- 总结
一、为什么要区分内部版本和外部版本?
打个比方:你在银行办理业务时,柜台外面看到的利率表(外部版本)和银行内部记账系统用的利率编码(内部版本)可能不一样。对外,银行用"年化 3.5%"这样用户友好的方式展示;对内,可能用 RATE_035_ANNUAL 这样的编码来统一管理。当利率调整时,只需要更新内部编码,然后让所有对外接口做一次"翻译"即可。
Kubernetes 也采用了同样的设计哲学:
- 外部版本(External Version):面向用户,对应具体的 API 版本如
v1、v1beta1,带有 JSON/protobuf 序列化标签,是用户通过 kubectl、REST API 直接交互的版本
- 内部版本(Internal Version):面向 apiserver 内部逻辑,版本号为
__internal,没有 JSON tag,是所有业务逻辑代码操作的数据结构
这样做的好处是:内部版本作为"枢纽",N 个外部版本只需要 N 套转换函数(内部↔外部),而不是 N×(N-1)/2 套外部↔外部转换函数。当新增一个外部版本时,只需编写它与内部版本之间的转换逻辑,就能自动兼容所有已有版本。
v1 (外部) → __internal (内部) ← v1beta1 (外部)
二、内部版本定义详解
内部版本定义在 pkg/apis/<group>/ 或 pkg/apis/apiextensions/ 目录下(对于 CRD 相关类型,位于 staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/)。以下从源码中截取关键部分:
2.1 注册文件 — 声明 Group 和 Internal Version
register.go — 内部版本注册
package apiextensionsimport ("k8s.io/apimachinery/pkg/runtime""k8s.io/apimachinery/pkg/runtime/schema"
)const GroupName = "apiextensions.k8s.io"// SchemeGroupVersion 使用 runtime.APIVersionInternal 作为版本号
var SchemeGroupVersion = schema.GroupVersion{Group: GroupName,Version: runtime.APIVersionInternal, // 值为 "__internal"
}
关键点:runtime.APIVersionInternal 的值是字符串 "__internal",这是 Kubernetes 内部版本的标识。这个版本号永远不会出现在 API 请求或 etcd 存储中,它纯粹是内存中的"枢纽"数据结构。
2.2 类型定义 — 无 JSON/protobuf Tag
types.go — 内部版本类型定义(节选)
// CustomResourceDefinitionSpec 描述用户希望资源如何呈现
type CustomResourceDefinitionSpec struct {// 注意:没有 json tag 和 protobuf tag!Group stringVersion string // 已弃用,用 Versions 替代Names CustomResourceDefinitionNamesScope ResourceScopeValidation *CustomResourceValidationSubresources *CustomResourceSubresourcesVersions []CustomResourceDefinitionVersionAdditionalPrinterColumns []CustomResourceColumnDefinitionSelectableFields []SelectableFieldConversion *CustomResourceConversionPreserveUnknownFields *bool
}type CustomResourceDefinition struct {metav1.TypeMetametav1.ObjectMetaSpec CustomResourceDefinitionSpecStatus CustomResourceDefinitionStatus
}
注意内部版本的显著特征:所有字段都没有 json:"..." 和 protobuf:"..." 标签。这是因为内部版本永远不需要直接序列化到 JSON 或 protobuf——它只存在于 apiserver 的内存中,所有持久化和网络传输都会先转换为外部版本。
同样地,以核心 API apps 组为例,内部版本位于 pkg/apis/apps/types.go:
pkg/apis/apps/types.go — Deployment 内部版本
type Deployment struct {metav1.TypeMetametav1.ObjectMeta // 没有 json tagSpec DeploymentSpec // 没有 json tagStatus DeploymentStatus
}type DeploymentSpec struct {Replicas int32 // 纯 Go 字段Selector *metav1.LabelSelectorTemplate api.PodTemplateSpecStrategy DeploymentStrategyMinReadySeconds int32RevisionHistoryLimit *int32Paused boolProgressDeadlineSeconds *int32
}
对比外部版本 k8s.io/api/apps/v1 中的 Deployment,你能看到每个字段都有 json:"replicas,omitempty" 和 protobuf:"varint,1,opt,name=replicas" 标签——这就是两者最直观的区别。
三、外部版本定义详解(v1)
外部版本定义在 pkg/apis/<group>/v1/ 子目录下(对于 CRD,位于 staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1/)。外部版本是用户真正接触到的 API 版本。
3.1 注册文件 — 声明具体版本号
v1/register.go — 外部版本注册
package v1import (metav1 "k8s.io/apimachinery/pkg/apis/meta/v1""k8s.io/apimachinery/pkg/runtime""k8s.io/apimachinery/pkg/runtime/schema"
)const GroupName = "apiextensions.k8s.io"// SchemeGroupVersion 使用具体的版本号 "v1"
var SchemeGroupVersion = schema.GroupVersion{Group: GroupName,Version: "v1", // 具体版本号,而非 __internal
}var (SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes, addDefaultingFuncs)localSchemeBuilder = &SchemeBuilderAddToScheme = localSchemeBuilder.AddToScheme
)func addKnownTypes(scheme *runtime.Scheme) error {scheme.AddKnownTypes(SchemeGroupVersion,&CustomResourceDefinition{},&CustomResourceDefinitionList{},&ConversionReview{}, // v1 额外注册了 ConversionReview)metav1.AddToGroupVersion(scheme, SchemeGroupVersion)return nil
}
注意几个关键差异:
- 版本号是具体的
"v1",而非 runtime.APIVersionInternal
- SchemeBuilder 额外注册了
addDefaultingFuncs:外部版本需要默认值填充逻辑
- 多注册了
ConversionReview 类型:这是版本转换 webhook 的请求/响应结构
- 调用了
metav1.AddToGroupVersion:注册 CreateOptions、UpdateOptions 等通用元数据类型
3.2 类型定义 — 带 JSON/protobuf Tag
v1/types.go — 外部版本类型定义(节选)
type CustomResourceDefinitionSpec struct {// 每个字段都有 json tag 和 protobuf tag!Group string `json:"group" protobuf:"bytes,1,opt,name=group"`Names CustomResourceDefinitionNames `json:"names" protobuf:"bytes,3,opt,name=names"`Scope ResourceScope `json:"scope" protobuf:"bytes,4,opt,name=scope,casttype=ResourceScope"`Versions []CustomResourceDefinitionVersion `json:"versions" protobuf:"bytes,7,rep,name=versions"`Conversion *CustomResourceConversion `json:"conversion,omitempty" protobuf:"bytes,9,opt,name=conversion"`PreserveUnknownFields bool `json:"preserveUnknownFields,omitempty" protobuf:"varint,10,opt,name=preserveUnknownFields"`
}type CustomResourceDefinition struct {metav1.TypeMeta `json:",inline"`metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`Spec CustomResourceDefinitionSpec `json:"spec" protobuf:"bytes,2,opt,name=spec"`Status CustomResourceDefinitionStatus `json:"status,omitempty" protobuf:"bytes,3,opt,name=status"`
}
外部版本的类型定义有几个显著特征:
- 每个字段都有
json tag:用于 REST API 的 JSON 序列化/反序列化,字段名对应 YAML/JSON 中的 key
- 每个字段都有
protobuf tag:用于 etcd 存储和 apiserver 之间的高效二进制传输
omitempty 标记:可选字段在序列化时如果为零值则省略,减少传输量
- 嵌入字段使用
json:",inline":将 TypeMeta 的 apiVersion/kind 展平到同一层级
💡 注意
外部版本的 CustomResourceDefinitionSpec 中没有 Version 字段(v1 已移除),也没有顶层的 Validation、Subresources、AdditionalPrinterColumns 字段——这些在 v1 中全部下沉到了 versions[] 数组的每个版本中。这是 v1beta1 → v1 最重要的结构变化之一,后文会详细分析。
四、内部版本与外部版本的核心差异对比
| 对比项 | 内部版本 (Internal) | 外部版本 (External) |
| 目录位置 |
pkg/apis/<group>/ |
pkg/apis/<group>/v1/ |
| 版本号 |
runtime.APIVersionInternal("__internal") |
具体版本如 "v1"、"v1beta1" |
| JSON tag |
❌ 无 |
✅ 有(json:"field,omitempty") |
| protobuf tag |
❌ 无 |
✅ 有(protobuf:"bytes,1,opt,...") |
| 序列化用途 |
不参与序列化,仅内存中使用 |
用于 REST API JSON、etcd protobuf 存储 |
| 字段完整性 |
包含所有版本的字段"超集" |
仅包含当前版本的字段子集 |
| 默认值函数 |
通常不需要 |
需要 addDefaultingFuncs 填充默认值 |
| 使用者 |
apiserver 内部控制器、admission 插件 |
kubectl、client-go、etcd |
五、版本注册与 Scheme 机制
runtime.Scheme 是 Kubernetes 类型系统的核心。它维护了 GroupVersionKind(GVK)到 Go 类型的映射关系,以及版本之间的转换函数。来看 install.go 是如何将所有版本注册到 Scheme 中的:
install/install.go — 统一注册入口
package installimport ("k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1""k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1""k8s.io/apimachinery/pkg/runtime"utilruntime "k8s.io/apimachinery/pkg/util/runtime"
)func Install(scheme *runtime.Scheme) {// 第一步:注册内部版本utilruntime.Must(apiextensions.AddToScheme(scheme))// 第二步:注册外部版本 v1beta1utilruntime.Must(v1beta1.AddToScheme(scheme))// 第三步:注册外部版本 v1utilruntime.Must(v1.AddToScheme(scheme))// 第四步:设置版本优先级(v1 > v1beta1)utilruntime.Must(scheme.SetVersionPriority(v1.SchemeGroupVersion,v1beta1.SchemeGroupVersion,))
}
注册流程的四步走:
- 1注册内部版本:将
apiextensions.CustomResourceDefinition 注册到 apiextensions.k8s.io/__internal GVK
- 2注册 v1beta1:将
v1beta1.CustomResourceDefinition 注册到 apiextensions.k8s.io/v1beta1 GVK,并注册 v1beta1 ↔ 内部版本的转换函数
- 3注册 v1:将
v1.CustomResourceDefinition 注册到 apiextensions.k8s.io/v1 GVK,并注册 v1 ↔ 内部版本的转换函数
- 4设置版本优先级:
SetVersionPriority(v1, v1beta1) 表示 v1 优先于 v1beta1,影响 API discovery 中的版本排序
🌟 实用技巧
SetVersionPriority 的参数顺序决定了 API discovery 中版本的推荐排序。排在第一位的版本会在 kubectl api-resources 等输出中被优先推荐。这就是为什么创建 CRD 时,kubectl 默认使用 v1 而非 v1beta1。
六、版本转换:内部 ↔ 外部的桥梁
版本转换是连接内部版本和外部版本的桥梁。Kubernetes 通过 conversion-gen 代码生成器自动生成大部分转换函数(zz_generated.conversion.go),同时允许开发者手动编写需要特殊处理的转换逻辑(conversion.go)。
6.1 自动生成的转换函数
zz_generated.conversion.go 中的自动生成函数主要负责字段名相同、类型兼容的简单映射:
zz_generated.conversion.go(节选)
// 注册 v1 ↔ 内部版本 的双向转换函数
func RegisterConversions(s *runtime.Scheme) error {// v1.CustomResourceDefinition → apiextensions.CustomResourceDefinitionif err := s.AddGeneratedConversionFunc((*CustomResourceDefinition)(nil),(*apiextensions.CustomResourceDefinition)(nil),func(a, b interface{}, scope conversion.Scope) error {return Convert_v1_CustomResourceDefinition_To_apiextensions_CustomResourceDefinition(a.(*CustomResourceDefinition),b.(*apiextensions.CustomResourceDefinition),scope,)},); err != nil {return err}// 反向:apiextensions.CustomResourceDefinition → v1.CustomResourceDefinitionif err := s.AddGeneratedConversionFunc((*apiextensions.CustomResourceDefinition)(nil),(*CustomResourceDefinition)(nil),func(a, b interface{}, scope conversion.Scope) error {return Convert_apiextensions_CustomResourceDefinition_To_v1_CustomResourceDefinition(a.(*apiextensions.CustomResourceDefinition),b.(*CustomResourceDefinition),scope,)},); err != nil {return err}// ... 其他类型的转换函数
}
6.2 手动转换函数 — 处理结构差异
当内部版本和外部版本之间存在结构差异时(如字段位置变化、类型变化),需要手动编写转换函数。CRD 最典型的一个案例是 v1 的 CustomResourceDefinitionSpec 转换:
v1/conversion.go — 手动转换逻辑(核心部分)
// 内部版本 → v1:将顶层的 Validation/Subresources 等下沉到 per-version
func Convert_apiextensions_CustomResourceDefinitionSpec_To_v1_CustomResourceDefinitionSpec(in *apiextensions.CustomResourceDefinitionSpec,out *CustomResourceDefinitionSpec,s conversion.Scope,
) error {if err := autoConvert_...(in, out, s); err != nil {return err}// 如果没有 versions 但有 version,构造一个 versions 条目if len(out.Versions) == 0 && len(in.Version) > 0 {out.Versions = []CustomResourceDefinitionVersion{{Name: in.Version, Served: true, Storage: true,}}}// 将顶层的 Subresources 复制到每个 versionif in.Subresources != nil {for i := range out.Versions {out.Versions[i].Subresources = subresources}}// 将顶层的 Validation 复制到每个 version 的 Schemaif in.Validation != nil {for i := range out.Versions {out.Versions[i].Schema = schema}}// ... AdditionalPrinterColumns、SelectableFields 同理return nil
}// v1 → 内部版本:将 per-version 字段提取到顶层(如果所有版本都相同)
func Convert_v1_CustomResourceDefinitionSpec_To_apiextensions_CustomResourceDefinitionSpec(in *CustomResourceDefinitionSpec,out *apiextensions.CustomResourceDefinitionSpec,s conversion.Scope,
) error {if err := autoConvert_...(in, out, s); err != nil {return err}// 复制 versions[0].Name 到 Versionout.Version = out.Versions[0].Name// 检测所有版本的 Subresources/Schema/... 是否一致// 如果一致,提升到顶层并清空 per-version 字段if subresourcesIdentical {out.Subresources = subresources}if validationIdentical {out.Validation = validation}// ... 清理 per-version 字段return nil
}
这段代码揭示了内部版本作为"超集"的设计意图:
- 内部 → v1 方向:将内部版本中"顶层的 Validation/Subresources"分发到 v1 中每个
versions[] 条目内,因为 v1 不再有顶层字段
- v1 → 内部方向:如果 v1 中所有版本的 Schema/Subresources 都相同,则提取到内部版本的顶层字段(内部版本保留顶层字段是为了兼容 v1beta1 的写入路径)
设计精髓:内部版本的 CustomResourceDefinitionSpec 同时保留了顶层字段(Validation、Subresources 等)和 Versions 数组中的 per-version 字段。这样内部版本就能同时兼容 v1(只有 per-version)和 v1beta1(有顶层字段)两种外部版本的结构,充当"万能适配器"。
6.3 另一个手动转换示例:Webhook 结构变化
v1 中 CustomResourceConversion 的结构也发生了重组,将 WebhookClientConfig 和 ConversionReviewVersions 包裹到新的 WebhookConversion 结构中:
v1/conversion.go — Webhook 结构转换
// 内部版本结构(扁平):
// Conversion.WebhookClientConfig
// Conversion.ConversionReviewVersions// v1 结构(嵌套):
// Conversion.Webhook.ClientConfig
// Conversion.Webhook.ConversionReviewVersions// 内部 → v1:将扁平字段包裹到 Webhook 子结构
func Convert_apiextensions_CustomResourceConversion_To_v1_...() {out.Webhook = nilif in.WebhookClientConfig != nil || in.ConversionReviewVersions != nil {out.Webhook = &WebhookConversion{}out.Webhook.ConversionReviewVersions = in.ConversionReviewVersionsif in.WebhookClientConfig != nil {out.Webhook.ClientConfig = &WebhookClientConfig{}// 转换 ClientConfig 字段...}}
}
七、CRD 的 v1beta1 → v1 版本演进
CRD 从 v1beta1 演进到 v1 是理解内部/外部版本差异的最佳案例。以下对比两个外部版本在 CustomResourceDefinitionSpec 上的关键变化:
| 对比项 | v1beta1 | v1 |
顶层 version |
✅ 存在(已弃用) |
❌ 移除 |
顶层 validation |
✅ 存在(所有版本共享) |
❌ 移除,下沉到 versions[].schema |
顶层 subresources |
✅ 存在 |
❌ 移除,下沉到 versions[].subresources |
顶层 additionalPrinterColumns |
✅ 存在 |
❌ 移除,下沉到 versions[].additionalPrinterColumns |
| Webhook 结构 |
conversion.webhookClientConfig(扁平) |
conversion.webhook.clientConfig(嵌套) |
preserveUnknownFields |
默认 true(*bool 指针类型) |
默认 false(bool 值类型) |
| 生命周期 |
v1.7 引入,v1.16 弃用,v1.22 移除 |
v1.16 引入,当前稳定版本 |
从源码可以看到 v1beta1 的类型定义上有清晰的声明周期标注:
v1beta1/types.go — 生命周期标注
// +k8s:prerelease-lifecycle-gen:introduced=1.7
// +k8s:prerelease-lifecycle-gen:deprecated=1.16
// +k8s:prerelease-lifecycle-gen:removed=1.22
// +k8s:prerelease-lifecycle-gen:replacement=apiextensions.k8s.io,v1,CustomResourceDefinition
type CustomResourceDefinition struct {// ...
}
这些 +k8s:prerelease-lifecycle-gen 注解会被代码生成器识别,自动生成 API 弃用警告和迁移指引。
八、实战:如何为自定义资源定义内部版本和外部版本
Kubernetes 在 apiextensions-apiserver 的 examples/client-go 目录下提供了一个完整的自定义资源示例项目。让我们看看它的目录结构:
目录结构
examples/client-go/pkg/apis/
└── cr/ ← Group 定义(无内部版本 types.go)├── register.go ← GroupName = "cr.example.apiextensions.k8s.io"└── v1/ ← 外部版本├── doc.go ← 代码生成指令├── register.go ← SchemeGroupVersion = {Group, "v1"}├── types.go ← Example 类型定义(带 json tag)└── zz_generated.deepcopy.go
注意这个示例项目没有定义内部版本——它只有 v1 外部版本。这是因为对于简单的自定义资源,如果你的代码不运行在 apiserver 进程内(不需要直接操作内存中的内部对象),就只需要外部版本。
如果你要开发一个运行在 apiserver 内部的自定义 API(类似于 CRD 本身的实现),就需要遵循完整的内部+外部版本模式。以下是推荐的步骤:
- 1定义 Group:在
pkg/apis/<group>/register.go 中声明 GroupName 和内部版本的 SchemeGroupVersion
- 2定义内部类型:在
pkg/apis/<group>/types.go 中定义不带 JSON tag 的 Go struct
- 3定义外部版本类型:在
pkg/apis/<group>/v1/types.go 中定义带 JSON/protobuf tag 的 Go struct
- 4运行代码生成:使用
conversion-gen 自动生成转换函数,用 deepcopy-gen 生成深拷贝方法
- 5编写手动转换:在
conversion.go 中处理有结构差异的字段映射
- 6统一注册:在
install/install.go 中按序注册内部版本、外部版本、转换函数和版本优先级
九、总结
通过源码分析,我们可以将 Kubernetes 自定义资源的版本定义机制总结如下:
- 内部版本是枢纽:版本号
__internal,无 JSON tag,是 apiserver 内存中的统一数据结构,包含所有版本字段的"超集"
- 外部版本是接口:版本号如
v1/v1beta1,有 JSON/protobuf tag,面向用户和持久化,每个版本只包含自己的字段子集
- 转换函数是桥梁:通过
runtime.Scheme 注册的双向转换函数,让请求流入时(外部→内部)和响应流出时(内部→外部)自动完成版本适配
- 内部版本解耦外部版本:N 个外部版本只需 N 套转换函数(与内部版本的双向转换),而非 N×(N-1)/2 套外部之间的转换
- CRD 的演进是最佳范例:v1beta1 → v1 的顶层字段下沉到 per-version、Webhook 结构重组,都通过内部版本作为中间适配层实现平滑过渡
理解了内部版本和外部版本的设计,你就能更好地理解 Kubernetes API 的演进机制、阅读 apiserver 源码时的类型流转过程,以及为自己的自定义资源设计合理的版本策略。下一步,建议阅读 runtime.Scheme 的实现代码,深入理解 GVK 到 Go 类型的映射和转换链路。
相关阅读:
• Kubernetes CRD 内部版本与外部版本源码
• runtime.Scheme 实现源码
• Kubernetes 官方文档:CRD Versioning
• Kubernetes API Change Guidelines
Kubernetes 自定义资源的内部版本与外部版本 · 基于 Kubernetes 1.36.1 源码分析