Kubernetes 编程 / Operator 专题【左扬精讲】—— Scheme 资源注册机制全解

Kubernetes 编程 / Operator 专题【左扬精讲】—— Scheme 资源注册机制全解

当我们写一个 YAML 文件提交给 Kubernetes 时,APIServer 怎么知道这个 YAML 对应的是 Pod、Deployment 还是 Service?当 kubectl 把 JSON 反序列化成一个 Go 对象时,它是怎么找到正确的 Go struct 的?当同一个资源有 v1、v1beta1、v1beta2 多个版本并存时,APIServer 又是怎么在不同版本之间做转换的?答案都指向同一个核心组件——Scheme。

Scheme 是 Kubernetes 类型系统的"户籍管理处"。它维护了 GVK(Group/Version/Kind)到 Go Type 的双向映射,管着版本之间的转换函数、默认值函数、字段标签转换函数,甚至还有验证函数。如果把 Kubernetes 比作一个操作系统,Scheme 就是它的"系统调用表"——所有资源类型必须先在这里"登记入户",才能被系统识别和处理。

这篇文章我们按照"为什么需要 Scheme → Scheme 的数据结构 → 资源注册入口 → 内部版本与外部版本 → 注册全流程 → 查询与创建 → 转换与默认值"的顺序,把 Scheme 的资源注册机制彻底讲透。所有源码均来自本地 Kubernetes 1.36.1 代码库,每个引用都标注了精确的文件路径。

Kubernetes 1.36.1 Go 1.24+ runtime.Scheme GVK 映射

🔑 学习重点提示  — 建议先通读全文,再重点回顾标注内容

★ 重点掌握(必须)
   • GVK 与 Go Type 的双向映射:理解 gvkToType 和 typeToGVK 两个核心 map 的含义
   • 内部版本 vs 外部版本:理解为什么 Kubernetes 需要一个 __internal 版本作为"枢纽"
   • install 包的 init() 注册模式:理解 Go 的 side-effect import 如何触发资源注册
   • SchemeBuilder 机制:理解 SchemeBuilder 如何将类型注册、默认值、转换函数组装到一起

☆ 次重点(了解即可)
   • unversionedTypes 的历史遗留含义
   • versionPriority 和 observedVersions 的优先级排序
   • ToOpenAPIDefinitionName 的 OpenAPI 映射规则


目录

  1. 一、为什么需要 Scheme —— 类型注册的必要性
  2. 二、Scheme 的核心数据结构
  3. 三、Scheme 核心字段逐行解读
  4. 四、Scheme 的初始化 —— NewScheme()
  5. 五、SchemeBuilder —— 注册函数的"积木组装器"
  6. 六、外部版本注册 —— k8s.io/api 中的 register.go
  7. 七、内部版本注册 —— pkg/apis 中的 register.go
  8. 八、所有资源的注册入口 —— install 包与 import_known_versions
  9. 九、资源注册的查询方法
  10. 十、资源对象的创建 —— Scheme.New()
  11. 十一、资源对象的版本转换
  12. 十二、资源对象的默认值设置
  13. 十三、资源字段标签转换
  14. 十四、资源对象的验证
  15. 十五、client-go 的 Scheme 与 legacyscheme 的 Scheme
  16. 十六、FAQ

一、为什么需要 Scheme —— 类型注册的必要性

我们先想一个最基本的问题:Kubernetes 支持几十种资源类型(Pod、Service、Deployment、ConfigMap……),每种资源还有多个 API 版本(v1、v1beta1、v1beta2……)。当 APIServer 收到一个 HTTP 请求,比如 POST /apis/apps/v1/namespaces/default/deployments,它需要回答三个问题:

  • 反序列化:请求 body 里的 JSON,应该解析成哪个 Go struct?
  • 版本转换:客户端提交的是 v1beta1 版本的 Deployment,但内部处理用的是 __internal 版本,怎么转?
  • 默认值填充:用户没写 replicas 字段,默认值 1 是怎么填上去的?

这三个问题,如果不用统一的注册表来解决,就得在每个地方硬编码 if-else——"如果 kind 是 Pod 就用 corev1.Pod,如果 kind 是 Deployment 就用 appsv1.Deployment……"。这显然不可维护。Scheme 就是这个统一的注册表,它让所有资源类型在一个地方集中管理,提供 GVK 到 Go Type 的双向查询、版本转换、默认值设置、验证等能力。

打个比方:Scheme 就像派出所的户籍系统。每个资源类型(Go struct)就像一个人,GVK(Group/Version/Kind)就像身份证号。一个人入户的时候,户籍系统记录"身份证号 → 人"和"人 → 身份证号"的映射。以后要查某个身份证号对应谁,或者某个人有哪些身份证号,都可以快速查到。

二、Scheme 的核心数据结构

Scheme 的完整定义在 staging/src/k8s.io/apimachinery/pkg/runtime/scheme.go 中。我们先把它的全貌看一遍:

  type Scheme struct {
    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(ctx context.Context, op operation.Operation, object, oldObject interface{}) field.ErrorList
    converter                 *conversion.Converter
    versionPriority           map[string][]string
    observedVersions          []schema.GroupVersion
    schemeName                string
}

Scheme 结构体一共有 11 个字段。我们用一张数据结构关系图把它的核心字段之间的关系展示出来:

