惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

P
Privacy International News Feed
云风的 BLOG
云风的 BLOG
E
Exploit-DB.com RSS Feed
GbyAI
GbyAI
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
S
SegmentFault 最新的问题
B
Blog
Schneier on Security
Schneier on Security
Scott Helme
Scott Helme
美团技术团队
博客园 - Franky
S
Security @ Cisco Blogs
D
Darknet – Hacking Tools, Hacker News & Cyber Security
Google Online Security Blog
Google Online Security Blog
G
GRAHAM CLULEY
The GitHub Blog
The GitHub Blog
AI
AI
Recorded Future
Recorded Future
博客园 - 三生石上(FineUI控件)
The Cloudflare Blog
Y
Y Combinator Blog
N
Netflix TechBlog - Medium
博客园_首页
C
Check Point Blog
Hacker News: Ask HN
Hacker News: Ask HN
Jina AI
Jina AI
Cyberwarzone
Cyberwarzone
酷 壳 – CoolShell
酷 壳 – CoolShell
P
Palo Alto Networks Blog
T
Troy Hunt's Blog
AWS News Blog
AWS News Blog
L
LangChain Blog
Help Net Security
Help Net Security
I
Intezer
W
WeLiveSecurity
D
Docker
H
Hacker News: Front Page
P
Proofpoint News Feed
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
小众软件
小众软件
S
Schneier on Security
G
Google Developers Blog
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
V
V2EX - 技术
N
News and Events Feed by Topic
C
CERT Recently Published Vulnerability Notes
阮一峰的网络日志
阮一峰的网络日志
罗磊的独立博客
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
Blog — PlanetScale
Blog — PlanetScale

博客园 - 左扬

