Kubernetes 基础体系 · 第 8/83 篇。示例基于 Kubernetes 当前稳定 API;弃用、版本偏差、云厂商差异和生产风险会明确说明。

Kubernetes 调谐循环:Desired State、Watch、Queue、幂等和最终一致

Kubernetes 的核心控制方式不是“执行一次命令后结束”,而是持续比较两种状态:

  • Desired State(期望状态):用户通过 API 对象声明系统应该是什么样。
  • Actual State(实际状态):集群当前观察到的资源、节点和工作负载是什么样。
  • 调谐(Reconcile):控制器根据两者的差异执行动作,使实际状态逐步接近期望状态。

Deployment、ReplicaSet、Job、Node 生命周期控制器以及大量 Operator,虽然职责不同,但都遵循这个基本模型。Watch 负责发现变化,Queue 负责安排处理,幂等逻辑负责允许重复处理,最终一致性则描述系统在故障和异步传播下如何收敛。


一、先建立系统模型:API 对象不是“命令”,而是状态记录

Kubernetes API 中的对象通常可以抽象为:

Object = metadata + spec + status

Deployment 为例:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: default
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
      - name: web
        image: nginx:1.27

这里的 spec.replicas: 3 不是“立即启动三个 Pod”的命令,而是一个持久化的声明:

对名为 web 的 Deployment,我希望有三个符合模板和选择器约束的副本。

控制器随后会创建或调整 ReplicaSet,ReplicaSet 再创建或删除 Pod,Scheduler 为未绑定节点的 Pod 选择节点,kubelet 在节点上创建容器。用户写入的是一个 API 对象,但最终效果由多个异步组件共同完成。

1. Desired State

通常,用户可以直接控制对象的 spec。例如:

  • Deployment 的副本数、Pod 模板;
  • Service 的端口和选择器;
  • Job 的并行度和完成次数;
  • PersistentVolumeClaim 的容量和访问模式。

spec 并不总是由用户直接填写。某些对象可能由其他控制器或 admission webhook 修改,但从控制器的角度看,spec 仍然代表需要实现的期望输入。

2. Actual State

实际状态不是单一字段,而是控制器从 API 对象、子资源、节点和外部系统中观察到的事实。例如 Deployment 的实际状态可能包括:

  • 当前 ReplicaSet 是哪个;
  • 有多少 Pod 已创建;
  • 有多少 Pod 已就绪;
  • 滚动更新是否完成;
  • 是否存在不可调度或启动失败的 Pod。

Kubernetes 通常把控制器观察到的摘要写入 status

status:
  replicas: 3
  updatedReplicas: 3
  readyReplicas: 2
  availableReplicas: 2
  conditions:
  - type: Available
    status: "False"
    reason: MinimumReplicasUnavailable

status 是观察结果,不应被误认为用户新的期望输入。控制器不能因为 status.readyReplicas 为 2,就把期望副本数改成 2;它应该继续根据 spec.replicas: 3 进行调谐。


二、调谐循环的形式化定义

设:

  • DD 表示期望状态;
  • SS 表示某一时刻观察到的实际状态;
  • R(D,S)R(D,S) 表示调谐函数;
  • AA 表示调谐函数产生的动作;
  • T(S,A)T(S,A) 表示执行动作后的新状态。

一次调谐过程可以写成:

A=R(D,S)A = R(D,S)

S=T(S,A)S' = T(S,A)

理想情况下,当系统达到稳定点时:

R(D,S)=R(D,S) = \varnothing

这里的空动作并不意味着“控制器停止运行”,而是表示在当前观察结果下不需要进一步改变。

例如,期望副本数为 3:

调谐轮次 观察到的 Pod 数 动作 执行后的 Pod 数
1 1 创建 2 个 Pod 3
2 2 创建 1 个 Pod 3
3 3,但 1 个未就绪 不再创建;等待就绪状态变化 3
4 3,全部就绪 无结构变更 3

