前言:那个让我无言以对的面试题
面试时被问过一个问题:
“你执行
kubectl create -f deploy.yaml,从 YAML 文件到 etcd 里多出一条记录,APIServer 内部到底发生了什么?”
我当时只答上来 “认证 → 鉴权 → 准入 → 存储”,面试官接着问:“存储那一步具体是怎么把 JSON 转成 Go struct、再存到 etcd 的?”
我懵了。
回去啃源码,发现答案藏在两个核心抽象里:
- Scheme —— 一张全局的"资源注册表",记录所有 Go 类型 ↔ GVK(Group/Version/Kind)的映射,以及它们的序列化/反序列化方法
- RESTStorage —— 每个资源的 CRUD 接口,把 REST 请求(GET/POST/DELETE)翻译成对 etcd 的操作
这俩抽象一搞清楚,你就明白了为什么 K8s 能做到:
- 同一个 Pod,能用 JSON / YAML / Protobuf 序列化
- 同一个资源,能在
v1beta1和v1之间自动转换 - 新增 CRD,APIServer 不重启就能识别
这篇就把这两个抽象逐行拆开讲,并把我踩过的坑标出来。
本节重点
- Scheme 定义了资源序列化/反序列化方法和资源类型与版本的对应关系——可以理解成一张全局记录表
- 所有 K8s 资源必须先注册到 Scheme 才能使用
- RESTStorage 定义了一种资源该如何 CRUD、如何和存储打交道
- 各个资源的 restStore 塞入
restStorageMap:key 是"资源名/子资源名",value 是对应的 restStore
一、入口:InstallLegacyAPI 在干嘛?
上节讲到 kubeAPIServer 初始化时会调用 InstallLegacyAPI,把 K8s 内置的核心资源(Pod / Service / Node / ConfigMap …)注册到 HTTP 路由。
位置:pkg/controlplane/instance.go
// InstallLegacyAPI will install the legacy APIs for the restStorageProviders if they are enabled.
func (m *Instance) InstallLegacyAPI(
c *completedConfig,
restOptionsGetter generic.RESTOptionsGetter,
legacyRESTStorageProvider corerest.LegacyRESTStorageProvider,
) error {
// 1. 构造所有 core group 资源的 RESTStorage
legacyRESTStorage, apiGroupInfo, err := legacyRESTStorageProvider.NewLegacyRESTStorage(restOptionsGetter)
if err != nil {
return fmt.Errorf("error building core storage: %v", err)
}
// 2. 注册 bootstrap-controller 的钩子
controllerName := "bootstrap-controller"
coreClient := corev1client.NewForConfigOrDie(c.GenericConfig.LoopbackClientConfig)
bootstrapController := c.NewBootstrapController(
legacyRESTStorage, coreClient, coreClient, coreClient,
coreClient.RESTClient(),
)
m.GenericAPIServer.AddPostStartHookOrDie(controllerName, bootstrapController.PostStartHook)
m.GenericAPIServer.AddPreShutdownHookOrDie(controllerName, bootstrapController.PreShutdownHook)
// 3. 把所有 storage 挂到 /api 路由前缀下
if err := m.GenericAPIServer.InstallLegacyAPIGroup(genericapiserver.DefaultLegacyAPIPrefix, &apiGroupInfo); err != nil {
return fmt.Errorf("error in registering group versions: %v", err)
}
return nil
}
三件事:
NewLegacyRESTStorage—— 给每个 core 资源生成 RESTStorage 实例bootstrap-controller—— 创建kubernetes这个默认 Service、维护default-token、初始化集群所需的 namespace 等InstallLegacyAPIGroup—— 把 RESTStorage 注册到 HTTP 路由(/api/v1/...)
💡 bootstrap-controller 是什么? 启动 K8s 集群时,
defaultnamespace、kubernetesService、system:masters这些"开箱即有"的资源就是它创建的。它跟 RESTStorage 强相关——必须等所有 storage 准备好才能跑——所以挂在PostStartHook里。
二、K8s 资源的完整身份:GVK
进入 Scheme 之前,先把 K8s 资源的"完整身份"讲清楚。
很多人写 YAML 时只关心 kind: Deployment,这是不准确的。K8s 的资源完整定位是:
<Group> / <Version> / <Resource> / <SubResource>
↓ ↓ ↓ ↓
apps / v1 / deployments / status
以 Deployment 为例:
| 维度 | 值 | 说明 |
|---|---|---|
| Group | apps | 资源组(APIGroup) |
| Version | v1 | 资源版本(APIVersion) |
| Resource | deployments | 资源(复数小写) |
| SubResource | status | 子资源(如 status、scale) |
| Kind | Deployment | 资源种类(单数大写) |
2.1 为什么要分 Group 和 Version?
K8s API
┌──────────┼──────────┐
│ │ │
core/v1 apps/v1 batch/v1
│ │ │
Pod, Svc Deployment Job
ConfigMap StatefulSet CronJob
…… DaemonSet ……
设计意图:
- Group:按业务域划分,让 K8s 能模块化演进。比如
apps专门管工作负载、networking.k8s.io专门管网络 - Version:允许同一资源多版本共存,并支持平滑升级。比如
Ingress从extensions/v1beta1→networking.k8s.io/v1beta1→networking.k8s.io/v1,三个版本能在过渡期同时存在
🤔 GVK vs GVR:源码里经常看到这两个缩写——GVK(Group/Version/Kind)用于"类型",GVR(Group/Version/Resource)用于"REST 路径"。Kind 是 Go 类型名(
Deployment),Resource 是 URL 路径段(deployments)。一个 Kind 可能对应多个 Resource(含子资源)。
2.2 Resource 和 Kind 的微妙关系
| Kind | Resource | URL |
|---|---|---|
Pod | pods | /api/v1/pods |
Pod | pods/status | /api/v1/pods/{name}/status |
Pod | pods/exec | /api/v1/pods/{name}/exec |
同一个 Kind(Pod),可以对应多个 Resource——主资源加上一堆子资源。每个子资源都是独立的 REST endpoint,有自己的权限控制(这就是为什么 RBAC 里可以单独授予 pods/exec 权限而不给 pods/delete)。
三、Scheme:全局资源注册表
3.1 Scheme 是什么
位置:staging/src/k8s.io/apimachinery/pkg/runtime/scheme.go
Scheme 定义了资源序列化/反序列化方法以及资源类型与版本的对应关系——可以理解成一张全局记录表。
K8s 系统有几百种资源类型,每一种都需要:
- 知道 Go struct 是哪个
- 知道怎么序列化成 JSON/YAML/Protobuf
- 知道怎么从 v1beta1 转成 v1
- 知道怎么 deep copy
Scheme 就是把这些信息统一起来的注册表,是一个内存型的资源注册表,特点:
- ✅ 支持注册多种资源类型(内部版本 + 外部版本)
- ✅ 支持多种版本转换机制
- ✅ 支持不同资源的序列化/反序列化机制
3.2 Scheme 支持的两种资源类型
| 类型 | 注册方法 | 应用场景 |
|---|---|---|
| KnownType(拥有版本的资源) | scheme.AddKnownTypes | 99% 的资源都是这种 |
| UnversionedType(无版本的资源) | scheme.AddUnversionedTypes | 早期遗留,少数 meta 类型 |
UnversionedType 在现代 K8s 里已经基本被弱化,但仍有几个保留:
metav1.Status—— API 错误响应(401/403/404 等)metav1.APIVersions——/api端点返回的版本列表metav1.APIGroupList——/apis返回的 group 列表metav1.APIGroup—— 单个 group 信息metav1.APIResourceList—— 某个 group/version 下的资源列表
这些类型不需要做版本转换——它们只是 API metadata,不属于业务资源。
3.3 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{}){},
versionPriority: map[string][]string{},
schemeName: naming.GetNameFromCallsite(internalPackages...),
}
字段含义:
| 字段 | 作用 |
|---|---|
gvkToType | 存储 GVK → reflect.Type 的映射(正向查找) |
typeToGVK | 存储 reflect.Type → []GVK 的映射(反向查找,一个 Type 可能对应多个 GVK) |
unversionedTypes | UnversionedType → GVK 的映射 |
unversionedKinds | Kind 名称 → reflect.Type 的映射 |
fieldLabelConversionFuncs | 字段选择器(spec.nodeName=xxx)的转换函数 |
defaulterFuncs | 给资源填默认值的函数 |
versionPriority | 同一个 group 下的版本优先级(决定 v1 还是 v1beta1 优先返回) |
💡 为什么
typeToGVK是[]GVK切片? 因为一个 Go 类型可能对应多个 GVK!比如 K8s 内部有"内部版本"和"外部版本"之分:apps.Deployment(内部)→ 对应apps/v1/Deployment、apps/v1beta1/Deployment、apps/v1beta2/Deployment等多个外部 GVK。Scheme 用切片记录这种一对多关系。
🚀 性能特性:Scheme 全部基于 Go map,正向/反向检索时间复杂度都是 O(1)。K8s APIServer 每秒要处理几千次序列化/反序列化,这个 O(1) 是性能的基石。
3.4 使用 Scheme 的标准姿势
// 1. 创建一个 Scheme 实例
var Scheme = runtime.NewScheme()
// 2. 通过 AddToScheme 注册各种资源类型
func init() {
_ = clientgoscheme.AddToScheme(Scheme) // 注册 K8s 内置资源
_ = crdv1.AddToScheme(Scheme) // 注册 CRD 类型
_ = oamcore.AddToScheme(Scheme) // 注册 OAM
_ = oamstandard.AddToScheme(Scheme)
_ = istioclientv1beta1.AddToScheme(Scheme)
_ = certmanager.AddToScheme(Scheme)
_ = kruise.AddToScheme(Scheme)
// +kubebuilder:scaffold:scheme
}
// 3. 从 Scheme 派生 Codec(编码器/解码器)
var Codecs = serializer.NewCodecFactory(Scheme)
var ParameterCodec = runtime.NewParameterCodec(Scheme)
// 4. 用 Codec 解码
var decode = Codecs.UniversalDeserializer().Decode
🚨 新手最容易踩的坑:自己写 controller / operator 时,忘记调用
AddToScheme注册 CRD 类型。后果:用client.Get(...)取自定义资源会报:no kind "MyCRD" is registered for version "mygroup.io/v1" in scheme这个错误很有迷惑性,因为它说的是 scheme 里没有,但你明明 import 了 CRD 包。原因是 import 不会触发注册,必须显式
AddToScheme。
3.5 实战示例:webhook 里的 Scheme 用法
回到我们前几节写的 sidecar 注入 webhook,开头那段代码就是标准的 Scheme 初始化:
var (
runtimeScheme = runtime.NewScheme()
codecs = serializer.NewCodecFactory(runtimeScheme)
deserializer = codecs.UniversalDeserializer()
)
func init() {
_ = corev1.AddToScheme(runtimeScheme) // 注册 core/v1(包含 Pod)
}
然后在 handler 里就可以用 deserializer 解码 AdmissionReview:
if _, _, err := deserializer.Decode(body, nil, &ar); err != nil {
glog.Errorf("Can't decode body: %v", err)
admissionResponse = &v1beta1.AdmissionResponse{
Result: &metav1.Status{
Message: err.Error(),
},
}
}
💡 为什么这里只注册
corev1? 因为 webhook 只处理 Pod 资源。但严格来说还应该_ = admissionv1.AddToScheme(runtimeScheme)——因为AdmissionReview本身也是个 K8s 资源。不注册的话用UniversalDeserializer还能跑(它有 fallback 逻辑),但严谨起见建议注册。
四、NewLegacyRESTStorage:核心资源 storage 的工厂
讲完了"类型注册表",回到 NewLegacyRESTStorage 看怎么创建 storage。
位置:pkg/registry/core/rest/storage_core.go
4.1 构造 APIGroupInfo
func (c LegacyRESTStorageProvider) NewLegacyRESTStorage(
restOptionsGetter generic.RESTOptionsGetter,
) (LegacyRESTStorage, genericapiserver.APIGroupInfo, error) {
apiGroupInfo := genericapiserver.APIGroupInfo{
PrioritizedVersions: legacyscheme.Scheme.PrioritizedVersionsForGroup(""),
VersionedResourcesStorageMap: map[string]map[string]rest.Storage{},
Scheme: legacyscheme.Scheme,
ParameterCodec: legacyscheme.ParameterCodec,
NegotiatedSerializer: legacyscheme.Codecs,
}
// ...
}
APIGroupInfo 是 K8s 给每个 API Group 的描述对象,里面塞了:
PrioritizedVersions:该 group 下所有版本的优先级排序VersionedResourcesStorageMap:核心字段,双层 map:版本 → 资源名 → RESTStorageScheme:刚才讲的资源注册表ParameterCodec:URL 参数的编码器(解析?fieldSelector=spec.nodeName%3Dxxx)NegotiatedSerializer:根据 HTTPAccept头自动选择 JSON / YAML / Protobuf
💡
legacyscheme.Scheme是 K8s 的"主 Scheme 单例"——全局唯一,所有 core 资源都注册到这里。源码里到处能看到legacyscheme.Scheme.AddKnownTypes(...)这种用法。
4.2 创建 RESTStorage:以 ConfigMap 为例(最简单的资源)
位置:pkg/registry/core/configmap/storage/storage.go
// REST implements a RESTStorage for ConfigMap
type REST struct {
*genericregistry.Store // 关键:嵌入通用 Store
}
// NewREST returns a RESTStorage object that will work with ConfigMap objects.
func NewREST(optsGetter generic.RESTOptionsGetter) (*REST, error) {
store := &genericregistry.Store{
NewFunc: func() runtime.Object { return &api.ConfigMap{} },
NewListFunc: func() runtime.Object { return &api.ConfigMapList{} },
PredicateFunc: configmap.Matcher,
DefaultQualifiedResource: api.Resource("configmaps"),
CreateStrategy: configmap.Strategy,
UpdateStrategy: configmap.Strategy,
DeleteStrategy: configmap.Strategy,
TableConvertor: printerstorage.TableConvertor{
TableGenerator: printers.NewTableGenerator().With(printersinternal.AddHandlers),
},
}
options := &generic.StoreOptions{
RESTOptions: optsGetter,
AttrFunc: configmap.GetAttrs,
TriggerFunc: map[string]storage.IndexerFunc{
"metadata.name": configmap.NameTriggerFunc,
},
}
if err := store.CompleteWithOptions(options); err != nil {
return nil, err
}
return &REST{store}, nil
}
字段拆解:
| 字段 | 作用 |
|---|---|
NewFunc | 创建该资源单个对象时用的工厂(GET /configmaps/foo 会用) |
NewListFunc | 创建该资源列表对象时的工厂(GET /configmaps 会用) |
PredicateFunc | 把 ?labelSelector=...&fieldSelector=... 转换成 K8s 内部的匹配器 |
DefaultQualifiedResource | 资源的复数小写名(configmaps) |
CreateStrategy | 创建时的策略:校验、补默认值、生成 UID 等 |
UpdateStrategy | 更新时的策略:判断哪些字段可改、哪些只读(如 metadata.uid) |
DeleteStrategy | 删除时的策略:处理 finalizer、cascading delete |
TableConvertor | 把资源对象转成 kubectl get 输出的表格格式 |
AttrFunc | 提取资源的索引属性(用于 fieldSelector) |
TriggerFunc | watch 事件触发函数 |
💡 Strategy 模式是 K8s 设计的精髓:把"业务校验"和"通用 CRUD"解耦。
genericregistry.Store提供通用 CRUD,每种资源通过自己的Strategy注入业务逻辑。比如Pod的 Strategy 会校验"不能修改 spec.containers",ConfigMap的 Strategy 会校验"data + binaryData 总大小不能超过 1MiB"。
4.3 CompleteWithOptions:必填字段校验
func (e *Store) CompleteWithOptions(options *generic.StoreOptions) error {
if e.DefaultQualifiedResource.Empty() {
return fmt.Errorf("store %#v must have a non-empty qualified resource", e)
}
if e.NewFunc == nil {
return fmt.Errorf("store for %s must have NewFunc set", e.DefaultQualifiedResource.String())
}
if e.NewListFunc == nil {
return fmt.Errorf("store for %s must have NewListFunc set", e.DefaultQualifiedResource.String())
}
if (e.KeyRootFunc == nil) != (e.KeyFunc == nil) {
return fmt.Errorf("store for %s must set both KeyRootFunc and KeyFunc or neither", e.DefaultQualifiedResource.String())
}
if e.TableConvertor == nil {
return fmt.Errorf("store for %s must set TableConvertor; rest.NewDefaultTableConvertor(e.DefaultQualifiedResource) can be used to output just name/creation time", e.DefaultQualifiedResource.String())
}
// ...
}
🚨 自己写 Aggregated APIServer 时,最容易在这里 panic:忘了设
NewFunc或TableConvertor,启动就报错。TableConvertor漏写是最常见的——如果你不需要自定义表格,用rest.NewDefaultTableConvertor(...)兜底即可(只输出 name + age)。
4.4 Pod 的特殊性:一堆子资源 storage 共享同一个 store
Pod 比 ConfigMap 复杂得多——它有 11 个子资源(status、log、exec、attach、portforward、proxy、binding、ephemeralcontainers、eviction…)。位置:pkg/registry/core/pod/storage/storage.go
func NewStorage(
optsGetter generic.RESTOptionsGetter,
k client.ConnectionInfoGetter,
proxyTransport http.RoundTripper,
podDisruptionBudgetClient policyclient.PodDisruptionBudgetsGetter,
) (PodStorage, error) {
// 1. 先建一个 "主 store"
store := &genericregistry.Store{
NewFunc: func() runtime.Object { return &api.Pod{} },
NewListFunc: func() runtime.Object { return &api.PodList{} },
PredicateFunc: registrypod.MatchPod,
DefaultQualifiedResource: api.Resource("pods"),
CreateStrategy: registrypod.Strategy,
UpdateStrategy: registrypod.Strategy,
DeleteStrategy: registrypod.Strategy,
ResetFieldsStrategy: registrypod.Strategy,
ReturnDeletedObject: true,
TableConvertor: printerstorage.TableConvertor{
TableGenerator: printers.NewTableGenerator().With(printersinternal.AddHandlers),
},
}
options := &generic.StoreOptions{
RESTOptions: optsGetter,
AttrFunc: registrypod.GetAttrs,
TriggerFunc: map[string]storage.IndexerFunc{
"spec.nodeName": registrypod.NodeNameTriggerFunc, // 按 nodeName 索引
},
Indexers: registrypod.Indexers(),
}
if err := store.CompleteWithOptions(options); err != nil {
return PodStorage{}, err
}
// 2. 基于主 store 派生子资源 store(关键技巧:浅拷贝 + 替换 Strategy)
statusStore := *store // 浅拷贝 store
statusStore.UpdateStrategy = registrypod.StatusStrategy // 替换为 status 专用 strategy
statusStore.ResetFieldsStrategy = registrypod.StatusStrategy
ephemeralContainersStore := *store
ephemeralContainersStore.UpdateStrategy = registrypod.EphemeralContainersStrategy
// 3. 组装 PodStorage(包含主资源 + 所有子资源)
bindingREST := &BindingREST{store: store}
return PodStorage{
Pod: &REST{store, proxyTransport},
Binding: &BindingREST{store: store},
LegacyBinding: &LegacyBindingREST{bindingREST},
Eviction: newEvictionStorage(store, podDisruptionBudgetClient),
Status: &StatusREST{store: &statusStore},
EphemeralContainers: &EphemeralContainersREST{store: &ephemeralContainersStore},
Log: &podrest.LogREST{Store: store, KubeletConn: k},
Proxy: &podrest.ProxyREST{Store: store, ProxyTransport: proxyTransport},
Exec: &podrest.ExecREST{Store: store, KubeletConn: k},
Attach: &podrest.AttachREST{Store: store, KubeletConn: k},
PortForward: &podrest.PortForwardREST{Store: store, KubeletConn: k},
}, nil
}
PodStorage 这个聚合结构体:
// PodStorage includes storage for pods and all sub resources
type PodStorage struct {
Pod *REST
Binding *BindingREST
LegacyBinding *LegacyBindingREST
Eviction *EvictionREST
Status *StatusREST
EphemeralContainers *EphemeralContainersREST
Log *podrest.LogREST
Proxy *podrest.ProxyREST
Exec *podrest.ExecREST
Attach *podrest.AttachREST
PortForward *podrest.PortForwardREST
}
💡
statusStore := *store这一行是设计精髓:通过浅拷贝复用同一份底层 etcd 连接、cache、index,但替换 Strategy 实现不同的行为约束。比如Status子资源的 UpdateStrategy 只允许改status字段,禁止改spec——这是 K8s “主资源管 spec、子资源管 status” 这套设计的代码实现。
🤔 为什么 log / exec / attach / portforward 不用 store? 因为它们不存数据,是实时连接到 kubelet的转发请求。所以这些 storage 只持有
Store引用(用来查 Pod 信息),实际逻辑是建立到 kubelet 的 stream。
4.5 Pod 的子资源都有什么用?
| 子资源 | 用途 | kubectl 入口 |
|---|---|---|
pods/status | 改 Pod 状态(kubelet 上报) | 内部使用 |
pods/binding | 把 Pod 绑定到 Node(scheduler 用) | 内部使用 |
pods/eviction | 优雅驱逐 Pod(遵循 PDB) | kubectl drain |
pods/log | 拉日志 | kubectl logs |
pods/exec | 执行命令 | kubectl exec |
pods/attach | 附加到主进程 | kubectl attach |
pods/portforward | 端口转发 | kubectl port-forward |
pods/proxy | HTTP 代理到 Pod | kubectl proxy |
pods/ephemeralcontainers | 添加临时容器 | kubectl debug |
🚨 生产实战经验:很多人 RBAC 给 dev 团队"看 Pod 日志"权限时,只给了
pods/log是不够的——还得给pods/get!因为查询子资源前必须能 GET 主资源。这是 K8s RBAC 的常见踩坑点。
五、塞进 restStorageMap:每条 URL 都对应一个 storage
回到 NewLegacyRESTStorage,所有资源的 storage 都创建完后,要塞进一个大 map:
restStorageMap := map[string]rest.Storage{
// === Pod 及其子资源 ===
"pods": podStorage.Pod,
"pods/attach": podStorage.Attach,
"pods/status": podStorage.Status,
"pods/log": podStorage.Log,
"pods/exec": podStorage.Exec,
"pods/portforward": podStorage.PortForward,
"pods/proxy": podStorage.Proxy,
"pods/binding": podStorage.Binding,
"bindings": podStorage.LegacyBinding,
// === PodTemplate ===
"podTemplates": podTemplateStorage,
// === ReplicationController(一主一从)===
"replicationControllers": controllerStorage.Controller,
"replicationControllers/status": controllerStorage.Status,
// === Service(一主两从)===
"services": serviceRest,
"services/proxy": serviceRestProxy,
"services/status": serviceStatusStorage,
// === Endpoints ===
"endpoints": endpointsStorage,
// === Node(一主两从)===
"nodes": nodeStorage.Node,
"nodes/status": nodeStorage.Status,
"nodes/proxy": nodeStorage.Proxy,
// === Events ===
"events": eventStorage,
// === LimitRange / ResourceQuota / Namespace ===
"limitRanges": limitRangeStorage,
"resourceQuotas": resourceQuotaStorage,
"resourceQuotas/status": resourceQuotaStatusStorage,
"namespaces": namespaceStorage,
"namespaces/status": namespaceStatusStorage,
"namespaces/finalize": namespaceFinalizeStorage,
// === Secret / ServiceAccount / PV / PVC / ConfigMap ===
"secrets": secretStorage,
"serviceAccounts": serviceAccountStorage,
"persistentVolumes": persistentVolumeStorage,
"persistentVolumes/status": persistentVolumeStatusStorage,
"persistentVolumeClaims": persistentVolumeClaimStorage,
"persistentVolumeClaims/status": persistentVolumeClaimStatusStorage,
"configMaps": configMapStorage,
// === ComponentStatus(已废弃但还在)===
"componentStatuses": componentstatus.NewStorage(componentStatusStorage{c.StorageFactory}.serversToValidate),
}
这个 map 就是 K8s core API 的"URL 路由表"——key 是路径段,value 是对应的 storage。
最后塞到 apiGroupInfo 的双层 map 里:
// 双层 map:第一层 key 是版本,第二层 key 是资源名称
apiGroupInfo.VersionedResourcesStorageMap["v1"] = restStorageMap
之后 InstallLegacyAPIGroup 会遍历这个 map,每个 entry 自动生成对应的 HTTP handler:
"pods" → POST/GET/DELETE /api/v1/namespaces/{ns}/pods
"pods/status" → PUT /api/v1/namespaces/{ns}/pods/{name}/status
"pods/exec" → POST /api/v1/namespaces/{ns}/pods/{name}/exec
"configMaps" → POST/GET/DELETE /api/v1/namespaces/{ns}/configmaps
"nodes" → POST/GET/DELETE /api/v1/nodes (集群级,无 namespace)
💡 K8s 资源的 namespace scope 怎么判断? 看资源的 Strategy 实现里的
NamespaceScoped()方法。返回true的资源(如 Pod、Service)URL 里有namespaces/{ns};返回false的(如 Node、PersistentVolume、Namespace 自己)则是集群级。
六、整体数据流:一次 kubectl create -f deploy.yaml 的完整链路
回到开头那个面试题。现在我们可以把整条链路画清楚了:
kubectl create -f deploy.yaml
│
│ HTTPS POST /apis/apps/v1/namespaces/default/deployments
▼
┌─────────────────────────────────┐
│ APIServer HTTPS handler │
│ (handler chain: 认证→鉴权→准入)│
└──────────────┬──────────────────┘
▼
┌─────────────────────────────────┐
│ NegotiatedSerializer 解码 │
│ 根据 Content-Type 选解码器 │
│ application/json → JSON 解码器 │
└──────────────┬──────────────────┘
▼
┌─────────────────────────────────┐
│ Scheme.Convert │
│ v1.Deployment → 内部版本 │
│ (查 typeToGVK 映射) │
└──────────────┬──────────────────┘
▼
┌─────────────────────────────────┐
│ RESTStorage.Create │
│ ├─ Strategy.PrepareForCreate │
│ │ (补默认值、生成 UID) │
│ ├─ Strategy.Validate │
│ │ (校验字段) │
│ └─ etcd Put │
└──────────────┬──────────────────┘
▼
┌─────────────────────────────────┐
│ Scheme.Convert (回程) │
│ 内部版本 → v1.Deployment │
└──────────────┬──────────────────┘
▼
┌─────────────────────────────────┐
│ NegotiatedSerializer 编码 │
│ 根据 Accept 头选编码器 │
└──────────────┬──────────────────┘
▼
HTTP 201 Created
│
▼
kubectl
每个箭头都对应源码里的一个明确步骤。Scheme 在两个地方关键发力:进来时反序列化 + 版本转换,出去时版本转换 + 序列化。RESTStorage 是中间那一段:校验 → 写 etcd。
七、踩坑总结(实操经验)
| # | 坑 | 现象 | 解决 |
|---|---|---|---|
| 1 | 写 controller 没 AddToScheme 自定义 CRD | no kind "Foo" is registered for version "..."" | 显式 _ = foov1.AddToScheme(scheme) |
| 2 | 写 Aggregated APIServer 没设 NewFunc | 启动 panic | 必填,对照 CompleteWithOptions 校验 |
| 3 | RBAC 只给 pods/log 没给 pods/get | forbidden: cannot get resource "pods" | 子资源访问必须先有主资源 get 权限 |
| 4 | CRD 不写 status 子资源就直接改 status | 改不动,spec 反而被覆盖 | CRD 定义里加 subresources.status |
| 5 | Strategy 漏写 NamespaceScoped | URL 错位(集群级资源出现在 namespace 路径下) | 显式实现 NamespaceScoped() bool |
| 6 | 自定义资源没注册到 legacyscheme.Scheme | 内部转换报错 | core group 用 legacy scheme,其他 group 用各自 scheme |
八、本节小结
- Scheme 是 K8s 的全局资源类型注册表,记录 Go 类型 ↔ GVK 的映射、序列化方法、版本转换函数。它是 K8s 多版本资源管理的核心基础设施。
- 资源完整身份是 GVK(Group/Version/Kind),不是单独的 Kind。一个 Kind 可能对应多个 Resource(含子资源)。
- RESTStorage 是每个资源的 CRUD 接口,本质是
genericregistry.Store+ 资源专属Strategy。Strategy 模式让 K8s 把通用 CRUD 和资源特定校验/默认值解耦。 - 子资源 storage 通过浅拷贝主 store + 替换 Strategy 实现,这是 K8s 用极少代码实现 Pod 11 个子资源的设计精髓。
- 所有 storage 最终塞进
restStorageMap,再挂到apiGroupInfo.VersionedResourcesStorageMap["v1"],由InstallLegacyAPIGroup自动生成 HTTP 路由。
下一节会进入更具体的话题:HTTP 请求到 etcd 的完整数据流——RESTStorage 内部到底怎么调 etcd、watch 机制怎么实现、cache 在哪儿、序列化的多种格式(JSON/YAML/Protobuf)怎么协商。
九、你踩过这些坑吗?
- 自己写过 Aggregated APIServer 或 controller-runtime operator 吗?最坑你的一次是什么场景?
- CRD 用 status subresource 的时候,有没有遇到过"客户端改 status 改不动"的问题?最后是怎么定位到是 subresources 配置漏写的?
- K8s 的多版本资源转换(如
Ingress v1beta1→v1)你有研究过 conversion webhook 吗?踩过哪些坑?
欢迎在评论区分享你的实战经验。
十、延伸思考
- K8s 为什么要设计"内部版本"(internal version)?为什么不直接在
v1和v1beta1之间转换? Scheme是全局单例,那不同的 controller import 同一个 scheme 包会不会有竞争问题?为什么不会?- CRD 的
additionalPrinterColumns和我们这里讲的TableConvertor是什么关系?


177

被折叠的 条评论
为什么被折叠?



