Client-go入门及实战

2025-11-05T14:11:02+08:00 | 33分钟阅读 | 更新于 2025-11-05T14:11:02+08:00

@

学习目标

学完本章你应该能够:

  1. 用一句话讲清 client-go 是什么:K8s 官方提供的 Go 客户端库(SDK),把你对任意 K8s 资源的「增删改查」翻译成对 API Server 的一条条 HTTP REST 请求,并帮你做鉴权、编解码与请求参数封装。
  2. 理解 GVK 与 GVR:为什么 kubectl get pod 落地后请求的是某个特定 URI,命名组(group)与核心组(core)在 URL 里的区别,以及 Kind、resource、group、version 如何一一对应。
  3. 掌握 client-go 的两种初始化方式:集群外读 kubeconfig,集群内用 rest.InClusterConfig()(依赖 ServiceAccount 的 token 与注入的环境变量),并亲手让容器里的程序通过 RBAC 拿到权限。
  4. 说清四种 Client 的关系与选型:RESTClient 最底层、Clientset 对标准对象最省事、DynamicClient 处理非结构化数据(尤其 CRD)、DiscoveryClient 做版本/资源发现。
  5. 亲手实现一个 Watch 客户端,并讲明裸 Watch 的五大坑(超时重连、API Server 压力、连接无法共享、无限流、失败无重试),从而理解为什么要用 Informer。
  6. 用 Informer + Indexer + Workqueue(RateLimiting 队列) 搭出一个 Controller 雏形,讲清 Reflector / DeltaFIFO / Indexer 的流水线,以及错误重试与指数退避。
  7. 用 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 通就行。

本章你会动手做的事:

  1. 用 Clientset 列出 default 命名空间的 Pod 和 Deployment,用 kubectl get pods -v=6 对照请求 URI。
  2. 把列 Pod 的程序容器化扔进集群,先看默认 default SA 无权限报错,再通过 Role + RoleBinding 授权,体会集群内认证与 RBAC。
  3. 用 Watch 客户端监听 Pod 变更,观察 60 秒超时断开;再用 Informer 替代,最后升级为 Informer + Workqueue 的重试消费。
  4. 定义并部署一个 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简介与配置四种 ClientWatch→InformIndexer+Queuekubectl get CRDGVK 与 GVR集群外 / In-ClusterCS/动态/REST/发现RESTful 封装Watch 五坑Informer 优势Reflector/DeltaFIFORateLimit 重试GVK→GVR 映射Dynamic 查询

一、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,访问的是一个(通常远端)集群。这种方法在本地调试很好用,但生产环境并不适用,原因有二:

  1. 生产集群一般没有公网 IP,你的远端程序根本连不上它。
  2. 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)
	}
}

这里有两个关键点:

  1. 先给 GVR:因为 DynamicClient 不知道你的对象长啥样,你必须用 GroupVersionResource{...} 明确告诉它要访问哪个资源。
  2. 返回的非结构化数据要自己转:runtime.DefaultUnstructuredConverter.FromUnstructured(unstructList.UnstructuredContent(), &podList) 就是把 map[string]interface{} 转换成分类型的结构体的核心调用。视频里特意打印过这个中间对象,确实是一堆 map[string]interface{} 嵌套的非结构化数据。

DynamicClient 除了查询,还有个很香的能力:从一个文件直接 apply 资源,像 kubectl apply -f 一样。做法是:

  1. 用 //go:embed deployment.yaml 干脆把 YAML 嵌入程序(生产里也可以改成读外部文件)。
  2. 用 unstructured.Unstructured 结构体 UnmarshalJSON / 手动解析把 YAML 转成非结构化对象。
  3. 从中提取 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 队列(限速队列)。它带来两个收益:

  1. 错误重试:处理失败可以把 key 重新放回队列,稍后再试。
  2. 防止 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 --> D

4. 完整 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

完整流转是:

  1. Reflector 借助 List + Watch 跟 API Server 通信,持续监听资源变化。
  2. 一旦有变化,它把这个对象放进 DeltaFIFO(注意:delta 是一种特殊数据结构,先进先出)。
  3. Reflector 把对象从队列弹出,进一步添加到 Indexer——Indexer 底层是一个加了锁的 map(缓存)。
  4. 同时把事件分发给你注册的 EventHandler 回调;你在回调里算出 key 并推进工作队列。
  5. 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,再回去看官方架构图就会有豁然开朗的感觉。