“Pod 数量为 3”和“3 个 Pod 全部就绪”是不同条件。控制器必须明确自己要收敛的状态维度,否则容易出现错误判断。

收敛需要哪些条件

最终一致性并不是“系统一定最终正确”的保证。一个典型控制器要收敛,至少需要以下条件:

  1. 期望状态最终稳定
    如果用户不断修改 spec,控制器没有固定目标可追赶。

  2. 实际状态变化最终停止或可被修复
    例如节点故障、镜像不存在、外部数据库拒绝请求,可能让系统持续偏离目标。

  3. 变化最终能够被控制器观察到
    Watch 断开后必须能够重新 List;队列中的失败项目必须能重试。

  4. 调谐动作不会持续制造反复变化
    如果控制器每次都随机生成名称或无条件更新对象,就可能永远触发自己。

  5. 动作具有幂等性,或至少重复执行不会造成不可接受的副作用

因此,调谐循环是一个反馈系统,而不是一次性的过程调用。


三、Kubernetes 中各组件如何参与这条反馈链

下面的流程以 Deployment 创建 Pod 为例:

flowchart LR
    U[用户 kubectl apply] --> API[API Server]
    API --> ETCD[(etcd)]
    API --> DW[Deployment Controller]
    DW --> RS[ReplicaSet API 对象]
    RS --> RC[ReplicaSet Controller]
    RC --> POD[Pod API 对象]
    POD --> SCH[Scheduler]
    SCH -->|绑定节点| API
    POD --> K[Kubelet]
    K --> C[容器运行时]
    K -->|状态上报| API
    API --> DW

关键路径如下:

  1. kubectl apply 向 API Server 提交 Deployment。
  2. API Server 完成认证、授权、准入、校验,并将对象持久化到 etcd。
  3. Deployment Controller 通过 Watch 或重新 List 看到 Deployment。
  4. 控制器创建或更新 ReplicaSet。
  5. ReplicaSet Controller 根据 ReplicaSet 创建 Pod。
  6. Scheduler 观察没有 spec.nodeName 的 Pod,选择节点并通过 API Server 写入绑定结果。
  7. kubelet 观察分配给本节点的 Pod,调用容器运行时启动容器。
  8. kubelet 将 Pod 状态写回 API Server。
  9. 各控制器再次观察状态变化,继续调谐或更新 status

API Server 是统一的 API 入口和并发控制点;etcd 是 Kubernetes API 持久化数据的存储;Controller Manager 中运行多个控制器;Scheduler 主要负责 Pod 到节点的调度;Node 上的 kubelet 负责把 PodSpec 转化为节点上的运行实例。

这些组件之间通常没有一个全局事务。例如,Deployment 对象写入成功,不代表 Pod 已经启动;Pod 创建成功,也不代表镜像已拉取完成。异步阶段之间的延迟正是最终一致性的来源之一。


四、Watch:变化通知,不是可靠消息队列

控制器不能每秒完整扫描所有对象,否则 API Server 和控制器都会承受不必要的负载。因此 Kubernetes API 提供了 Watch:

GET /apis/apps/v1/namespaces/default/deployments?watch=true

逻辑上,Watch 会返回类似事件:

{
  "type": "ADDED",
  "object": {
    "metadata": {
      "name": "web",
      "resourceVersion": "1024"
    }
  }
}

常见事件类型包括:

  • ADDED:观察范围内出现对象;
  • MODIFIED:对象发生变化;
  • DELETED:对象被删除;
  • BOOKMARK:服务端报告一个进度位置,是否使用取决于客户端和服务端能力;
  • ERROR:Watch 流本身报告错误。

1. ResourceVersion 的作用

对象的 metadata.resourceVersion 是 API 存储层用于表示观察位置的版本标识。控制器通常需要基于它重新建立 Watch,避免在断线后遗漏变化。

resourceVersion 不应被当作业务版本号,也不能简单理解为“该对象的第几次修改”。它主要服务于 API 的一致读取和 Watch 恢复机制。

