学习目标
学完本章你应该能够:
- 用一句话讲清 client-go 是什么:K8s 官方提供的 Go 客户端库(SDK),把你对任意 K8s 资源的「增删改查」翻译成对 API Server 的一条条 HTTP REST 请求,并帮你做鉴权、编解码与请求参数封装。
- 理解 GVK 与 GVR:为什么
kubectl get pod落地后请求的是某个特定 URI,命名组(group)与核心组(core)在 URL 里的区别,以及 Kind、resource、group、version 如何一一对应。 - 掌握 client-go 的两种初始化方式:集群外读
kubeconfig,集群内用rest.InClusterConfig()(依赖 ServiceAccount 的 token 与注入的环境变量),并亲手让容器里的程序通过 RBAC 拿到权限。 - 说清四种 Client 的关系与选型:RESTClient 最底层、Clientset 对标准对象最省事、DynamicClient 处理非结构化数据(尤其 CRD)、DiscoveryClient 做版本/资源发现。
- 亲手实现一个 Watch 客户端,并讲明裸
Watch的五大坑(超时重连、API Server 压力、连接无法共享、无限流、失败无重试),从而理解为什么要用 Informer。 - 用 Informer + Indexer + Workqueue(RateLimiting 队列) 搭出一个 Controller 雏形,讲清 Reflector / DeltaFIFO / Indexer 的流水线,以及错误重试与指数退避。
- 用 DynamicClient + DiscoveryRESTMapper 实现
kubectl get <CRD>,理解 GVK 到 GVR 的转换原理(依赖 CRD 的复数/单数字段)。
前置知识:
- Go 基础:goroutine、
channel、context、接口、struct、go mod。不需要是 Go 高手,能编译会跑即可。 - 一点 Kubernetes 常识:Pod / Deployment 是什么,RESTful 的 GET/POST/PUT/DELETE 语义。
- 一个能连的 K8s 集群(或 kind/minikube),以及本地
~/.kube/config。操作前能kubectl get pods通就行。
本章你会动手做的事:
- 用 Clientset 列出
default命名空间的 Pod 和 Deployment,用kubectl get pods -v=6对照请求 URI。 - 把列 Pod 的程序容器化扔进集群,先看默认
defaultSA 无权限报错,再通过 Role + RoleBinding 授权,体会集群内认证与 RBAC。 - 用 Watch 客户端监听 Pod 变更,观察 60 秒超时断开;再用 Informer 替代,最后升级为 Informer + Workqueue 的重试消费。
- 定义并部署一个 CRD,写一个
get <myresource>小工具读取它。
类比:把 K8s 集群想象成一座大酒店的前台(API Server)。你想订房、换房、退房、查询房价——每种动作前台都有固定的单据格式(REST URI)。你当然可以自己跑去前台排队填单,但那样又慢又容易错。client-go 就像一个贴身管家:你只消说一句"我要查订房记录",它自动帮你把单据填好、带好证件(鉴权)、去前台办好,再把结果翻译成人话(Go 结构体)交给你。这张图就是在讲这个管家替你把"增删改查"翻译成对 API Server 的一条条请求。
flowchart LR
A[你的 Go 代码] --> B[管家 client-go]
B --> C[翻译成 REST URI
并带上鉴权/参数]
C --> D[前台 API Server]
D --> E[JSON 结果]
E --> F[管家转成 Go 结构体]
F --> G[你拿到对象直接用]还没有全局概念时先记住一句话:client-go 解决的核心问题,就是让你用 Go 代码方便地访问 API Server,后面四种 Client、Informer、RESTMapper 全是围绕这一句展开的分工。
一、Client-go 简介与配置
1. 什么是 Client-go
client-go 是 Kubernetes 官方提供给 Go 语言的客户端库,也就是我们常说的 SDK(软件开发工具包)。它解决的核心问题只有一个:让你用 Go 代码方便地访问 API Server。
那访问 API Server 具体要干嘛?自然是实现对本集群内资源(包括标准资源和自定义 CRD 资源)的「增删改查」。
这里先回顾一个 K8s 的基本事实:Kubernetes 的资源可以分成两大类——
- 核心 API 组(core group):代号
co,包含 K8s 最底层、最基础的一些资源,比如namespace、node、pod、service、endpoint、event等。 - 命名 API 组(named group):包含的资源更多,比如
apps(其下是deployment、statefulset、replicaset),还有networking、storage等。
而你对所有资源的「增删改查」,本质上都是对 Kubernetes 的一次 REST 请求——GET(查)、POST(增)、PUT(改)、DELETE(删)。
flowchart LR
A[Go 业务代码] -->|cli.Add / cli.List / cli.Create| B[client-go]
B -->|封装鉴权/请求参数/编解码| C[REST 请求 GET_POST_PUT_DELETE]
C --> D[API Server]client-go 帮你封装了发起请求时所需要的鉴权、请求参数,以及把 API Server 返回的 JSON 转换成 Go 结构体的编解码过程。所以你可以像调用普通 Go 方法一样去操作集群资源,而不必自己手写 HTTP 请求和 JSON 序列化。
2. 所有增删改查都是对一条 URI 的请求
为了直观理解,可以先在终端里验证一下 kubectl get pods 到底请求了什么。
kubectl 增加 -v=6(verbose 日志级别)就能打印底层请求的 URL:
kubectl get pods -v=6
视频里演示的是 kubectl get ns,最终请求的 URI 大致长这样:
GET https://<API-SERVER>/api/v1/namespaces
GET https://<API-SERVER>/api/v1/namespaces?limit=500
注意核心组的 URI 只包含两段:/api/v1/namespaces。它没有 group 段,因为核心组没有分组名,仅仅有 version(v1)加 resource(namespaces)。
而命名组的请求会多一段 group,例如 apps/v1 下的 deployment:
GET https://<API-SERVER>/apis/apps/v1/namespaces/default/deployments
对比一下两者的差异:
| 请求目标 | URI 形态 | 说明 |
|---|---|---|
核心组 namespace | /api/v1/namespaces | 固定前缀 /api + version + resource |
命名组 apps 的 Deployment | /apis/apps/v1/... | 前缀 /apis + group + version + resource |
所以「增删改查任何一个资源」=「对这串 URL 发一次对应方法的 REST 请求」。这个思路极其重要,后面看源码和 DynamicClient 时都会反复用它来理解问题。
3. GVK 与 GVR:Kind、Group、Version、Resource
在继续深入前,需要引入 client-go 世界里几乎无处不在的两个概念:GVK 和 GVR。
GVK = Group + Version + Kind(描述“这个对象是什么”)。假设有一个 Deployment 工作负载:
- Group:它属于命名组
apps。 - Version:
v1。 - Kind:
Deployment。
我们写 Deployment 的 YAML 时,前面那个 kind 字段和 apiVersion 字段(apps/v1)表达的就是这个 GVK:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
GVR = Group + Version + Resource(描述“这条请求路径上要访问什么”)。在对 Deployment 做 get 操作时,它会被转换成 GVR 再去请求 API Server。这里的 resource 是 Kind 的复数形式——即 URI 里的 deployments。
所以每个 Kind 都能对应到一个 Resource,这个 kind → resource 的映射在 K8s 是约定俗成的(大部分是纯字母复数,个别特殊)。client-go 内部就是靠它把对象的 GVK 换算成请求路径用的 GVR。原理细节我们会留到实现 CRD 那一讲再用 RESTMapper 展开。
4. 第一个 Demo:用 Clientset 列出 Pod 与 Deployment
我们先用最简单、最常用的 Clientset 写一个例子:列出 default 命名空间下的 Pod 和 Deployment。
先初始化工程并编写 main.go:
package main
import (
"context"
"flag"
"fmt"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/util/homedir"
)
func main() {
// 1. 确定 kubeconfig 路径:优先用命令行参数,缺省用 ~/.kube/config
var kubeconfig string
if home := homedir.HomeDir(); home != "" {
flag.StringVar(&kubeconfig, "kubeconfig", home+"/.kube/config", "kubeconfig 路径")
} else {
flag.StringVar(&kubeconfig, "kubeconfig", "", "kubeconfig 路径")
}
flag.Parse()
// 2. 从 kubeconfig 构建 REST 配置
config, err := clientcmd.BuildConfigFromFlags("", kubeconfig)
if err != nil {
panic(err.Error())
}
// 3. 用配置初始化 Clientset
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
panic(err.Error())
}
ctx := context.Background()
// 4. 列出 default 下的所有 Pod(core 组)
pods, err := clientset.CoreV1().Pods("default").List(ctx, metav1.ListOptions{})
if err != nil {
panic(err.Error())
}
fmt.Println("== Pods in default ==")
for _, p := range pods.Items {
fmt.Println(" ", p.Name)
}
// 5. 列出 default 下的所有 Deployment(apps 组)
deployments, err := clientset.AppsV1().Deployments("default").List(ctx, metav1.ListOptions{})
if err != nil {
panic(err.Error())
}
fmt.Println("== Deployments in default ==")
for _, d := range deployments.Items {
fmt.Println(" ", d.Name)
}
}
编译运行:
go mod init client-example
go mod tidy
go run main.go
运行输出与 kubectl get pods、kubectl get deploy 的结果一致。你可以顺手验证:clientset.AppsV1() 返回的就是 apps/v1 组对应的接口,里面能看到 Deployments、StatefulSets、ReplicaSets 等标准工作负载;clientset.CoreV1() 里则是 Pod、Namespace、Service、Secret、ServiceAccount 等核心资源。
List 还支持筛选条件,例如按 label 只取你关心的工作负载:
options := metav1.ListOptions{LabelSelector: "app=nginx"}
5. 两种初始化方式:集群外 vs 集群内
上面这个 Demo 用的是集群外访问方式:读取本机的 ~/.kube/config,访问的是一个(通常远端)集群。这种方法在本地调试很好用,但生产环境并不适用,原因有二:
- 生产集群一般没有公网 IP,你的远端程序根本连不上它。
kubeconfig的权限一般过高,不适合直接给业务集成方拿来用。
所以在集群内跑的程序(尤其是你将来写的 Operator、控制器),通常都用第二种方式:In-Cluster 配置。
写法极其简单,把上面的初始化替换成:
config, err := rest.InClusterConfig()
if err != nil {
panic(err.Error())
}
clientset, err := kubernetes.NewForConfig(config)
那 InClusterConfig() 是怎么拿到凭证和服务地址的?深入原理,它建立在 K8s 给每个 Pod 默认注入的两样东西之上:
基础一:ServiceAccount 配置文件
K8s 会给每个 Pod 自动挂载一个 ServiceAccount 的配置文件目录:
/var/run/secrets/kubernetes.io/serviceaccount/
├── ca.crt # K8s 的 CA 证书
└── token # 用于请求 API Server 的令牌(JWT)
你进到一个 Pod 里,看到这个目录下确实有 token 和 ca.crt 两个文件。InClusterConfig 读的就是这里。
基础二:默认注入的环境变量
K8s 还会给每个 Pod 注入两个环境变量,描述 API Server 的连接信息:
KUBERNETES_SERVICE_HOST # 例如 10.96.0.1
KUBERNETES_SERVICE_PORT # 例如 443
InClusterConfig 的核心逻辑就是这样:读 /var/run/secrets/kubernetes.io/serviceaccount/token 和 ca.crt,再读 KUBERNETES_SERVICE_HOST / KUBERNETES_SERVICE_PORT,拼出 API Server 地址与凭据。
视频里还专门打开过
InClusterConfig的源码逐行验证了这一点。这个认知很重要:In-Cluster 是依赖 ServiceAccount 的 token 的,所以如果你要访问某些资源,必须先给那个 SA 授相应的 RBAC 权限,否则就是 403。
实操:容器化 Demo 并遇到 RBAC 授权
下面的实验能帮你把「In-Cluster 认证 + RBAC」这条链彻底走通。
先把刚才列 Pod 的程序容器化。用多阶段构建写一个 Dockerfile:
# 阶段一:编译 Go 程序
FROM golang:1.22 AS build
WORKDIR /app
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 go build -o main main.go
# 阶段二:精简运行镜像
FROM alpine:latest
COPY --from=build /app/main /
ENTRYPOINT ["/main"]
构建镜像并推送到 Registry:
docker build -t <registry>/client-demo:outer .
docker push <registry>/client-demo:outer
再写一个 Deployment 把它跑进集群,这里暂不改代码(仍用 kubeconfig 方式),并把 imagePullPolicy 设为 Always 方便反复替换镜像:
apiVersion: apps/v1
kind: Deployment
metadata:
name: client-demo
spec:
replicas: 1
selector:
matchLabels:
app: client-demo
template:
metadata:
labels:
app: client-demo
spec:
containers:
- name: app
image: <registry>/client-demo:outer
imagePullPolicy: Always
kubectl apply 之后看日志——它报错了:找不到 kubeconfig。这很正常,集群里当然没有你的本机 kubeconfig。这正好演示了“集群外方式在集群内跑不通”。
接着改造代码,把初始化换成 rest.InClusterConfig(),重新构建推送、改镜像版本、删除旧 Pod 让其重启。这时日志报:
E ... can not list resource "pods": ... is forbidden: User "system:serviceaccount:default:default"
这说明 InClusterConfig 已经成功了(能拿到 SA 的 token 并发起请求),但默认的 default SA 没有 list 的权限。于是给它建一个 Role 和 RoleBinding 授权:
# 创建 Role:允许 list 命名空间 default 下的 pod 和 deployment
kubectl create role client-demo-role --verb=list,get,watch \
--resource=pods,deployments -n default
# 创建 RoleBinding:把该 Role 绑定到 default 命名空间的 default SA
kubectl create rolebinding client-demo-rb \
--role=client-demo-role \
--serviceaccount=default:default -n default
删除 Pod 重启后,这次能正常打印出 Pod 和 Deployment 了。
flowchart LR
A[容器内程序] --> B[InClusterConfig 读 SA token]
B --> C[发起请求]
C --> D{default SA 有权限吗?}
D -- 否 403 --> E[报错: is forbidden]
D -- 是 --> F[正常返回]
E --> G[创建 Role + RoleBinding]
G --> D总结:In-Cluster 认证这种初始化方式,在以后写自定义 Operator(运行在集群内、以容器方式部署)时几乎是标配。
二、Client-go 里的 4 种 Client
这一讲是选型的关键。client-go 提供了四种 Client,日常开发中前两种(Clientset、DynamicClient)用得最多,RESTClient 偏底层通常不直接用,DiscoveryClient 了解一下即可。它们之间的关系一句话就能说清:
RESTClient 是最底层的客户端,Clientset、DynamicClient、DiscoveryClient 都建立在它之上。
flowchart TD
D[DynamicClient] --> R[RESTClient]
C[Clientset] --> R[RESTClient]
I[DiscoveryClient] --> R[RESTClient]
R --> A[API Server]1. RESTClient:最底层的封装
RESTClient 直接和 API Server 交互,它把「增删改查」映射成 REST 方法。打开它的源码(rest 包里的 interface)能看到一组对应 REST 动词的方法:
| 方法 | HTTP 动词 | 语义 |
|---|---|---|
Post() | POST | 创建资源 |
Get() | GET | 获取资源 |
Put() | PUT | 修改资源 |
Patch() | PATCH | 部分更新 |
Delete() | DELETE | 删除资源 |
这些方法最终都会返回一个 *Request,再由你来填充路径、解码器等细节。
因为它够底层,用起来也比较繁琐。下面用 RESTClient 获取 kube-system 命名空间下所有 Pod:
package main
import (
"context"
"flag"
"fmt"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/util/homedir"
)
func main() {
var kubeconfig string
if home := homedir.HomeDir(); home != "" {
flag.StringVar(&kubeconfig, "kubeconfig", home+"/.kube/config", "")
}
flag.Parse()
// 从 kubeconfig 构建基础 REST 配置
config, err := clientcmd.BuildConfigFromFlags("", kubeconfig)
if err != nil {
panic(err)
}
// 使用 RESTClient 需要额外补齐三样配置
config.GroupVersion = &schema.GroupVersion{Group: "", Version: "v1"} // 目标:core group v1
config.APIPath = "/api" // core group 前缀是 /api
config.NegotiatedSerializer = serializer.WithoutConversionCodecFactory{
CodecFactory: scheme.Codecs,
}
// 用补齐后的 config 初始化 RESTClient
client, err := rest.RESTClientFor(config)
if err != nil {
panic(err)
}
// 声明一个空结构体,用于承接反序列化后的 PodList
var podList corev1.PodList
// 拼出 GET /api/v1/namespaces/kube-system/pods?limit=500 并解码
err = client.Get().
Namespace("kube-system").
Resource("pods").
VersionedParams(&metav1.ListOptions{}, scheme.ParameterCodec).
Do(context.Background()).
Into(&podList)
if err != nil {
panic(err)
}
for _, p := range podList.Items {
fmt.Println(" ", p.Name, " -> ", p.Namespace)
}
}
注意看,用 RESTClient 你必须亲自配置 GroupVersion、APIPath、NegotiatedSerializer,再一步步把 Get().Namespace().Resource().VersionedParams().Do().Into() 串起来。它能用,但太繁琐,生产环境几乎不会这么用,所以上层才有 Clientset。
运行结果和 kubectl get pods -n kube-system 一致。
2. Clientset:面向标准对象的最省事选择
Clientset 是最常用、最方便的客户端,它内部封装好了几乎所有标准资源的 interface —— 每个资源都自带增删改查、List、Watch、Patch 等完整方法。
以 core/v1 的 Pod 为例,其 interface 里就有 Create / Update / Delete / Get / List / Watch / Patch 等一整套方法;apps/v1 的 Deployment 同理。
它用起来极其清晰:你需要先定义一个结构体,把要创建的对象填好,然后把它作为参数传给对应方法即可。
下面用 Clientset 创建一个 Deployment(nginx):
package main
import (
"context"
"flag"
"fmt"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/util/homedir"
)
func main() {
var kubeconfig string
if home := homedir.HomeDir(); home != "" {
flag.StringVar(&kubeconfig, "kubeconfig", home+"/.kube/config", "")
}
flag.Parse()
config, err := clientcmd.BuildConfigFromFlags("", kubeconfig)
if err != nil {
panic(err)
}
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
panic(err)
}
replicas := int32(1) // replicas 是指针类型,需要先取地址
// 用结构体描述一个 Deployment —— 等价于写一份 Deployment YAML
deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "nginx",
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "nginx"},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": "nginx"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "nginx",
Image: "nginx:latest",
Ports: []corev1.ContainerPort{
{ContainerPort: 80},
},
},
},
},
},
},
}
result, err := clientset.AppsV1().
Deployments("default").
Create(context.Background(), deployment, metav1.CreateOptions{})
if err != nil {
if !apierrors.IsAlreadyExists(err) { // 兼容已存在的情况
panic(err)
}
fmt.Println("deployment already exists")
return
}
fmt.Printf("created deployment %s/%s\n", result.Namespace, result.Name)
}
运行后 kubectl get deploy 里就多了 nginx,Pod 也能正常起来。创建 Service、Pod 的思路完全一样:定义好对应的结构体,再调用相应 client 的 Create。
Clientset 的优点是类型安全、结构清晰,但有一个硬性局限:它只封装了标准对象。
还记得我们一路强调的 GVK 吗?CRD(自定义资源)是你自己定义的对象,client-go 的 Clientset 里根本不可能预置它的 interface 和结构体——SDK 不会提前知道你自定义的类型长什么样。所以操作 CRD,就要请出下一个主角。
3. DynamicClient:动态、非结构化、通吃 CRD
DynamicClient 号称「什么都能操作」——标准资源、非标准资源(CRD)都能处理。它的关键特殊性在于:它返回的所有数据都是非结构化(unstructured)的。
看它的源码你会发现,DynamicClient 的每个方法返回的都是 unstructured.Unstructured 指针。这个 Unstructured 内部就是一个 map[string]interface{}——一个像「泛型」一样的容器,能存任意数据结构。
所以方言点讲:DynamicClient 不认你的结构体,它给的是「裸数据」;你把它拉回来之后,得自己转换成你已知的结构体(标准对象结构体,或你 CRD 对应的结构体)才能用。
用 DynamicClient 列 kube-system 下的 Pod:
package main
import (
"context"
"flag"
"fmt"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/util/homedir"
)
func main() {
var kubeconfig string
if home := homedir.HomeDir(); home != "" {
flag.StringVar(&kubeconfig, "kubeconfig", home+"/.kube/config", "")
}
flag.Parse()
config, err := clientcmd.BuildConfigFromFlags("", kubeconfig)
if err != nil {
panic(err)
}
dynClient, err := dynamic.NewForConfig(config)
if err != nil {
panic(err)
}
// 声明 GVR:我们要操作 core 组 v1 的 pod
gvr := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}
// 注意:返回的不是 corev1.PodList,而是一个非结构化对象
unstructList, err := dynClient.Resource(gvr).
Namespace("kube-system").
List(context.Background(), metav1.ListOptions{})
if err != nil {
panic(err)
}
// 把非结构化数据转换成标准 PodList
var podList corev1.PodList
err = runtime.DefaultUnstructuredConverter.FromUnstructured(
unstructList.UnstructuredContent(), &podList,
)
if err != nil {
panic(err)
}
for _, p := range podList.Items {
fmt.Println(" ", p.Name)
}
}
这里有两个关键点:
- 先给 GVR:因为 DynamicClient 不知道你的对象长啥样,你必须用
GroupVersionResource{...}明确告诉它要访问哪个资源。 - 返回的非结构化数据要自己转:
runtime.DefaultUnstructuredConverter.FromUnstructured(unstructList.UnstructuredContent(), &podList)就是把map[string]interface{}转换成分类型的结构体的核心调用。视频里特意打印过这个中间对象,确实是一堆map[string]interface{}嵌套的非结构化数据。
DynamicClient 除了查询,还有个很香的能力:从一个文件直接 apply 资源,像 kubectl apply -f 一样。做法是:
- 用
//go:embed deployment.yaml干脆把 YAML 嵌入程序(生产里也可以改成读外部文件)。 - 用
unstructured.Unstructured结构体UnmarshalJSON/ 手动解析把 YAML 转成非结构化对象。 - 从中提取 GVK 信息(
apiVersion、kind)→ 换算成 GVR →Resource(gvr).Namespace("default").Create(...)。
这个流程对「凡是文件里写的任意类型资源都能一次性 apply 出去」非常有用——开发自定义 CII 工具(类似自己的 kubectl/helm 插件)时,DynamicClient 是你的主力。
4. DiscoveryClient:发现 API 支持的资源
最后一个,DiscoveryClient(发现客户端)。顾名思义,它用来发现 API Server 当前支持的 GroupVersion 和 Resource(包括通过 CRD 注册的新资源)。
为什么需要它?因为不同 K8s 版本的集群,支持的 GroupVersion / Resource 组合可能不一样。DiscoveryClient 正是为这种版本兼容而存在的——客户端先探测服务端支持什么,再做相应适配。
值得一提的细节:DiscoveryClient 会把查到的信息缓存到本地,放在 ~/.kube/cache/discovery 目录下,以缓解 API Server 的压力——因为一次性拉取 ClusterVersion + ResourceList 的代价比较大。你本机用过 kubectl 之后,去这个目录看一眼,里面就是一张 groupversion/...json 之类的清单,记录了每个 group 下有哪些 resource、每个 resource 的 kind 等。
总结一下四种 Client 的选型:
| Client | 面向对象 | 数据类型 | 常见用途 |
|---|---|---|---|
| RESTClient | 任意资源,最底层 | 结构化(需自己装配) | 学习原理、底层定制 |
| Clientset | 标准对象 | 类型安全(已经定好结构体) | 日常开发、封装控制器的主要入口 |
| DynamicClient | 任意资源(含 CRD) | 非结构化 map[string]interface{} | 操作 CRD、从文件 apply 任意资源 |
| DiscoveryClient | 发现能力 | 元数据清单 | 版本兼容、探测资源,一般用不上 |
三、实现一个 Client-go Watch 客户端
1. 第一个 Watch Demo
我们看看能不能用 Clientset 自带的方法实现一个「监听 Pod 变化」的客户端,效果上接近 kubectl get pods -w。
package main
import (
"context"
"flag"
"fmt"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/util/homedir"
)
func main() {
var kubeconfig string
if home := homedir.HomeDir(); home != "" {
flag.StringVar(&kubeconfig, "kubeconfig", home+"/.kube/config", "")
}
flag.Parse()
config, _ := clientcmd.BuildConfigFromFlags("", kubeconfig)
clientset, _ := kubernetes.NewForConfig(config)
ctx := context.Background()
// 为方便测试,Watch 60 秒
watcher, err := clientset.CoreV1().
Pods("default").
Watch(ctx, metav1.ListOptions{TimeoutSeconds: int64Ptr(60)})
if err != nil {
panic(err)
}
// 遍历事件通道
for event := range watcher.ResultChan() {
pod, ok := event.Object.(*corev1.Pod) // 断言成 Pod
if !ok {
continue
}
// 根据事件类型分派到业务逻辑
switch event.Type {
case watch.Added:
processPod("ADDED", pod.Name)
case watch.Modified:
processPod("MODIFIED", pod.Name)
case watch.Deleted:
processPod("DELETED", pod.Name)
default:
fmt.Println("unknown event", event.Type)
}
}
fmt.Println("watch 通道关闭,退出")
}
func processPod(action, name string) {
fmt.Printf("[%s] %s\n", action, name)
// 这里可以写你的业务逻辑,比如触发扩容、发告警……
}
func int64Ptr(v int64) *int64 { return &v }
启动后,它先列出 default 下已存在的 Pod,然后保持监听。你可以另开一个终端创建一个 Deployment(或者先 kubectl delete 再创建),就会看到 [ADDED] / [MODIFIED] 等事件实时打印出来——只要对象一变,就走 processPod 执行业务逻辑。
它的效果和 kubectl get pods -w 类似:一个进程不断连接 API Server,监听你感兴趣的资源事件。
2. 裸 Watch 的五个坑
Demo 能跑,但视频反复强调:生产环境不推荐直接用 Watch(尤其是监控长期资源变化)。原因有五个,逐个看:
坑一:超时与重连问题。
Watch 本身有超时机制——Demo 里那个 TimeoutSeconds: 60。到点后连接会断开;即便没到超时,网络波动也可能导致断开。断开后你得自己处理重连,而且重连的瞬间可能丢事件,导致你听不到某次变更。
亲自验证:把超时改成
5秒,运行就会发现 5 秒后这个程序直接退出了(通道关闭)。因为 Watch 又不能设无限超时,所以长期监听场景下它很别扭。
坑二:直接压 API Server。 Watch 是直接请求 API Server 的。连接开多了,必然增加集群(尤其是 API Server)负载。
坑三:多资源连接无法共享。 如果你要同时 Watch Pod、Deployment、Service 等多个资源,每个 Watch 各开一条连接,互不共享,进一步放大资源消耗。
坑四:无限流,事件洪峰下业务过载。 集群里大量 Pod 调度时会产生大批事件。裸 Watch 拿到事件就直接调业务逻辑、没有限流,一旦事件量超过业务处理能力,业务会崩溃(雪崩)。
坑五:事件驱动,失败无二次机会。 当业务处理某个事件失败时,因为这个事件已经被消费掉,没有重试机会——不像队列机制可以把失败的事件重新放回去晚点再处理。
所以,真正用于生产长期监听的组件,是它——Informer。
四、用 Informer 替代 Watch
1. Informer 解决了什么
Informer 是基于 Watch 实现的更高层抽象,使用更简单、更安全、性能更高。它替你解决了裸 Watch 的那些问题:
- 自动处理超时与重连:网络波动、超时导致的断连它自动处理,不用担心丢事件。
- 本地缓存:Informer 本地维护一份资源缓存,不用频繁打 API Server,减轻负载。
- 全量 + 增量同步:内置了首次全量 List 和后续增量 Watch 的机制,还提供
resync兜底。 - 保证事件不丢失:通过工作队列机制来保证,并能限流、错误重试(见下节)。
2. Informer 的使用:JOE Factory + EventHandler
用 Informer 监听 Deployment 和 Service,核心是用 shared informer factory 创建共享 informer(多个资源共用工厂,能省资源):
package main
import (
"context"
"flag"
"fmt"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/cache"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/util/homedir"
)
func main() {
var kubeconfig string
if home := homedir.HomeDir(); home != "" {
flag.StringVar(&kubeconfig, "kubeconfig", home+"/.kube/config", "")
}
flag.Parse()
config, _ := clientcmd.BuildConfigFromFlags("", kubeconfig)
clientset, _ := kubernetes.NewForConfig(config)
// 新建共享 informer 工厂;第二个参数 12h 是"兜底 resync"周期
factory := informers.NewSharedInformerFactory(clientset, 12*time.Hour)
// 用工厂分别创建 Deployment 和 Service 的 informer
depInformer := factory.Apps().V1().Deployments()
svcInformer := factory.CoreV1().Services()
// 给 Deployment 挂事件回调
depInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
fmt.Println("Deployment ADDED:", obj.(*appsv1.Deployment).Name)
},
UpdateFunc: func(old, new interface{}) {
fmt.Println("Deployment UPDATED:", new.(*appsv1.Deployment).Name)
},
DeleteFunc: func(obj interface{}) {
fmt.Println("Deployment DELETED:", obj.(*appsv1.Deployment).Name)
},
})
// 给 Service 挂事件回调(示例只挂 Add)
svcInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
fmt.Println("Service ADDED:", obj.(*corev1.Service).Name)
},
})
// 接收终止信号,让程序能优雅退出
stopCh := make(chan struct{})
defer close(stopCh)
// 启动所有 informer 的 List & Watch
factory.Start(stopCh)
// 等待缓存同步完成(第一次全量列表拉完)
factory.WaitForCacheSync(stopCh)
// 从本地缓存直接读取 default 下的所有 Deployment(不走 API 请求)
deps, _ := depInformer.Lister().Deployments("default").List(labels.Everything())
fmt.Println("== cached deployments ==")
for _, d := range deps {
fmt.Println(" ", d.Name)
}
// 阻塞主 goroutine,保持监听
<-stopCh
}
几个容易混淆的点:
NewSharedInformerFactory(clientset, 12h)的第二个参数不是 Watch 超时,而是resync(重新同步)的兜底周期。Informer 平时靠 List-Watch 拿到对象,通过对象的resourceVersion(版本号)做增量同步;同时也支持隔一段时间做一次全量 resync 兜底,防止长期增量下状态走偏。factory.Start(stopCh)会启动所有 informer 的 List & Watch。factory.WaitForCacheSync(stopCh)阻塞等待缓存同步完成。- 之后读数据走的是本地缓存(
Lister()),而不是每次去请求 API Server——这就是 Informer 减负的关键。
delete 再 create 一个 Deployment,你能看到 Informer 同样收到 DELETED、ADDED、UPDATED 事件。和 Watch 的效果一致,但底层更健壮。
3. 事件回调处理失败怎么办?引入工作队列
Informer 解决了重连、缓存、同步等问题,但注意:你的 EventHandler 回调里如果业务逻辑处理失败,仍然没有第二次机会——回调是事件驱动的,消费完就没了。
要获得「失败重试」能力,就要引入工作队列(Workqueue),具体是这个:RateLimiting 队列(限速队列)。它带来两个收益:
- 错误重试:处理失败可以把 key 重新放回队列,稍后再试。
- 防止 hot-loop / 业务过载:通过**指数退避(exponential backoff)**策略(比如隔 1s、2s、4s、8s…,也就是 2^0、2^1、2^2、2^3…… 越来越慢地重试),避免对失败事件一拥而上地狂刷。
flowchart TD
A[ApiServer 资源变化] --> B[Informer 事件回调]
B --> C[把 key 写入 RateLimiting 队列]
C --> D[Worker 消费: 从队列取 key]
D --> E[按 key 从 Indexer 取完整对象]
E --> F[执行业务逻辑]
F -->|失败| G[AddRateLimited 重新入队
指数退避]
F -->|成功| H[Done 标记完成]
G --> D4. 完整 Demo:Informer + Indexer + Workqueue 的 Controller 雏形
下面这段把上面所有东西串起来——它已经有了一个自定义 Controller / Operator 的雏形(informer 监听 + 队列消费 + 重试)。
核心结构体定义:
type controller struct {
indexer cache.Indexer // 从 informer 拿到的本地缓存索引器
queue workqueue.TypedRateLimitingInterface[string] // 限速工作队列
informer informers.DeploymentInformer // Deployment informer
}
func newController(
queue workqueue.TypedRateLimitingInterface[string],
informer informers.DeploymentInformer,
) *controller {
return &controller{
indexer: informer.Informer().GetIndexer(),
queue: queue,
informer: informer,
}
}
初始化工厂、监听 Deployment、把事件 key 入队:
factory := informers.NewSharedInformerFactory(clientset, 12*time.Hour)
depInformer := factory.Apps().V1().Deployments()
// 限速队列:key 的类型用 string
queue := workqueue.NewTypedRateLimitingQueue[string](
workqueue.DefaultTypedControllerRateLimiter[string](),
)
// 事件回调统一做一件事:计算 key 并入队
depInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) { addDeployment(obj, queue) },
UpdateFunc: func(old, new interface{}) { updateDeployment(old, new, queue) },
DeleteFunc: func(obj interface{}) { deleteDeployment(obj, queue) },
})
c := newController(queue, depInformer)
stopCh := make(chan struct{})
factory.Start(stopCh)
factory.WaitForCacheSync(stopCh)
// 开一个 worker goroutine 消费队列
go func() {
for c.processNextItem() {
}
}()
<-stopCh
回调内部用 cache.MetaNamespaceKeyFunc(obj) 生成唯一 key(格式是 namespace/name),然后 queue.Add(key):
func addDeployment(obj interface{}, queue workqueue.TypedRateLimitingInterface[string]) {
key, _ := cache.MetaNamespaceKeyFunc(obj) // 生成 "default/nginx" 这样的 key
queue.Add(key)
}
func updateDeployment(old, new interface{}, queue workqueue.TypedRateLimitingInterface[string]) {
key, _ := cache.MetaNamespaceKeyFunc(new)
queue.Add(key)
}
func deleteDeployment(obj interface{}, queue workqueue.TypedRateLimitingInterface[string]) {
// delete 事件对象可能被包装成 DeletedFinalStateUnknown,处理更健壮
key, _ := cache.DeletionHandlingMetaNamespaceKeyFunc(obj)
queue.Add(key)
}
消费端 processNextItem,按 key 从 Indexer 取出完整对象再处理:
func (c *controller) processNextItem() bool {
// 从队列取出一个 key;若队列已关闭则返回 false
key, shutdown := c.queue.Get()
if shutdown {
return false
}
// 处理完成后必须调用 Done(这决定是否重新入队)
defer c.queue.Done(key)
err := c.syncToStdout(key)
if err == nil {
// 成功:forget,之后不再重试
c.queue.Forget(key)
return true
}
// 失败:交给 handleErr 决定是否重试
c.handleErr(err, key)
return true
}
syncToStdout 用 indexer 的 GetByKey 读取完整对象:
func (c *controller) syncToStdout(key string) error {
obj, exists, err := c.indexer.GetByKey(key)
if err != nil {
return err
}
if !exists {
fmt.Printf("object %q not found (可能已被删除)\n", key)
return nil
}
dep, ok := obj.(*appsv1.Deployment)
if !ok {
return fmt.Errorf("unexpected type")
}
fmt.Printf("[process] %s/%s\n", key, dep.Name)
// 故意制造一个错误场景:名字等于 test-deployment 即失败,验证重试
if dep.Name == "test-deployment" {
return fmt.Errorf("simulate: deployment %s failed", key)
}
return nil
}
handleErr 控制重试策略:
func (c *controller) handleErr(err error, key string) {
// 因为失败次数过多而放弃重试(这里是 <5 才继续)
if c.queue.NumRequeues(key) < 5 {
fmt.Printf("error processing %q, retrying: %v\n", key, err)
c.queue.AddRateLimited(key) // 重新入队并限速(指数退避)
return
}
// 重试次数够了,放弃
c.queue.Forget(key)
fmt.Printf("drop key %q after too many retries: %v\n", key, err)
}
运行后创建一个 Deployment 试试,能看到 [process] 日志;再创建一个名字恰为 test-deployment 的 Deployment,就会看到报错后不断 retrying,并且重试间隔逐步拉大(retry 1、retry 2、retry 3……),这正是错误重试 + 指数退避在起作用——解决了裸 Watch 只能消费一次的问题。
到这一步,你的程序已经是一个小型控制器了:inform 监听 → 入队 → worker 消费 → 成功 forget / 失败 AddRateLimited。它就像一个小型的 MQ/消息卡,让事件处理变得灵活可控。
五、深度理解 Informer / Indexer / Workqueue
看完代码,我们从架构层面把这三个组件一次讲透。
1. Informer 的整体架构
Kubernetes 官方给出一张 Informer 架构图,整体分两层:
- 上半部分是 client-go 自带的实现(Reflector、DeltaFIFO、Indexer、以及通知分发)。
- 下半部分是 你的控制器业务逻辑(EventHandler 回调、工作队列、处理器)。
flowchart LR
subgraph client-go
REF[Reflector
List & Watch] --> DF[DeltaFIFO
先进先出队列]
DF --> IDX[Indexer
加锁 map 缓存]
end
subgraph 你的控制器
DF --> EH[EventHandler 回调]
EH --> WQ[Workqueue 工作队列]
WQ --> PROC[Processor
从 Indexer 读对象处理]
PROC --> WQ
end完整流转是:
- Reflector 借助 List + Watch 跟 API Server 通信,持续监听资源变化。
- 一旦有变化,它把这个对象放进 DeltaFIFO(注意:delta 是一种特殊数据结构,先进先出)。
- Reflector 把对象从队列弹出,进一步添加到 Indexer——Indexer 底层是一个加了锁的 map(缓存)。
- 同时把事件分发给你注册的 EventHandler 回调;你在回调里算出 key 并推进工作队列。
- Processor 从工作队列取出 key,用
GetObjectByKey/GetByKey从 Indexer 把完整对象读出来再执行业务逻辑。
2. Indexer:本地缓存 + 检索
Indexer 提供对象的检索能力。之前代码里用到的 indexer.GetByKey(key) 就是它:给一个 key,返回缓存中对应的完整对象。
key 是怎么生成的?就是我们用的 cache.MetaNamespaceKeyFunc(obj),其内容是 namespace/name。所以 key 能唯一定位一个对象:default/nginx 就是「default 命名空间的 nginx」。
Indexer 内部其实由三块组成:
| 部分 | 含义 |
|---|---|
Indexers | 索引器名称 → 索引函数(计算对象的索引 key) |
Indices | 索引类型名称 → 索引值 → 对象键的映射 |
Store / ThreadSafeStore | 底层的 KV 缓存数据结构 |
日常使用我们通常只关心两个:生成 key(MetaNamespaceKeyFunc)和按 key 读对象(GetByKey)——这就足够支撑绝大多数场景了。深入源码的部分(哪些索引函数、怎么建索引)可以留给对实现感兴趣的同学。
3. Workqueue:三种队列
K8s 的工作队列有三种类型,层层封装:
flowchart LR
A[Interface 通用 FIFO] --> B[DelayingInterface
延迟队列]
B --> C[RateLimitingInterface
限速队列 ★最常用]- Interface:通用先进先出(FIFO)队列,最基础。
- DelayingInterface:基于 Interface 封装,支持延迟一段时间后元素才进入队列。
- RateLimitingInterface:基于 DelayingInterface 封装,是最常用的。它提供了失败重试、指数退避、限速等能力——对应我们在上一节的 Demo 里用到的
workqueue.NewTypedRateLimitingQueue[string](...)。
顺带一提,视频里用的老 API
workqueue.NewRateLimitingQueue在较新版本已标记废弃,提示改用NewTypedRateLimitingQueue[string]。
4. DeltaFIFO 的小原理
深入一点看 DeltaFIFO 的实现,它的队列里每个元素其实都是 key,而不是完整的 delta 对象;队列底层再用一个 map[string]*StoreToDeltaFIFO(一个以 key 为索引、value 是 *Delta 的 map)存放在那个 key 下的真实 delta。
Delta 这个结构体包含两部分:
Type(操作类型):Created(创建)、Updated(更新)、Deleted(删除)、Sync(同步)等。Object:具体对象。
所以 FIFO 是「先进现出」地按 key 排队,弹出 key 后再从 map 里按 key 取出真实的 delta。这套设计让「去重」「合并」操作更高效。
六、实现一个简单的 kubectl get CRD
最后一讲回到开头的问题,用代码实现 kubectl get <CRD>。这里的核心痛点是:CRD 不能通过 Clientset 的标准方法获取(因为 Clientset 只有标准对象的 interface),必须用 DynamicClient。而这个获取过程需要做一次 GVK → GVR 的转换,转换工具是 RESTMapper。
1. 定义一个示例 CRD
我们先准备一个示例 CRD,kind 叫 MyResource,group 是 example.com,version 是 v1alpha1,作用是 Namespaced,含两个字符串字段:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: myresources.example.com
spec:
group: example.com
names:
kind: MyResource
listKind: MyResourceList
plural: myresources # 复数形式 → 请求路径/URI 里的 resource
singular: myresource # 单数形式
scope: Namespaced # 基于命名空间隔离
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
key1: { type: string } # 两个示例字段
key2: { type: string }
把 CRD 部署到集群,并创建一个实例:
kubectl apply -f crd.yaml
cat <<EOF | kubectl apply -f -
apiVersion: example.com/v1alpha1
kind: MyResource
metadata:
name: myresource-sample
spec:
key1: hello
key2: world
EOF
留意 CRD 里 names.plural(复数 myresources)和 names.singular(单数 myresource)这两个字段——后面讲 RESTMapper 原理时要用。
2. 用 DynamicClient + RESTMapper 实现 get
实现流程一句话:先用 GVK 通过 RESTMapper 换出 GVR,再用 DynamicClient 按 GVR 去取资源。
package main
import (
"context"
"errors"
"fmt"
"os"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/restmapper"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/util/homedir"
)
func main() {
// 期望用法: go run main.go get myresource
if len(os.Args) != 3 {
panic("usage: go run main.go <get|other> <kind>")
}
command, kind := os.Args[1], os.Args[2]
if command != "get" {
panic("本示例只实现了 get")
}
var kubeconfig string
if home := homedir.HomeDir(); home != "" {
kubeconfig = home + "/.kube/config"
}
config, _ := clientcmd.BuildConfigFromFlags("", kubeconfig)
// 1) 需要 Clientset 来拿 Discovery
clientset, _ := kubernetes.NewForConfig(config)
// 2) 需要 DynamicClient 来真正取数据
dynClient, _ := dynamic.NewForConfig(config)
// 3) 用 DiscoveryClient 枚举服务端支持的资源清单
dc := clientset.Discovery()
// ServerPreferredResources 返回服务端偏爱的资源清单 []*meta.APIResourceList
apiGroupResources, err := dc.ServerPreferredResources()
if err != nil && !errors.IsNotFound(err) && len(apiGroupResources) == 0 {
panic(err)
}
// 4) 用 RESTMapper 基于这些资源建立 GVK<->GVR 映射
restMapper := restmapper.NewDiscoveryRESTMapper(apiGroupResources)
// 5) 构造目标 GVK
gvk := schema.GroupVersionKind{
Group: "example.com",
Version: "v1alpha1",
Kind: kind, // MyResource
}
// 6) GVK -> GVR(RESTMapping 的的核心封装)
mapping, err := restMapper.RESTMapping(gvk.GroupKind(), gvk.Version)
if err != nil {
panic(err)
}
gvr := mapping.Resource // 这才是真正的 GVR
fmt.Println("resolved GVR:", gvr)
// 7) 用 DynamicClient 按 GVR 去 List
list, err := dynClient.Resource(gvr).
Namespace("default").
List(context.Background(), metav1.ListOptions{})
if err != nil {
panic(err)
}
// 8) 打印(非结构化数据)
for _, item := range list.Items {
name := item.GetName()
rv := item.GetResourceVersion()
fmt.Printf(" %s (resourceVersion=%s)\n", name, rv)
}
}
运行:
go run main.go get MyResource
就能打印出我们创建的那个 myresource-sample。注意 kind 参数传的是 MyResource(首字母大写),GVR 落地后 resource 是 myresources,输出里会打印 resolved GVR: example.com/v1alpha1, Resource=myresources。
这里 DynamicClient 返回的仍是 unstructured,但它自带了 GetName() / GetResourceVersion() 等方便方法可以直接读;要进一步强类型化,就用前面讲过的 runtime.DefaultUnstructuredConverter.FromUnstructured 转成你自己的结构体。
3. RESTMapper 原理
RESTMapping 是怎么把 MyResource(GVK)映射成 myresources/myresource(GVR)的?
关键在 restmapper.NewDiscoveryRESTMapper。它在构建映射关系时,利用了 CRD 定义里的复数(plural)、单数(singular)两个字段去建立 kind 和 resource 的一一映射:MyResource → myresources(复数)/ myresource(单数)/ myresources(listKind 等)。
进入源码看,它遍历每个 API resource 的定义,把其中的 kind、singular、plural、shortNames 等全部登记进映射表;之后你调用 RESTMapping(gvk.GroupKind(), gvk.Version),它就去查这张表,返回一个 mapping,而 mapping.Resource 就是你要的 GVR:
flowchart LR
A[discovery 枚举到的资源清单
含 kind/plural/singular] --> B[NewDiscoveryRESTMapper
建立 Kind→Resource 映射]
B --> C{RESTMapping GVK → GVR}
C --> D[mapping.Resource = GVR]
D --> E[DynamicClient 按 GVR 查询]把 GVK 换成 GVR 之后,DynamicClient 就知道该请求哪条路径了(不用再像上一讲那样手写 switch 去猜 resource 复数),因为RESTMapper 替你把复数资源名算好了。这就是为什么在 K8s SDK 生态里,凡是"拿到 kind 就想去取数据"的地方,几乎都会遇到 RESTMapper。
到这里,本模块 client-go 二次开发的全部内容就结束了。
总结
本章从「client-go 是什么」一路推进到能独立开发一个简单控制器,核心脉络如下:
- 一句话理解 client-go:官方 Go SDK,把对 K8s 资源的增删改查翻译成对 API Server 的 REST 请求,并封装鉴权、请求参数与 JSON 编解码。
- GVK / GVR:
apiVersion(group/version)+kind定义"对象是什么",落地请求时换算成 GVR(resource 是复数),核心组 URL 是/api/v1/<resource>,命名组是/apis/<group>/<version>/<resource>。 - 两种初始化:集群外读
kubeconfig;集群内rest.InClusterConfig(),依赖 K8s 自动注入的 SA token/config 与KUBERNETES_SERVICE_HOST/PORT环境变量,且必须配好 RBAC。 - 四 Client 选型:RESTClient 最底层最繁琐;Clientset 面向标准对象最省心;DynamicClient 面向任意资源(含 CRD)、返回非结构化数据;DiscoveryClient 负责探测资源和版本兼容。
- Watch 的坑 → 用 Informer:裸 Watch 有超时重连、API Server 压力、连接不共享、无限流、失败无重试五坑;Informer 基于 Watch 提供自动重连 + 本地缓存 + 全量/增量同步 + 工作队列。
- Informer + Indexer + Workqueue:Reflector→DeltaFIFO→Indexer 是 client-go 自有链路;EventHandler→队列→Processor 是你的控制器。RateLimiting 队列提供失败重试与指数退避。
- CRD 查询:Clientset 不认 CRD,必须用 DynamicClient,并用 RESTMapper 完成 GVK→GVR 转换(依赖复数/单数字段)。
说真的这一章是全营里"偏工程量"最多的一章,很多概念(Reflector、Indexer、工作队列)一开始看着绕,但只要你把自己写的那个 Informer + Workqueue Demo 跑通一遍、再手写一次 get CRD,再回去看官方架构图就会有豁然开朗的感觉。
自测题与动手练习
核心组:
/api/v1/<复数resource>(固定 /api 前缀 + version + resource,没有 group 段,如 /api/v1/namespaces)。命名组:
/apis/<group>/<version>/<复数resource>(有 group 段,如 /apis/apps/v1/...)。kind 和 apiVersion;GVR(Group+Version+Resource)描述"请求路径上访问什么",resource 通常是 kind 的复数形式。一个 kind 对应一个主 resource(如 Deployment→deployments),通常还有单数形式(deployment)和 shortNames,可另配别名,但"主 resource(复数)“是唯一的请求路径。
/var/run/secrets/kubernetes.io/serviceaccount/(内含 token 和 ca.crt);②K8s 给每个 Pod 注入环境变量 KUBERNETES_SERVICE_HOST 和 KUBERNETES_SERVICE_PORT。rest.InClusterConfig() 靠 SA 的 token 发起请求,默认的 default SA 毫无权限,故先 403;直到用 Role + RoleBinding 给 default:default SA 加了 list/get/watch pods,deployments 权限后才正常。Clientset:标准对象、日常控制器主力,类型安全;DynamicClient:操作 CRD 或从文件 apply 任意资源;DiscoveryClient:探测、版本兼容;RESTClient:学原理 / 底层定制。
DynamicClient 返回非结构化
map[string]interface{},需要自己用 runtime.DefaultUnstructuredConverter.FromUnstructured 转成已知结构体才能用。Informer:自动处理超时/重连、本地缓存不再频繁请求、支持多种资源共用一个 factory、通过工作队列限流与错误重试、基于 resync/全量+增量保证一致性。
<5 这个判断起了什么作用?cache.MetaNamespaceKeyFunc(obj) 生成,格式为 namespace/name,入队时唯一标识一个对象。失败时
queue.AddRateLimited(key)(重新入队并加指数退避),成功时 queue.Forget(key)(不再重试)。c.queue.NumRequeues(key) < 5 表示"同 key 重试次数还没到 5 次就继续排重试",达到了就放弃(Forget),避免无限重试打爆系统。GVK→GVR 用
restmapper.NewDiscoveryRESTMapper(以 DiscoveryClient 枚举到的资源清单构建 Kind↔Resource 映射),再调用 RESTMapping(gvk.GroupKind(), gvk.Version),取 mapping.Resource 即 GVR。它依赖 CRD 里 names.plural/names.singular 字段建立映射。动手练习(建议真做一遍):
- 把 Demo 的 kubeconfig 加载部分改成
rest.InClusterConfig(),容器化放进集群,跑通"默认 SA 无权 → 建 Role+RoleBinding 后能列 Pod"的完整链路。 - 分别用 Clientset、RESTClient、DynamicClient 列
kube-system的 Pod,对比三者的初始化与调用差异,加深选型记忆。 - 用 Informer 同时监听 Deployment 和 Service,并故意让
test-deployment处理失败,观察 RateLimit 队列的指数退避重试(retry 1/2/3…)。 - 定义你的 CRD,扩一个 DynamicClient 的
get小工具,打印出 instance 名称与resourceVersion。
本章小结
- client-go = 官方 Go SDK,把增删改查翻译成对 API Server 的 REST 请求,封装鉴权、请求参数与 JSON 编解码。
- 核心组 URL 无 group 段(
/api/v1/<res>),命名组有 group 段(/apis/<group>/<v>/<res>)。 - GVK(Group/Version/Kind)表达"对象是什么",落地时换算成 GVR(resource 为复数)。
- 初始化:集群外读 kubeconfig;集群内用
rest.InClusterConfig()(依赖 SA token +KUBERNETES_SERVICE_HOST/PORT),并务必配好 RBAC。 - 四 Client:RESTClient(底层);Clientset(标准对象,主力);DynamicClient(任意资源,返回非结构化,处理 CRD);DiscoveryClient(探测/版本兼容)。
- 裸 Watch 有五坑;生产长期监听用 Informer(自动重连 + 本地缓存 + 全量/增量同步)。
- Informer + Indexer + Workqueue 是控制器/Operator 的标准骨架:事件→入队→Worker 消费,RateLimit 队列提供失败重试与指数退避。
- CRD 用 DynamicClient 取数,用 RESTMapper 做 GVK→GVR 转换;RESTMapping 依赖 CRD 的复数/单数字段。
- 下一章将基于这些 client-go 能力,结合大模型做真正结合 AIOps 的工具。