




















在 k8s 里,"知道一个资源叫 pods" 和"知道怎么访问它"是完全不同的两件事。你写 controller 时,手里有的是 core.Pod 这个 Go struct,k8s 内部要知道它对应的是 namespaced 资源、REST 路径是 /api/v1/namespaces/{namespace}/pods、GVK 是 core/v1, Kind=Pod、GVR 是 core/v1, Resource=pods。这些信息谁来翻译?——RESTMapper。
RESTMapper 在 k8s 里是一个"字典"——它的输入是"残缺的 GVK/GVR"(比如只知道 Kind=Pod,不知道在哪个 Group),输出是"完整的 REST 端点信息"(GVR、GVK、Scope)。这个字典不只是 /api/v1/pods 这种简单映射,它要处理:① 多版本(v1 / v1beta1 / v2 并存);② 多 Group(apps/v1、networking.k8s.io/v1);③ Ambiguous 消歧(当多个版本匹配时按优先级选一个);④ 懒加载(controller 启动时根本不知道集群里有哪些 API);⑤ 动态失效(集群升级了,字典要刷新)。
这一篇我们用源码把 RESTMapper 讲透:① 为什么要有它(GVR / GVK / Kind / Resource 的关系);② RESTMapper 接口的 8 个方法逐个讲;③ DefaultRESTMapper 的五表结构怎么用;④ PriorityRESTMapper 的优先级模式怎么消歧;⑤ DeferredDiscoveryRESTMapper 的懒加载机制;⑥ 实际使用场景(controller-runtime、kubectl、API Server 初始化);⑦ RESTMapping 数据结构;⑧ 20 个高频 Q&A。读完你就知道:当你调 client.Get(ctx, name, ns, &pod) 时,背后 RESTMapper 经历了什么。
Kubernetes 1.36.1 apimachinery RESTMapper Discovery API client-go
🔓 学习重点提示 — 建议先通读全文,再重点回顾标注内容
★ 重点掌握(必须)
• GVR / GVK / GroupKind / GroupResource 四个元组:各自的字段含义和使用场景
• RESTMapper 8 个方法:KindFor / KindsFor / ResourceFor / ResourcesFor / RESTMapping / RESTMappings / ResourceSingularizer / Reset
• DefaultRESTMapper 五表结构:resourceToKind / kindToPluralResource / kindToScope / singular/plural 互查
• PriorityRESTMapper 消歧模式:ResourcePriority / KindPriority 的链式过滤
• DeferredDiscoveryRESTMapper:懒加载 + cache 失效机制
☆ 次重点(了解即可)
• MultiRESTMapper 的聚合模式
• UnsafeGuessKindToResource 的单复数转换逻辑
• NewDiscoveryRESTMapper 中 Priority 初始化顺序
k8s 是一个"一切都用 REST API" 的系统。Pod 在 YAML 里是 kind: Pod,在 Go 代码里是 core.Pod,在 HTTP 请求里是 GET /api/v1/namespaces/default/pods/nginx,在 etcd 里存的是 v1/Pod 格式。这四种表达方式之间需要翻译,RESTMapper 就是这个翻译官。
在讲 RESTMapper 之前,必须先把四个概念区分清楚——这是理解 RESTMapper 的前置知识。
// staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/group_version.go(行 82-90)
// GroupVersionKind 明确标识一种"数据类型"。它不匿名包含 GroupVersion,
// 以避免自动类型转换。
type GroupVersionKind struct {
Group string // "apps"、"networking.k8s.io"、""(core 组为空)
Version string // "v1"、"v1beta1"、"v2"
Kind string // "Deployment"、"Service"、"Pod"(首字母大写)
}
func (gvk GroupVersionKind) String() string {
return gvk.Group + "/" + gvk.Version + ", Kind=" + gvk.Kind
}
GVK 是数据的格式描述——知道 GVK 就知道这个对象是 JSON 还是 protobuf、字段名叫什么。GVK 是"读什么",不是"去哪里读"。序列化时 apiVersion: apps/v1 + kind: Deployment 就是 GVK。
// staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/group_version.go(行 46-61)
// GroupVersionResource 明确标识一个"REST 端点"。它不使用 GroupVersion
// 来避免自定义编组。
type GroupVersionResource struct {
Group string // "apps"、"networking.k8s.io"、""(core)
Version string // "v1"、"v1beta1"
Resource string // "deployments"、"pods"(小写复数)
}
func (gvr GroupVersionResource) String() string {
return gvr.Group + "/" + gvr.Version + ", Resource=" + gvr.Resource
}
GVR 是HTTP 路径描述——知道 GVR 就知道往哪个 URL 发请求。apps/v1, Resource=deployments 对应 /apis/apps/v1/namespaces/{ns}/deployments。GVR 是"去哪读",不是"读什么"。
有时候我们不知道版本,只说"我要 Deployment"(GK)或"我要 deployments 端点"(GR)。这时 GK/GR 就是模糊键,RESTMapper 会根据优先级规则找一个确定的 GVK/GVR 返回。
// GroupKind:只知道 Group 和 Kind,不知道 Version(用于注册/查找)
type GroupKind struct {
Group string // "apps"
Kind string // "Deployment"
}
// GroupResource:只知道 Group 和 Resource,不知道 Version
type GroupResource struct {
Group string // "apps"
Resource string // "deployments"
}
| 元组 | 字段 | 含义 | 典型值 |
|---|---|---|---|
| GVK | Group+Version+Kind | 数据的序列化格式(数据类型) | apps/v1, Kind=Deployment |
| GVR | Group+Version+Resource | REST 路径(数据位置) | apps/v1, Resource=deployments |
| GK | Group+Kind(无 Version) | 模糊键:不知道用哪个版本 | apps, Kind=Deployment |
| GR | Group+Resource(无 Version) | 模糊键:不知道哪个版本有这个端点 | apps, Resource=deployments |
RESTMapper 的核心职责就是在这四个元组之间做翻译:GVK ↔ GVR(数据类型和路径互查)、GK/GR → GVK/GVR(按优先级消歧)、Resource ↔ Kind(单复数互查)。这就是它的价值——在 k8s 这个庞大的 REST API 系统里,每一个 Go struct、每一个 YAML、每一个 HTTP 请求之间,都依赖 RESTMapper 做翻译。
RESTMapper 是一个接口,定义在 staging/src/k8s.io/apimachinery/pkg/api/meta/interfaces.go(行 113-134)。它只有 8 个方法,但覆盖了 GVK / GVR / GK / GR 之间的所有翻译场景。
// staging/src/k8s.io/apimachinery/pkg/api/meta/interfaces.go(行 113-134)
// RESTMapper 让客户端把"资源"映射到"类型",把"类型"和"版本"映射到"REST 操作接口"。
// k8s API 是版本化的(不同 Group/Version 的资源作用域不同)。
type RESTMapper interface {
// KindFor:给一个部分 GVR,返回"唯一的确定 GVK"。多个匹配时返回 AmbiguousResourceError。
KindFor(resource schema.GroupVersionResource) (schema.GroupVersionKind, error)
// KindsFor:给一个部分 GVR,返回"所有可能的 GVK"(按优先级排序)。零个返回 NoResourceMatchError。
KindsFor(resource schema.GroupVersionResource) ([]schema.GroupVersionKind, error)
// ResourceFor:给一个部分 GVR,返回"唯一的确定 GVR"。多个匹配时返回 AmbiguousResourceError。
ResourceFor(input schema.GroupVersionResource) (schema.GroupVersionResource, error)
// ResourcesFor:给一个部分 GVR,返回"所有可能的 GVR"(按优先级排序)。
ResourcesFor(input schema.GroupVersionResource) ([]schema.GroupVersionResource, error)
// RESTMapping:给一个 GK(Group+Kind),返回"一个完整的 RESTMapping"
//(含 GVR、GVK、Scope)。versions 参数指定版本搜索顺序,不填则用默认顺序。
RESTMapping(gk schema.GroupKind, versions ...string) (*RESTMapping, error)
// RESTMappings:返回"所有版本的 RESTMapping"。用于需要遍历所有版本的情况。
RESTMappings(gk schema.GroupKind, versions ...string) ([]*RESTMapping, error)
// ResourceSingularizer:把资源名从复数转单数(deployments → deployment)
ResourceSingularizer(resource string) (singular string, err error)
}
8 个方法可以分成三组:
| 分组 | 方法 | 方向 | 返回 |
|---|---|---|---|
| GVR → GVK | KindFor | GVR → 单个 GVK | (GVK, error) |
| KindsFor | GVR → 所有 GVK | ([]GVK, error) | |
| GVR → GVR | ResourceFor | GVR → 确定 GVR | (GVR, error) |
| ResourcesFor | GVR → 所有 GVR | ([]GVR, error) | |
| GK → RESTMapping | RESTMapping | GK → 单个 RESTMapping | (*RESTMapping, error) |
| RESTMappings | GK → 所有 RESTMapping | ([]*RESTMapping, error) | |
| ResourceSingularizer | String → String(复数→单数) | (string, error) | |
注意:KindFor 返回一个,KindsFor 返回多个。这是 k8s API 设计的惯例:For 带 s 表示"返回全部",不带 s 表示"返回确定的那个"(Ambiguous 时报错)。这个命名规则在整个 k8s 里是一致的。
RESTScope 接口定义也很简洁:
// staging/src/k8s.io/apimachinery/pkg/api/meta/interfaces.go(行 78-89)
// RESTScope 表示资源的层级:namespace 级还是 cluster 级。
type RESTScopeName string
const (
RESTScopeNameNamespace RESTScopeName = "namespace" // Pod、Service、Deployment 等
RESTScopeNameRoot RESTScopeName = "root" // Node、PersistentVolume、Namespace 等
)
type RESTScope interface {
Name() RESTScopeName
}
// 两个预制实例
var RESTScopeNamespace = &restScope{name: RESTScopeNameNamespace}
var RESTScopeRoot = &restScope{name: RESTScopeNameRoot}
RESTScope 只有两种:namespace(需要写 namespace 参数)和root(cluster 级,不需要 namespace)。这个信息对 RESTMapper 很重要——当它返回 RESTMapping 时,Scope 告诉调用方这个资源是 namespaced 还是 cluster-scoped,从而决定 HTTP 路径里有没有 /namespaces/{ns}/ 这一段。
RESTMapping 是 RESTMapper.RESTMapping() 的返回值。它把一次"翻译查询"的结果打包成一个结构体,包含调用方构造 REST 请求所需的全部信息。
// staging/src/k8s.io/apimachinery/pkg/api/meta/interfaces.go(行 91-102)
// RESTMapping 包含以 RESTful 方式操作特定资源类型所需的全部信息。
type RESTMapping struct {
// Resource 是 GVR(REST 端点的位置)
Resource schema.GroupVersionResource
// GroupVersionKind 是 GVK(数据的格式)
GroupVersionKind schema.GroupVersionKind
// Scope:namespace 级还是 cluster 级(决定路径格式)
Scope RESTScope
}
一个 RESTMapping 包含了"去哪读"(GVR/Scope)和"读什么格式"(GVK)两组信息。实际使用中,调用方拿到 RESTMapping 后:
// 用 RESTMapping 构造一个 RESTClient 的典型流程
mapping, err := mapper.RESTMapping(schema.GroupKind{Group: "apps", Kind: "Deployment"})
// mapping.Resource = {Group:"apps", Version:"v1", Resource:"deployments"}
// mapping.GroupVersionKind = {Group:"apps", Version:"v1", Kind:"Deployment"}
// mapping.Scope = RESTScopeNamespace(Deployment 是 namespace 级别的)
// 构造 HTTP 路径:取决于 Scope
if mapping.Scope.Name() == meta.RESTScopeNameNamespace {
// namespaced:/apis/apps/v1/namespaces/{ns}/deployments
url = fmt.Sprintf("/apis/%s/namespaces/%s/%s", gv.String(), ns, mapping.Resource.Resource)
} else {
// cluster-scoped:/apis/apps/v1/deployments
url = fmt.Sprintf("/apis/%s/%s", gv.String(), mapping.Resource.Resource)
}
DefaultRESTMapper 是 RESTMapper 的内存实现——所有映射都存在五张 Go map 里,不需要网络请求。适合"已经知道集群所有 API"的场景(比如 API Server 启动阶段、代码里硬编码了所有内置类型的场景)。
// staging/src/k8s.io/apimachinery/pkg/api/meta/restmapper.go(行 57-65)
type DefaultRESTMapper struct {
defaultGroupVersions []schema.GroupVersion
// 表1:GVR → GVK(双向之一)
// 存 复数GVR→Kind 和 单数GVR→Kind
resourceToKind map[schema.GroupVersionResource]schema.GroupVersionKind
// 表2:GVK → GVR(双向之二)
kindToPluralResource map[schema.GroupVersionKind]schema.GroupVersionResource
// 表3:GVK → Scope(namespace 还是 cluster)
kindToScope map[schema.GroupVersionKind]RESTScope
// 表4:单数 → 复数
singularToPlural map[schema.GroupVersionResource]schema.GroupVersionResource
// 表5:复数 → 单数
pluralToSingular map[schema.GroupVersionResource]schema.GroupVersionResource
}
五张表的关系如下(图 1:DefaultRESTMapper 五表关系):
DefaultRESTMapper 内部五表 ================= 表1: resourceToKind (GVR → GVK) apps/v1, Resource=deployments → apps/v1, Kind=Deployment apps/v1, Resource=deployment → apps/v1, Kind=Deployment ← 单数也存 core/v1, Resource=pods → core/v1, Kind=Pod core/v1, Resource=pod → core/v1, Kind=Pod 表2: kindToPluralResource (GVK → GVR) apps/v1, Kind=Deployment → apps/v1, Resource=deployments 表3: kindToScope (GVK → RESTScope) apps/v1, Kind=Deployment → RESTScopeNamespace core/v1, Kind=Node → RESTScopeRoot core/v1, Kind=Namespace → RESTScopeRoot 表4: singularToPlural (单数 → 复数) apps/v1, Resource=deployment → apps/v1, Resource=deployments 表5: pluralToSingular (复数 → 单数) apps/v1, Resource=deployments → apps/v1, Resource=deployment
为什么单数和复数都存进表 1 和表 2?k8s 的历史兼容性——kubectl get pod 和 kubectl get pods 都能工作。表 4 和表 5 保证了单复数互查。
注册映射有两种方式:
// staging/src/k8s.io/apimachinery/pkg/api/meta/restmapper.go(行 99-113)
// Add:自动推导单复数和 GVR(内部调 UnsafeGuessKindToResource)
func (m *DefaultRESTMapper) Add(kind schema.GroupVersionKind, scope RESTScope) {
plural, singular := UnsafeGuessKindToResource(kind)
m.AddSpecific(kind, plural, singular, scope)
}
// AddSpecific:显式指定 GVK / 复数资源名 / 单数资源名 / Scope
func (m *DefaultRESTMapper) AddSpecific(
kind schema.GroupVersionKind,
plural, singular schema.GroupVersionResource,
scope RESTScope,
) {
// 写表1:单数+复数 GVR → GVK
m.resourceToKind[singular] = kind
m.resourceToKind[plural] = kind
// 写表2:GVK → 复数 GVR
m.kindToPluralResource[kind] = plural
// 写表3:GVK → Scope
m.kindToScope[kind] = scope
// 写表4:单数 → 复数
m.singularToPlural[singular] = plural
// 写表5:复数 → 单数
m.pluralToSingular[plural] = singular
}
一次 AddSpecific 调用同时写入五张表,保证五个方向的查询都能命中。这就是 DefaultRESTMapper 性能高的原因——所有查询都是 O(1) map 查找,没有任何网络请求。
KindsFor 是 DefaultRESTMapper 里最复杂的查询。它的输入是一个部分 GVR(比如只填了 Resource=pods),输出是所有匹配的 GVK(可能来自 core/v1、policy/v1beta1 等)。
// staging/src/k8s.io/apimachinery/pkg/api/meta/restmapper.go(行 291-356)
func (m *DefaultRESTMapper) KindsFor(input schema.GroupVersionResource) ([]schema.GroupVersionKind, error) {
resource := coerceResourceForMatching(input)
// 归一化:Resource 全小写;__internal 版本转空字符串
hasResource := len(resource.Resource) > 0
hasGroup := len(resource.Group) > 0
hasVersion := len(resource.Version) > 0
ret := []schema.GroupVersionKind{}
switch {
// 情况1:全填了(Fully Qualified)→ 精确查找
case hasGroup && hasVersion:
kind, exists := m.resourceToKind[resource]
if exists { ret = append(ret, kind) }
// 情况2:填了 Group,没填 Version → 找该 Group 下所有版本的 GVK
case hasGroup:
for currResource, currKind := range m.resourceToKind {
if currResource.GroupResource() == resource.GroupResource() {
ret = append(ret, currKind)
}
}
// 情况3:填了 Version,没填 Group → 找该 Version 下所有 GVK
case hasVersion:
for currResource, currKind := range m.resourceToKind {
if currResource.Version == resource.Version && currResource.Resource == resource.Resource {
ret = append(ret, currKind)
}
}
// 情况4:只填了 Resource → 找所有 Group/Version 下的 GVK
default:
for currResource, currKind := range m.resourceToKind {
if currResource.Resource == resource.Resource {
ret = append(ret, currKind)
}
}
}
if len(ret) == 0 {
return nil, &NoResourceMatchError{PartialResource: input}
}
// 按 defaultGroupVersions 指定的版本优先级排序
sort.Sort(kindByPreferredGroupVersion{ret, m.defaultGroupVersions})
return ret, nil
}
四个分支对应四种输入情况——输入越完整,查找越精确。KindFor 拿到 KindsFor 的结果后,取第一个(最高优先级)返回,如果只有一个匹配则直接返回,多个匹配才报错 AmbiguousResourceError。这就是为什么 KindFor 能做到"确定性"——它把"模糊"的结果先全部找出来,再用 defaultGroupVersions 排序后取第一个。
defaultGroupVersions 是 NewDefaultRESTMapper 传入的"版本优先级表",比如 [core/v1, apps/v1, networking.k8s.io/v1]。k8s 内部启动时用这个顺序来保证"总是优先用稳定版本"。
DefaultRESTMapper 有个局限:它只能处理"精确匹配",当多个版本都匹配时直接报错。但实际场景中,controller 启动时只注册了部分 GVK,调用方传的是 Resource=pods(没有 Group),这时 KindsFor 会返回 core/v1.Pod、policy/v1beta1.Pod 等多个结果。PriorityRESTMapper 登场了——它在多个结果之间按用户定义的优先级链做过滤,最终返回唯一确定的那个。
// staging/src/k8s.io/apimachinery/pkg/api/meta/priority.go(行 36-53)
// PriorityRESTMapper:包装一个 RESTMapper,自动在多个匹配中按优先级选一个
type PriorityRESTMapper struct {
// 被委托的实际 mapper(可以是 DefaultRESTMapper / MultiRESTMapper 等)
Delegate RESTMapper
// 资源优先级列表:链式过滤,每条 Pattern 可以用通配符
ResourcePriority []schema.GroupVersionResource
// Kind 优先级列表
KindPriority []schema.GroupVersionKind
}
PriorityRESTMapper 是一个装饰器(Decorator)——它包装任意一个 RESTMapper,往外暴露的接口签名完全一样,但把每个方法的返回值再过一遍"优先级过滤"。Delegate 不需要知道优先级的事,PriorityRESTMapper 也不需要知道底层怎么查表——各司其职。
// staging/src/k8s.io/apimachinery/pkg/api/meta/priority.go(行 96-129)
// KindFor:先用 Delegate 找所有匹配 → 链式过滤 → 唯一结果
func (m PriorityRESTMapper) KindFor(partiallySpecifiedResource schema.GroupVersionResource) (schema.GroupVersionKind, error) {
// ① 先委托 Delegate 找到所有可能的 GVK
originalGVKs, originalErr := m.Delegate.KindsFor(partiallySpecifiedResource)
if len(originalGVKs) == 1 { return originalGVKs[0], originalErr }
remainingGVKs := append([]schema.GroupVersionKind{}, originalGVKs...)
// ② 链式过滤:按 KindPriority 逐条 Pattern 过滤
for _, pattern := range m.KindPriority {
matchedGVKs := []schema.GroupVersionKind{}
for _, gvk := range remainingGVKs {
if kindMatches(pattern, gvk) { matchedGVKs = append(matchedGVKs, gvk) }
}
switch len(matchedGVKs) {
case 0: continue // 没命中,跳到下一条 Pattern
case 1: return matchedGVKs[0], originalErr // 唯一命中,返回
default: remainingGVKs = matchedGVKs // 多个命中,用这批结果继续下一条 Pattern
}
}
// ③ 所有 Pattern 都用完仍多余 → 报 AmbiguousResourceError
return schema.GroupVersionKind{}, &AmbiguousResourceError{..., MatchingKinds: originalGVKs}
}
kindMatches 函数支持通配符:
// kindMatches:pattern 支持 * 通配符(AnyGroup / AnyVersion / AnyKind)
func kindMatches(pattern schema.GroupVersionKind, kind schema.GroupVersionKind) bool {
if pattern.Group != AnyGroup && pattern.Group != kind.Group { return false }
if pattern.Version != AnyVersion && pattern.Version != kind.Version { return false }
if pattern.Kind != AnyKind && pattern.Kind != kind.Kind { return false }
return true
}
const AnyGroup = "*"; AnyVersion = "*"; AnyKind = "*"
这个链式过滤的好处是:可以用多条 Pattern 做级联选择。比如第一条 apps/v1/* 把范围缩小到 apps 组,第二条 */v1/* 再缩小到 v1 版本,最终唯一命中。这比"给每种组合写一个 Pattern"优雅得多。
🌟 实用技巧
PriorityRESTMapper 典型使用:ResourcePriority 设 [core/v1/*] 让 Pod/Service 这些 core 组资源总是优先用 v1;KindPriority 设 [apps/v1/*, networking.k8s.io/v1/*] 让自定义工作负载优先用 apps 组 v1。这就是 NewDiscoveryRESTMapper 的默认初始化逻辑。
DefaultRESTMapper 需要提前注册所有 GVK;PriorityRESTMapper 同样。DeferredDiscoveryRESTMapper 解决的是"controller 启动时根本不知道集群里有什么 API"这个问题——它把字典构建推迟到"第一次真正需要查询时"。
// staging/src/k8s.io/client-go/restmapper/discovery.go(行 176-209)
// DeferredDiscoveryRESTMapper:直到第一次调 KindFor/RESTMapping 才初始化
type DeferredDiscoveryRESTMapper struct {
initMu sync.Mutex // 保护 delegate 的双重检查锁
delegate meta.RESTMapper // nil 表示"还没初始化"
cl discovery.CachedDiscoveryInterface // 缓存了 API Discovery 结果的客户端
}
// NewDeferredDiscoveryRESTMapper:只接受一个 CachedDiscoveryInterface,不立刻查集群
func NewDeferredDiscoveryRESTMapper(cl discovery.CachedDiscoveryInterface) *DeferredDiscoveryRESTMapper {
return &DeferredDiscoveryRESTMapper{cl: cl} // delegate 初始为 nil
}
// getDelegate:懒加载 + double-checked locking
func (d *DeferredDiscoveryRESTMapper) getDelegate() (meta.RESTMapper, error) {
d.initMu.Lock()
defer d.initMu.Unlock()
if d.delegate != nil { return d.delegate, nil } // 快速路径:已初始化则返回
// 慢速路径:第一次调用,真正去集群查 Discovery API
groupResources, err := GetAPIGroupResources(d.cl)
if err != nil { return nil, err }
// NewDiscoveryRESTMapper:把 API Group 信息转成 PriorityRESTMapper
d.delegate = NewDiscoveryRESTMapper(groupResources)
return d.delegate, nil
}
为什么用 double-checked locking?——getDelegate() 会被所有 RESTMapper 方法(KindFor、KindsFor、RESTMapping 等)调用,是热点路径。第一个请求触发初始化(加锁慢路径),之后所有请求走 if d.delegate != nil { return } 快速路径——只有初始化那一次加锁,后续调用完全没有锁开销。
// staging/src/k8s.io/client-go/restmapper/discovery.go(行 211-235)
// Reset:失效缓存,让下一次查询重新从集群拉取
func (d *DeferredDiscoveryRESTMapper) Reset() {
d.initMu.Lock()
defer d.initMu.Unlock()
d.cl.Invalidate() // 使 CachedDiscoveryInterface 缓存失效
d.delegate = nil // 清空已构建的 mapper
}
// 所有 RESTMapper 方法都用了"缓存失效自动重查":
// 如果调用 delegate 返回空结果且缓存已过期 → Reset() + 重新调用
func (d *DeferredDiscoveryRESTMapper) KindFor(resource schema.GroupVersionResource) (gvk schema.GroupVersionKind, err error) {
del, err := d.getDelegate()
if err != nil { return }
gvk, err = del.KindFor(resource)
// 关键:若 delegate 返回错误且缓存已过期(!Fresh()),则失效后重查
if err != nil && !d.cl.Fresh() {
d.Reset()
gvk, err = d.KindFor(resource) // 递归重查
}
return
}
Reset() 有什么用?operator 升级了 k8s 版本,新版本带来了新的 API Group(比如 resource.k8s.io/v1alpha2),这时需要重建字典。Controller Manager 在每次集群版本变更时会调 MaybeResetRESTMapper(mapper),自动触发懒加载 mapper 的 Reset。
DeferredDiscoveryRESTMapper 的懒加载三定律
① 第一次使用时才查集群:NewDeferredDiscoveryRESTMapper 本身不发任何网络请求。
② Init 后缓存永不失效直到 Reset():CachedDiscoveryInterface 会定期刷新。
③ 空结果 + 缓存过期 → 自动重查:如果 delegate 返回空且缓存已过期,说明字典过时了,Reset 后递归重查。
NewDiscoveryRESTMapper 是 client-go 提供的一个工厂函数——它接收 API Server 的 Discovery 信息(/apis 和 /api 的响应),输出一个完整的 PriorityRESTMapper。
// staging/src/k8s.io/client-go/restmapper/discovery.go(行 43-143)
// NewDiscoveryRESTMapper 返回一个 PriorityRESTMapper
// Priority 顺序:① core/v1(最高)→② 各 Group 的 PreferredVersion → ③ 其他版本
func NewDiscoveryRESTMapper(groupResources []*APIGroupResources) meta.RESTMapper {
unionMapper := meta.MultiRESTMapper{}
// resourcePriority / kindPriority:构建优先级列表(核心逻辑)
resourcePriority := []schema.GroupVersionResource{{Group:"", Version:"v1", Resource:meta.AnyResource}}
kindPriority := []schema.GroupVersionKind{{Group:"", Version:"v1", Kind:meta.AnyKind}}
// AnyResource = "*",AnyKind = "*",表示"通配"
for _, group := range groupResources {
groupPriority = append(groupPriority, group.Group.Name)
// 每个 Group:PreferredVersion 最优先
if len(group.Group.PreferredVersion.Version) != 0 {
preferred := group.Group.PreferredVersion.Version
resourcePriority = append(resourcePriority, schema.GroupVersionResource{
Group: group.Group.Name, Version: preferred, Resource: meta.AnyResource,
})
kindPriority = append(kindPriority, schema.GroupVersionKind{
Group: group.Group.Name, Version: preferred, Kind: meta.AnyKind,
})
}
// 每个 Version:建一个 DefaultRESTMapper,逐资源 AddSpecific
for _, discoveryVersion := range group.Group.Versions {
gv := schema.GroupVersion{Group: group.Group.Name, Version: discoveryVersion.Version}
versionMapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{gv})
for _, resource := range resources {
scope := meta.RESTScopeNamespace
if !resource.Namespaced { scope = meta.RESTScopeRoot }
// 自动处理 Kind 大小写:Deployment → deployments & deployment
plural := gv.WithResource(resource.Name)
singular := gv.WithResource(resource.SingularName)
// 两个都注册(Deployment 和 deployment 都可查)
versionMapper.AddSpecific(gv.WithKind(strings.ToLower(resource.Kind)), plural, singular, scope)
versionMapper.AddSpecific(gv.WithKind(resource.Kind), plural, singular, scope)
// List 类型也要注册
versionMapper.Add(gv.WithKind(resource.Kind+"List"), scope)
}
unionMapper = append(unionMapper, versionMapper)
}
}
// 返回 PriorityRESTMapper:MultiRESTMapper 做 Union,所有结果再按优先级过滤
return meta.PriorityRESTMapper{
Delegate: unionMapper,
ResourcePriority: resourcePriority,
KindPriority: kindPriority,
}
}
这是一个三明治结构:Discovery API 原始信息 → DefaultRESTMapper(每个 Version 一个)→ MultiRESTMapper(Union)→ PriorityRESTMapper(消歧)→ DeferredDiscoveryRESTMapper(懒加载包装)。整个链路从下到上:
Discovery API (/apis/apps, /api/v1, ...)
│
▼
NewDiscoveryRESTMapper()
│
├── 每个 API Group 建一个 DefaultRESTMapper
│ ├── apps/v1 → DefaultRESTMapper(deployments/statefulsets)
│ ├── networking.k8s.io/v1 → DefaultRESTMapper(ingresses/networkpolicies)
│ └── core/v1 → DefaultRESTMapper(pods/services/configmaps)
│
├── MultiRESTMapper 合并(unionMapper)
│ └── 一个 MultiRESTMapper 持有多个 DefaultRESTMapper
│
└── PriorityRESTMapper 包装
├── ResourcePriority = [core/v1/*, apps/v1/*, net.k8s.io/v1/*, ...]
└── KindPriority = [core/v1/*, apps/v1/*, ...]
controller-runtime(kubebuilder / operator-sdk)的 manager 默认使用 DeferredDiscoveryRESTMapper:
// controller-runtime/pkg/controller/controller.go(典型初始化)
func New(cfg *rest.Config) (*Manager, error) {
// 1. 创建一个 CachedDiscoveryClient(带内存缓存)
cl, err := discovery.NewDiscoveryClientForConfig(cfg)
cachedCl := discovery.NewCachedClient(cl, ...) // 自动缓存 /apis 和 /api 响应
// 2. 构建 DeferredDiscoveryRESTMapper(懒加载)
mapper := restmapper.NewDeferredDiscoveryRESTMapper(cachedCl)
// 3. 用这个 mapper 创建 RESTClient / Client
client, err := client.New(cfg, client.Options{Mapper: mapper})
// 后续 controller 用 client.Get(ctx, name, ns, &pod) 时,
// client 内部调 mapper.RESTMapping(GroupKind{Group:"", Kind:"Pod"}) → RESTMapping
// → 用 RESTMapping.Resource(GVR)+ RESTMapping.Scope 构造 HTTP 路径
return manager, nil
}
这就是为什么你写 controller 时只需要写 kind: Pod 而不用关心它是 core/v1 还是 v1——RESTMapper 在幕后帮你做了版本消歧。
kubectl 的 kubectl patch、kubectl apply --dry-run=client 等命令都需要知道"目标资源的 GVR 和 GVK",这些全靠 RESTMapper。kubectl 同样用 DeferredDiscoveryRESTMapper,所以 kubectl 启动时也不发 Discovery 请求,直到第一次需要转换 YAML 时才触发。
API Server 启动时,内置资源的 GVK/GVR 是已知固定的(Pod、Service、Deployment 这些 k8s 原生类型,永远存在于所有集群),所以不需要 Discovery,直接用 DefaultRESTMapper 注册硬编码的映射表。这就是为什么 API Server 启动快——没有 Discovery 延迟。
▼ Q1: GVK 和 GVR 到底有什么区别?为什么要分成两个概念?
A: GVK 描述"数据的格式"(序列化时用 apiVersion: apps/v1 + kind: Deployment),GVR 描述"数据的位置"(HTTP 路径是 /apis/apps/v1/namespaces/{ns}/deployments)。同一个 GVK(如 apps/v1, Kind=Deployment)理论上可以对应不同的 GVR(虽然 k8s 约定一个 Kind 对应一个 Resource),但 CRD 的 Conversion Webhook 可以把 apps/v1 里的 Deployment 存成不同的内部结构,所以分开更灵活。本质上 GVK 是"类型系统",GVR 是"路由系统"。
▼ Q2: KindFor 返回一个,KindsFor 返回多个,这个命名规则在整个 k8s 里一致吗?
A: 完全一致。这是一条贯穿整个 k8s 的命名约定:带 s 返回全部(列表),不带 s 返回确定的那个(Ambiguous 时报错)。举例:ResourcesFor 返回 []GVR,ResourceFor 返回单个 GVR;KindsFor 返回 []GVK,KindFor 返回单个 GVK;RESTMappings 返回 []*RESTMapping,RESTMapping 返回单个 *RESTMapping。记住这个规律,看任何 k8s API 都不会迷路。
▼ Q3: DefaultRESTMapper 为什么需要五张 map?不能合并成更少的表吗?
A: 可以从逻辑上理解这五张表的分工:resourceToKind(双向之一)和 kindToPluralResource(双向之二)实现 GVR ↔ GVK 的双向 O(1) 查找;kindToScope 实现 GVK → RESTScope(判断 namespaced / cluster);singularToPlural 和 pluralToSingular 实现单复数互查。每张表都是 O(1) map,如果合并成一张大表,查找复杂度会退化到 O(n) 扫描。k8s 是性能敏感的系统,五张表是时间和空间的折中——以 5 个 map 的内存代价,换来所有查询都是常量时间。
▼ Q4: PriorityRESTMapper 的 KindPriority 里的 Pattern 怎么写?能不能给个具体例子?
A: Pattern 用通配符 *(AnyGroup / AnyVersion / AnyKind)。实际 NewDiscoveryRESTMapper 的初始化就是最权威的例子:KindPriority = [{Group:"", Version:"v1", Kind:"*"}, {Group:"apps", Version:"v1", Kind:"*"}, {Group:"networking.k8s.io", Version:"v1", Kind:"*"}]。第一条的意思是"如果有 core/v1 的匹配就用它";第二条是"apps/v1 优先于 apps/v1beta1";第三条是"networking.k8s.io/v1 优先"。如果想自定义,比如"让 storage.k8s.io/v1beta1 的 CSI 驱动优先于 v1",可以把 {Group:"storage.k8s.io", Version:"v1beta1", Kind:"*"} 加到 KindPriority 列表的最前面。
▼ Q5: DeferredDiscoveryRESTMapper 第一次调用会不会阻塞?有多慢?
A: 会。getDelegate() 第一次被调用时,会同步调 ServerGroupsAndResources()(两个 HTTP GET:/api 和 /apis),典型延迟 50-200ms(取决于集群规模)。但只有这一次——CachedDiscoveryInterface 会把结果存到内存,后续调用直接走 delegate != nil 快速路径。另外 CachedDiscoveryInterface 支持文件缓存(~/.kube/cache/discovery/),controller 重启后如果缓存还在,第一次调用也可能命中缓存,延迟降到 0。生产中这 50-200ms 的初始化延迟是完全可以接受的,相比 controller 从 informer 同步全量资源状态的几秒到几十秒,这是毛毛雨。
▼ Q6: AmbiguousResourceError 和 AmbiguousKindError 有什么区别?什么时候抛哪个?
A: 两个都是"多匹配"错误,但触发场景不同:AmbiguousResourceError 来自 ResourceFor(输入是 GVR),表示多个 GVR 都匹配;AmbiguousKindError 来自 KindFor 或 RESTMapping(输入是 GK 或 GVR),表示多个 GVK 都匹配。在实际生产中,KindFor 返回 AmbiguousKindError 更常见(比如只传了 Resource=pods),ResourceFor 返回 AmbiguousResourceError 相对少见(因为 Resource 名本身在集群内通常是唯一的)。
▼ Q7: 我写的 CRD 被 kubectl get 报 "Unable to construct REST mapping" 错误,是 RESTMapper 的问题吗?
A: 很可能。kubectl 的 RESTMapper(DeferredDiscoveryRESTMapper)依赖 API Server 的 Discovery 信息。如果你的 CRD 安装完了但 kubectl 还没刷新本地缓存(缓存 TTL 过期前),或者 kubectl 版本不支持新的 API Group,就会报这个错。排查三步:① 确认 CRD 确实已安装(kubectl get crd 能看到);② 确认 kubectl 有对应版本的 API Group(kubectl api-resources);③ 手动让 kubectl 刷新缓存(删 ~/.kube/cache/discovery/ 目录,或者重启 kubectl)。另外,kubectl 1.28+ 对 CRD 多版本有更好支持,老版本 kubectl 可能无法正确处理。
▼ Q8: DefaultRESTMapper.Add 和 AddSpecific 有什么区别?我该用哪个?
A: Add 自动推导单复数和 GVR(调 UnsafeGuessKindToResource),适用于标准命名规范(Kind 名 + s 就是复数、y 结尾变 ies)。AddSpecific 是显式全参数注册,适用于:① 单复数不规则(如 endpoints 不变);② Kind 和 Resource 名不一致(如 Deployment → deployments 但 Kind 里可能有自定义);③ 需要精确控制 Scope。生产中 90% 场景用 Add 即可,因为 k8s 内置资源都遵循命名规范。
▼ Q9: UnsafeGuessKindToResource 为什么叫 Unsafe?有什么坑?
A: 因为它只做了"简单字符串操作":ToLower、+s、y→ies。这对标准 k8s 资源正确,但对不规则的 CRD 资源可能猜错。比如一个 Kind 名叫 VolumeAttachmentRequest,它会猜 Resource = volumeattachmentrequests(+s),但 CRD 实际的 Resource 可能是 volumeattachmentrequests 也可能是 var(缩写)。AddSpecific 专门用来绕过 UnsafeGuessKindToResource 的局限性——这就是为什么 NewDiscoveryRESTMapper 不用 Add 而是用 AddSpecific(gv.WithKind(resource.Kind), plural, singular, scope) 直接用 Discovery API 返回的 resource.Name(来自 CRD spec)。
▼ Q10: MultiRESTMapper 和 PriorityRESTMapper 都是"包装多个 Mapper",区别在哪?
A: MultiRESTMapper 是"聚合":把多个 Mapper 的结果 Union 起来(收集所有结果,忽略 NoMatchError),ambiguous 时聚合所有 GVK 返回。它没有优先级概念,只负责"把所有 Mapper 的结果合在一起"。PriorityRESTMapper 是"消歧":在聚合结果之上加了一层优先级过滤,最终只返回一个。NewDiscoveryRESTMapper 的模式是:多个 DefaultRESTMapper → MultiRESTMapper(聚合)→ PriorityRESTMapper(消歧),两者配合使用。
▼ Q11: controller-runtime 的 client.New 时没传 Mapper 参数,默认用什么?
A: controller-runtime 会自动构造一个。实际路径是:manager.New(cfg) → 内部创建 DeferredDiscoveryRESTMapper(传入 NewCachedDiscoveryClient)→ Wrap 成 PriorityRESTMapper(通过 discoveryutil.GetAPIGroupResources)→ 注入 client.Options{Mapper: mapper}。所以你即使不传 Mapper,也默认得到的是 Deferred + Priority 的组合,既懒加载又消歧。
▼ Q12: 为什么 core/v1(core 组)用空字符串 Group 而不是 "core"?
A: 这是 k8s 的历史约定。Pod、Service、ConfigMap 这些最原始的内置资源在 2015 年 k8s 诞生时就有了,当时还没有 API Group 的概念,它们的路径是 /api/v1/pods(不是 /apis/core/v1/pods)。为了让 core/v1 和 apps/v1 能在同一套 API 路径规范下共存,k8s 决定 core 组的 Group 字段用空字符串。这个设计一直保持到今天,所以你在 Go 代码里看到 schema.GroupVersion{Group: "", Version: "v1"},它对应的就是 core/v1 这个特殊组。
▼ Q13: RESTMapper 的 Reset() 方法什么时候该调?
A: 两种场景:① 集群版本升级——新版本的 API Server 引入了新的 API Group(比如从 k8s 1.28 → 1.36),Controller Manager 在收到 DiscoveryChanged 信号时会调 MaybeResetRESTMapper(mapper);② 用户删除了 CRD——kubectl 的 cache 里还有旧的 CRD 信息,需要 Reset 后重新 Discovery。普通 controller 运行期间不需要调 Reset——缓存 TTL(默认 10 分钟)到了 CachedDiscoveryInterface 会自动刷新。
▼ Q14: 为什么 KindFor 的输入是 GVR 而不是 GK?这不是很反直觉吗?
A: 看起来反直觉,但有历史原因:用户给出的"部分信息"通常是"资源名"(如 kubectl get pods 里填的是 pods 这个 Resource,不是 Kind)。KindFor 的输入其实是"部分 GVR"(可以有 Resource、Group、Version 中的任意组合),它在内部把它解释为"一个用来匹配 Kind 的资源标识"。反过来,RESTMapping 的输入是 GK(Group+Kind),因为用户通常是"我要 Deployment"(Kind)而不是"我要 deployments 端点"(Resource)。两个方法的输入方向不同,分别对应"找端点"和"找类型"的场景。
▼ Q15: 我能给 DefaultRESTMapper 动态加新的 GVK 映射吗?还是只能初始化时注册?
A: 可以动态加。DefaultRESTMapper 是并发安全的(Go map 本身不是,但所有写操作在 manager 启动阶段完成,没有并发写),随时可以调 mapper.Add(gvk, scope) 或 mapper.AddSpecific(gvk, plural, singular, scope)。实际场景:Operator 启动后注册了 CRD,CRD 安装完成后需要把新 CR 的 GVK 注入 mapper,这是通过 controller-runtime/pkg/cache/cache.go 里的 tracker 机制做的——CRD 安装触发 informer,informer 回调里调 scheme.RESTMapper 重新注册。
▼ Q16: NewDiscoveryRESTMapper 为什么把 Kind 和 Resource 都注册两遍(大写和小写)?
A: 因为 k8s 的 Kind 名是首字母大写(Deployment),但 Discovery API 返回的 resource.Name 是小写复数(deployments)。用户的 YAML 里可能写 kind: Deployment(GVK 的 Kind 字段),也可能写 kind: deployment(虽然不标准但历史上有人这么写过)。两遍注册保证了 Deployment 和 deployment 都能正确解析。另外还注册了 Kind+"List"(如 DeploymentList),这样 kubectl get deploy 时 informer 也能正确处理 list 类型。
▼ Q17: ResourceSingularizer 的"单复数转换"在 kubectl 哪里用到了?
A: 典型场景是 kubectl get deployment my-app(单数)。kubectl 的 RESTMapper 用 ResourceSingularizer 把 deployment(用户输入)转成 deployments(REST 端点)。另外在错误消息里也有用——如果用户输入了一个不存在的资源名,kubectl 报错时会用 ResourceSingularizer 把复数资源名变单数,说"未找到资源 'deployment'(单数)"而不是暴露内部的复数形式。
▼ Q18: 为什么 CachedDiscoveryInterface 的缓存 TTL 是多久?能不能改成永久缓存?
A: CachedDiscoveryInterface 默认 TTL 是 10 分钟(600 秒)。这个时间是平衡点——太短(1 分钟)会导致频繁发 Discovery 请求增加 API Server 压力;太长(1 小时)会导致 CRD 安装后 kubectl 长时间看不到新资源。生产中不建议改成永久缓存,因为集群升级/扩缩容/新装 CRD 等场景都需要刷新。最优实践是用 controller-runtime 的 manager 配置 ControllerManagerOptions.CacheSyncTimeout 来控制 informer cache 的同步超时,而不是改 Discovery 缓存 TTL。
▼ Q19: 在 operator 里调 scheme.RESTMapping 和调 client-go 的 RESTMapper 有什么本质区别?
A: scheme.RESTMapping 用的是 DefaultRESTMapper(编译时已知),它只认识内置类型(Pod/Deployment 等)——CRD 的 GVK 根本没有注册进来,所以会报 NoKindMatchError。client-go 的 RESTMapper 是 DeferredDiscoveryRESTMapper(运行时从集群 Discovery),它通过 API Server 的 /apis 响应构建映射表,CRD 安装后就能查到。两者的数据来源完全不同:前者是"代码里硬编码",后者是"集群里动态发现"。写 operator 时注意:你用 client-go 操作 CR,用 client-go 的 mapper;用 scheme 操作内置类型,用 scheme 的 mapper。
▼ Q20: 当一个 Kind 有多个版本(比如 deployment 有 v1beta2 和 v1),controller 应该用哪个版本做 reconcile?
A: 最佳实践是 永远用内部版本(__internal) 做 reconcile。理由:① 所有版本的 CR 数据存到 etcd 时都会先转成 storage version(通常是 v1),读出来也要转回 internal;② controller 处理业务逻辑时应该基于"稳定的语义结构",不关心外部版本演进;③ k8s 的 Converter 负责 v1beta2 ↔ __internal ↔ v1 的转换,controller 只需要处理 internal 类型。实际做法:informer 监听时拿到的是已转好 internal 类型的对象(watch 在内部做了转换),reconcile 里用 obj.(*core.Deployment) 直接处理,不需要自己调 RESTMapper。
RESTMapper 的设计哲学是"字典分两层:本地字典 + 远程字典"。本地字典(DefaultRESTMapper)O(1) 查找但需要预注册;远程字典(DeferredDiscoveryRESTMapper)懒加载但有网络延迟。四种实现层层包装(Default → Multi → Priority → Deferred),每个包装只加一个功能(聚合 / 消歧 / 懒加载),符合单一职责原则。
RESTMapper 实现家族(从内到外)
===========================
┌─────────────────────────────────────────┐
│ DefaultRESTMapper │ ← 内存五表,所有查询 O(1),无网络
│ ┌─────────────────────────────────┐ │
│ │ resourceToKind │ │
│ │ kindToPluralResource │ │
│ │ kindToScope │ │
│ │ singular/plural 互查 │ │
│ └─────────────────────────────────┘ │
└──────────────┬──────────────────────────┘
│ 被包装(MultiRESTMapper)
▼
┌─────────────────────────────────────────┐
│ MultiRESTMapper │ ← 聚合:把多个 Mapper 的结果 Union 起来
│ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ apps/v1 │ │ net.k8 │ │ core/v1│ │
│ │ Default │ │ Default │ │Default │ │
│ └──────────┘ └──────────┘ └────────┘ │
└──────────────┬──────────────────────────┘
│ 被包装(PriorityRESTMapper)
▼
┌─────────────────────────────────────────┐
│ PriorityRESTMapper │ ← 消歧:按 KindPriority 链式过滤
│ ResourcePriority: [core/v1/*, apps/v1/*] │
│ KindPriority: [core/v1/*, apps/v1/*] │
└──────────────┬──────────────────────────┘
│ 被包装(DeferredDiscoveryRESTMapper)
▼
┌─────────────────────────────────────────┐
│ DeferredDiscoveryRESTMapper │ ← 懒加载:第一次调用时才从集群 Discovery
│ getDelegate() → NewDiscoveryRESTMapper │
│ Reset() → Invalidate + nil delegate │
└─────────────────────────────────────────┘
读完整篇文章,你应该能回答这几个核心问题:① GVK 是"数据类型",GVR 是"数据位置",RESTMapper 在两者之间翻译;② DefaultRESTMapper 用五张 map 做 O(1) 查找;③ PriorityRESTMapper 用链式 Pattern 过滤解决多匹配;④ DeferredDiscoveryRESTMapper 用 double-checked locking 实现懒加载 + 缓存自动失效;⑤ 实际开发中 controller-runtime 默认用 Deferred+Priority 的组合。 下一步:建议结合 client-go 的 tools/cache/shared_informer.go 来看——informer 初始化时就是用 RESTMapper 把 Watch 的 GVR 解析出来,再构造对应的 Reflector。下一次你看 informer 的源码,会发现 RESTMapper 知识无处不在。
Kubernetes 编程 / Operator 专题【左扬精讲】—— RESTMapper 管理 GVR 和 GVK 映射 · 全文完
源码基于 k8s 1.36.1;引用的核心文件:staging/src/k8s.io/apimachinery/pkg/api/meta/interfaces.go、staging/src/k8s.io/apimachinery/pkg/api/meta/restmapper.go、staging/src/k8s.io/apimachinery/pkg/api/meta/priority.go、staging/src/k8s.io/client-go/restmapper/discovery.go、staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/group_version.go
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。