frontendfacile.it

当多个 controller 操作同一 Custom Resource 时,并发就成了现实问题:如何以健壮且可扩展的方式处理。

Operator 的真正问题:更新并发

现实世界中,你很少只有一个 controller 能完全独立地管理“自己的”资源。一旦你引入:

  • 同一项目(或同一 manager)中的多个 controller;
  • controller 写入其他资源的场景;
  • 耗时操作(创建 VM、注册节点、资源配置等);

你就会遇到一个不可避免的问题:同一 Custom Resource(CR)并发更新

要理解发生了什么,需要掌握 Kubernetes 的两个核心概念:

  • metadata.generation:仅当 spec 发生变化时才会增加;
  • metadata.resourceVersion:每次对象修改(spec、status、label、annotation 等)都会变化。

这一区别至关重要,因为更新冲突几乎总是源于 resourceVersion,而非 generation

Generation 与 resourceVersion 的实际差异

  • 修改 spec.targetNodes:generation 和 resourceVersion 都会变化;
  • 添加 label 或 annotation:generation 不变,但 resourceVersion 变化。

换句话说:对对象的任何写入都会产生新的 resourceVersion。Kubernetes 正使用该值来防止你更新“旧副本”。

典型场景:NodePool + Autoscaler,两个 controller 共享一个 CR

假设一个 operator 管理 NodePool CRD:

  • spec.targetNodes:期望节点数;
  • status.scaleOperation:取值 inactive | active,表示是否正在扩容。

NodePoolReconciler 的流程:

  1. 读取 spec.targetNodes
  2. 在云中创建 VM;
  3. 将节点注册到集群;
  4. 更新 status(从 activeinactive)。

随后引入第二个 CRD Autoscaler,它的 controller 会定期:

  • 扫描若干 namespace;
  • 查找因 CPU/内存不足而 Pending 的 Pod;
  • 增加 NodePool 的 spec.targetNodes 以触发新 VM 创建。

因此:

  • Controller A(NodePool):主要写入 status,偶尔写入 spec;
  • Controller B(Autoscaler):写入 spec

问题由此产生。

经典冲突:更新旧副本(resourceVersion 不匹配)

典型流程:

  1. NodePool controller 以 resourceVersion = 1 读取并缓存到内存;
  2. 与此同时,Autoscaler 将 spec.targetNodes 从 1 改为 2,etcd 中的对象 resourceVersion 变为 2;
  3. NodePool controller 完成配置后,尝试用旧副本(rv=1)更新 status;
  4. Kubernetes 返回 409 Conflict(“对象已被修改;请将更改应用到最新版本”)。

这并非罕见异常:慢操作 + 多 controller 的场景下,这是“正常现象”。

最佳实践 1:分离 spec 与 status(并使用 status 子资源)

第一条规则:如果 CRD 支持,启用并使用 status 子资源

  • Spec:用户(或模拟用户的 controller)的期望;
  • Status:controller 的观测结果。

在 Kubebuilder 中,这意味着在设计 API 后,使用以下方式更新 status:

  • r.Status().Update(ctx, obj)

从而避免意外触碰 spec。

这虽不能完全消除冲突,但能大幅减少 controller 相互干扰。

最佳实践 2:显式处理 Conflict(仅在必要时重试)

写入 Kubernetes 时,应将 Conflict 视为正常情况

稳健策略:

  1. 尝试 update;
  2. 若收到 apierrors.IsConflict(err)
    • 执行 Get() 获取最新对象;
    • 仅重新应用自己的修改(通常是 status);
    • 重试 update。

重要提示:不要对任何错误都盲目重试。对永久性错误(验证、权限、配额)进行无限重试只会使情况恶化。

最佳实践 3:避免 reconciliation 无限循环(status 触发事件)

每次对象更新都可能产生事件并重新触发 reconciliation。

两个常见陷阱:

  • 即使 status 没有变化,也在每次 reconcile 时更新;
  • 写入“噪声”字段(时间戳、无用计数器),这些字段总在变化。

解决方案:

  • 仅在 status 真正变化时更新(结构化比较或特定字段);
  • 不要把 status 当作日志;使用 event/metric。

这能降低 controller 和 apiserver 的负载,并避免你的逻辑变成永不停歇的 reconcile 机器。

最佳实践 4:允许多 controller,但需明确边界并控制并行度

同一项目中存在多个 controller 通常是合理的(可获得并行性),但必须明确:

  • 哪些字段由谁所有
  • controller 可更新哪些资源;
  • 如何处理“交接”(例如:autoscaler 更新 spec,nodepool controller 执行并更新 status)。

若两个 controller 频繁更新同一资源,副作用不可避免:更多更新 → 更多 resourceVersion → 更多冲突 → 更多 reconcile。

此时更好的做法是:

  • 让 autoscaler 写入专用资源(如 ScaleRequest),仅允许 NodePool controller 修改 NodePool.spec;或
  • 引入独立的 “requested” 字段,由其中一个 controller 负责将其转换为实际的 “desired state”。

最佳实践 5:使用 generation 了解发生了什么变化(而非避免冲突)

generation 非常适合区分:

  • 因 spec 变化(新期望)而触发的 reconcile;
  • 因 metadata/status 变化而触发的 reconcile。

常见模式:

  • 在 status 中保存 observedGeneration
  • metadata.generation > status.observedGeneration 时,说明有新期望需要处理。

这有助于避免在仅发生 label 等元数据变化时重复昂贵的操作。

总结:可扩展 operator = 规范的更新

当多个 controller 开始协作时,operator 的质量主要取决于如何管理更新:

  • resourceVersion 始终变化:冲突是正常的,必须处理;
  • 分离 spec 与 status,并最小化 status 写入;
  • 避免触发无意义 reconcile 的“噪声”更新;
  • 明确 controller 边界:谁写入什么,以及原因。

真正的提升不在于“编写能创建资源的 reconciler”,而在于构建在负载和并发下依然稳定的系统。在 Kubernetes 中,这意味着:少写、写好、仅在有意义时重试


原文:https://frontendfacile.it/blog/kubernetes-operator-con-kubebuilder-best-practice-per-evitare-conflitti-retry-in