2. Watch 会断开,也可能失效

Watch 不是永久连接,也不是由 Kubernetes 保证永不丢失的消息日志。网络断开、API Server 重启、代理超时或服务端历史事件不可用,都可能导致 Watch 结束。

如果客户端使用过期的资源版本重新 Watch,API Server 可能返回 410 Gone。正确处理方式通常是:

  1. 重新 List 当前对象;
  2. 用 List 结果重建本地缓存;
  3. 从新的资源版本继续 Watch。

这也是 Informer 采用 List-Watch 组合,而不是只使用 Watch 的原因。

3. Watch 事件只是提示

控制器不应该把一条 MODIFIED 事件直接解释为“只需要执行某个固定动作”。事件更适合表达:

某个对象可能需要重新调谐,请重新读取它以及相关对象的当前状态。

例如,Pod 被删除时,ReplicaSet Controller 不应只执行“创建一个 Pod”这一条命令,而应重新计算:

期望副本数 - 当前符合条件的副本数

如果期间用户已经修改了副本数,或者其他控制器已经创建了替代 Pod,重新读取状态才能避免过度创建。


五、Informer 和本地缓存:降低读取成本,但引入可见性延迟

client-go 中常见的控制器结构是:

API Server
    │
    ├── List
    └── Watch
          │
       Reflector
          │
       DeltaFIFO
          │
       Indexer / Informer Cache
          │
       Event Handler
          │
       WorkQueue

不同版本的 client-go 内部实现细节可能调整,但 List-Watch、缓存和队列这几个概念是稳定的控制器基础。

1. Reflector

Reflector 负责:

  • 初始 List;
  • 建立 Watch;
  • 在 Watch 断开或资源版本失效后重新 List;
  • 将对象变化放入本地处理管道。

2. Informer Cache

Informer Cache 保存对象的本地副本。控制器通常从缓存读取对象,而不是每次调谐都直接请求 API Server。

好处是:

  • 大幅减少 API Server 读请求;
  • 多个处理逻辑可以共享缓存;
  • 通过索引快速查找关联对象,例如按 owner 或 label 查找。

代价是缓存不是强一致读取源。对象刚刚写入 API Server 时,控制器从缓存中暂时可能还看不到它。

因此,典型控制器要接受这种情况:

已经创建成功
    ↓
缓存尚未同步
    ↓
下一次调谐仍认为对象不存在
    ↓
尝试再次创建

这并不一定会造成重复资源,因为 API Server 会根据唯一名称返回 AlreadyExists;但控制器必须正确处理这一结果,或者使用稳定名称和关系查询避免无意义重试。

3. 删除事件中的 Tombstone

对象删除后,Informer 可能只剩下删除通知,而缓存中已经找不到对象。事件处理器因此可能收到 DeletedFinalStateUnknown 这样的 tombstone,而不是直接收到对象指针。

删除处理逻辑应能够从 tombstone 中取出对象键,或者至少可靠地得到:

namespace/name

不能假设删除事件中的对象一定仍然可以按正常对象类型直接断言。


六、Queue:把“需要处理的对象”变成可调度任务

事件处理器通常不会直接执行复杂业务逻辑,而是把对象的键放入工作队列:

namespace/name

这样做有三个目的:

  1. 事件接收和业务处理解耦;
  2. 可以控制并发 worker 数量;
  3. 可以对失败任务重试和限速。

1. Queue 中存的是键,不是最终状态

队列一般只存对象键,而不是事件发生时的完整对象。worker 取出 default/web 后,会从缓存重新读取当前对象。

假设短时间内发生以下事件:

t1: Deployment web 修改副本数为 3
t2: Deployment web 修改副本数为 5
t3: worker 开始处理 web

worker 不应处理 t1 的旧快照,而应从缓存读取当前对象,直接按副本数 5 调谐。

2. 队列通常是至少一次处理