自测题与动手练习

面试官
为什么说对任何 K8s 资源的「增删改查」本质上都是对一个 REST 请求?核心组和命名组的 URL 结构分别长什么样?
候选人
API Server 是标准 REST API,同一资源的不同动作对应不同 HTTP 动词(GET/POST/PUT/DELETE),落到不同 URL 子路径。
核心组:/api/v1/<复数resource>(固定 /api 前缀 + version + resource,没有 group 段,如 /api/v1/namespaces)。
命名组:/apis/<group>/<version>/<复数resource>(有 group 段,如 /apis/apps/v1/...)。
面试官
请解释 GVK 与 GVR 的区别。一个 Kind 是不是一定对应唯一的 resource?举例说明。
候选人
GVK(Group+Version+Kind)描述"对象是什么",来自 YAML 的 kind 和 apiVersion;GVR(Group+Version+Resource)描述"请求路径上访问什么",resource 通常是 kind 的复数形式。
一个 kind 对应一个主 resource(如 Deployment→deployments),通常还有单数形式(deployment)和 shortNames,可另配别名,但"主 resource(复数)“是唯一的请求路径。
面试官
In-Cluster 配置赖以成立的"两个基础"是什么?工厂里为什么我们的容器程序一开始报 403、后来又能列 Pod?
候选人
①K8s 给每个 Pod 自动挂载 SA 配置文件目录 /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 权限后才正常。
面试官
四种 Client 的关系是什么?各自在什么场景下选型?DynamicClient 返回的数据是什么类型、为什么要转换?
候选人
RESTClient 最底层,Clientset / DynamicClient / DiscoveryClient 都基于它实现。
Clientset:标准对象、日常控制器主力,类型安全;DynamicClient:操作 CRD 或从文件 apply 任意资源;DiscoveryClient:探测、版本兼容;RESTClient:学原理 / 底层定制。
DynamicClient 返回非结构化 map[string]interface{},需要自己用 runtime.DefaultUnstructuredConverter.FromUnstructured 转成已知结构体才能用。
面试官
裸 Watch 有哪些坑?Informer 是怎么解决这些问题的?
候选人
五坑:超时/断连及重连丢事件、直接压 API Server、多资源连接无法共享、无限流事件洪峰下业务过载、事件驱动失败无二次机会。
Informer:自动处理超时/重连、本地缓存不再频繁请求、支持多种资源共用一个 factory、通过工作队列限流与错误重试、基于 resync/全量+增量保证一致性。
面试官
自写的 Informer + Workqueue 控制器里,key 是怎么生成的?process 成功和失败分别怎么处理?handleErr 里 <5 这个判断起了什么作用?
候选人
key 用 cache.MetaNamespaceKeyFunc(obj) 生成,格式为 namespace/name,入队时唯一标识一个对象。
失败时 queue.AddRateLimited(key)(重新入队并加指数退避),成功时 queue.Forget(key)(不再重试)。
c.queue.NumRequeues(key) < 5 表示"同 key 重试次数还没到 5 次就继续排重试",达到了就放弃(Forget),避免无限重试打爆系统。
面试官
为什么 CRD 必须用 DynamicClient 而 Clientset 不行?IAM 是如何做 GVK→GVR 转换的?
候选人
Clientset 里只有标准资源的 interface 和结构体,CRD 是你自定义的对象,SDK 不知道,所以没得操作。DynamicClient 面向任意资源。
GVK→GVR 用 restmapper.NewDiscoveryRESTMapper(以 DiscoveryClient 枚举到的资源清单构建 Kind↔Resource 映射),再调用 RESTMapping(gvk.GroupKind(), gvk.Version),取 mapping.Resource 即 GVR。它依赖 CRD 里 names.plural/names.singular 字段建立映射。

动手练习(建议真做一遍):

  1. 把 Demo 的 kubeconfig 加载部分改成 rest.InClusterConfig(),容器化放进集群,跑通"默认 SA 无权 → 建 Role+RoleBinding 后能列 Pod"的完整链路。
  2. 分别用 Clientset、RESTClient、DynamicClient 列 kube-system 的 Pod,对比三者的初始化与调用差异,加深选型记忆。
  3. 用 Informer 同时监听 Deployment 和 Service,并故意让 test-deployment 处理失败,观察 RateLimit 队列的指数退避重试(retry 1/2/3…)。
  4. 定义你的 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 的工具。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!