Kubernetes 编程 / Operator 专题【左扬精讲】—— RESTMapper:把 Group / Version / Kind / Resource 四元组翻译成 REST 路径的"查字典"大师 Kubernetes 编程 / Operator 专题【左扬精讲】—— OwnerReference / Finalizer / 准入控制:k8s 资源生命周期的三大支柱 Kubernetes 编程 / Operator 专题【左扬精讲】—— controller-runtime 框架内幕:从 Manager 到 Reconcile 的全栈拆解 Kubernetes 编程 / Operator 专题【左扬精讲】—— 生产级 Operator 最佳实践:并发安全、资源清理与高可用设计 Kubernetes 编程 / Operator 专题【左扬精讲】—— application-operator Reconcile 循环源码精讲:从 client-go Informer 到 workqueue 的全链路解剖 Kubernetes 编程 / Operator 专题【左扬精讲】—— 从零搭建一个 application-operator 新项目:脚手架、API 设计与基于原生 DeploymentStatus/ServiceStatus 的状态建模 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:生产级 Controller 实践:并发安全、资源清理与高可用设计 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析: Controller 调试与诊断工具:从日志分析到问题定位 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:DynamicClient 操作 CRD:无需代码生成的动态操作 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:控制器与 APIServer 完整交互流程:从 Watch 到缓存同步 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:错误处理与重试机制:WorkQueue 限速器详解 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:Leader 选举机制:高可用控制器的必备技能 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:Controller 开发模式完整实战 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:SharedInformerFactory 与等待缓存同步 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:从认证配置到 Deployment 操作 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:版本对应、架构组件与组件关系 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:Informer 源码深度解析:从底层原理到实战应用 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:Reflector 源码深度解析 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:ListWatcher 源码深度解析 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:Indexer 与 ThreadSafeStore 核心原理与源码深度剖析 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:DeltaFIFO 核心原理与源码深度剖析 Kubernetes 编程 / Operator 专题【左扬精讲】—— Client-go 源代码分析:workqueue 核心原理与实战 Kubernetes 编程 / Operator 专题【左扬精讲】—— runtime.Codec 资源编解码:serializer 与 codec 差异、编解码数据结构、codec 核心调用链路 Kubernetes 编程 / Operator 专题【左扬精讲】—— Scheme 资源注册机制全解 Kubernetes 编程 / Operator 专题【左扬精讲】—— Kubernetes 自定义资源的内部版本与外部版本:从源码看版本定义机制 Kubernetes 编程 / Operator 专题【左扬精讲】—— Kubernetes 1.36.1 核心 API 数据结构全解 Kubernetes 编程 / Operator 专题【左扬精讲】—— Kubernetes 构建过程 【AIOPS】一文读懂LLM【左扬精讲】:从诞生到普及,解锁大语言模型的核心密码 【AIOPS】AI Agent 专题【左扬精讲】核心功能篇:MCP-VictoriaMetrics Hooks 源码精讲:Hooks 可观测性的无侵入式实现 【AIOPS】AI Agent 专题【左扬精讲】核心功能篇:MCP-VictoriaMetrics Golang 配置解析源码精讲 ——SRE 自定义 Agent 核心技巧 【AIOPS】AI Agent 专题【左扬精讲】核心功能篇:MCP-VictoriaMetrics Golang 并发模型解析 ——SRE 应对高并发采集的调优思路 【AIOPS】AI Agent 专题【左扬精讲】基础架构篇:MCP-VictoriaMetrics Golang 源码整体架构拆解 ——SRE 必懂的核心模块与数据流 OpenTelemetry 开发实战【左扬精讲】—— 云原生可观测体系构建与分布式追踪二次开发 Kubernetes 编程 / Operator 专题【左扬精讲】—— Operator 开发实战项目 7 —— 基于流量预测模型的智能弹性扩缩容 Operator 实战(AIOps 模型训练与智能扩容(下篇)—— 预测式弹性扩缩容 Operator 落地实现) Kubernetes 编程 / Operator 专题【左扬精讲】—— Operator 开发实战项目 7 —— 基于流量预测模型的智能弹性扩缩容 Operator 实战(AIOps 模型训练与智能扩容(上篇)—— 时序预测模型构建与离线训练) Kubernetes 编程 / Operator 专题【左扬精讲】—— Operator 开发实战项目 6 —— 基于运维专家知识库的智能故障诊断与排查 Operator 实战 Kubernetes 编程 / Operator 专题【左扬精讲】—— Operator 开发实战项目 5 —— 基于大语言模型(LLM)的实时日志流智能监测 Operator 实现 Kubernetes 编程 / Operator 专题【左扬精讲】—— Operator 开发实战项目 4 —— 基于 Operator 实现大模型私有化部署与管理 Kubernetes 编程 / Operator 专题【左扬精讲】—— Operator 开发实战项目 3(上篇)—— 面向 AI / 算力调度场景:GPU 竞价实例资源池统一调度管理 Operator 开发 Kubernetes编程 / Operator专题【左扬精讲】—— Operator 开发实战项目 2 —— 面向零售 / 电商潮汐流量难题:多云多集群数据中心级全链路弹性伸缩 DataCenter Scaler Operator 从 0 到 1 全链路开发 Kubernetes编程 / Operator专题【左扬精讲】—— 深入理解Kubebuilder注解:为什么Operator开发离不开这些特殊注释 Kubernetes编程 / Operator专题【左扬精讲】—— Operator 开发实战项目1 —— Applicaion Operator(通用应用生命周期管理 Operator 实战) Pod 镜像拉取失败?kubectl edit pods修改镜像地址的底层原理与实操 (该方法仅为临时应急方案,并非长期解决方案) Kubernetes编程/Operator专题精讲—— 理解控制器模式 —— 控制器模式的核心原理与实现逻辑(从原理到实践) 【AIOPS】AI Agent 专题【左扬精讲】模型微调实战:一站式平台 LLaMA-Factory 【AIOPS】AI Agent 专题【左扬精讲】基于 k8s+vLLM+Ray 分布式部署全指南:架构设计、资源调度与性能优化 【AIOPS】AI Agent专题【左扬精讲】非量化版DeepSeek分布式部署全指南:精度保障、显存规划与Ollama/vLLM选型 【AIOPS】AI Agent 专题【左扬精讲】零开发框架实现 ReAct Agent(Go SRE友好)
Kubernetes 编程 / Operator 专题【左扬精讲】—— Converter 资源版本转换器
左扬 · 2026-06-15 · via 博客园 - 左扬

Kubernetes 编程 / Operator 专题【左扬精讲】—— Converter 资源版本转换器

学 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


一、为什么需要 Converter:k8s 是不可能"只活在一个版本里"的系统

