jimyag's Blog

Kubernetes Controller 调谐机制:工作队列、更新冲突与幂等设计

一个 Controller 收到对象更新事件,创建了云盘,随后还没来得及把云盘 ID 写回状态就退出了。进程重启后,同一个对象再次进入队列:这次应该再创建一块云盘,还是找到第一次创建的那一块?

这类问题很难靠“正常情况下事件只来一次”解决。Controller 必须允许同一个对象被反复处理,并让重复执行继续接近期望状态。本文用真实 Kubernetes API 验证两个问题:旧版本写入为什么被拒绝,以及操作成功但没有确认时怎样避免重复副作用。

事件告诉你要检查谁,不保证带来最终状态

已有的 Informer、Reflector、Indexer 和 Workqueue 介绍解释了事件与队列之间的关系。实现调谐时,还需要区分三件事:

  • 事件发生时的对象状态。
  • 本次读取拿到的对象状态。
  • 写入请求被 API Server 处理时的对象状态。

典型 client-go Controller 让 Informer 把对象变化同步到本地缓存,事件处理函数只把 namespace/name 放入 workqueue;worker 出队后读取当前对象并执行调谐。队列可以合并相同 key 的重复入队,所以 worker 不应假设每次执行都对应一个独立、完整的历史事件。需要推进的是对象现在的期望状态。

worker 处理时再次发生更新,或一次失败进入重试,同一个 key 都可能再次被处理。通常成功后调用 Forget 清除重试计数,失败时通过 AddRateLimited 安排重试,并用 Done 结束本次队列处理。这些 API 管理处理机会与退避,不会替业务逻辑撤销已经创建的外部资源。调用模式见 client-go controller 示例。

读取和写入之间,用户与其他控制器仍可能修改对象。即使直接 GET API Server,也存在这个并发窗口;所以失败后应重新读取、重新计算差异,不能无限重放旧对象。

Kubernetes 用 metadata.resourceVersion 检查部分更新操作的并发条件。它是 API 使用的版本标识,不应当解析成业务序号或时间戳。相关约定见 Kubernetes API Concepts。

队列怎样既合并事件,又保留处理期间的新变化

client-go 的 workqueue用两个集合记录不同事实:dirty 表示这个 key 有待处理的更新,processing 表示某个 worker 正在处理它。重复 Add 一个已经 dirty 的 key,不会增加多份队列项;Get 取出 key 时,把它加入 processing,同时从 dirty 移除。

真正容易漏掉的是处理期间再次发生更新。此时 Add 会重新标记 dirty,但发现 key 仍在 processing,暂不入队;原 worker 调用 Done 时,才把这个 dirty key 再次放回队列。这样,同一队列不会让两个 worker 同时处理同一个 key,也不会因为它正在处理就丢弃新的处理需求。

  flowchart TB
    A[Get 取出 key] --> B[加入 processing 并清除 dirty]
    B --> C[worker 读取当前对象并处理]
    C --> D{期间再次 Add?}
    D -->|是| E[标记 dirty 暂不入队]
    D -->|否| F[保持非 dirty]
    E --> G[Done 移除 processing]
    F --> G
    G --> H{key 是否 dirty?}
    H -->|是| I[重新入队 等待再次处理]
    H -->|否| J[本次处理结束]

这提供的是同一队列内的串行处理和更新合并,不是业务操作恰好执行一次。controller 重启后会重新观察对象;其他 controller 也可能同时操作外部系统。Forget 只清理限速队列的重试历史,不能代替 Done,更不能撤销已经成功的外部调用。

因此,下面的实验特意分别验证旧版本写入冲突和重复执行的幂等结果。它们没有运行完整的 Informer/workqueue;队列状态机来自上述版本源码,实验用于验证调谐函数必须面对的并发与重复执行条件。

实验一:读到 one,不代表还能写回 one

