12. 控制器模式与CRD
不只是 Deployment:K8S 最本质的设计模式
前面讲了 Deployment、StatefulSet、DaemonSet 这些内置控制器。它们的本质都一样——Watch 期望状态、比较实际状态、修正差异。
K8S 把这个模式通用化了。控制器模式不是"K8S 有个 Controller Manager"这么简单,它是一个开源的、可复制的设计范式。
任何东西——不管是不是 K8S 自带的——只要你遵守这个模式,就能作为一个一等公民跑在集群里。
控制循环的约束接口
一个控制器只需要满足三个条件:
1. 能 Watch 某种 API 对象的变更事件
2. 能从集群中读取实际状态
3. 能通过 API Server 修改资源的状态
K8S 的 client-go 库已经把这个模式的骨架搭好了。Informer / Lister / Workqueue 构成了所有控制器的标准"管道":
// client-go 的典型控制器骨架
type Controller struct {
indexer cache.Indexer // 本地缓存
queue workqueue.RateLimitingInterface // 工作队列
informer cache.Controller // Watch API + 同步本地缓存
}
func (c *Controller) Run(stopCh <-chan struct{}) {
// 1. 启动 Informer,Watch API 并填充本地缓存
go c.informer.Run(stopCh)
// 2. 等待缓存同步完成
cache.WaitForCacheSync(stopCh, c.informer.HasSynced)
// 3. 启动 worker,从队列拿事件做调和
wait.Until(c.runWorker, time.Second, stopCh)
}
func (c *Controller) runWorker() {
for c.processNextItem() {
}
}
func (c *Controller) processNextItem() bool {
key, _ := c.queue.Get()
defer c.queue.Done(key)
// 核心逻辑:比较期望状态 vs 实际状态
err := c.syncHandler(key.(string))
if err != nil {
c.queue.AddRateLimited(key) // 失败重试
}
return true
}
所有控制器的 syncHandler 都一样——拿到 key(比如 default/nginx),从本地缓存取到当前对象,检查是否在生命周期的正确位置,不是就修。
这套骨架在 k8s.io/client-go 和 k8s.io/controller-runtime 里都有实现。区别是 controller-runtime 封装了更多细节——OwnerReference、Admission Webhook、Metrics——所以一个完整的 operator 项目通常用 controller-runtime 而不是 client-go 起手。
workqueue 的限流机制
骨架代码里 c.queue.AddRateLimited(key) 这一行藏了一个关键设计——失败重试不是立即的,而是带退避的。如果 syncHandler 调外部 API 失败(网络抖动、云 API 限流、资源还没 Ready),控制器不会疯狂重试把 API Server 或外部系统打爆。
workqueue.RateLimitingInterface 内置了组合限流策略:
// client-go/util/workqueue/default_rate_limiters.go
func DefaultControllerRateLimiter() RateLimiter {
return NewMaxOfRateLimiter(
NewItemExponentialFailureRateLimiter(5*time.Millisecond, 1000*time.Second), // 指数退避
&BucketRateLimiter{Limiter: rate.NewLimiter(rate.Limit(10), 100)}, // 令牌桶
)
}
两层合在一起的逻辑:
1. 指数退避——ItemExponentialFailureRateLimiter
同一个 key 每失败一次,等待时间翻倍:
第 1 次失败 → 等 5ms 后重试
第 2 次失败 → 等 10ms
第 3 次 → 等 20ms
第 4 次 → 等 40ms
...
第 n 次 → 等 min(5ms × 2^(n-1), 1000s)
上限 1000 秒(约 16 分钟),不会无限增长。key 成功处理一次后,失败计数清零。
2. 令牌桶——BucketRateLimiter
全局限流,不管哪个 key 失败——每秒最多放 10 个 key 进队列(rate.Limit(10)),桶里最多攒 100 个 token 应对突发。防止的是"整个集群的所有控制器一起崩了、同时重试、同时打 API Server"——也就是 thundering herd。
两个一起用的效果:
- 同一个 key 反复失败 → 指数退避让它间隔越来越远
- 100 个 key 同时失败 → 令牌桶限制每秒只处理 10 个,其余排队
- key 终于成功了 → 下次再进队列就是正常的立即处理,不会再被限速
controller-runtime 的默认值更保守——MaxOfRateLimiter 工作频率上限 5 QPS、突发上限 10。如果你的 Controller 需要处理大量对象,调高这两个值即可。
什么时候你需要调?
// 场景:批处理 controller,一次 sync 要处理 1000 个 job
// 默认 5 QPS 太慢了,调成 50
ctrl.Options{
RateLimiter: workqueue.NewMaxOfRateLimiter(
workqueue.NewItemExponentialFailureRateLimiter(5*time.Millisecond, 1000*time.Second),
&workqueue.BucketRateLimiter{Limiter: rate.NewLimiter(rate.Limit(50), 200)},
),
}
为什么代码骨架里写的是 AddRateLimited 而不是 Add:因为 syncHandler 返回了 error——这个错误可能是暂时的(云 API 超时),重试之后可能会成功。AddRateLimited 让重试带退避。如果直接 Add,队列会立刻把这个 key 塞回去,worker 拿起来又失败、又塞——死循环。RateLimiter 是整个 Controller 模式的稳定器——没有它,一个短暂的故障能把控制循环变成自杀式 DDoS。
K8S 自己的控制器怎么写的
Deployment Controller 的 syncHandler(简化后):
// k8s.io/kubernetes/pkg/controller/deployment/deployment_controller.go
func (dc *DeploymentController) syncDeployment(key string) error {
namespace, name, _ := cache.SplitMetaNamespaceKey(key)
deployment, _ := dc.dLister.Deployments(namespace).Get(name)
// 找到当前 ReplicaSet
rsList, _ := dc.getReplicaSetsForDeployment(deployment)
// 如果 replicas 不对,更新
if deployment.Spec.Replicas != currentReplicas {
dc.scale(deployment, rs)
}
// 更新 status
dc.updateStatus(deployment, rs)
}
StatefulSet 的 syncHandler 大同小异:
// k8s.io/kubernetes/pkg/controller/statefulset/stateful_set_control.go
func (ssc *defaultStatefulSetControl) updateStatefulSet(set *apps.StatefulSet, pods []*v1.Pod) {
// 按序处理每个 Pod
for i := 0; i < *set.Spec.Replicas; i++ {
expectedName := fmt.Sprintf("%s-%d", set.Name, i)
pod := getPod(expectedName, pods)
if pod == nil {
ssc.createPod(set, i) // 没有就创建
} else if needsUpdate(pod, set) {
ssc.updatePod(set, pod, i) // 不对就更新
}
}
}
都是同一套逻辑。区别只在于"什么时候 Pod 算对"——Deployment 只看个数,StatefulSet 还要看名字和顺序。
CRD:你也能定义一种"Deployment"
如果想写自己的控制器,你需要先定义一个 API 对象——这就是 CRD(Custom Resource Definition)。
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: databases.example.com
spec:
group: example.com
names:
kind: Database
plural: databases
singular: database
shortNames: ["db"]
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
engine:
type: string
enum: ["mysql", "postgres"]
version:
type: string
storageGB:
type: integer
minimum: 1
status:
type: object
properties:
phase:
type: string
enum: ["Creating", "Running", "Failed", "Deleting"]
kubectl apply 这个 CRD 之后,K8S 的 API Server 就认识了一种新的资源类型—— Database。你可以 kubectl get databases、用 YAML 创建它、Watch 它的变化。它的行为跟 Deployment 完全一样——你建立了一种新的"期望状态"。
# 用你自己的类型
apiVersion: example.com/v1
kind: Database
metadata:
name: my-db
spec:
engine: mysql
version: "8.0"
storageGB: 100
然后你需要一个控制器 Watch 这个 Database 对象的变化,并在后台操作 MySQL/Postgres 或云厂商 API 去真的创建数据库。
思考题:CRD 是扩展 K8S API 的主要方式,但不是唯一的。K8S 还有一种聚合 API(Aggregated API,简称 AA)——跟 CRD 有什么区别?什么时候用 CRD,什么时候用 AA?
思考题:你装过 cert-manager、Prometheus Operator、Istio、Argo CD——这些工具都在集群里注册了 CRD。列几个你实际用过的 CRD 类型,想想它们分别管了什么外部资源。
思考题:有一种 Controller 模式叫 Shadow CR——它创建的对象不是管理外部资源,而是"投射"另一个 CR 的副本。这有什么用?什么场景下需要一个 CR 的"影子"?
Operator:控制器 + CRD + 运维知识
"CRD + 控制器 = Operator" 这个说法普遍但过于简单了。Operator 跟普通控制器的区别在于:它把运维知识编码进了代码。
一个 Database 类型的 CRD,普通控制器可能只处理简单的版本切换——换成新版本就行。但实际的 DBA 知道这个操作应该在业务低峰期、在维护窗口内、需要先创建新副本并确保主从同步完成才能切。Operator 把这个上下文编码进了 syncHandler。
func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
db := &examplev1.Database{}
r.Get(ctx, req.NamespacedName, db)
switch db.Spec.Engine {
case "mysql":
if db.Status.Phase == "" {
// 创建 MySQL 实例
r.createMySQLInstance(db)
db.Status.Phase = "Creating"
r.Status().Update(ctx, db)
}
if db.Status.Phase == "Creating" && r.isInstanceReady(db) {
db.Status.Phase = "Running"
db.Status.Endpoint = r.getInstanceEndpoint(db)
r.Status().Update(ctx, db)
}
}
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
Operator 的 Reconcile 方法本质上就是一个不断被调用的函数——每次 Database 对象变化都跑一次。逻辑上跟 Deployment Controller 的 syncDeployment 没区别,不同的是它操作的对象是外部资源(云 API、数据库实例),而不是集群内的 Pod。
K3S 里 CRD 的表现
K3S 对 CRD 的支持跟 K8S 一模一样——API Server 的 CRD 注册机制、controller-runtime 的 Reconcile 模型,都完全一致。K3S 自己不注册额外的 CRD(除了它自己的 HelmChart 控制器),但你在 K3S 上 kubectl apply 任何 CRD 都能正常工作。
区别在于 K3S 删掉了一些 K8S 的内置 CRD 和相关的 controller——比如云厂商的 CCM 相关资源。K3S 更"干净",只有核心的 API 资源,扩展全部留给你自己。
小结
- 控制循环 = Watch 变更 + 比较差异 + 修正状态,所有控制器共享这个骨架
- client-go 和 controller-runtime 提供了标准骨架,不用你手写 Informer/Workqueue
- CRD 让你定义自己的 API 类型,"像用 Deployment 一样用数据库"
- Operator = CRD + 控制器 + 运维知识(领域逻辑)
- K3S 支持完整的 CRD 机制,只去掉了云厂商相关资源
解答
1. 聚合 API(AA)和 CRD 有什么区别?
CRD 和 AA 是 K8S 扩展 API 的两种方式,核心区别在于谁来处理请求:
| 维度 | CRD | AA(Aggregated API) |
|---|---|---|
| 请求处理 | API Server 自己处理——它内置了通用的 CRD handler | 转发给外部服务——AA Server 是一个独立的进程 |
| 存储 | etcd(跟 Pod/Deployment 一样) | 完全自定义——可以是 etcd、MySQL、文件、内存 |
| API 行为 | 标准 CRUD + Watch,K8S 默认行为 | 完全自定义——你可以实现任意 gRPC/REST 逻辑 |
| 实现复杂度 | 写一个 YAML 定义 CRD + 一个 Controller | 需要实现一个完整的 API Server(gRPC service) |
| 适用场景 | 99% 的扩展需求 | 需要自定义 API 行为或非 etcd 存储的场景 |
CRD 的局限——它只能做标准 REST 操作:Create、Get、List、Watch、Update、Delete。如果你的 API 需要:
- 自定义子资源(如
/scale、/status之外的路径) - 非 etcd 存储(如从外部系统实时查询)
- 复杂的请求-响应逻辑(不只是 CRUD)
AA 更适合。Metrics Server 就是用 AA 注册的——它不存 etcd,每次请求实时从 kubelet 拉数据返回。
什么时候选 AA? 99% 的情况用 CRD 就够了。AA 是给 K8S 自己的扩展点设计的(metrics API、custom metrics API)。普通应用开发不需要也不应该用 AA。
2. 有哪些有名的 CRD?
K8S 生态里,CRD 已经成了"在 K8S 上管理一切"的标准方式。常见的几类:
证书:
Certificate(cert-manager)→ 管 TLS 证书的自动签发和续期Issuer/ClusterIssuer(cert-manager)→ 定义 CA 来源(Let's Encrypt、Vault)
监控:
ServiceMonitor/PodMonitor(Prometheus Operator)→ 声明"我要监控这个 Service 的哪个端口、哪个路径"PrometheusRule(Prometheus Operator)→ 管理 Alerting/Recording rules
流量:
VirtualService/DestinationRule(Istio)→ 流量路由、金丝雀、熔断HTTPRoute(Gateway API)→ 替代 Ingress 的路由规则TraefikService/Middleware(Traefik)→ Traefik 特有的路由增强
CI/CD:
Application(Argo CD)→ 声明"这个 Git 仓库里的 Helm Chart/Kustomize 要部署到集群"Rollout(Argo Rollouts)→ 蓝绿/金丝雀部署
数据:
Kafka/KafkaTopic(Strimzi)→ 管理 Kafka 集群和 TopicPostgresCluster(Crunchy Data)→ 管理 PostgreSQL 主从RedisCluster(Redis Operator)
安全:
NetworkPolicy(K8S 原生,但 Calico/Cilium 扩展)FalcoRule(Falco)→ 运行时安全规则
这些 CRD 的共同点:把外部系统的运维操作翻译成 K8S YAML。你不用记 AWS CLI 怎么申请证书——写一个 Certificate YAML,cert-manager 做剩下的。
3. Shadow CR 是什么?
Shadow CR(影子资源)是一种 Controller 设计模式——一个 Controller 不直接管理外部资源,而是"投射"另一份 K8S 对象的副本到别的地方。
最常见的场景:多集群资源同步。Karmada 把用户在 Control Plane 上写的 Deployment 复制("投射")到每个成员集群。成员集群里出现的 Deployment 就是原始 Deployment 的"Shadow"——它是一个副本,Controller 负责保持它跟原始对象一致。
Control Plane Member Cluster
┌─────────────┐ ┌─────────────┐
│ Deployment │ Shadow CR │ Deployment │ ← 这个是副本
│ (原始) │ ──Controller──→ │ (影子) │
│ replicas: 3 │ │ replicas: 3 │ 一直同步
└─────────────┘ └─────────────┘
为什么需要 Shadow? 因为两个集群的 API Server 是独立的——Control Plane 的对象不能直接被 Member Cluster 的 Scheduler 看到。你需要一个 Controller 读取原始对象,在目标集群创建一个"影子"。当源对象变化时,Shadow Controller 更新影子的 spec。当影子对象的状态变化时(比如 Pod 从 Pending 变成 Running),Shadow Controller 把状态回写到源对象。
另一个场景:多租户隔离。假设你给每个租户创建了一个独立的 namespace,你需要让每个租户能看到集群的总资源 Usage(如节点数)但不能修改它们。可以创建一个 ClusterInfo CRD,一个 Shadow Controller 把真实的 Node 信息投射到每个租户 namespace 里的 ClusterInfo 副本——租户只能读自己的影子,碰不到真的 Node 对象。
核心模式就一句话:源对象在一个地方 → Controller 创建/同步影子到另一个地方 → 保持两边一致。
下一章,控制器的视角往外看——CRI、CNI、CSI 这三个标准接口是怎么把"实现"从 K8S 本体剥离出去的。
本章讲了 Controller 的骨架和模式。如果你想从零手写一个真正的 Controller——不用框架、纯 client-go 裸写 Informer + Workqueue,再到 Kubebuilder 生产化——可以看 Controller 系列,那是本章的实战延伸。