client-go 的工作队列提供去重和重试语义,但控制器仍应按“至少一次处理”设计:

  • 同一个 key 可能被多次加入;
  • 同一个 key 可能因为多个事件被重复触发;
  • worker 处理成功后才应 Forget
  • 处理失败应 AddRateLimited
  • 退出时需要正确 Done,否则队列可能认为项目仍在处理中。

队列不是 etcd,也不是跨进程持久化消息系统。控制器进程完全崩溃时,内存队列中的任务会丢失。不过只要 Informer 重启后能够重新 List,当前对象仍然会重新进入调谐流程。

3. 同一 key 的并发

常见实现会避免同一 key 被多个 worker 同时处理,但不同 key 可以并发调谐。控制器仍不能假设全局串行,因为:

  • 多个资源之间可能存在关系;
  • 不同控制器可能同时写同一个对象;
  • 用户、Webhook 和其他控制器也可能修改对象。

如果确实存在跨对象的原子性要求,不能仅靠单个队列解决;Kubernetes API 通常也不提供跨资源事务。


七、幂等:重复调谐必须得到相同的正确结果

幂等不是“函数只执行一次”,而是:

在相同目标和相同有效状态下,重复执行调谐不会继续产生不必要变化,也不会造成额外不可接受的副作用。

形式上,如果第一次执行后得到状态 S1S_1,则再次执行应满足:

R(D,S1)=R(D,S_1) = \varnothing

或者至少再次执行的结果等价于不执行。

1. 非幂等控制器的反例

以下伪代码存在严重问题:

func reconcile() {
    child := &Pod{
        ObjectMeta: metav1.ObjectMeta{
            GenerateName: "worker-",
        },
    }
    client.CoreV1().Pods(ns).Create(ctx, child)
}

如果父对象只要存在就执行这段代码,那么每次 Watch 事件、队列重试或进程重启都会创建一个新 Pod。实际状态会从:

0 个 Pod → 1 → 2 → 3 → 4 → ...

这不是收敛,而是控制器不断制造偏差。

2. 幂等的创建与更新

更安全的模式是:

  1. 使用稳定名称,或通过标签、OwnerReference 查找已有子资源;
  2. 获取当前子资源;
  3. 若不存在,创建;
  4. 若存在,比较需要由自己管理的字段;
  5. 只有确实不一致时才更新;
  6. 若已经符合目标,不执行写操作。

伪代码:

desired := buildChild(parent)

actual, err := childLister.Get(desired.Name)
switch {
case apierrors.IsNotFound(err):
    _, err = childClient.Create(ctx, desired)
    if apierrors.IsAlreadyExists(err) {
        // 说明其他调谐或并发请求已经创建,下一轮重新读取
        return nil
    }
    return err

case err != nil:
    return err
}

if equalManagedFields(actual, desired) {
    return nil
}

patched := actual.DeepCopy()
patchManagedFields(patched, desired)

_, err = childClient.Update(ctx, patched)
if apierrors.IsConflict(err) {
    // 资源版本过期;重新读取并重试
    return err
}
return err

这里的 AlreadyExistsConflict 不是同一种错误:

  • AlreadyExists:创建目标已经存在,通常说明并发或缓存延迟;
  • Conflict:更新时携带的 resourceVersion 已经过期,说明对象在读取后被其他写入者修改。

3. 不要覆盖不属于自己的字段

控制器如果拿到整个对象后无条件替换,可能覆盖:

  • 用户字段;
  • 其他控制器维护的字段;
  • admission webhook 注入的字段;
  • API Server 默认值。

更新时应只修改自己负责的字段。多个写入者需要协作时,可以使用 JSON Merge Patch、JSON Patch 或 Server-Side Apply,但字段管理关系和冲突策略必须明确。Server-Side Apply 是 API 能力,不意味着所有场景都应该无条件使用。

4. 外部副作用的幂等性更困难