实验通过 SSH 在 Linux 主机 zj 上运行,使用 kind v0.33.0、Kubernetes v1.35.1,namespace 为 dd-reconcile。所有操作都作用于实验 ConfigMap,不创建真实云资源。

脚本执行了四个动作:

  1. 创建 desired,令 data.value=one,保存当时读取到的完整对象。
  2. 使用另一个请求把值改成 two。
  3. 在旧对象上把值改成 stale-write,保留旧 resourceVersion,执行 kubectl replace。
  4. 再次读取 API 中的值。

实际结果是旧写入被拒绝,服务端仍然保留 two:

1
2
3
4
cached_value: one
current_value: two
Error from server (Conflict): ... the object has been modified;
please apply your changes to the latest version and try again

这里的 cached_value 是脚本保存的旧快照,故意制造并发窗口,用来验证 API 冲突检测;它不是对 client-go Informer 缓存传播延迟的测量。

遇到 Conflict 后,正确动作取决于修改意图。如果想“把计数加一”,需要重新读取最新计数再计算;如果想“将某个字段设置成指定值”,需要确认最新对象是否仍满足操作前提。直接把旧对象的 resourceVersion 换成新的,再提交旧内容,可能把其他人的有效修改覆盖掉。

实验二:操作成功后没有确认,再执行会发生什么

为了模拟外部副作用,实验创建另一个 ConfigMap,名称为 effect-<desired UID>。它代表“为这个对象创建的资源”,不是 Controller 的状态字段。

第一次调谐读取最新的 desired,成功创建 effect,然后故意不写确认标记。这对应“外部操作成功,Controller 在状态持久化前退出”的窗口。第二次和第三次再次处理同一个对象:创建请求得到 AlreadyExists,随后把已有 effect 的数据调整为当前期望值。

实测记录如下:

调谐次数创建新 effect写入确认标记effect 中的值
第一次是否,模拟操作后中断two
第二次否,发现对象已存在是two
第三次否,复用已有对象是two

三次处理结束后只有一个 effect。重复调用没有变成三份资源,也没有用最早事件对应的 one 覆盖最新期望值。

这里验证的是一个明确的设计:副作用有稳定身份,重试能够找到它。实验使用 ConfigMap 的唯一名称保证原子创建;它不证明任意云厂商 API 都有同样的语义。对真实云盘、IP 或账户,需要检查对方是否提供幂等键、查询标签或其他可靠的关联方式,并保留资源归属校验。

为什么名称最好包含 UID

同一个 namespace 中,名为 desired 的对象可以被删除后重新创建。名称相同,UID 已经不同。若外部资源只用 namespace/name 关联,新对象可能错误接管旧对象遗留的资源。

实验把 UID 放进 effect 名称,使这两个生命周期能够区分。真实控制器还需要决定删除时怎样清理、失败时怎样恢复,以及哪些资源允许显式接管。Finalizer 可以阻止对象在清理责任完成前消失,但清理逻辑本身仍然必须允许重试。

缓存、冲突和幂等各解决一个问题

缓存减少 API 读取压力,代价是读取可能落后于刚完成的写入。冲突检测防止部分陈旧更新悄悄覆盖新版本。幂等设计则处理“同一操作再次执行”的后果。这三者不能互相替代。

例如,绕过缓存重新 GET 可以减少读到旧状态的机会,却无法消除“云盘创建成功,状态写入失败”的窗口;给更新加重试可以处理 Conflict,却可能在每次重试时再创建一块云盘。

检查 Controller 时,可以沿着一次调谐逐个确认:读取的对象是否仍存在、UID 是否匹配、期望值是否已改变、关联资源是否已创建、写入是否产生冲突。如果已经达到期望状态,就应该停止产生新的副作用。没有变化时反复写状态,还可能制造额外更新事件和 API 请求。

实验代码与使用说明见 k8sdev/deep-dive-reconcile。

#Kubernetes #Kind #Troubleshooting