



















学 k8s 源码的"分水岭"在两处:一个是 client-go 的 Informer,另一个就是 apimachinery 的 Converter。前者解决了"集群状态如何被监听",后者解决了"同一个东西在 v1 / v1beta1 / v2 三个版本里怎么对齐"。这两块一起构成了 k8s "状态机 + 类型系统"的两根顶梁柱。
Converter 在源码里只有 200 来行,但承载的职责非常重:① 维护"类型对 → 转换函数"的注册表;② 充当"反射遍历 + 字段拷贝"的默认执行器;③ 配合 Scheme 跑通 hub-and-spoke 中心辐射式版本模型(以 __internal 内部版本为 hub,所有外部版本都跟它对话);④ 在 CRD 多版本场景下,把转换决策委托给用户自己的 Webhook。它不像 Scheduler 那么宏观,但少了它,etcd 里的存储版本、客户端发来的请求版本、API Server 真正在内存里跑的对象版本,就根本无法对齐。
这一篇我们就用源码 + 流程图 + 真实 CR 案例,把 Converter 讲透。重点覆盖:① 数据结构——Converter / Scope / Meta / ConversionFuncs / typePair 各自是什么;② 转换函数的注册——AddConversionFunc vs AddGeneratedConversionFunc vs AddIgnoredConversionType 的差异;③ Scheme 初始化过程——NewScheme 里 Converter 是怎么和 GVK 映射表勾连起来的;④ 转换的实现路径——从 Scheme.Convert → Scheme.ConvertToVersion → Converter.Convert 的完整调用栈;⑤ Hub-and-spoke 模式——为什么所有外部版本都要先"绕一圈"进 __internal。最后用 20 个高频 Q&A 把新手最容易卡住的细节收个尾。
Kubernetes 1.36.1 apimachinery Converter Hub-and-Spoke conversion-gen
🔓 学习重点提示 — 建议先通读全文,再重点回顾标注内容
★ 重点掌握(必须)
• Converter 核心结构体:conversionFuncs / generatedConversionFuncs / ignoredUntypedConversions 三个 map 各自的角色
• typePair 注册键:(source, dest) 二元组如何决定函数匹配
• AddConversionFunc vs AddGeneratedConversionFunc:手写 vs 自动生成的优先级与可重入性
• Convert / ConvertToVersion / UnsafeConvertToVersion 三种调用方式的差异
• Hub-and-spoke 模式:__internal 内部版本做"中转站"的原因
☆ 次重点(了解即可)
• Scope / Meta 接口的传递用途
• RegisterEmbeddedConversions / RegisterStringConversions 的内置转换
• CRD Webhook Converter 单独走 apiextensions 的 CRConverterFactory
在 k8s 这种持续 10 年还在迭代的项目里,"同一个东西,多个版本"是常态。举个最具体的例子——Deployment 这个资源在 1.36 之前,已经历过 extensions/v1beta1 → apps/v1beta1 → apps/v1beta2 → apps/v1 四次"搬家"。每一次"搬家"都会带来三个问题:
这三种"版本不一致"必须被某个东西动态缝合。这个缝合的活,就是 Converter 干的。它向上接 Scheme 的"GVK 查找表",向下接 conversion-gen 自动生成的具体转换函数,左右对接"反射默认拷贝"作为兜底。Converter 不是"功能",它是"基础设施"——平时你感觉不到它,但凡有任何一个版本被错转,整个集群的状态就会出现"读出来是对的,存进去是错的"这种诡异问题。
学习 Converter 的另一个价值:它是 k8s 源码里少数几个不依赖任何外部依赖(除了 reflect)的"纯类型体操"模块。换句话说,搞懂它,等于搞懂 k8s 类型系统的运作规律。后面我们看所有的 CRD / Operator / 多版本管理,背后的"Go 类型 ↔ GVK 转换"思维都是一致的。
Converter 的三大典型出场
① 客户端发 v1beta1,API Server 内部处理用 __internal,存 etcd 用 v1;② CRD 多版本时,spec.versions 配了 v1 和 v2,v1 写入后客户端用 v2 读出来要"无感";③ 写 controller 时,Scheme.ConvertToVersion(obj, targetGV) 把对象换成你想要的版本。
Converter 整个模块就 200 来行,但里面每一个字段、每一个 map、每一个接口都承担特定职责。我们先把它"拆成零件",再"装回去"。下面所有源码都来自 staging/src/k8s.io/apimachinery/pkg/conversion/converter.go(行号标注 1.36.1 版本)。
Converter 内部所有查找都是"按 类型对 走",所以先要有一个二元组作为 map 的 key。
// staging/src/k8s.io/apimachinery/pkg/conversion/converter.go(行 24-27)
// typePair 是 Converter 内部所有 map 的 key,由 (源类型, 目标类型) 组成。
type typePair struct {
source reflect.Type
dest reflect.Type
}
为什么用 reflect.Type 而不是字符串?k8s 的版本演进常常需要同一个 Go struct 改字段,用类型而非字符串能避免"忘记同步硬编码字符串"这种坑。两个相同的 typePair 在 Go 里是天然去重的。
💡 注意
typePair 是单向的!(A, B) 注册的函数不能直接用于 (B, A)。这跟 JSON Marshal/Unmarshal 不一样。所以 k8s 每一个版本对都要注册两个方向的函数——A→B 和 B→A。
转换函数长这样——是一个普通的 Go 函数签名:
// staging/src/k8s.io/apimachinery/pkg/conversion/converter.go(行 33-37)
// ConversionFunc 把 a 转成 b,可以原地复用底层数组/指针。
// 返回 error 表示失败或不合法数据。
type ConversionFunc func(a, b interface{}, scope Scope) error
三个参数逐个说:
Converter 是个注册表,不是一个"工具箱"。它的结构非常简单:
// staging/src/k8s.io/apimachinery/pkg/conversion/converter.go(行 40-48)
// Converter 知道怎么把一种类型转成另一种类型。
type Converter struct {
// 手写 / 手动注册的转换函数
conversionFuncs ConversionFuncs
// conversion-gen 自动生成的转换函数
generatedConversionFuncs ConversionFuncs
// 显式声明"这对类型转换直接忽略"的 no-op 列表
ignoredUntypedConversions map[typePair]struct{}
}
为什么分成 2 套转换函数?这里有个隐藏的优先级机制:
这个"两层注册 + 忽略列表"的设计,巧妙地解决了"代码生成 + 人工订正"的协作问题——开发人员想修一个 conversion-gen 误生成的转换,不用改 zz_generated 文件,直接在 RegisterConversions 里 AddConversionFunc 覆盖。
Scope 是一个接口,作用是"让你的转换函数在嵌套子对象上能继续调 Converter":
// staging/src/k8s.io/apimachinery/pkg/conversion/converter.go(行 91-101)
// Scope 允许转换函数继续一次"进行中的"转换。
type Scope interface {
// 调 Convert 转子对象。注意:调自己原来的入参会爆栈。
Convert(src, dest interface{}) error
// 拿到最初调用 Convert 时传入的 Meta。
Meta() *Meta
}
而 Meta 是 Scheme 在调用 Converter.Convert 时塞进来的"上下文",比如"目标 GroupVersion"——这样转换函数内部也能感知到要转成哪个版本:
// staging/src/k8s.io/apimachinery/pkg/conversion/converter.go(行 141-145)
// Meta 由 Scheme 在调用 Convert 时塞入。
type Meta struct {
// Context 是个 interface{},调用方可以传任何东西,
// 比如 "目标 GroupVersion",让转换函数感知"我要转成哪个版本"。
Context interface{}
}
这个 Context 字段的 interface{} 看着很松散,实际传递的是 GroupVersioner。下文五.2 我们会看到 Scheme 是怎么把目标 GV 塞进 Context 的。
staging/src/k8s.io/apimachinery/pkg/conversion/converter.go
├── type typePair struct { source, dest reflect.Type }
│ 转换注册表的 key
│
├── type ConversionFunc func(a, b interface{}, scope Scope) error
│ 一次具体转换的动作
│
├── type Converter struct { ← 核心!
│ ├── conversionFuncs ConversionFuncs ← 手写(高优先级)
│ ├── generatedConversionFuncs ConversionFuncs ← 自动生成(兜底)
│ └── ignoredUntypedConversions map[typePair]struct{} ← 忽略列表(最高优先级)
│ }
│
├── type ConversionFuncs struct { untyped map[typePair]ConversionFunc }
│ 实际的 map 容器
│
├── type Scope interface { Convert(); Meta() } ← 递归句柄
│
├── type Meta struct { Context interface{} } ← 调用方上下文
│
└── type scope struct { converter *Converter; meta *Meta } ← scope 接口的实现
这张图就是 Converter 的"全貌"。它没有算法逻辑,全靠 map 查表 + 反射。下一节看这些零件怎么被"装起来"。
Converter 暴露的注册接口很少,主要有 3 个:AddConversionFunc、AddGeneratedConversionFunc、AddIgnoredConversionType。它们都是 Scheme 透传过来的——你不会直接调 converter.AddXxx,而是用 scheme.AddXxx。我们逐个看。
// staging/src/k8s.io/apimachinery/pkg/runtime/scheme.go(行 324-329)
// AddConversionFunc 注册"手写"转换函数。
// 用 a, b 的零值指针作为类型签名。
func (s *Scheme) AddConversionFunc(a, b interface{}, fn conversion.ConversionFunc) error {
return s.converter.RegisterUntypedConversionFunc(a, b, fn)
}
调用方式很特别:a 和 b 都传 零值指针(nil),用于取类型。实际转换时 Converter 用 reflect.TypeOf 解析出真实 Go 类型。比如注册 v1.Pod → core.Pod 的转换:
// 用法:把一对类型的"转换动作"挂到 Scheme 上
err := scheme.AddConversionFunc(
(*corev1.Pod)(nil), // 源类型,零值指针
(*core.Pod)(nil), // 目标类型,零值指针
func(a, b interface{}, scope conversion.Scope) error {
src := a.(*corev1.Pod)
dst := b.(*core.Pod)
// 写实际转换逻辑
return nil
},
)
// staging/src/k8s.io/apimachinery/pkg/runtime/scheme.go(行 331-336)
// AddGeneratedConversionFunc 注册 conversion-gen 生成的函数。
// 优先级低于 AddConversionFunc。
func (s *Scheme) AddGeneratedConversionFunc(a, b interface{}, fn conversion.ConversionFunc) error {
return s.converter.RegisterGeneratedUntypedConversionFunc(a, b, fn)
}
签名一样,但走的是 generatedConversionFuncs 这个"低优先级 map"。这俩 map 在 Convert() 里的查找顺序是:先 conversionFuncs,后 generatedConversionFuncs。看下面源码:
// staging/src/k8s.io/apimachinery/pkg/conversion/converter.go(行 197-225)
func (c *Converter) Convert(src, dest interface{}, meta *Meta) error {
pair := typePair{reflect.TypeOf(src), reflect.TypeOf(dest)}
scope := &scope{converter: c, meta: meta}
// 1) ignore list:直接返回 nil
if _, ok := c.ignoredUntypedConversions[pair]; ok {
return nil
}
// 2) 手写优先
if fn, ok := c.conversionFuncs.untyped[pair]; ok {
return fn(src, dest, scope)
}
// 3) 生成版兜底
if fn, ok := c.generatedConversionFuncs.untyped[pair]; ok {
return fn(src, dest, scope)
}
// 4) 都没有 → 报错 "unknown conversion"
...
return fmt.Errorf("converting (%s) to (%s): unknown conversion", sv.Type(), dv.Type())
}
这套"两层 + 忽略"三档优先级就是 Converter 灵活性的根因。一个真实场景:假设 conversion-gen 把 v1.Pod.Spec.NodeSelector 转 core.Pod 时漏了某个新加的 annotation,你不用动 zz_generated 文件,只要在 init 代码里 AddConversionFunc 一次,下一次 Convert() 就会用你的版本。
// staging/src/k8s.io/apimachinery/pkg/conversion/converter.go(行 177-190)
// RegisterIgnoredConversion 把"类型对"标记为 no-op。
// 任何请求转 (from, to) 的动作会直接返回 nil。
func (c *Converter) RegisterIgnoredConversion(from, to interface{}) error {
typeFrom := reflect.TypeOf(from)
typeTo := reflect.TypeOf(to)
if typeFrom.Kind() != reflect.Pointer { return ... }
if typeTo.Kind() != reflect.Pointer { return ... }
c.ignoredUntypedConversions[typePair{typeFrom, typeTo}] = struct{}{}
return nil
}
这个 API 看似奇怪,实际用途是"显式丢弃"。比如新版某字段不再需要,对应的转换就注册成 ignore——Converter 看到这类型对会直接返回 nil,连"空 struct 拷贝"都省了。
| API | 存入哪个 map | 查找优先级 | 典型场景 |
|---|---|---|---|
| AddConversionFunc | conversionFuncs | 2(高) | 人工订正 conversion-gen 生成的函数;特殊业务转换 |
| AddGeneratedConversionFunc | generatedConversionFuncs | 3(低) | conversion-gen 工具生成的 zz_generated.conversion.go 默认调用 |
| AddIgnoredConversionType | ignoredUntypedConversions | 1(最高) | 明确"这对类型不转",节省运行时拷贝 |
Converter 是个"沉默的零件",它从 NewScheme() 开始就跟着 Scheme 一起被创建,但没人会直接 new 它。我们看 NewScheme 源码:
// staging/src/k8s.io/apimachinery/pkg/runtime/scheme.go(行 100-119)
// NewScheme 创建一个新的 Scheme。
func NewScheme() *Scheme {
s := &Scheme{
gvkToType: map[schema.GroupVersionKind]reflect.Type{},
typeToGVK: map[reflect.Type][]schema.GroupVersionKind{},
unversionedTypes: map[reflect.Type]schema.GroupVersionKind{},
unversionedKinds: map[string]reflect.Type{},
fieldLabelConversionFuncs: map[schema.GroupVersionKind]FieldLabelConversionFunc{},
defaulterFuncs: map[reflect.Type]func(interface{}){},
validationFuncs: map[reflect.Type]func(...)field.ErrorList{},
versionPriority: map[string][]string{},
schemeName: naming.GetNameFromCallsite(internalPackages...),
}
// ① 创建一个空 Converter
s.converter = conversion.NewConverter(nil)
// ② 默认注册一些"基本类型"对:map、string、[]byte
utilruntime.Must(RegisterEmbeddedConversions(s))
utilruntime.Must(RegisterStringConversions(s))
return s
}
NewScheme 做三件事:
看完 NewScheme,我们看 NewConverter 本身:
// staging/src/k8s.io/apimachinery/pkg/conversion/converter.go(行 50-65)
// NewConverter 创建一个新的 Converter。
// NameFunc 参数仅为向后兼容,可传 nil。
func NewConverter(NameFunc) *Converter {
c := &Converter{
conversionFuncs: NewConversionFuncs(),
generatedConversionFuncs: NewConversionFuncs(),
ignoredUntypedConversions: make(map[typePair]struct{}),
}
// 默认注册 []byte → []byte 的特殊转换(不递归进每个 byte)
c.RegisterUntypedConversionFunc(
(*[]byte)(nil), (*[]byte)(nil),
func(a, b interface{}, s Scope) error {
return Convert_Slice_byte_To_Slice_byte(a.(*[]byte), b.(*[]byte), s)
},
)
return c
}
注意 Convert_Slice_byte_To_Slice_byte 这个特殊处理——为什么不走默认的反射拷贝?因为 []byte 在反射里会被当作 []uint8,Converter 默认会逐元素拷贝,而 []byte 在网络里经常是整段二进制,逐 byte 拷贝既慢又会引入不必要的内存分配。所以 Converter 专门给 []byte 留了"特殊通道"——这就是为什么 NewConverter 一上来就注册这个 pair。
Scheme 与 Converter 的关系
Scheme 持有一个 converter *Converter 字段(Scheme.Converter() 可拿到)。所有"用户级 API"(AddConversionFunc、Convert、ConvertToVersion)都在 Scheme 上;Converter 只做"内部 map + 反射执行"这一段。Scheme 负责"找目标版本对应的 Go 类型",Converter 负责"找到了之后怎么转"。
再看 RegisterEmbeddedConversions,这个函数注册的是 k8s 内部用的特殊类型对——比如 resource.Quantity、metav1.Time 等:
// staging/src/k8s.io/apimachinery/pkg/runtime/embedded_conversion.go(节选)
// RegisterEmbeddedConversions 注册 apimachinery 自身类型的转换函数。
func RegisterEmbeddedConversions(s *runtime.Scheme) error {
if err := s.AddGeneratedConversionFunc(
(*utilintstr.IntOrString)(nil), (*intstr.IntOrString)(nil),
func(a, b interface{}, s conversion.Scope) error {
return Convert_intstr_IntOrString_To_utilintstr_IntOrString(a, b, s)
}); err != nil { return err }
// ... 几百个 util 包类型之间的转换 ...
return nil
}
这是 Converter 收到的第一波注册。第二波发生在每个 API 包的 init() 里——下面我们就讲它。
现在到最核心的部分了:用户给一个对象 obj 和目标 targetGV,Converter 是怎么把它转出来的?完整调用链是 3 步:
用户: scheme.ConvertToVersion(obj, targetGV)
↓
Scheme.convertToVersion() 【选目标 Go 类型 + 拷贝】
↓
Scheme.converter.Convert() 【查表 + 执行】
↓
找到的转换函数 / 反射默认拷贝
我们一节一节拆。
// staging/src/k8s.io/apimachinery/pkg/runtime/scheme.go(行 472-486)
// ConvertToVersion 是用户最常用的入口:把 obj 转到 target 指定的版本。
// "safe" 表示会先 DeepCopy,再转。
func (s *Scheme) ConvertToVersion(in Object, target GroupVersioner) (Object, error) {
return s.convertToVersion(true, in, target) // copy=true
}
// UnsafeConvertToVersion:原地转(不拷贝)。性能高但会修改 in。
func (s *Scheme) UnsafeConvertToVersion(in Object, target GroupVersioner) (Object, error) {
return s.convertToVersion(false, in, target) // copy=false
}
关键区别是 copy 参数。ConvertToVersion 会调 DeepCopyObject 拷贝输入,避免污染调用方;UnsafeConvertToVersion 不会,直接转。生产中 90% 场景用 ConvertToVersion。
// staging/src/k8s.io/apimachinery/pkg/runtime/scheme.go(行 488-564)
func (s *Scheme) convertToVersion(copy bool, in Object, target GroupVersioner) (Object, error) {
var t reflect.Type
// 1) 取到 in 的反射类型
if u, ok := in.(Unstructured); ok {
// Unstructured 类型:先转成 typed 对象
typed, err := s.unstructuredToTyped(u)
if err != nil { return nil, err }
in = typed
t = reflect.TypeOf(in).Elem()
} else {
t = reflect.TypeOf(in).Elem()
if t.Kind() != reflect.Struct { return nil, fmt.Errorf(...) }
}
// 2) 查 GVK 映射
kinds, ok := s.typeToGVK[t]
if !ok { return nil, NewNotRegisteredErrForType(s.schemeName, t) }
// 3) 问 target:"你想用这个 kinds 列表里的哪个版本?"
gvk, ok := target.KindForGroupVersionKinds(kinds)
if !ok {
// 找不到目标版本 → 可能是 unversioned 类型
if unversionedKind, ok := s.unversionedTypes[t]; ok {
return copyAndSetTargetKind(copy, in, unversionedKind)
}
return nil, NewNotRegisteredErrForTarget(s.schemeName, t, target)
}
// 4) 如果目标版本就是当前类型,啥也不做,直接返回(设个 GVK 即可)
for _, kind := range kinds {
if gvk == kind { return copyAndSetTargetKind(copy, in, gvk) }
}
// 5) 拿到目标版本的 Go 类型
out, err := s.New(gvk) // 用 reflect.New(t) 创建空对象
if err != nil { return nil, err }
// 6) safe 模式:先深拷贝输入
if copy { in = in.DeepCopyObject() }
// 7) 关键:构造 meta,把 target 当 Context 塞进去
meta := s.generateConvertMeta(in)
meta.Context = target
// 8) 调用 Converter.Convert,传入 in、out、meta
if err := s.converter.Convert(in, out, meta); err != nil { return nil, err }
setTargetKind(out, gvk)
return out, nil
}
这一段最关键的两件事:① 定位目标版本的 Go 类型(第 3-5 步);② 把 target GroupVersioner 塞进 meta.Context(第 7 步)。前者让 Converter 知道"把数据填到哪个 Go 类型里";后者让转换函数内部能感知"我要转成哪个版本"。
这里有个细节:target.KindForGroupVersionKinds(kinds) 是 GroupVersioner 接口的方法。实际传进来的 target 多半是 schema.GroupVersion(单个版本)或 runtime.MultiGroupVersioner(多个版本中选一个)。
// staging/src/k8s.io/apimachinery/pkg/conversion/converter.go(行 197-225)
func (c *Converter) Convert(src, dest interface{}, meta *Meta) error {
pair := typePair{reflect.TypeOf(src), reflect.TypeOf(dest)}
scope := &scope{converter: c, meta: meta}
// ① 忽略列表:直接返回 nil(最速通道)
if _, ok := c.ignoredUntypedConversions[pair]; ok {
return nil
}
// ② 手写函数:最高优先级(除 ignore 外)
if fn, ok := c.conversionFuncs.untyped[pair]; ok {
return fn(src, dest, scope)
}
// ③ 自动生成函数:兜底
if fn, ok := c.generatedConversionFuncs.untyped[pair]; ok {
return fn(src, dest, scope)
}
// ④ 三处都没有 → 报 "unknown conversion"
...
return fmt.Errorf("converting (%s) to (%s): unknown conversion", sv.Type(), dv.Type())
}
整段逻辑三档优先级一目了然。注意它没有"反射字段遍历"这个分支——很多人误以为 k8s 转换是"反射默认拷贝" + "重写覆盖",但 1.36 版本里 Convert_Slice_byte_To_Slice_byte 是唯一注册的反射型特例。普通 struct 转换,必须显式注册,否则就是 unknown conversion 错误。
⚠️ 踩坑警告
很多人写自定义资源时,调 scheme.ConvertToVersion(myCR, internalGV) 报 unknown conversion,根因 90% 是 没调 conversion-gen 生成 zz_generated.conversion.go,或者 AddToScheme 顺序错了,导致目标类型没注册。Converter 不会"猜"——它只认注册表里有的函数。
conversion-gen 生成的转换函数长这样(这是真正"工作"的代码):
// staging/src/k8s.io/api/core/v1/zz_generated.conversion.go(节选 Convert_v1_Pod_To_core_Pod)
func Convert_v1_Pod_To_core_Pod(in *v1.Pod, out *core.Pod, s conversion.Scope) error {
// 1) 内嵌字段 ObjectMeta:递归转换
if err := metav1.Convert_v1_ObjectMeta_To_core_ObjectMeta(&in.ObjectMeta, &out.ObjectMeta, s); err != nil {
return err
}
// 2) 简单字段:直接赋值
out.Spec.RestartPolicy = core.RestartPolicy(in.Spec.RestartPolicy)
out.Spec.DNSPolicy = core.DNSPolicy(in.Spec.DNSPolicy)
// 3) 复杂切片:用 scope.Convert 递归
if in.Spec.Containers != nil {
in, out := &in.Spec.Containers, &out.Spec.Containers
*out = make([]core.Container, len(*in))
for i := range *in {
if err := s.Convert(&(*in)[i], &(*out)[i], 0); err != nil {
return err
}
}
}
return nil
}
关键技法:所有嵌套类型转换都走 s.Convert(&a, &b, 0),而不是直接调 Convert_v1_Container_To_core_Container。这有两个好处:
第 3 个参数 0 是 Meta 索引,但实际没用到——Meta 只在"最外层"调用时设置一次。1.36 这版直接传 0,意思是"用本次转换的 meta"(通过 scope 拿到)。
讲完单个版本的转换,必须讲 k8s 最聪明的设计——中心辐射式版本模型(Hub-and-Spoke)。看一段真实代码:
// staging/src/k8s.io/metrics/pkg/client/custom_metrics/converter.go(行 108-122)
// UnsafeConvertToVersionVia:先转 internal,再转 external。
// "via" 关键字就是 hub-and-spoke 的具象化。
func (c *MetricConverter) UnsafeConvertToVersionVia(obj runtime.Object, externalVersion schema.GroupVersion) (runtime.Object, error) {
// 第一步:obj (e.g. v1beta1.MetricValue) → __internal.MetricValue
objInt, err := c.scheme.UnsafeConvertToVersion(obj, schema.GroupVersion{Group: externalVersion.Group, Version: runtime.APIVersionInternal})
if err != nil { return nil, fmt.Errorf("failed to convert ... to internal version: %v", err) }
// 第二步:__internal.MetricValue → v1beta2.MetricValue
objExt, err := c.scheme.UnsafeConvertToVersion(objInt, externalVersion)
if err != nil { return nil, fmt.Errorf("failed to convert ... back to external version: %v", err) }
return objExt, err
}
注意两次调用都是 UnsafeConvertToVersion,且都过 APIVersionInternal(即 __internal)。它的定义:
// staging/src/k8s.io/apimachinery/pkg/runtime/interfaces.go
// APIVersionInternal 是内部版本的版本名"__internal"。 // 它在 Scheme 注册里以空字符串的形式存在,但用 "__internal" 来表示。 const APIVersionInternal = "__internal"
Hub-and-Spoke 的"中心"就是 __internal。每一种资源(比如 Pod、Deployment、CustomMetric)都同时存在多个外部版本(v1、v1beta1、v1beta2...)和一个内部版本(__internal,也就是 pkg/apis/core/types.go 里的那个 Pod)。所有外部版本之间互不直接转换,全都要先转 __internal,再从 __internal 转出去。
假设有 N 个外部版本(v1, v1beta1, v1beta2, ...),如果两两配对需要注册 N*(N-1) 个转换函数。比如 3 个版本就要 6 个;4 个版本要 12 个;5 个版本要 20 个——组合爆炸。
用 hub-and-spoke:每个外部版本只跟 __internal 配对,需要 2 * N 个转换函数。3 个版本只要 6 个;4 个版本只要 8 个;5 个版本只要 10 个——线性增长。
| 版本数 N | 两两配对 N*(N-1) | Hub-and-Spoke 2N |
|---|---|---|
| 2 | 2 | 4 |
| 3 | 6 | 6 |
| 4 | 12 | 8 |
| 10 | 90 | 20 |
第二个好处:__internal 是"绝对稳定"的。所有 Controller、Scheduler、kubelet 都用 internal 版本写代码,不关心外部版本怎么演进。外部版本脱保了,只需要写一份"vN → __internal"和"__internal → vN"的转换函数,核心代码一行不用改。
client 发 v1beta1 client 期望 v1beta2
│ ▲
▼ │
┌────────────┐ ┌────────────┐
│ v1beta1.X │ ─── 转 ① ──→ │ v1beta2.X │
└────────────┘ └────────────┘
│ ▲
│ ┌──────────────────┐ │
└──→│ __internal.X │←──┘
│ (稳定 hub) │
└──────────────────┘
▲
│
┌────────────┴─────────────┐
│ │
┌────────────┐ ┌────────────┐
│ v1.X │ │ v2.X │ (新增)
└────────────┘ └────────────┘
Hub-and-Spoke 还有第三个好处:它把"丢掉字段"的决策点集中。v1beta1 上的某个字段已经在 v1 里被删了,那这个字段从 v1beta1 → __internal 时就会被丢弃,不存在 v1beta1 → v1 "如何处理已删除字段"的歧义。
看 staging/src/k8s.io/metrics/pkg/client/custom_metrics/converter.go 的初始化:
// staging/src/k8s.io/metrics/pkg/client/custom_metrics/converter.go(行 50-62)
func NewMetricConverter() *MetricConverter {
return &MetricConverter{
scheme: scheme.Scheme,
codecs: serializer.NewCodecFactory(scheme.Scheme),
// 关键:定义 "version 优先级" —— 多个版本时优先选谁
internalVersioner: runtime.NewMultiGroupVersioner(
scheme.SchemeGroupVersion, // 默认版本
schema.GroupKind{Group: cmint.GroupName, Kind: ""}, // internal
schema.GroupKind{Group: cmv1beta1.GroupName, Kind: ""}, // v1beta1
schema.GroupKind{Group: cmv1beta2.GroupName, Kind: ""}, // v1beta2
),
}
}
MultiGroupVersioner 的核心作用:当你调 ConvertToVersion(obj, target) 时,target.KindForGroupVersionKinds(kinds) 会按"你给的优先级列表"挑第一个匹配的 GVK。生产中常见配置:internal 优先,多个外部版本按废弃时间倒序。
🌟 实用技巧
在你写 controller 时,99% 的情况你应该转 internal 版本(用 runtime.APIVersionInternal)来处理业务逻辑。理由:① internal 是"未来兼容"的,新版本字段加进来不会破坏 controller;② 性能——internal 类型不带 protobuf 标签,遍历更快;③ 一致性——所有 controller 都跑同一份"语义类型"。
上面讲的是内置类型(Pod、Deployment)的转换,用的是 apimachinery 的 Converter。CRD 多版本转换走的是另一条路——apiextensions-apiserver 自带的 CRConverterFactory,它把转换决策委托给用户自己的 Webhook。
// staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/conversion/converter.go(行 57-106)
// NewConverter 根据 CRD 配置决定用什么转换策略。
func (m *CRConverterFactory) NewConverter(crd *apiextensionsv1.CustomResourceDefinition) (safe, unsafe runtime.ObjectConvertor, err error) {
validVersions := map[schema.GroupVersion]bool{}
for _, version := range crd.Spec.Versions {
validVersions[schema.GroupVersion{Group: crd.Spec.Group, Version: version.Name}] = true
}
var converter crConverterInterface
switch crd.Spec.Conversion.Strategy {
case apiextensionsv1.NoneConverter:
converter = &nopConverter{} // 不转
case apiextensionsv1.WebhookConverter:
converter, err = m.webhookConverterFactory.NewWebhookConverter(crd) // 用户 Webhook
default:
return nil, nil, fmt.Errorf("unknown conversion strategy %q", crd.Spec.Conversion.Strategy)
}
// 包装一层:safe / unsafe 两份
unsafe = &crConverter{
convertScale: convertScale,
validVersions: validVersions,
clusterScoped: crd.Spec.Scope == apiextensionsv1.ClusterScoped,
converter: converter,
}
return &safeConverterWrapper{unsafe}, unsafe, nil
}
这里有两个关键点:① safe / unsafe 同时返回,分别对应"先深拷贝再转"和"原地转";② NoneConverter 是个 no-op,意味着 CRD 多版本字段不同会直接报字段丢失错误。生产中 CRD 多版本必须配 WebhookConverter。
// staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/conversion/converter.go(行 216-239)
// safeConverterWrapper 把 unsafe 包裹一层,强制先深拷贝。
type safeConverterWrapper struct {
unsafe runtime.ObjectConvertor
}
func (c *safeConverterWrapper) Convert(in, out, context interface{}) error {
inObject, ok := in.(runtime.Object)
if !ok { return fmt.Errorf("input type %T is not valid", in) }
return c.unsafe.Convert(inObject.DeepCopyObject(), out, context)
}
func (c *safeConverterWrapper) ConvertToVersion(in runtime.Object, target runtime.GroupVersioner) (runtime.Object, error) {
return c.unsafe.ConvertToVersion(in.DeepCopyObject(), target)
}
API Server 内部用 safe(因为对象可能被多处共享),业务 controller / kubectl 工具可以用 unsafe(性能更高)。这种"安全 + 高性能双轨" 是 k8s 内部的常用模式。
CRD Webhook 转换的流程
① client 发 v1 CR;② etcd 存的是 v2(Storage Version);③ API Server 看到版本不一致,调用户 ConversionReview Webhook;④ Webhook 把 v1 转成 v2 并返回;⑤ etcd 存 v2。读路径反向。整个过程 Converter 不直接干活,只负责"调用谁"。
▼ Q1: Converter 和 Scheme 是什么关系?为什么不把 Converter 直接合并到 Scheme 里?
A: Scheme 是"用户侧"接口——它管"我有什么 GVK、能 New 什么对象、Default 怎么设、Validate 怎么跑"。Converter 是"执行侧"——它只管"两个 Go 类型怎么转"。把 Converter 抽离出来有两个好处:① 转换逻辑可独立测试(converter_test.go 不依赖 Scheme);② 同一个 Converter 实例可以挂在多个 Scheme 上,多 Scheme 复用同一套转换函数。Source 上一目了然:Scheme.converter 是个独立字段,Scheme.Converter() 只是个 getter。
▼ Q2: typePair 注册的是 (源, 目标),为什么不能 A→B 自动被 B→A 用?
A: 因为 A→B 和 B→A 是两段不同代码,A→B 把字段 1 复制到字段 2,B→A 可能要把字段 2 解析回字段 1(语义不同)。k8s 的设计哲学是"显式胜于隐式",所以每个方向都要求显式注册。zz_generated.conversion.go 之所以又臭又长(动辄几万行),就是因为它把所有 N*(N-1) 方向的转换都生成了。
▼ Q3: AddConversionFunc 和 AddGeneratedConversionFunc 都注册到同一个 typePair,调用哪个?
A: 走 AddConversionFunc(手写)。Converter.Convert 里的查找顺序是 ignore → 手写 → 生成,手写 map 优先级高。这意味着如果 conversion-gen 生成的函数有 bug,你可以在 RegisterConversions 里手动 AddConversionFunc 覆盖它,完全不用动 zz_generated 文件。生产中很多"修了字段映射"的操作都是这么干的。
▼ Q4: APIVersionInternal = "__internal" 看起来很奇怪,能改吗?
A: 不能随便改。这个常量定义在 staging/src/k8s.io/apimachinery/pkg/runtime/interfaces.go,是 API Server 内部版本的实际字符串。__internal 这个名字是约定:__ 双下划线开头表示"非用户可见",internal 表示"内部使用"。所有 core 类型(Pod/Deployment/Service...)的 internal 版本都注册在 __internal 这个 GV 下。如果你写自定义 controller 想用 internal 模式,直接引用这个常量即可,不要自己造一个新名字。
▼ Q5: ConvertToVersion 和 UnsafeConvertToVersion 该用哪个?
A: 99% 场景用 ConvertToVersion。它内部会 in.DeepCopyObject() 拷贝输入,避免污染调用方。UnsafeConvertToVersion 是给已经知道输入独占的场景用的,比如 API Server 内部刚反序列化出来的对象。差别只有性能:safe 模式多一次 DeepCopy,对大对象来说可能慢 10%-30%。生产中优先 correctness,性能不够再考虑 unsafe。
▼ Q6: 调 ConvertToVersion 报 "unknown conversion" 怎么排查?
A: 99% 是忘了调 RegisterConversions。三个排查方向:① scheme.AllKnownTypes() 看 GVK 有没有注册;② scheme.Converter().conversionFuncs 里查 typePair 是否存在;③ hack/update-codegen.sh 重新跑一下 conversion-gen,zz_generated.conversion.go 没生成就是 0 候选。如果是 CRD 自定义资源,确认 CRD spec.conversion.strategy 设了 Webhook,且 Webhook 服务可访问——NoneConverter 模式下 API Server 直接忽略不兼容的版本。
▼ Q7: 为什么 conversion-gen 生成的转换函数要传 0 作为 meta 参数?
A: 这里的 0 是Meta 的索引位置(在多 Meta 场景下),但 k8s 1.36 只支持单 Meta,所以始终是 0。Scope.Convert 的第三个参数设计成"索引"是为了未来扩展,目前等价于"用本次的 Meta"。转换函数要拿 Meta 里的 Context(如目标 GroupVersion)应该用 scope.Meta().Context,而不是直接拿参数。
▼ Q8: Hub-and-Spoke 模式,__internal 一定要存在吗?我能不能不要?
A: 不能完全不要。k8s 内置类型(Pod/Deployment/Service...)的 __internal 是核心调度器、控制器、kubelet 写代码的依据。如果你写自定义 controller,可以在不引入 __internal 的情况下只用外部版本互转(CRD 默认走 Webhook 模式),但这意味着每次增加新外部版本都要写一份"X → Y"全量转换。生产中 99% 的 CRD 都会同时注册 v1 + v1beta1,且通过 Webhook 实现互转(等价于人为定义了 Webhook 版的 hub)。
▼ Q9: RegisterEmbeddedConversions 注册了哪些类型?我能加自定义类型进去吗?
A: RegisterEmbeddedConversions 只注册 staging/src/k8s.io/apimachinery/pkg 下的基础工具类型,比如 resource.Quantity、metav1.Time、intstr.IntOrString、runtime.RawExtension 等。这些是其他类型转换时绕不开的依赖,必须先注册好。RegisterStringConversions 类似,处理 string ↔ []byte。你写自定义类型时不能修改这两个函数,应该在你的 init() 里独立调 AddGeneratedConversionFunc。
▼ Q10: 转换函数对 nil 指针的处理有标准吗?
A: 没有强标准,但 convention 是:源是 nil,目标设为 nil(零值)。conversion-gen 生成的代码会显式判断 if in.X != nil { ... } 后再赋值,不会把 nil 复制成"空 struct"。手动写转换函数时建议遵循这个 convention——尤其是嵌套指针字段,否则容易出现"obj.Spec.X = &Foo{} 看着像空实际是有效指针"的诡异 bug。Converter.Convert_Slice_byte_To_Slice_byte 是经典范例:源 nil → 目标 nil。
▼ Q11: Scheme 里 unversioned 类型是什么?为什么它走"特殊规则"?
A: Unversioned 类型指那些不参与版本演进的类型,比如 runtime.TypeMeta、metav1.Status、runtime.Unknown。它们没有"v1 / v2"概念,序列化时直接用给定的 GroupVersion,不转换、不丢失。Scheme.AddUnversionedTypes 就是注册这种类型,注册后 Converter 看到这种类型直接原样返回,连查表都省了。生产中不要把业务类型注册为 unversioned——那等于放弃了未来的兼容性。
▼ Q12: conversion-gen 怎么用?什么场景下我必须自己跑一次?
A: 在 k8s 仓库里,调 hack/update-codegen.sh 会顺带跑 conversion-gen。你必须自己跑的 3 个时机:① 给 pkg/apis/<group>/<version>/types.go 加了新字段;② 新增了一个 GroupVersion(v1 → v2);③ 新增了内部类型。运行后 zz_generated.conversion.go 会被更新,不要手改这个文件(开头有 DO NOT EDIT 注释)。验证手段:跑 hack/verify-codegen.sh,看是不是 0 diff。
▼ Q13: 我能在 conversion-gen 生成的函数里加自定义逻辑吗?
A: 强烈不建议。zz_generated.conversion.go 每次跑 hack/update-codegen.sh 都会被覆盖。你应该:① 在 pkg/apis/<group>/<version>/conversion.go 写一个手写转换函数,覆盖生成的;② 用 scheme.AddConversionFunc 注册手写版本(高优先级)。这样下次 codegen 不会清掉你的逻辑,且行为可预测。生产中 conversion-gen 漏掉的"业务级映射"(比如把 enum 字符串重新编码)都走这条路。
▼ Q14: AddIgnoredConversionType 用在什么场景?
A: 三类场景:① 显式丢弃字段——v1beta1 某个字段在 v1 中已删除,且后续不需要回填;② 性能优化——某些已知"无字段差异"的对(如 runtime.TypeMeta),注册 ignore 节省一次反射遍历;③ 类型兼容——某些历史遗留类型对根本不会发生转换,注册 ignore 让误调用直接返回 nil 而不是报 unknown conversion。生产中 ① 最常见。
▼ Q15: 内置类型(Pod)和 CRD 类型,转换的差异在哪?
A: 内置类型编译时就知道 Go 类型,所以走 apimachinery 的 Converter——查 typePair 找到 ConversionFunc,直接执行。CRD 类型是 Unstructured(map[string]interface{}),Go 类型在运行时才确定(看 CRD spec),所以 apiextensions 自带 CRConverterFactory 把转换委托给 Webhook。Webhook 拿到的是 JSON,可以"看字段名"决定怎么转。本质区别是Converter 用 Go 类型,CRD Converter 用 JSON 字段名。
▼ Q16: 转换函数里的 Scope 参数和 Scheme 是什么关系?
A: Scope 是转换函数内的"局部转换器"。它的 Convert 方法永远绑在最初调用的那个 Converter 上——即使中间用 WithConversions 复制出了新 Converter,原有 Scope 仍然指向原 Converter。这是为了避免递归爆栈:你 scope.Convert(&a, &b) 用的是同一个 (a,b),Converter 不会无限递归。Scope.Meta() 拿到的是"最初那次调用的 Meta",方便转换函数内部感知"我这次要转成哪个版本"。
▼ Q17: 怎么让 CRD 多版本用相同的 hub 模式(__internal)?
A: CRD 的 spec.conversion.strategy 设为 Webhook,Webhook 实现里自己定义一个内部 JSON 结构作为 hub。流程是 v1 → hub → v2 或 v2 → hub → v1。Webhook 的代码通常这么写:① 定义一个 Go struct 模拟 hub;② 调 v1 → hub 函数、hub → v2 函数;③ 用 json.Marshal/Unmarshal 序列化回到 JSON 写回 ConversionReview Response。生产中 kube-builder 的 conversion webhook scaffold 直接帮你生成这套模板。
▼ Q18: Convert_Slice_byte_To_Slice_byte 为什么是 Converter 唯一内置的反射型转换?
A: 因为 []byte 在 Go 反射里被识别为 []uint8,Converter 默认会逐元素拷贝(相当于逐 byte 拷贝)。但 []byte 在网络传输、配置文件、Secret 数据中经常是整段二进制(图片、证书、压缩数据),逐 byte 拷贝既慢又浪费内存。所以 NewConverter 一开始就显式注册了 []byte → []byte 的特殊转换,用 copy(*out, *in) 整段复制。生产中你不需要为 []byte 单独写转换函数,Converter 已经帮你处理了。
▼ Q19: 在 controller 里用 internal 版本处理业务,外部版本怎么下发?
A: 典型模式:① Informer 拿到的对象在 memory cache 里就是 internal 版本(因为 conversion 发生在 watch 阶段);② Reconcile 逻辑全部基于 internal 类型编写;③ 当你要 Update / Patch 时,调 scheme.ConvertToVersion(obj, externalGV) 转回外部版本。controller-runtime 的 client.Update 会自动处理转换,所以你直接传 internal 对象即可。但如果用 raw client-go,则需要自己 ConvertToVersion。
▼ Q20: 怎么写一个"自己版本"的转换函数(手写覆盖生成)?能给个例子吗?
A: 假设 corev1.Pod.Spec.NodeSelector 转 core.Pod 时你想做"key=foo 时改成 key=bar"的业务级转换,conversion-gen 不会做。写法:① 在 pkg/apis/core/v1/conversion.go 写 func Convert_v1_PodSpec_To_core_PodSpec(in *v1.PodSpec, out *core.PodSpec, s conversion.Scope) error;② 在 RegisterConversions 里 scheme.AddConversionFunc((*v1.PodSpec)(nil), (*core.PodSpec)(nil), fn) 注册;③ 不要删除 zz_generated.conversion.go 里的生成版(它会被覆盖不调用)。生产中这种"业务订正"往往出现在"deprecation 转换"和"enum 重命名"两类场景。
回过头看 Converter 这个模块,它的设计哲学是 "把多版本当成一等公民,用中央注册表缝合它们"。三个核心心智模型:
① Converter 是个"类型对查表器"
↑
② Scheme 是个"GVK ↔ Go 类型"路由器
↑
③ Hub-and-Spoke 用 __internal 做"中转站"
↑
④ CRD 多版本通过 apiextensions 单独走 Webhook
我们这一篇从 数据结构(typePair、ConversionFunc、Converter、Scope、Meta)入手,到 注册机制(AddXxx 三件套),再到 初始化(NewScheme + NewConverter + RegisterConversions),最后到 实际转换(Convert → ConvertToVersion → 转换函数递归)。看完后你应该能够:
下一步建议:如果你还没读过 Informer,建议接着看 client-go 那一块——它和 Converter 一起,构成了 k8s "监听 + 转换"的两条腿。再之后可以看 Webhook 那一块(Mutating / Validating / Conversion),把"拦截 → 转换 → 持久化"这条链路彻底打通。
Kubernetes 编程 / Operator 专题【左扬精讲】—— Converter 资源版本转换器 · 全文完
源码基于 k8s 1.36.1;引用的核心文件:staging/src/k8s.io/apimachinery/pkg/conversion/converter.go、staging/src/k8s.io/apimachinery/pkg/runtime/scheme.go、staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/conversion/converter.go、pkg/apis/core/v1/zz_generated.conversion.go
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。