如果调谐逻辑调用云厂商 API、支付系统或数据库,API Server 的幂等更新并不能自动保证外部调用幂等:

调用云 API 创建负载均衡器
    ↓
云 API 已创建成功
    ↓
网络超时,控制器没有收到响应
    ↓
控制器重试

如果第二次调用会再创建一个负载均衡器,就会产生泄漏。常见解决办法包括:

  • 使用稳定的外部资源 ID;
  • 使用外部 API 支持的幂等键;
  • 保存外部资源标识到 status
  • 在重试前查询外部系统;
  • 明确“已提交但未确认”的状态。

status 可以帮助恢复,但不能单独解决外部系统不支持查询或幂等的问题。


八、一个完整的调谐算例:副本数从 1 变成 3

假设用户执行:

kubectl create deployment web --image=nginx:1.27
kubectl scale deployment web --replicas=3
kubectl get deployment web,pods -o wide

第一条命令创建的 Deployment 初始期望状态近似为:

D.replicas = 1

用户随后将其修改为:

D.replicas = 3

下面展示可能的状态变化。这里的 Pod 数和时序是示意,不是 Kubernetes 的时间保证。

第一次调谐

控制器读取:

期望副本数 = 3
当前 ReplicaSet 期望副本数 = 1

计算差异:

Δ=31=2\Delta = 3 - 1 = 2

动作:

将 ReplicaSet.spec.replicas 更新为 3

这次调谐并不直接启动容器,它只改变下游控制器的期望状态。

第二次调谐

ReplicaSet Controller 观察到:

ReplicaSet.spec.replicas = 3
当前 Pod 数 = 1

于是创建两个 Pod:

pod-a
pod-b
pod-c

其中新 Pod 可能还没有节点:

pod-a: Pending,未绑定节点
pod-b: Pending,未绑定节点
pod-c: Running

第三次调谐

Scheduler 观察到未调度的 Pod,并分别写入绑定结果。kubelet 再根据绑定结果启动容器。

可能出现:

pod-a: Running / Ready
pod-b: ImagePullBackOff
pod-c: Running / Ready

此时:

当前 Pod 数 = 3
Ready Pod 数 = 2

ReplicaSet 的副本数量可能已经满足,但 Deployment 的可用性条件仍未满足。控制器会更新 Deployment 的 status.conditions,而不是继续创建 Pod。

镜像故障的反例

如果 pod-b 使用了不存在的镜像:

D.replicas = 3
当前 Pod 数 = 3
Ready Pod 数 = 2

控制器重复调谐也不会凭空修复错误镜像。因为结构上的副本数已经是 3,继续创建 Pod 只会导致过量副本。正确行为通常是:

  • 维持当前期望副本;
  • status 中反映不可用原因;
  • 由用户修改镜像或由其他机制修复故障;
  • 状态变化后重新调谐。

这说明“调谐”不是保证任何条件都能成功,而是根据可观察事实持续执行正确动作。


九、事件如何进入 Queue:不要遗漏关联资源

一个 Deployment 的变化不仅可能由 Deployment 自身事件触发,也可能由以下事件触发:

  • ReplicaSet 创建、删除或状态变化;
  • Pod 创建、删除或就绪状态变化;
  • 节点故障;
  • 用户修改 Deployment;
  • 控制器重启后的初始 List;
  • 定期 resync。

因此控制器通常需要建立事件到父对象的映射:

Pod 事件
   ↓ ownerReferences / labels
找到所属 ReplicaSet
   ↓ ownerReferences
找到所属 Deployment
   ↓
将 Deployment 的 namespace/name 放入队列

如果只监听父对象,子资源异常时控制器可能没有及时机会重新调谐。反过来,如果监听了大量无关对象但没有过滤,队列会产生很多无效工作。

删除和 OwnerReference

Kubernetes 常用 metadata.ownerReferences 表示资源归属。控制器可以通过 owner reference:

  • 找到父对象;
  • 在父对象删除时进行级联清理;
  • 通过 garbage collector 清理已无主资源。