Scheme
├── gvkToType  ────────→ map[GroupVersionKind]reflect.Type        // 正向映射:GVK → Go Type
├── typeToGVK  ────────→ map[reflect.Type][]GroupVersionKind      // 反向映射:Go Type → GVK 列表
├── unversionedTypes  ─→ map[reflect.Type]GroupVersionKind        // 无版本类型(历史遗留)
├── unversionedKinds  ─→ map[string]reflect.Type                  // Kind名 → Go Type(无版本)
├── defaulterFuncs    ─→ map[reflect.Type]func(interface{})       // 默认值函数
├── validationFuncs   ─→ map[reflect.Type]func(...)field.ErrorList // 验证函数
├── fieldLabelConversionFuncs ─→ map[GVK]FieldLabelConversionFunc  // 字段标签转换
├── converter         ─→ *conversion.Converter                     // 版本转换引擎
├── versionPriority   ─→ map[string][]string                       // 组内版本优先级
├── observedVersions  ─→ []GroupVersion                            // 观察到的所有版本
└── schemeName        ─→ string                                     // Scheme 名称(调试用)

三、Scheme 核心字段逐行解读

3.1 gvkToType —— 正向映射表

这是 Scheme 最核心的字段。它记录了"一个 GVK 对应哪个 Go struct"。比如:

  • apps/v1.Deploymentappsv1.Deployment(即 k8s.io/api/apps/v1 包里的 Deployment 结构体)
  • apps/__internal.Deploymentapps.Deployment(即 k8s.io/kubernetes/pkg/apis/apps 包里的 Deployment 结构体)
  • "/v1.Pod"corev1.Pod(core 组 Group 为空字符串)

注意:一个 Go Type 可以被注册到多个 GVK(因为同一个 struct 在多个版本里都可能出现),但一个 GVK 只能对应一个 Go Type。如果重复注册同一个 GVK 到不同的 Type,会直接 panic。

3.2 typeToGVK —— 反向映射表

和 gvkToType 相反,typeToGVK 记录了"一个 Go struct 对应哪些 GVK"。值是切片而非单个值,因为同一个 Go Type 可能对应多个版本。比如内部版本的 pkg/apis/apps.Deployment 这个 Go Type,同时注册为 apps/__internal.Deployment;而外部版本的 k8s.io/api/apps/v1.Deployment,只注册为 apps/v1.Deployment。

在版本转换时,Scheme 会用这个 map 来查一个 Go Type 有哪些可用的 GVK,然后选择目标版本来执行转换。

3.3 defaulterFuncs —— 默认值函数表

key 是 Go Type(reflect.Type),value 是一个 func(interface{})。当调用 Scheme.Default(obj) 时,Scheme 会查找 obj 对应的 Go Type,然后调用注册好的默认值函数。比如 Deployment 的默认值函数会把 Replicas 设为 1、Strategy 设为 RollingUpdate、MaxUnavailable 设为 25% 等等。

3.4 converter —— 版本转换引擎

converter 是 conversion.Converter 的指针,它内部维护了两个 ConversionFuncs 表:一个是手写的转换函数(conversionFuncs),一个是代码生成的转换函数(generatedConversionFuncs)。当 Scheme 执行 Convert 时,会委托给 converter 来做实际的字段赋值和转换逻辑。

3.5 versionPriority 和 observedVersions —— 版本优先级

versionPriority 记录了每个 Group 内版本的优先级排序。比如 apps 组的优先级是 [v1, v1beta2, v1beta1],意味着 v1 是最优先的版本。observedVersions 记录了注册过程中看到的所有 GroupVersion,用于补全优先级列表。

小结:Scheme 的核心就是两个 map(gvkToType + typeToGVK)加上几个辅助功能表(defaulterFuncs、converter、fieldLabelConversionFuncs、validationFuncs)。两个 map 构成双向索引,辅助功能表提供默认值、转换、验证等增值能力。

四、Scheme 的初始化 —— NewScheme()

NewScheme() 是创建 Scheme 实例的工厂函数,在 staging/src/k8s.io/apimachinery/pkg/runtime/scheme.go 的第 101 行:

// staging/src/k8s.io/apimachinery/pkg/runtime/scheme.go(行 101-119)
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(ctx context.Context, op operation.Operation, object, oldObject interface{}) field.ErrorList{},
        versionPriority:           map[string][]string{},
        schemeName:                naming.GetNameFromCallsite(internalPackages...),
    }
    s.converter = conversion.NewConverter(nil)

    // 启用两个默认转换:嵌入式类型转换 和 字符串转换
    utilruntime.Must(RegisterEmbeddedConversions(s))
    utilruntime.Must(RegisterStringConversions(s))
    return s
}

初始化做了三件事:① 创建空的 map 和 converter;② 给 schemeName 赋值(通过调用栈自动推断,方便调试时区分不同的 Scheme 实例);③ 注册两个内置转换函数——RegisterEmbeddedConversions 处理嵌入结构体的转换,RegisterStringConversions 处理字符串相关的转换。这两个是所有 Scheme 都需要的"基础能力"。

Kubernetes 中最重要的 Scheme 实例是 legacyscheme.Scheme,定义在 pkg/api/legacyscheme/scheme.go:

// pkg/api/legacyscheme/scheme.go(行 24-37)
var (
    Scheme        = runtime.NewScheme()                   // 全局 Scheme,APIServer 使用的
    Codecs        = serializer.NewCodecFactory(Scheme)    // 编解码器工厂
    ParameterCodec = runtime.NewParameterCodec(Scheme)    // 查询参数编解码器
)

