CRD、Operator、Reconcile 与 Finalizer

Operator 的本质:把运维知识写成控制循环

普通 YAML 能描述一组静态对象,但很难表达这些规则:

  • 一个业务对象自动派生一个 Namespace;
  • 先校验资源预算,再创建任何持久资源;
  • Secret 缺失时随机生成,存在时绝不覆盖;
  • Gateway 模式才创建 HTTPRoute;
  • suspend 时缩容但保留 PVC;
  • 删除时等待 Namespace 真正清理完;
  • 持续把依赖状态汇总成 Conditions。

Operator 把这类领域运维知识编码进 controller。使用者面对的是 SubPool,底层 Kubernetes 积木由 controller 组织。

CRD:给 Kubernetes 扩展一种新对象

CustomResourceDefinition 告诉 API Server:集群现在支持一种新资源。

本项目安装:

1
2
3
4
5
6
plural: subpools
group: subrelay.example.com
version: v1alpha1
kind: SubPool
scope: Cluster
shortName: sp

安装 CRD 后,才能执行:

1
2
3
kubectl get subpools
kubectl get sp
kubectl apply -f examples/subpool-internal.yaml

CRD 只定义 API 和校验规则,不会自己创建 StatefulSet。真正的自动化逻辑来自 Operator controller。

Go 类型如何变成 Kubernetes API

api/v1alpha1/subpool_types.go 定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
SubPool
  ├─ ObjectMeta
  ├─ Spec
  │   ├─ Exposure
  │   ├─ Storage
  │   ├─ CPA
  │   ├─ CPAManager
  │   └─ Suspend
  └─ Status
      ├─ ObservedGeneration
      ├─ Namespace
      ├─ CPAService
      ├─ Hostname
      └─ Conditions

Kubebuilder markers 描述 scope、short name、status subresource、打印列和 CEL 校验。生成的 CRD YAML被放在 config/crd/bases/

Go 类型是 controller 编译时使用的强类型模型,CRD schema 是 API Server 接收对象时使用的运行时契约。修改其中一边却忘记重新生成另一边,会造成代码和集群 API 不一致。

静态校验与动态校验的边界

CRD schema 适合检查:

  • exposure mode 枚举;
  • 必填字段;
  • 名称格式;
  • storage.size 不可变;
  • resources 必须包含 CPU 和内存。

Operator 适合检查:

  • 配置的 ConfigMap 能否读取与解析;
  • 两个容器的合计资源是否超过平台包络;
  • 派生 Namespace 是否已被别人占用;
  • Secret 是否属于当前 SubPool;
  • Shared Mihomo 有没有 Ready Endpoint;
  • HTTPRoute 和 Gateway status 是否就绪。

静态规则尽早拒绝,动态规则通过 Condition 提供可诊断状态。

Controller Manager、Scheme 与 Client

main.go 创建 controller-runtime Manager,并把这些 API 注册到 Scheme:

  • Kubernetes core API;
  • Gateway API v1;
  • SubPool v1alpha1。

Scheme 让 client 知道 Go 类型与 Group/Version/Kind 的对应关系。没有注册类型,controller 无法正确编码或解码对象。

Manager 统一负责 client/cache、controller 生命周期、metrics、health endpoints、leader election 和信号处理。

Watch 不是每秒全量扫描

Controller 通常通过 list/watch 与本地 cache 感知对象变化。SubPool 创建、spec 更新或受管对象变化时,事件进入 work queue,触发 reconcile。

本项目声明 Owns Namespace、Secret、ConfigMap、ServiceAccount、Service、StatefulSet、ResourceQuota、NetworkPolicy 和 PDB。它们的 owner reference 让 controller-runtime 能把子资源事件映射回 SubPool。

共享 Gateway 和 Mihomo EndpointSlice 不归任何 SubPool 所有,不能通过 Owns 关联。因此 controller 在活动状态下默认每 30 秒 requeue,周期性复核这些共享依赖。

这不是用轮询创建资源,而是为无法直接归属到单个 SubPool 的共享状态提供保守复查。

predicate 为什么过滤 status-only 更新

Controller 更新 SubPool status 本身也会产生对象更新事件。如果每次 status 更新都再次触发 reconcile,可能形成无意义循环。

项目的 predicate 只在以下根对象变化时继续:

  • 新建或删除事件;
  • metadata.generation 变化,也就是 spec 更新;
  • 首次出现 deletionTimestamp。

单纯 status 更新不会触发新的根 reconcile。这能降低 API Server 和 work queue 噪声。

一次 reconcile 的固定顺序

活动 SubPool 的流程可以概括为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
1. 读取并校验 immutable Operator Configuration
2. 校验 Gateway 模式配置是否完整
3. 校验 SubPool ID、镜像、资源、存储与资源包络
4. 构造全部期望对象
5. 创建或确认 Namespace 所有权
6. 创建或确认 Credential Secret
7. 清理本 Pool 所有的旧版 LimitRange
8. 创建或修正其余受管对象
9. 根据 Internal/Gateway/suspend 删除或保留 HTTPRoute
10. 读取实际状态并更新 Conditions