但 OwnerReference 不是任意场景都能使用。例如跨命名空间的 owner 关系受到 API 规则限制,控制器不能仅凭标签就假设资源生命周期一定绑定成功。

Finalizer 则是另一种机制:对象删除请求到达后,如果仍有 finalizer,API Server 会先设置删除时间,控制器必须完成清理并移除 finalizer。控制器如果崩溃,删除对象可能长期停留在 terminating 状态。


十、一个基于 client-go 的核心 worker 结构

下面是典型的 worker 逻辑,展示队列的生命周期。具体 client-go 版本的导入路径和泛型 API 可能不同,生产代码应与项目所用 Kubernetes 依赖版本匹配。

func runWorker(ctx context.Context) {
    for processNextItem(ctx) {
    }
}

func processNextItem(ctx context.Context) bool {
    item, shutdown := queue.Get()
    if shutdown {
        return false
    }

    defer queue.Done(item)

    key, ok := item.(string)
    if !ok {
        queue.Forget(item)
        return true
    }

    err := reconcile(ctx, key)
    if err == nil {
        queue.Forget(item)
        return true
    }

    // 生产代码应使用带退避的重试,而不是立即无限循环。
    queue.AddRateLimited(key)
    return true
}

reconcile 通常执行以下步骤:

1. 解析 namespace/name
2. 从 Informer Cache 读取父对象
3. 如果父对象不存在:
   - 可能是已删除对象
   - 清理需要清理的外部资源(若仍可确定归属)
   - 返回成功
4. 读取或索引查找子对象
5. 根据 spec 和实际状态计算动作
6. 创建、更新或删除子对象
7. 更新 status 或 conditions
8. 返回成功或可重试错误

几个重要的生命周期细节:

  • Get 后必须调用 Done
  • 成功处理后调用 Forget
  • 暂时性错误应进入限速重试;
  • 永久性配置错误不能无上限高速重试;
  • API Server 的 NotFound 对已经删除的父对象通常不是失败;
  • 不能把缓存对象直接修改后写回,通常应先 DeepCopy,否则可能破坏 Informer Cache 中的对象。

调谐函数应尽量是一个基于当前状态的纯决策过程:

当前父对象 + 当前子对象 + 外部观察结果
                ↓
       应该达到的子对象状态
                ↓
          最小必要 API 动作

这样更容易测试“输入状态到输出动作”的关系。


十一、最终一致性到底意味着什么

最终一致性表示:在输入最终稳定、故障能够恢复、控制器持续运行且动作可完成的前提下,系统状态会逐步接近期望状态,而不是保证每次读取都立即看到最终结果。

可以把一次状态传播写成:

用户写入 spec
  ↓
API Server 接收并持久化
  ↓
Informer 收到 Watch 事件
  ↓
事件进入 Queue
  ↓
worker 执行 Reconcile
  ↓
子资源写入 API Server
  ↓
Scheduler / kubelet 继续处理
  ↓
status 回写
  ↓
用户读取到新状态

每一段都可能有延迟或失败,所以以下现象是正常的:

kubectl apply -f deployment.yaml
kubectl get pods

紧接着执行时,可能暂时看不到 Pod,或者看到 Pod 处于 Pending。这不表示 apply 失败;它只证明 API 对象已经提交,并不证明所有下游控制器已经完成工作。

API Server 对单次对象读写提供相应的一致性语义,但 Kubernetes 的多控制器链路没有跨组件的全局强一致事务。用户应区分:

  • 提交成功:API Server 接受了对象;
  • 控制器已观察:事件进入某个 Informer 或调谐流程;
  • 子资源已创建:例如 Pod 对象存在;
  • 节点已执行:kubelet 已启动容器;
  • 业务已可用:应用通过探针并满足服务条件。

这些是不同层次的事实。


十二、故障路径:为什么系统仍能恢复