legacyscheme.Scheme 是 APIServer 内部使用的全局 Scheme 实例。它通过 NewScheme() 创建出来的时候是空的,什么资源都没注册。资源的注册是由各个 install 包的 init() 函数在程序启动时自动完成的——这正是下一节要讲的内容。

五、SchemeBuilder —— 注册函数的"积木组装器"

在讲具体的资源注册之前,必须先理解 SchemeBuilder,因为几乎所有资源包都靠它来组织注册逻辑。它的定义在 staging/src/k8s.io/apimachinery/pkg/runtime/scheme_builder.go 中:

// staging/src/k8s.io/apimachinery/pkg/runtime/scheme_builder.go(全文)
type SchemeBuilder []func(*Scheme) error

func (sb *SchemeBuilder) AddToScheme(s *Scheme) error {
    for _, f := range *sb {
        if err := f(s); err != nil {
            return err
        }
    }
    return nil
}

func (sb *SchemeBuilder) Register(funcs ...func(*Scheme) error) {
    for _, f := range funcs {
        *sb = append(*sb, f)
    }
}

func NewSchemeBuilder(funcs ...func(*Scheme) error) SchemeBuilder {
    var sb SchemeBuilder
    sb.Register(funcs...)
    return sb
}

SchemeBuilder 的设计非常简洁:它本质上就是一个 func(*Scheme) error 的切片。每个注册函数(如"注册类型"、"注册默认值函数"、"注册转换函数")都是这个切片里的一个元素。调用 AddToScheme 时,按顺序执行所有注册函数。

这个设计的精妙之处在于:它允许延迟组装。我们可以在包初始化时创建一个 SchemeBuilder,先放进类型注册函数,然后在 init() 里再追加默认值函数和转换函数。这样一来,即使代码生成的文件(如 zz_generated.conversion.go、zz_generated.defaults.go)还没生成,代码也能编译——因为 Register 只是往切片里追加函数,并不立即执行。

典型的使用模式如下:

// 典型模式(以 apps/v1 为例)
var localSchemeBuilder = &appsv1.SchemeBuilder  // 引用外部包的 SchemeBuilder
var AddToScheme = localSchemeBuilder.AddToScheme

func init() {
    // 在 init 中追加上手写的默认值和转换函数
    localSchemeBuilder.Register(addDefaultingFuncs)
    // zz_generated.conversion.go 的 init() 会追加 RegisterConversions
    // zz_generated.defaults.go 的 init() 会追加 RegisterDefaults
}

最终 localSchemeBuilder 里积攒了所有的注册函数:addKnownTypes(类型注册)+ addDefaultingFuncs(手写默认值)+ RegisterDefaults(生成默认值)+ RegisterConversions(转换函数)。当 install 包调用 AddToScheme 时,这些函数按顺序一次性全部执行。

六、外部版本注册 —— k8s.io/api 中的 register.go

Kubernetes 的资源类型定义在两个地方:外部版本在 staging/src/k8s.io/api/ 下,内部版本在 pkg/apis/ 下。我们先看外部版本的注册。

以 apps/v1 为例,staging/src/k8s.io/api/apps/v1/register.go 是外部版本注册的入口:

// staging/src/k8s.io/api/apps/v1/register.go
const GroupName = "apps"
var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: "v1"}

var (
    SchemeBuilder      = runtime.NewSchemeBuilder(addKnownTypes)  // 创建时就放入类型注册函数
    localSchemeBuilder = &SchemeBuilder
    AddToScheme        = localSchemeBuilder.AddToScheme
)

func addKnownTypes(scheme *runtime.Scheme) error {
    scheme.AddKnownTypes(SchemeGroupVersion,
        &Deployment{},
        &DeploymentList{},
        &StatefulSet{},
        &StatefulSetList{},
        &DaemonSet{},
        &DaemonSetList{},
        &ReplicaSet{},
        &ReplicaSetList{},
        &ControllerRevision{},
        &ControllerRevisionList{},
    )
    metav1.AddToGroupVersion(scheme, SchemeGroupVersion)  // 注册 metav1 公共类型
    return nil
}

关键逻辑在 addKnownTypes 函数中:它调用 scheme.AddKnownTypes,把 Deployment、StatefulSet 等外部版本的 Go struct 注册到 SchemeGroupVersion(即 apps/v1)下。这意味着这些类型会被映射到 GVK = {Group:"apps", Version:"v1", Kind:"Deployment"} 等键上。

另外注意最后调用 metav1.AddToGroupVersion —— 它会注册 WatchEvent、ListOptions、Status 等跨资源通用的 meta 类型,以及 Status、APIGroupList 等"无版本"类型。

💡 注意
外部版本的 register.go 只做"类型注册"这一件事。默认值函数、转换函数都不在这里注册——它们在 pkg/apis/xxx/v1/ 的子包里注册。这是因为 k8s.io/api 是对外发布的独立仓库,不应该包含 Kubernetes 内部的转换逻辑。

七、内部版本注册 —— pkg/apis 中的 register.go

内部版本的注册在 pkg/apis/ 下,每个资源组有两个层次的 register.go:一个是内部版本(__internal),一个是外部版本(v1、v1beta1 等)。我们先看内部版本的注册。

7.1 内部版本注册(pkg/apis/core/register.go)

// pkg/apis/core/register.go
const GroupName = ""
var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: runtime.APIVersionInternal}

var (
    SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
    AddToScheme   = SchemeBuilder.AddToScheme
)