在 k8s 这种持续 10 年还在迭代的项目里,"同一个东西,多个版本"是常态。举个最具体的例子——Deployment 这个资源在 1.36 之前,已经历过 extensions/v1beta1 → apps/v1beta1 → apps/v1beta2 → apps/v1 四次"搬家"。每一次"搬家"都会带来三个问题:

  1. 1老客户端还在发 extensions/v1beta1 的 YAML;
  2. 2etcd 存储只能保留一个版本(Storage Version),比如 apps/v1;
  3. 3Controller调度器认证鉴权这些内部组件,又往往基于稳定的内部类型(__internal)写代码。

这三种"版本不一致"必须被某个东西动态缝合。这个缝合的活,就是 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 拆成 5 个零件

Converter 整个模块就 200 来行,但里面每一个字段、每一个 map、每一个接口都承担特定职责。我们先把它"拆成零件",再"装回去"。下面所有源码都来自 staging/src/k8s.io/apimachinery/pkg/conversion/converter.go(行号标注 1.36.1 版本)。

2.1 typePair:注册表的"钥匙"

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。

2.2 ConversionFunc:转换函数本体

转换函数长这样——是一个普通的 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

三个参数逐个说:

  • a:源对象。Converter 约定必须是 指针,否则报错。
  • b:目标对象。同样必须是 指针,且由 Converter 自己创建(用 reflect.New),转换函数往里填数据即可。
  • scope:作用域句柄。转换函数自己内部遇到嵌套类型时,可以调 scope.Convert(src, dest) 递归转换——这就是 k8s 转换函数能"只写一层,其他全递归"的秘密。

2.3 Converter 主体:3 个 map + 3 个公共方法

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 套转换函数?这里有个隐藏的优先级机制

  • conversionFuncs:手写。优先级最高。开发者可以覆写生成出来的转换函数(比如修 bug、加字段映射)。
  • generatedConversionFuncs:自动生成。优先级次之。
  • ignoredUntypedConversions:忽略列表。最高优先级,命中即返回 nil,连查表都省了。

这个"两层注册 + 忽略列表"的设计,巧妙地解决了"代码生成 + 人工订正"的协作问题——开发人员想修一个 conversion-gen 误生成的转换,不用改 zz_generated 文件,直接在 RegisterConversions 里 AddConversionFunc 覆盖。

2.4 Scope 和 Meta:递归转换的"传话筒"

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 的。

2.5 数据结构总览:一张图看完所有零件

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 查表 + 反射。下一节看这些零件怎么被"装起来"。


三、转换函数的注册:3 个 AddXxx 的微妙区别

Converter 暴露的注册接口很少,主要有 3 个:AddConversionFunc、AddGeneratedConversionFunc、AddIgnoredConversionType。它们都是 Scheme 透传过来的——你不会直接调 converter.AddXxx,而是用 scheme.AddXxx。我们逐个看。

3.1 AddConversionFunc:手写的"覆写"入口

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

3.2 AddGeneratedConversionFunc:conversion-gen 的"自动产物"注册

// 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() 就会用你的版本。

3.3 AddIgnoredConversionType:显式声明"这对类型不需要转换"

// 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 拷贝"都省了。

3.4 三种 Add 的对照表

API存入哪个 map查找优先级典型场景
AddConversionFunc conversionFuncs 2(高) 人工订正 conversion-gen 生成的函数;特殊业务转换
AddGeneratedConversionFunc generatedConversionFuncs 3(低) conversion-gen 工具生成的 zz_generated.conversion.go 默认调用
AddIgnoredConversionType ignoredUntypedConversions 1(最高) 明确"这对类型不转",节省运行时拷贝

四、初始化:NewScheme 怎么"组装"出 Converter

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 做三件事:

  1. 1初始化 GVK → Go Type、Go Type → GVKs 两个反向 map;
  2. 2conversion.NewConverter(nil) 创建空 Converter;
  3. 3注册 string ↔ []byte、map ↔ map 等"嵌入式"基础转换。

看完 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() 里——下面我们就讲它。


五、资源版本转换的实现:从 Scheme.Convert 到 Convert_Slice_byte

现在到最核心的部分了:用户给一个对象 obj 和目标 targetGV,Converter 是怎么把它转出来的?完整调用链是 3 步:

用户: scheme.ConvertToVersion(obj, targetGV)

Scheme.convertToVersion() 【选目标 Go 类型 + 拷贝】

Scheme.converter.Convert() 【查表 + 执行】

找到的转换函数 / 反射默认拷贝

我们一节一节拆。

5.1 Scheme.ConvertToVersion 入口

// 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。

5.2 convertToVersion:定位目标 Go 类型 + 准备 meta

// 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(多个版本中选一个)。

5.3 Converter.Convert 查表执行

// 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 不会"猜"——它只认注册表里有的函数。

5.4 转换函数内部:用 scope.Convert 递归

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。这有两个好处:

  • ① 走 s.Convert 意味着走完整优先级——手写函数能覆盖自动生成函数;
  • ② 未来如果某个嵌套类型加了 ignore,手写优先级也会生效——行为可预测

第 3 个参数 0 是 Meta 索引,但实际没用到——Meta 只在"最外层"调用时设置一次。1.36 这版直接传 0,意思是"用本次转换的 meta"(通过 scope 拿到)。


六、Hub-and-Spoke 模式:为什么所有版本都要"绕一圈"内部版本

讲完单个版本的转换,必须讲 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 转出去

6.1 为什么要绕一圈?

假设有 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"的转换函数,核心代码一行不用改

6.2 Hub-and-Spoke 的数据流图

  client 发 v1beta1                       client 期望 v1beta2
        │                                          ▲
        ▼                                          │
  ┌────────────┐                            ┌────────────┐
  │ v1beta1.X  │  ─── 转 ① ──→            │ v1beta2.X  │
  └────────────┘                            └────────────┘
                       │                          ▲
                       │   ┌──────────────────┐   │
                       └──→│   __internal.X   │←──┘
                           │   (稳定 hub)     │
                           └──────────────────┘
                                    ▲
                                    │
                       ┌────────────┴─────────────┐
                       │                          │
                ┌────────────┐              ┌────────────┐
                │   v1.X     │              │  v2.X      │  (新增)
                └────────────┘              └────────────┘

Hub-and-Spoke 还有第三个好处:它把"丢掉字段"的决策点集中。v1beta1 上的某个字段已经在 v1 里被删了,那这个字段从 v1beta1 → __internal 时就会被丢弃,不存在 v1beta1 → v1 "如何处理已删除字段"的歧义

6.3 真实案例:MetricConverter 的版本选择

看 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 都跑同一份"语义类型"。


七、CRD 多版本转换:当 Webhook 接管 Converter

上面讲的是内置类型(Pod、Deployment)的转换,用的是 apimachinery 的 Converter。CRD 多版本转换走的是另一条路——apiextensions-apiserver 自带的 CRConverterFactory,它把转换决策委托给用户自己的 Webhook

7.1 CRD 转换的入口:CRConverterFactory.NewConverter

// 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

7.2 safe vs unsafe:为啥要分两份

// 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 不直接干活,只负责"调用谁"


八、FAQ(20 个高频问题)

▼ 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 这个模块,它的设计哲学是 "把多版本当成一等公民,用中央注册表缝合它们"。三个核心心智模型:

① Converter 是个"类型对查表器"
    ↑
② Scheme 是个"GVK ↔ Go 类型"路由器
    ↑
③ Hub-and-Spoke 用 __internal 做"中转站"
    ↑
④ CRD 多版本通过 apiextensions 单独走 Webhook

我们这一篇从 数据结构(typePair、ConversionFunc、Converter、Scope、Meta)入手,到 注册机制(AddXxx 三件套),再到 初始化(NewScheme + NewConverter + RegisterConversions),最后到 实际转换(Convert → ConvertToVersion → 转换函数递归)。看完后你应该能够:

  • ① 准确解释 unknown conversion 错误的根因;
  • ② 在 controller 里用 internal 版本做业务处理,external 版本对外通信;
  • ③ 知道 conversion-gen 的产物在哪、什么时候该跑、什么时候该手写覆盖;
  • ④ 在 CRD 多版本时正确选 Conversion Strategy: Webhook,理解它和内置 Converter 的差异;
  • ⑤ 在排查"集群状态对不齐"问题时,知道去看 Converter 三个 map 是不是漏注册。

下一步建议:如果你还没读过 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