1. Watch 断开

Watch 断开
  ↓
Reflector 发现错误
  ↓
重新 List
  ↓
重建缓存
  ↓
建立新的 Watch
  ↓
相关对象再次进入调谐

控制器不需要依赖“每一条历史事件都必须被处理”。重新读取当前状态后,只要当前状态仍然偏离目标,就可以再次生成修复动作。

2. worker 在 API 写入后崩溃

假设控制器已经成功创建子资源,但在返回前进程崩溃。重启后可能再次处理同一个父对象:

第一次:Create → 成功 → 进程崩溃
第二次:Create → AlreadyExists

如果控制器把 AlreadyExists 视为可接受结果,并重新读取实际对象,就能恢复。若每次都使用随机名称,则无法判断第一次创建的对象是否就是自己需要的对象,容易产生泄漏。

3. 更新冲突

两个参与者同时修改同一个对象:

控制器读取 resourceVersion=10
用户修改对象,resourceVersion=11
控制器用 version=10 更新
API Server 返回 Conflict

控制器应丢弃旧对象,重新读取当前对象,再根据最新状态计算更新。强行覆盖可能删除其他参与者刚写入的字段。

4. 缓存延迟

控制器刚创建对象,但缓存还没有反映创建结果。下一次调谐可能暂时仍看到 NotFound。稳定名称、AlreadyExists 处理和后续 Watch 事件共同保证它最终恢复。

5. 外部系统不可用

如果调谐依赖云 API,而云 API 长时间不可用,队列会反复重试,但系统不会收敛。此时控制器应该:

  • 把可诊断错误写入 status.conditions
  • 使用指数退避限制压力;
  • 区分可重试的网络错误和不可重试的参数错误;
  • 在外部调用前后保持可恢复的状态记录。

十三、常见错误理解与实际表现

误解一:Watch 保证每个事件都被业务处理一次

Watch 只提供变化通知和恢复位置,不是带业务确认的消息系统。事件可能合并、连接可能断开,控制器应该依赖“重新读取当前状态”而不是依赖某个事件的具体内容。

误解二:队列保证任务只执行一次

队列通常提供去重和至少一次处理语义,不提供 exactly-once 执行。API 写操作和外部副作用都必须考虑重复执行。

误解三:调谐只在用户修改时运行

控制器还会被子资源变化、定期 resync、重启后的初始同步、删除事件和状态变化触发。仅处理用户修改会漏掉节点故障、Pod 被删除等关键路径。

误解四:返回 nil 就代表业务已经完成

reconcile 返回成功通常只表示本轮不需要立即重试。对于异步动作,例如 Pod 尚未就绪,控制器可能已经正确完成当前阶段,但最终目标仍未达到。后续状态事件会再次触发调谐。

误解五:无条件 Update 是安全的

无条件更新会带来:

  • 不必要的 MODIFIED 事件;
  • 自触发调谐循环;
  • 与其他写入者产生冲突;
  • 资源版本快速变化;
  • 覆盖不属于自己的字段。

正确做法是比较自己管理的字段,只在实际不一致时写入。


十四、用 kubectl 观察一条调谐链路

以下命令使用稳定 API apps/v1 的 Deployment,适用于具备 Kubernetes 集群访问权限的环境。

创建资源:

kubectl create deployment web --image=nginx:1.27
kubectl scale deployment web --replicas=3

观察父资源和子资源:

kubectl get deployment web
kubectl get rs -l app=web
kubectl get pods -l app=web -o wide

查看 Deployment 的期望与观察状态:

kubectl get deployment web -o yaml

重点观察:

spec:
  replicas: 3
status:
  replicas: 3
  readyReplicas: 3

spec.replicas 表示期望值;status.replicasstatus.readyReplicas 表示控制器观察到的不同实际维度。它们暂时不相等是调谐进行中的正常表现。

观察事件:

kubectl describe deployment web
kubectl get events --sort-by=.lastTimestamp