func addKnownTypes(scheme *runtime.Scheme) error {
    if err := scheme.AddIgnoredConversionType(&metav1.TypeMeta{}, &metav1.TypeMeta{}); err != nil {
        return err
    }
    scheme.AddKnownTypes(SchemeGroupVersion,
        &Pod{}, &PodList{},
        &Service{}, &ServiceList{},
        &Node{}, &NodeList{},
        &ConfigMap{}, &ConfigMapList{},
        // ... 还有 20+ 种类型
    )
    return nil
}

注意两点关键区别:① Version 是 runtime.APIVersionInternal,即字符串 "__internal";② 注册的是内部版本的 Go struct(pkg/apis/core 包里的 Pod,而不是 k8s.io/api/core/v1 包里的 Pod)。内部版本的 Pod 和外部版本的 Pod 是两个完全不同的 Go struct,字段可能不同(内部版本通常有更多字段或不同的字段命名)。

7.2 外部版本注册的"二次封装"(pkg/apis/core/v1/register.go)

pkg/apis/core/v1/register.go 和 staging/src/k8s.io/api/core/v1/register.go 不同,它不是"定义外部版本类型"的地方,而是"把外部版本类型注册到同一个 Scheme 并追加默认值和转换函数"的地方:

// pkg/apis/core/v1/register.go
var (
    localSchemeBuilder = &v1.SchemeBuilder  // 指向 staging 中的 SchemeBuilder
    AddToScheme        = localSchemeBuilder.AddToScheme
)

func init() {
    // 追加手写的默认值和转换函数
    localSchemeBuilder.Register(addDefaultingFuncs, addConversionFuncs)
    // zz_generated.conversion.go 的 init() 会追加 RegisterConversions
    // zz_generated.defaults.go 的 init() 会追加 RegisterDefaults
}

这里的关键设计是:localSchemeBuilder 引用了 v1.SchemeBuilder(即 staging 中的 SchemeBuilder),然后在 init() 中往里追加 addDefaultingFuncs 和 addConversionFuncs。这意味着当最终调用 AddToScheme 时,会同时执行:staging 中的 addKnownTypes + 手写的 addDefaultingFuncs + 手写的 addConversionFuncs + 生成的 RegisterConversions + 生成的 RegisterDefaults。

对于 apps/v1 也是同样的模式:pkg/apis/apps/v1/register.go 引用 appsv1.SchemeBuilder,然后在 init() 中追加 addDefaultingFuncs。

核心理解:同一个 Go Type(如 appsv1.Deployment)会被注册到两个 SchemeGroupVersion 中——在 staging 的 register.go 里注册为 apps/v1,在 pkg/apis 的 register.go 里也注册为 apps/v1。但在 install 包中,它们不会冲突,因为 staging 的 AddToScheme 和 pkg/apis/v1 的 AddToScheme 注册的是同一组 GVK 和 Type,重复注册时 Scheme 会检测到已存在并跳过。

八、所有资源的注册入口 —— install 包与 import_known_versions

前面讲了每种资源"怎么注册",现在讲"什么时候注册"。答案在 install 包里。

每个资源组都有一个 install 子包,以 apps 组为例:

// pkg/apis/apps/install/install.go
func init() {
    Install(legacyscheme.Scheme)            // 程序启动时自动调用
}

func Install(scheme *runtime.Scheme) {
    utilruntime.Must(apps.AddToScheme(scheme))      // 注册内部版本
    utilruntime.Must(v1beta1.AddToScheme(scheme))   // 注册 v1beta1 外部版本
    utilruntime.Must(v1beta2.AddToScheme(scheme))   // 注册 v1beta2 外部版本
    utilruntime.Must(v1.AddToScheme(scheme))        // 注册 v1 外部版本
    utilruntime.Must(scheme.SetVersionPriority(     // 设置版本优先级
        v1.SchemeGroupVersion,
        v1beta2.SchemeGroupVersion,
        v1beta1.SchemeGroupVersion,
    ))
}

install 包的 Install 函数做了三件事:① 注册内部版本(apps.AddToScheme);② 注册所有外部版本(v1beta1、v1beta2、v1 的 AddToScheme);③ 设置版本优先级(v1 > v1beta2 > v1beta1)。init() 函数在包被导入时自动执行,所以只要 import 了这个 install 包,apps 组的所有资源就自动注册到 legacyscheme.Scheme 里了。

那么谁 import 了这些 install 包?答案是 pkg/controlplane/import_known_versions.go:

// pkg/controlplane/import_known_versions.go(完整文件)
package controlplane

import (
    _ "k8s.io/kubernetes/pkg/apis/admission/install"
    _ "k8s.io/kubernetes/pkg/apis/admissionregistration/install"
    _ "k8s.io/kubernetes/pkg/apis/apiserverinternal/install"
    _ "k8s.io/kubernetes/pkg/apis/apps/install"
    _ "k8s.io/kubernetes/pkg/apis/authentication/install"
    _ "k8s.io/kubernetes/pkg/apis/authorization/install"
    _ "k8s.io/kubernetes/pkg/apis/autoscaling/install"
    _ "k8s.io/kubernetes/pkg/apis/batch/install"
    _ "k8s.io/kubernetes/pkg/apis/certificates/install"
    _ "k8s.io/kubernetes/pkg/apis/coordination/install"
    _ "k8s.io/kubernetes/pkg/apis/core/install"
    _ "k8s.io/kubernetes/pkg/apis/discovery/install"
    _ "k8s.io/kubernetes/pkg/apis/events/install"
    _ "k8s.io/kubernetes/pkg/apis/extensions/install"
    _ "k8s.io/kubernetes/pkg/apis/flowcontrol/install"
    _ "k8s.io/kubernetes/pkg/apis/imagepolicy/install"
    _ "k8s.io/kubernetes/pkg/apis/networking/install"
    _ "k8s.io/kubernetes/pkg/apis/node/install"
    _ "k8s.io/kubernetes/pkg/apis/policy/install"
    _ "k8s.io/kubernetes/pkg/apis/rbac/install"
    _ "k8s.io/kubernetes/pkg/apis/resource/install"
    _ "k8s.io/kubernetes/pkg/apis/scheduling/install"
    _ "k8s.io/kubernetes/pkg/apis/storage/install"
    _ "k8s.io/kubernetes/pkg/apis/storagemigration/install"
)

