当多个 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 的流程:
- 读取
spec.targetNodes; - 在云中创建 VM;
- 将节点注册到集群;
- 更新 status(从
active到inactive)。
随后引入第二个 CRD Autoscaler,它的 controller 会定期:
- 扫描若干 namespace;
- 查找因 CPU/内存不足而 Pending 的 Pod;
-
增加 NodePool 的
spec.targetNodes以触发新 VM 创建。
因此:
- Controller A(NodePool):主要写入
status,偶尔写入 spec; - Controller B(Autoscaler):写入
spec。
问题由此产生。
经典冲突:更新旧副本(resourceVersion 不匹配)
典型流程:
- NodePool controller 以
resourceVersion = 1读取并缓存到内存; - 与此同时,Autoscaler 将
spec.targetNodes从 1 改为 2,etcd 中的对象 resourceVersion 变为 2; - NodePool controller 完成配置后,尝试用旧副本(rv=1)更新 status;
- 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 视为正常情况。
稳健策略:
- 尝试 update;
- 若收到
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 中,这意味着:少写、写好、仅在有意义时重试。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.