查看 Pod 无法启动的原因:

kubectl describe pod <pod-name>
kubectl logs <pod-name> -c <container-name>

例如镜像拉取失败时,describe 中通常会看到 ErrImagePullImagePullBackOff 相关事件。此时如果 Deployment 的 Pod 数已达到 3,控制器不会通过继续创建 Pod 来修复镜像问题;应修改 Deployment 的 spec.template.spec.containers[].image,再让新的期望状态触发滚动更新。

实时观察对象变化:

kubectl get deployment web -w

-w 本身也是基于 Watch 的客户端行为。网络断开或对象历史版本不可用时,客户端会重新建立连接;它不是永久可靠的审计日志。需要审计完整变更历史时,应使用审计日志或专门的事件系统,而不是依赖 Watch 输出。


十五、生产实现中的取舍

控制器设计的关键不是让每次调谐都执行更多动作,而是让每次调谐都能根据当前事实做出可重复、可恢复的最小动作。

需要明确区分三类内容:

Kubernetes API 的规范保证

  • API 对象由 API Server 统一管理;
  • 对象具有 resourceVersion 等并发控制信息;
  • Watch 可能结束,需要客户端重新同步;
  • specstatus 表达期望与观察结果的不同角色;
  • 删除、OwnerReference、Finalizer 具有明确的 API 生命周期语义。

常见 client-go 实现

  • Informer 使用 List-Watch 构建本地缓存;
  • 事件处理器通常将 key 放入 WorkQueue;
  • Worker 从缓存读取对象并执行 Reconcile;
  • WorkQueue 通常支持去重、确认完成和限速重试。

这些是主流控制器开发方式,但具体类型、泛型接口和内部实现会随 client-go 版本变化,应以项目锁定的依赖版本为准。

工程上的经验选择

  • 对缓存可接受的场景优先使用 Informer;
  • 对必须确认最新写入结果的少数操作,谨慎使用 API Server 直接读取;
  • 为每个外部副作用设计查询、幂等键或恢复记录;
  • 通过指标观察队列深度、处理延迟、重试次数和 API 错误;
  • 对状态条件写入做去重,避免控制器因自己更新 status 形成高频自触发;
  • 使用超时、退避和并发限制保护 API Server 与外部系统。

十六、诊断调谐问题的正确顺序

当资源“没有按预期运行”时,可以按状态链路定位,而不是只看一条错误日志:

spec 是否正确?
  ↓
API Server 是否接受对象?
  ↓
父控制器是否创建了子资源?
  ↓
子资源的 spec 是否正确?
  ↓
Scheduler 是否完成调度?
  ↓
kubelet 是否创建容器?
  ↓
status 和 conditions 反映了什么?

对应命令可以是:

kubectl get <resource> -o yaml
kubectl describe <resource>
kubectl get events --sort-by=.lastTimestamp
kubectl get pods -o wide
kubectl describe pod <pod>
kubectl logs <pod>

如果子资源完全没有出现,重点检查控制器是否观察到父对象、RBAC 是否允许创建、队列是否持续重试、Webhook 是否拒绝请求。如果子资源存在但无法运行,则问题可能已经从控制器调谐转移到调度、镜像、存储、网络或节点运行时。


Kubernetes 调谐循环的本质,是让一组独立组件通过 API 对象不断交换状态,并在 Watch、缓存、队列和重试机制的帮助下逐步收敛。Watch 负责提供变化线索,Queue 负责安排工作,Reconcile 负责重新计算动作,幂等性保证重复处理不会破坏系统,最终一致性则允许这些过程在延迟、断线、冲突和重启后继续恢复。只要控制器始终基于当前状态做决策,而不是把事件当作一次性命令,系统就能在异步和不可靠环境中保持可恢复性。


系列导航与关联阅读

官方资料

本文依据 Kubernetes、CNCF 与相关项目官方文档重新梳理;正文和生产清单由 WR BLOG 编写。