这个文件用 Go 的 side-effect import(下划线 import)来触发所有 install 包的 init() 函数。当 APIServer 启动时,只要 import 了 controlplane 包,所有 24 个资源组的类型就会自动注册到 legacyscheme.Scheme 中。

整个注册链路可以用下面的流程图来概括:

APIServer 启动  →  import import_known_versions  →  触发 install 包 init()  →  调用各版本 AddToScheme  →  Scheme 表填充完成

以 core 组为例,install 包的具体注册顺序是:先注册内部版本(core.AddToScheme,注册 __internal 版本的 Go Type),再注册外部版本(v1.AddToScheme,注册 v1 版本的 Go Type + 默认值 + 转换函数),最后设置版本优先级。

九、资源注册的查询方法

注册完之后,Scheme 提供了多种查询方法来使用注册信息。下面按使用场景逐一讲解:

方法功能典型场景
ObjectKinds(obj) 通过 Go Type 查 GVK 列表 序列化时查对象属于哪个 GVK
Recognizes(gvk) 判断 GVK 是否已注册 反序列化时检查类型是否支持
KnownTypes(gv) 返回某个 GroupVersion 下所有 Kind→Type 映射 API 发现(api-resources)
AllKnownTypes() 返回所有 GVK→Type 映射 OpenAPI 文档生成
New(gvk) 根据 GVK 创建新对象 反序列化时创建目标类型实例
VersionsForGroupKind(gk) 查某个 GroupKind 有哪些可用版本 版本协商
IsGroupRegistered / IsVersionRegistered 判断某个 Group 或 Version 是否有注册类型 API 路由注册前检查

其中最常用的是 ObjectKinds 和 New,前者用于"从对象查 GVK",后者用于"从 GVK 创建对象"。它们就是反序列化的两个核心步骤:先根据 JSON 中的 apiVersion + kind 构造 GVK,再用 New 创建对应的 Go struct,最后把 JSON 字段填进去。

十、资源对象的创建 —— Scheme.New()

// staging/src/k8s.io/apimachinery/pkg/runtime/scheme.go(行 306-315)
func (s *Scheme) New(kind schema.GroupVersionKind) (Object, error) {
    if t, exists := s.gvkToType[kind]; exists {
        return reflect.New(t).Interface().(Object), nil  // 用反射创建新实例
    }
    if t, exists := s.unversionedKinds[kind.Kind]; exists {
        return reflect.New(t).Interface().(Object), nil  // 无版本类型的后备查找
    }
    return nil, NewNotRegisteredErrForKind(s.schemeName, kind)
}

New 方法的逻辑很直接:先在 gvkToType 中查找 GVK 对应的 reflect.Type,然后用 reflect.New 创建一个新的指针实例,再断言为 Object 接口返回。如果找不到,就回退到 unversionedKinds 查找;还找不到就返回未注册错误。

这个方法在反序列化时被广泛使用。当解码器从 JSON/YAML 中读出 apiVersion 和 kind 后,会构造 GVK,然后调用 Scheme.New 创建空对象,再把字段值填充进去。

十一、资源对象的版本转换

版本转换是 Scheme 最核心也最复杂的能力。Kubernetes 的版本转换遵循一个"枢纽模式":所有外部版本(v1、v1beta1、v1beta2)都不直接互相转换,而是统一转换到内部版本(__internal),再从内部版本转换到目标外部版本。这就像是航空公司的"枢纽机场"——小城市之间没有直飞航班,都要经过枢纽中转。

                    版本转换枢纽模式

   v1beta1.Deployment          v1.Deployment          v1beta2.Deployment
        │                          │                        │
        ▼                          ▼                        ▼
   ┌─────────────────────────────────────────────────────────────┐
   │              apps.Deployment(__internal 版本)               │
   │                        枢 纽                                 │
   └─────────────────────────────────────────────────────────────┘

ConvertToVersion 是版本转换的入口方法:

// staging/src/k8s.io/apimachinery/pkg/runtime/scheme.go(行 477-564,简化版)
func (s *Scheme) convertToVersion(copy bool, in Object, target GroupVersioner) (Object, error) {
    // 1. 获取 in 对象的 Go Type
    t := reflect.TypeOf(in).Elem()

    // 2. 通过 typeToGVK 查找这个 Go Type 对应的所有 GVK
    kinds, ok := s.typeToGVK[t]

    // 3. 让 target(目标版本)从 kinds 中选出最合适的 GVK
    gvk, ok := target.KindForGroupVersionKinds(kinds)

    // 4. 如果目标 GVK 就是当前 GVK,无需转换,直接复制并设置 GVK
    for _, kind := range kinds {
        if gvk == kind {
            return copyAndSetTargetKind(copy, in, gvk)
        }
    }

    // 5. 否则,创建目标 GVK 的新对象
    out, err := s.New(gvk)

    // 6. 执行转换:把 in 的字段值拷贝/转换到 out
    if err := s.converter.Convert(in, out, meta); err != nil {
        return nil, err
    }

    // 7. 设置 out 的 GVK
    setTargetKind(out, gvk)
    return out, nil
}