先校验再创建很重要。无效请求不会留下 Namespace、PVC 或 Secret 等持久半成品。

Reconcile 必须幂等

幂等表示:对同样的期望和实际状态重复执行,最终结果不变,也不会每次制造新资源。

项目的 ensureManagedObject 使用这种模式:

1
2
3
4
5
6
Get 当前对象
  ├─ 不存在 → Create
  └─ 已存在
       ├─ owner 不匹配 → 报冲突,不接管
       ├─ 已等于期望 → 什么也不做
       └─ 有受控字段漂移 → Update

这比“每次先删再建”安全得多。Service 的 ClusterIP 等 API Server 分配字段必须保留,不能拿一份新对象整体覆盖。

只管理明确拥有的字段

Controller 同步稳定 labels 和 owner references,并按 Kind 更新特定 spec。

以 Service 为例,它只同步:

  • type;
  • ports;
  • selector。

ClusterIP、IP family 等由 API Server 分配或默认的字段被保留。

Metadata 中的额外 labels 也被保留,只覆盖 Operator 自己期望的键。这让其他合法系统可以添加自己的 label,而不会被每轮 reconcile 清掉。

“SubPool 是唯一期望来源”不意味着粗暴覆盖对象所有字节,而是要明确 controller 对哪些字段拥有写入权。

所有权冲突为什么必须阻塞

若同名对象已存在但不由当前 SubPool 控制,项目不会自动加 owner reference。否则可能出现:

1
2
3
4
别人创建了 subpool-demo-pool Namespace
  → Operator 只因名字相同就接管
  → 用户删除 demo-pool
  → Operator 删除别人的 Namespace 和数据

因此 Namespace、Secret 和其他受管资源都会检查 metav1.IsControlledBy。Namespace 还额外检查 managed label 和 Pool label。

冲突会写入稳定 reason,例如 NamespaceConflictCredentialConflictManagedResourceConflict

Finalizer:删除前的待办清单

SubPool 使用:

1
subrelay.example.com/finalizer

正常活动对象先加入 finalizer。用户执行 delete 后,API Server 设置 deletionTimestamp,但在 finalizer 清空前保留对象。

Operator 删除路径:

1
2
3
4
5
6
7
看到 deletionTimestamp
  → 计算派生 Namespace
  → Namespace 不存在:移除 finalizer
  → Namespace 存在且属于当前 SubPool:请求删除 Namespace
  → 等 Namespace 及内部资源完成删除
  → Namespace 消失后移除 finalizer
  → SubPool 最终从 API 消失

如果同名 Namespace 不属于当前 SubPool,Operator 不删除它,只移除自己的 finalizer。这保持所有权边界。

为什么不要手工移除 finalizer

直接移除 finalizer 会让 SubPool 消失,但它负责清理的 Namespace 可能继续存在,形成孤儿资源、PVC 和 Secret。

若删除卡住,应检查:

  • DeletionBlocked 的 reason 和 message;
  • Namespace 的 finalizers;
  • PVC/PV 与 CSI 状态;
  • 第三方 controller 是否还在运行;
  • API 删除是否收到 Forbidden 或其他错误。

修复负责收尾的依赖,比强行撕掉“待办清单”更安全。

suspend 是期望状态,不是删除流程

spec.suspend=true 时:

  • StatefulSet replicas 改为 0;
  • HTTPRoute 删除;
  • Namespace、PVC 和 Secret 保留;
  • Conditions 明确使用 Suspended
  • ProxyGatewayReady 设为 Unknown,因为暂停期间不需要评估代理。

这说明 Operator API 不只有 CRUD。一个业务资源可以拥有清晰的生命周期状态,而不需要通过手工改底层对象实现暂停。

Status 更新也要避免噪声

Condition 只有在 status、reason、message 或 observedGeneration 变化时才替换,并保留未变化条件的 lastTransitionTime。

如果每 30 秒 reconcile 都刷新时间,会让监控以为状态持续变化,也产生大量无意义 API 写入。稳定的 transition time 才能回答“这个问题从什么时候开始”。

阅读 Operator 代码的推荐路线

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
api/v1alpha1/subpool_types.go
  → 先看 API 契约

controllers/subpool_controller.go
  → 再看 Reconcile 入口、watch、finalizer

controllers/subpool_reconcile.go
  → 看活动路径的顺序

internal/subpool/resources.go
internal/subpool/workloads.go
internal/subpool/network.go
  → 看期望对象怎样构造

controllers/subpool_readiness.go
controllers/subpool_status.go
  → 看现实状态怎样投影回 CR

Operator 并不是 Kubernetes 的魔法插件。它只是一个认真遵守幂等、所有权和异步状态语义的程序:不断比较愿望与现实,并把差距一点点收敛掉。

使用 Hugo 构建
主题 StackJimmy 设计