转换函数的注册是通过 SchemeBuilder 中的 RegisterConversions 完成的。以 apps/v1 为例,在 pkg/apis/apps/v1/zz_generated.conversion.go 中,init() 函数把 RegisterConversions 追加到 localSchemeBuilder:

// pkg/apis/apps/v1/zz_generated.conversion.go(行 38-100,节选)
func init() {
    localSchemeBuilder.Register(RegisterConversions)
}

func RegisterConversions(s *runtime.Scheme) error {
    // 注册 v1.ControllerRevision ↔ apps.ControllerRevision 的双向转换
    if err := s.AddGeneratedConversionFunc(
        (*appsv1.ControllerRevision)(nil), (*apps.ControllerRevision)(nil),
        func(a, b interface{}, scope conversion.Scope) error {
            return Convert_v1_ControllerRevision_To_apps_ControllerRevision(
                a.(*appsv1.ControllerRevision), b.(*apps.ControllerRevision), scope)
        }); err != nil {
        return err
    }
    if err := s.AddGeneratedConversionFunc(
        (*apps.ControllerRevision)(nil), (*appsv1.ControllerRevision)(nil),
        func(a, b interface{}, scope conversion.Scope) error {
            return Convert_apps_ControllerRevision_To_v1_ControllerRevision(
                a.(*apps.ControllerRevision), b.(*appsv1.ControllerRevision), scope)
        }); err != nil {
        return err
    }
    // ... 继续注册其他类型的转换函数
}

每个外部版本包都会注册两组转换函数:外部版本→内部版本 和 内部版本→外部版本。它们由 conversion-gen 代码生成器自动生成。当 Scheme.Convert 被调用时,converter 会根据源类型和目标类型查找对应的转换函数并执行。

十二、资源对象的默认值设置

当用户提交的 YAML 缺少某些字段时(比如没写 replicas),Kubernetes 需要自动填充默认值。这个能力由 defaulterFuncs 提供。

默认值函数的注册分两层:手写的和生成的。

12.1 手写默认值函数(defaults.go)

以 Deployment 为例,pkg/apis/apps/v1/defaults.go 中的 SetDefaults_Deployment 函数:

// pkg/apis/apps/v1/defaults.go(行 38-73,节选)
func SetDefaults_Deployment(obj *appsv1.Deployment) {
    if obj.Spec.Replicas == nil {
        obj.Spec.Replicas = new(int32)
        *obj.Spec.Replicas = 1                   // Replicas 默认为 1
    }
    strategy := &obj.Spec.Strategy
    if strategy.Type == "" {
        strategy.Type = appsv1.RollingUpdateDeploymentStrategyType  // 默认滚动更新
    }
    if strategy.Type == appsv1.RollingUpdateDeploymentStrategyType {
        if strategy.RollingUpdate.MaxUnavailable == nil {
            maxUnavailable := intstr.FromString("25%")  // MaxUnavailable 默认 25%
            strategy.RollingUpdate.MaxUnavailable = &maxUnavailable
        }
        if strategy.RollingUpdate.MaxSurge == nil {
            maxSurge := intstr.FromString("25%")        // MaxSurge 默认 25%
            strategy.RollingUpdate.MaxSurge = &maxSurge
        }
    }
    if obj.Spec.RevisionHistoryLimit == nil {
        obj.Spec.RevisionHistoryLimit = new(int32)
        *obj.Spec.RevisionHistoryLimit = 10              // 默认保留 10 个历史版本
    }
    if obj.Spec.ProgressDeadlineSeconds == nil {
        obj.Spec.ProgressDeadlineSeconds = new(int32)
        *obj.Spec.ProgressDeadlineSeconds = 600           // 默认 600 秒超时
    }
}

12.2 生成的默认值注册(zz_generated.defaults.go)

defaulter-gen 代码生成器会生成 RegisterDefaults 函数,它把每种类型的默认值函数注册到 Scheme 中:

// pkg/apis/apps/v1/zz_generated.defaults.go(行 34-44,节选)
func RegisterDefaults(scheme *runtime.Scheme) error {
    scheme.AddTypeDefaultingFunc(&appsv1.DaemonSet{},
        func(obj interface{}) { SetObjectDefaults_DaemonSet(obj.(*appsv1.DaemonSet)) })
    scheme.AddTypeDefaultingFunc(&appsv1.Deployment{},
        func(obj interface{}) { SetObjectDefaults_Deployment(obj.(*appsv1.Deployment)) })
    scheme.AddTypeDefaultingFunc(&appsv1.StatefulSet{},
        func(obj interface{}) { SetObjectDefaults_StatefulSet(obj.(*appsv1.StatefulSet)) })
    // ...
    return nil
}

RegisterDefaults 为每种类型调用 scheme.AddTypeDefaultingFunc,把默认值函数注册到 defaulterFuncs 这个 map 中。之后调用 Scheme.Default(obj) 时,Scheme 会查找 obj 对应的 Go Type,然后调用注册好的函数。

SetObjectDefaults_Deployment 是一个"总入口"函数,它会依次调用手写的 SetDefaults_Deployment(处理 Deployment 自身的默认值)以及嵌套类型的默认值函数(如 SetDefaults_PodSpec 处理 Pod 模板的默认值)。这确保了所有嵌套字段的默认值都能被正确填充。

十三、资源字段标签转换

字段标签转换(Field Label Conversion)是 Scheme 中一个容易被忽略但很实用的功能。当我们用 kubectl get pods --field-selector spec.nodeName=node1 时,APIServer 需要把"spec.nodeName"这个字段选择器从外部版本转换成内部版本。这个过程就是通过 fieldLabelConversionFuncs 完成的。

以 Pod 为例,pkg/apis/core/v1/conversion.go 中注册了 Pod 的字段标签转换函数:

// pkg/apis/core/v1/conversion.go(行 36-58,节选)
err := scheme.AddFieldLabelConversionFunc(SchemeGroupVersion.WithKind("Pod"),
    func(label, value string) (string, string, error) {
        switch label {
        case "metadata.name", "metadata.namespace",
            "spec.nodeName", "spec.restartPolicy",
            "spec.schedulerName", "spec.serviceAccountName",
            "spec.hostNetwork",
            "status.phase", "status.podIP",
            "status.podIPs", "status.nominatedNodeName":
            return label, value, nil               // 大部分字段标签直接透传
        case "spec.host":                          // 兼容旧客户端
            return "spec.nodeName", value, nil     // spec.host 转成 spec.nodeName
        default:
            return "", "", fmt.Errorf("field label not supported: %s", label)
        }
    },
)

这个转换函数做了两件事:① 对于支持的字段标签,直接透传;② 对于旧版本的兼容标签(如 spec.host),转换成新的字段名(spec.nodeName);③ 对于不支持的标签,返回错误。这样可以防止用户使用不合法的字段选择器。

十四、资源对象的验证

Scheme 在 1.36.1 版本中新增了 validationFuncs 字段,用于注册声明式验证函数。这是通过 AddValidationFunc 注册的:

// staging/src/k8s.io/apimachinery/pkg/runtime/scheme.go(行 366-388)
func (s *Scheme) AddValidationFunc(srcType Object,
    fn func(ctx context.Context, op operation.Operation, object, oldObject interface{}) field.ErrorList) {
    s.validationFuncs[reflect.TypeOf(srcType)] = fn
}

func (s *Scheme) Validate(ctx context.Context, options []string, object Object, subresources ...string) field.ErrorList {
    if fn, ok := s.validationFuncs[reflect.TypeOf(object)]; ok {
        return fn(ctx, operation.Operation{
            Type: operation.Create, Request: operation.Request{Subresources: subresources},
            Options: options}, object, nil)
    }
    return nil
}

验证函数的注册方式和默认值函数类似:key 是 Go Type,value 是验证函数。目前这个验证能力主要用于声明式验证(由 zz_generated.validations.go 生成的代码),和传统的手写验证函数(在 validation.go 中)是并行的两套体系。

💡 注意
Scheme.Validate 只运行声明式验证代码(zz_generated.validations.go),不会运行手写的验证逻辑。手写验证仍在 APIStrategy 的 Validate/ValidateUpdate 中被调用。两套验证体系目前并存。

十五、client-go 的 Scheme 与 legacyscheme 的 Scheme

Kubernetes 中存在多个 Scheme 实例,最常见的有三个:

Scheme 实例定义位置注册内容用途
legacyscheme.Scheme pkg/api/legacyscheme/scheme.go 内部版本 + 所有外部版本 + 转换 + 默认值 APIServer 内部使用
client-go Scheme staging/src/k8s.io/client-go/kubernetes/scheme/register.go 只有外部版本(无 __internal) 客户端编程使用
kubectl Scheme staging/src/k8s.io/kubectl/pkg/scheme/scheme.go 只有外部版本 + metav1 扩展 kubectl 命令行工具使用

关键区别在于:legacyscheme.Scheme 包含内部版本(__internal)和所有外部版本,因为 APIServer 需要在内部版本和外部版本之间做转换;而 client-go 和 kubectl 的 Scheme 只注册外部版本,因为客户端不需要知道内部版本的存在——它们只和外部 API 交互。

client-go 的 Scheme 注册方式也不同:它直接在 localSchemeBuilder 中列出所有版本的 AddToScheme 函数(由 client-gen 生成),然后在 init() 中一次性调用:

// staging/src/k8s.io/client-go/kubernetes/scheme/register.go(行 82-160,节选)
var Scheme = runtime.NewScheme()
var localSchemeBuilder = runtime.SchemeBuilder{
    admissionregistrationv1.AddToScheme,
    appsv1.AddToScheme,
    appsv1beta1.AddToScheme,
    appsv1beta2.AddToScheme,
    corev1.AddToScheme,
    // ... 所有版本的 AddToScheme
}
var AddToScheme = localSchemeBuilder.AddToScheme

func init() {
    v1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"})
    utilruntime.Must(AddToScheme(Scheme))
}

这里的 AddToScheme 来自 k8s.io/api 包里的各版本,只注册了外部版本的类型。没有内部版本,也没有转换函数——因为客户端不需要在本地做版本转换,它只需要知道如何序列化/反序列化外部版本的对象。

十六、FAQ

▼ Q: 为什么要区分内部版本和外部版本?直接用外部版本处理不就行了吗?

A: 内部版本是 APIServer 的"工作语言"。如果控制器、调度器、存储层都直接用外部版本,那每次 API 版本变更(比如 v1beta1 升级到 v1),所有消费端代码都得跟着改。有了内部版本做"枢纽",所有内部逻辑只依赖一个稳定的 Go Type,外部版本的增删改只影响转换层,内部代码完全不用动。这就是为什么转换是"v1↔__internal"和"v1beta1↔__internal",而不是"v1↔v1beta1"——N 个外部版本只需要 N 组转换函数,而不是 N×(N-1)/2 组。


▼ Q: staging/src/k8s.io/api 和 pkg/apis 里的同名类型(如 appsv1.Deployment)是同一个吗?

A: 是的,它们是同一个 Go Type。staging/src/k8s.io/api/apps/v1/types.go 定义了 appsv1.Deployment,而 pkg/apis/apps/v1 包只是 import 了这个类型,然后为它注册默认值函数和转换函数。staging 目录是 Kubernetes 的"软仓库"——代码先写在这里,再通过脚本同步到独立的 GitHub 仓库(如 k8s.io/api)。所以 k8s.io/api/apps/v1.Deployment 和 pkg/apis/apps/v1 引用的 Deployment 是完全相同的 Go struct。


▼ Q: 如果我自定义一个 CRD,它的类型是怎么注册到 Scheme 的?

A: CRD 的注册走的是不同的路径。CRD 的类型不提前注册到 Scheme 中——因为 CRD 是动态创建的,APIServer 启动时并不知道会有哪些 CRD。CRD 的反序列化使用 Unstructured 类型(一种通用的 map[string]interface{} 包装器),不需要提前定义 Go struct。只有在 Operator/Controller 代码中,如果你用 client-go 的 typed client 访问 CRD,才需要用 SchemeBuilder 注册生成的 Go Type。这种情况下,注册方式和内置资源一样——调用 AddToScheme 把类型注册到 Scheme 中。


▼ Q: AddKnownTypeWithName 和 AddKnownTypes 有什么区别?

A: AddKnownTypes 用 Go struct 的类型名作为 Kind 名(如 Deployment),而 AddKnownTypeWithName 允许你指定自定义的 Kind 名。AddKnownTypes 内部其实调用的就是 AddKnownTypeWithName,只是 Kind 默认取 reflect.TypeOf(obj).Elem().Name()。AddKnownTypeWithName 的典型用途是注册 WatchEvent(Kind 名和 Go struct 名不同)。


▼ Q: 同一个 GVK 被注册两次会怎样?

A: 如果 GVK 相同且 Go Type 也相同,第二次注册会被跳过(AddKnownTypeWithName 在检查到 gvkToType[gvk] 已存在且类型相同时直接 return)。但如果 GVK 相同而 Go Type 不同,会直接 panic,报错"Double registration of different types"。这是 Scheme 的安全机制——防止同一个 GVK 被两个不同的 Go Type"抢注"。


▼ Q: Scheme 是线程安全的吗?

A: Scheme 的注释明确写了:"Schemes are not expected to change at runtime and are only threadsafe after registration is complete." 也就是说,所有注册操作必须在程序启动阶段完成(在 init() 中),注册完成之后才是线程安全的。运行时不能动态增删类型。这和 Go 的 init() 执行顺序保证是一致的——所有 init() 在 main() 之前执行完毕,所以 main() 开始时 Scheme 已经是只读的、线程安全的了。


▼ Q: 为什么 core 组的 GroupName 是空字符串?

A: 这是历史原因。Kubernetes 最早只有一组 API(Pod、Service、Node 等),当时没有 API Group 的概念,API 路径是 /api/v1。后来引入了 API Group 机制,新增的资源组走 /apis/{group}/{version} 路径。为了向后兼容,core 组的 Group 留空,API 路径仍然是 /api/v1,而不是 /apis/core/v1。所以在 GroupVersionKind 中,Pod 的 Group 是空字符串,而 Deployment 的 Group 是 "apps"。


本节我们系统学习了 Kubernetes Scheme 资源注册机制的完整链路:

  • 为什么需要 Scheme:统一的类型注册表,解决 GVK↔Go Type 映射、版本转换、默认值填充三大问题
  • Scheme 数据结构:以 gvkToType 和 typeToGVK 两个双向 map 为核心,辅以 defaulterFuncs、converter、fieldLabelConversionFuncs、validationFuncs 四个功能表
  • 注册链路:staging 的 register.go 定义外部版本类型 → pkg/apis 的 register.go 追加默认值和转换 → install 包的 init() 触发注册 → import_known_versions 的 side-effect import 触发所有 install
  • 内部版本枢纽模式:所有外部版本统一通过 __internal 中转,N 个版本只需 N 组转换函数
  • SchemeBuilder 延迟组装:先放类型注册,再追加默认值和转换,最终 AddToScheme 一次性执行
  • 多个 Scheme 实例:legacyscheme.Scheme 包含内部版本(APIServer 用),client-go/kubectl 的 Scheme 只有外部版本(客户端用)

下一步:下一节我们将深入 APIServer 的请求处理流程,看看 Scheme 注册的类型在反序列化、admission、存储等环节是如何被使用的。届时我们会从 HTTP 请求入口出发,一路追踪到 etcd 存储,把 Scheme 在运行时的角色彻底讲透。

相关阅读:
   • Scheme 核心源码(scheme.go)
   • SchemeBuilder 源码(scheme_builder.go)
   • 所有资源组的注册入口(import_known_versions.go)
   • API Group 设计提案

Kubernetes Scheme 资源注册机制全解 · 基于 Kubernetes 1.36.1 源码

posted @ 2026-06-11 19:00  左扬  阅读(7)  评论(0)    收藏  举报