AI时代的Kubernetes教程36招-第23招-CRD、Custom Controller和Operator怎么把运维知识变成平台能力?
Kubernetes 扩展能力来自 API:从 DigitalHumanProvider 到 Operator 的边界判断
CRD 十分钟能写完,边界判断可能要一个季度。
[读者] 维护内部平台、准备把运维动作搬进 Kubernetes 的工程师
[痛点] ConfigMap 加定时脚本同步外部系统,删不干净、改不生效、状态看不见
[现在读] 团队要把数字人供应商的接入改造成 CRD 加控制器
[读完] 能交付一个可用的 V1,并说清哪些能力值得 Operator 化
[旧方案] ConfigMap 存供应商配置,CronJob 定时轮询同步
|
v
[新需求] 供应商要在线上线、要暴露健康状态、要被平台统一编排
|
v
[冲突] ConfigMap 只有声明没有收敛,脚本只有动作没有状态
|
v
[后果] 资源删了外部还在,状态看不见,故障只能人工对账
我是老李,在一家做数字人内容生产的公司带平台工程组。我们内部有一个叫 Studio 的平台,编排从脚本生成、形象渲染到口型驱动的整条生产链路。
链路里最关键的一环是「供应商」。文字转语音、形象渲染、口型驱动,这些能力我们都不自研,全部接第三方,一家一个供应商。平台的活儿,本质就是把这些供应商管好。
到今年年初,供应商从 2 家变成了 7 家,接入、下线、改地域、查健康,全部由平台组三个人手工做。我们决定把这块搬进 Kubernetes。
一个声明式 API,到底比一份 ConfigMap 多给了什么?
当控制器写坏状态、外部资源又删不掉的时候,责任到底落在哪一层?
平台里那么多能力,哪些真的值得做成 Operator?
01、故事
最初的方案很朴素:一个 ConfigMap 存所有供应商的连接信息,一个 CronJob 每五分钟跑一次 Python 脚本,读 ConfigMap,依次调供应商的 OpenAPI 做注册、探活,再把结果写回另一个 ConfigMap 当「状态」。
这套东西跑了两年,够用。供应商少,改一次配置重启一下脚本就行。
变化来自一次组织调整。上层内容中台要在每次直播开播前动态挑选供应商:哪个地域的实例还活着、哪个供应商今天限流了、哪个已经欠费——这些信息要在十秒内被上游感知。
任务是:把「供应商」从一个配置项,变成平台里一个可查询、可收敛、可回收的对象。
我们第一反应是继续加脚本。给 CronJob 加探活频率,给状态 ConfigMap 加几个字段。改到第三版的时候,我们发现脚本要做的事情已经变成:比较期望、比较实际、补齐差异、记录状态、清理残留、处理并发。
这六件事,正好就是控制器在做的事。我们其实是在用 Python 手搓一个劣质版的 controller-runtime。
配置只是声明
脚本只会动作
状态没有归处
故障只好对账
02、问题
旧方案失效的地方,一条条列出来。
第一,声明和执行是断开的。ConfigMap 改了,脚本还不知道,要等下一个五分钟。中间这段时间里,平台认为供应商已经切过去了,实际没有。
第二,状态和对象是分开的。供应商实例活在外部世界,状态写在一个 ConfigMap 里,两者之间没有任何机制保证一致。脚本崩一次,状态就永久错一次。
第三,删除没有语义。ConfigMap 里删掉一行,脚本分不清「这个供应商被下线了」和「这个供应商从来没过」,外部实例永远残留。
第四,没有并发保护。CronJob 的 concurrencyPolicy: Forbid 只能防同一份 CronJob 内部并发,两个集群同时跑,或者有人手动触发一次,就会重复创建。
第五,没有归属关系。一个外部实例是谁创建的、应该跟着谁一起消失,完全没有记录,清理只能靠人去翻账单。
业务影响很具体:直播开播前 15 分钟,导演组发现某个地域的供应商其实早就不可用了,而状态 ConfigMap 里显示的还是 40 分钟前的健康数据。
技术表现同样具体:脚本本身没有任何失败重试之外的收敛能力,它只会在下一次整点再跑一遍,把同样的错误再犯一次。
所以这一轮我们给自己定了可验证的完成标准:
kubectl apply一个 DigitalHumanProvider,外部供应商实例被创建;- 对象的 status 中能看到 Ready 条件与 providerID,且 observedGeneration 跟得上 metadata.generation;
kubectl delete之后外部实例被清理,对象彻底消失,不残留 Terminating;- 控制器重启、事件丢失,都不影响最终一致;
- 同一份输入重复 Reconcile 不产生任何副作用。
删不净的残留
看不见的健康
改不动的期望
说不清的责任
03、原理
Kubernetes 的扩展能力,全部来自一件事:apiserver 是一个可以被扩展的、声明式的、带版本的对象存储。
CRD 做的事情,是把一份自定义 schema 注册进 apiserver,让它在存储层和 Deployment、Service 一样拥有:etcd 存储、版本升降级、watch 通道、OpenAPI 校验、子资源、打印列。
所以 CRD 给出来的从来不是「自动化」,而是「一个可以被声明、被观测的对象」。自动化是控制器给的,是另一件事。
控制器模式的官方描述很克制:控制器至少跟踪一种 Kubernetes 资源类型,持续观察对象的期望状态与实际状态,并尝试让实际状态向期望状态靠拢。这句话里最关键的是「持续」和「靠拢」,而不是「事件」。
这就是第一个反直觉判断:控制器最重要的能力不是处理事件,而是随时可以被要求重新收敛。事件只是提醒你去读一次当前状态;即便所有事件全部丢失,只要循环还在转,最终结果依然正确。
这条判断直接决定了 Reconcile 里能写什么、不能写什么。不能写「创建成功后计数加一」这种增量逻辑,因为事件可能重放;只能写「读一遍全量实际状态,算差异,执行让差异消失的最小动作」。这就是 Reconcile 必须幂等的原因,不是为了好看。
第二个概念是 spec 与 status 的分离。spec 是用户写的期望,status 是控制器写的观测。两者存在同一个对象里,但语义归属完全不同。
CRD 的 subresources.status 是在 apiserver 层面把这两者切成两个写入口:写 status 不会碰 spec,写 spec 不会改变 status 的语义。
这里有第二个反直觉判断:没有 status 子资源的时候,控制器写状态会变成一次全对象写入,它读到什么就写回什么。用户在控制器读取和写回之间的那几百毫秒里改掉的 spec,会被控制器手里的旧副本静默覆盖。这不是并发 bug 的边角料,这是默认行为。
第三个概念是 finalizer。删除不是「删一下就没了」,而是「标记删除 → 控制器收尾 → 摘掉标记 → 真正删除」。metadata.deletionTimestamp 一旦被设置,对象就进入只读待删状态,控制器有机会在它消失前把外部世界收拾干净。
第四个概念是 OwnerReference。它做的事是让垃圾回收器知道归属关系:谁是谁的下属,上级消失时下级该不该一起消失。控制器创建的每一个下游对象,都应该想清楚它的 owner 是谁。
期望写进存储
观测写进状态
事件可以丢失
收敛不能缺席
04、架构
[输入] DigitalHumanProvider CR(平台或用户写入 spec)
|
v
[模块] DigitalHumanProviderReconciler(controller-runtime Manager 驱动)
|
v
[数据/状态] .metadata.finalizers / .status.conditions / .status.providerID
|
v
[处理] 读 spec → 调供应商 OpenAPI 做 Ensure → 回写 status
|
v
[输出] 外部供应商实例 + 平台可读的 Ready 条件
这张图的边界很清楚。左侧只认一个入口:CR 的 spec。右侧只产出两样东西:外部世界里被收敛过的实例,以及对象上可被查询的 status。中间不允许有隐藏通道,比如「脚本顺手改了一下别的地方」。
收益有三条。第一,期望态有了唯一的、带校验的落点,schema 挡掉大部分错误输入。第二,实际态有了唯一的、带条件语义的出口,上游可以按 condition 判断,而不是解析一段自由文本。第三,删除有了明确的两阶段语义,清理逻辑有地方落脚。
代价也很清楚。控制器本身成为一个需要被部署、被监控、被升级的组件;RBAC 需要精细到资源级别;CRD 的版本演进从此变成一件需要认真对待的事,因为集群里可能已经存了 v1alpha1 的对象。
适用条件只有两条,但两条都很硬。第一,这个能力的期望态可以被明确写下来,而不是「看情况」。第二,从期望态到实际态的动作可以幂等执行,或者说重复执行不会造成额外副作用。
只要这两条里有一条不成立,就不该做控制器。比如「按当日流量人工判断该不该扩容」这种带主观判断的动作,硬编码进 Reconcile 只会制造一个更难维护的黑盒。
一份声明入口
一个循环心跳
一处状态出口
一条清理通道
05、实战一次
环境与版本,以下都为撰写时的常见组合,具体版本以当前官方文档核验为准:
go version go1.22.x
kubebuilder version: 4.x
Kubernetes 1.29+(kind 或任一托管集群均可)
kubectl v1.29+
初始化工程,依赖就是 Kubebuilder 自带的那套脚手架,不需要额外引入框架:
kubebuilder init --domain example.com --repo example.com/dhp
kubebuilder create api --group dhp --version v1alpha1 --kind DigitalHumanProvider
第一条命令生成 Manager 入口和 Scheme 注册骨架;第二条生成 API 类型与一个空的 Reconciler,并在 main.go 里把 Scheme 和 Controller 都接好。
接下来是核心类型。注意三个标记:subresource:status 开启状态子资源,两条 printcolumn 让 kubectl get 直接可读:
// api/v1alpha1/digitalhumanprovider_types.go
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// DigitalHumanProviderSpec 描述期望接入的外部数字人供应商。
type DigitalHumanProviderSpec struct {
// Endpoint 是供应商 OpenAPI 的接入地址。
// +kubebuilder:validation:Pattern=`^https://`
Endpoint string `json:"endpoint"`
// Region 是供应商侧的资源地域。
// +kubebuilder:validation:MinLength=1
Region string `json:"region"`
// DisplayName 用于平台展示。
DisplayName string `json:"displayName,omitempty"`
}
// DigitalHumanProviderStatus 描述控制器观测到的实际状态。
type DigitalHumanProviderStatus struct {
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
ProviderID string `json:"providerID,omitempty"`
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Region",type=string,JSONPath=`.spec.region`
// +kubebuilder:printcolumn:name="ProviderID",type=string,JSONPath=`.status.providerID`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
type DigitalHumanProvider struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec DigitalHumanProviderSpec `json:"spec,omitempty"`
Status DigitalHumanProviderStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
type DigitalHumanProviderList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []DigitalHumanProvider `json:"items"`
}
func init() {
SchemeBuilder.Register(&DigitalHumanProvider{}, &DigitalHumanProviderList{})
}
SchemeBuilder.Register 这一行是 Scheme 的入口。Scheme 是「Go 类型 ↔ apiVersion/Kind」的映射表,控制器要能根据 watch 到的对象反序列化成正确类型,靠的就是它。
下面是 Reconcile 的完整实现。三条路径:删除、首次挂 finalizer、收敛。
// internal/controller/digitalhumanprovider_controller.go
package controller
import (
"context"
"errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
dhpv1 "example.com/dhp/api/v1alpha1"
)
const providerFinalizer = "dhp.example.com/provider-cleanup"
// ErrProviderNotFound 表示供应商侧实例不存在。
var ErrProviderNotFound = errors.New("provider instance not found")
// ProviderAPI 封装外部供应商 OpenAPI。
type ProviderAPI interface {
Ensure(ctx context.Context, spec dhpv1.DigitalHumanProviderSpec) (string, error)
Delete(ctx context.Context, providerID string) error
}
type DigitalHumanProviderReconciler struct {
client.Client
API ProviderAPI
}
func (r *DigitalHumanProviderReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var dhp dhpv1.DigitalHumanProvider
if err := r.Get(ctx, req.NamespacedName, &dhp); err != nil {
// 对象已被删除,这不是错误。
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// 路径一:删除中。先清外部,再摘 finalizer。
if !dhp.DeletionTimestamp.IsZero() {
if !controllerutil.ContainsFinalizer(&dhp, providerFinalizer) {
return ctrl.Result{}, nil
}
if err := r.API.Delete(ctx, dhp.Status.ProviderID); err != nil && !errors.Is(err, ErrProviderNotFound) {
return ctrl.Result{}, err
}
// 外部实例已经不存在,同样视为清理完成。
patch := client.MergeFrom(dhp.DeepCopy())
controllerutil.RemoveFinalizer(&dhp, providerFinalizer)
return ctrl.Result{}, r.Patch(ctx, &dhp, patch)
}
// 路径二:首次进入,先挂 finalizer,再干别的。
if !controllerutil.ContainsFinalizer(&dhp, providerFinalizer) {
patch := client.MergeFrom(dhp.DeepCopy())
controllerutil.AddFinalizer(&dhp, providerFinalizer)
return ctrl.Result{}, r.Patch(ctx, &dhp, patch)
}
// 路径三:收敛。Ensure 必须幂等:先查,不存在才创建。
providerID, err := r.API.Ensure(ctx, dhp.Spec)
statusPatch := client.MergeFrom(dhp.DeepCopy())
dhp.Status.ObservedGeneration = dhp.Generation
if err != nil {
meta.SetStatusCondition(&dhp.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionFalse,
ObservedGeneration: dhp.Generation,
Reason: "EnsureFailed",
Message: err.Error(),
LastTransitionTime: metav1.Now(),
})
if perr := r.Status().Patch(ctx, &dhp, statusPatch); perr != nil {
return ctrl.Result{}, perr
}
return ctrl.Result{}, err
}
dhp.Status.ProviderID = providerID
meta.SetStatusCondition(&dhp.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionTrue,
ObservedGeneration: dhp.Generation,
Reason: "ProviderReady",
Message: "供应商实例可用",
LastTransitionTime: metav1.Now(),
})
if err := r.Status().Patch(ctx, &dhp, statusPatch); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func (r *DigitalHumanProviderReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&dhpv1.DigitalHumanProvider{}).
Complete(r)
}
启动。make install 把 CRD 装进集群,make run 在本地把控制器跑起来连集群:
make install
make run
准备一份最小样例,只填必填字段:
apiVersion: dhp.example.com/v1alpha1
kind: DigitalHumanProvider
metadata:
name: aliyun-tts
spec:
endpoint: https://tts.example.com/openapi
region: cn-hangzhou
displayName: 杭州文字转语音
kubectl apply -f config/samples/dhp_v1alpha1_digitalhumanprovider.yaml
kubectl get dhp
未在当前环境实测,以下为预期结果,示例输出:
NAME REGION PROVIDERID READY
aliyun-tts cn-hangzhou prvd-7f3c91ab2d True
再验证一次幂等:连续执行三次 kubectl annotate dhp aliyun-tts touch=$(date +%s) --overwrite,触发三次 Reconcile,观察供应商侧实例数量是否仍然只有一个,observedGeneration 是否等于 metadata.generation。这一步会留下后面两个问题:删不干净,和 spec 被覆盖。
先跑通最小环
再验证幂等性
状态回写成功
删除不再残留
06、排查
诊断链一:删除卡在 Terminating。
现象:kubectl delete dhp aliyun-tts 之后,对象一直显示 Terminating,几分钟都不消失,供应商控制台里实例还在。
怀疑:finalizer 没有被摘掉。
检查:
kubectl get dhp aliyun-tts -o jsonpath='{.metadata.deletionTimestamp}{"\n"}{.metadata.finalizers}{"\n"}'
kubectl logs deploy/dhp-controller-manager --tail=100
证据:日志里反复出现 reconcile error: provider instance not found,而 deletionTimestamp 一直有值,finalizers 一直包含 dhp.example.com/provider-cleanup。
根因:Delete 调用返回的不是 nil,也不是我们定义的 ErrProviderNotFound,而是一个没有包装过的 404 错误。代码里的 errors.Is(err, ErrProviderNotFound) 永远为 false,于是每次 Reconcile 都在删除分支直接 return err,永远走不到摘 finalizer 那一行。
修复:在 ProviderAPI.Delete 的实现里,把供应商返回的 404 显式映射成 ErrProviderNotFound,让「外部已经不存在」变成一种正常完成态,而不是失败态。
错误尝试: 第一反应是怀疑 finalizer 的名字配错了,于是直接 kubectl patch dhp aliyun-tts -p '{"metadata":{"finalizers":null}}' --type=merge 手动把 finalizer 摘掉。对象确实立刻消失了,但这么做有两个后果:第一,外部实例依然活着,平台账面上少了一个供应商但对端还在计费;第二,下次线上真出故障时,你会失去「控制器是否被正确触发」这条唯一的观测线索。手动摘 finalizer 是绕过机制,不是修复机制。
诊断链二:用户的 spec 被改回去。
现象:运营在平台上把 spec.endpoint 从 https://tts-a.example.com/openapi 改成 https://tts-b.example.com/openapi,几秒后又变回了旧值。
怀疑:有人覆盖了 spec。但平台组只有三个人,谁都没动。
检查:
kubectl get crd digitalhumanproviders.dhp.example.com -o jsonpath='{.spec.versions[*].subresources}{"\n"}'
kubectl get dhp aliyun-tts -w -o jsonpath='{.metadata.generation}{"\t"}{.status.observedGeneration}{"\n"}'
证据:第一条命令输出为空,说明 CRD 里根本没有开启 status 子资源;第二条显示 generation 和 observedGeneration 都在随着每次修改往上涨,说明每次 status 写回都算作一次完整的对象写入。
根因:Reconcile 结束时用的是 r.Update(ctx, &dhp) 写入整个对象,而这个 dhp 是 Reconcile 开头 Get 到的那一份副本。用户在这中间改掉的 spec 被旧副本覆盖。因为没有 status 子资源,写 status 和写 spec 走的是同一个写入口,apiserver 无法替我们隔离。
修复:一是给 CRD 加 +kubebuilder:subresource:status,把两个写入口切开;二是写入时不再用 Update 全量覆盖,而是用 client.MergeFrom 生成一个只包含实际差异的 patch。
现象先被记录
怀疑逐个排除
证据落在日志
根因写进结论
07、优化
V2 的每一条修改,都直接对应第 06 章里拿到的那条证据,没有一条是「顺手改的」。
根因一:外部 404 被当成失败。修改方式是在 ProviderAPI.Delete 的实现里把供应商的 404 显式翻译成 ErrProviderNotFound,并在 Reconcile 里把 errors.Is(err, ErrProviderNotFound) 当作正常完成。原因很简单:对控制器而言,「目标已经不存在」和「目标已经被删除」在收敛意义上是同一件事。新行为是删除路径不再因为对端残留状态而卡死,Delete 幂等。验证方式是构造一个外部实例已被手工删除的 DHP,执行删除,断言对象在数秒内消失且不再出现 Terminating。
根因二:没有 status 子资源,status 写入污染 spec。修改方式有两处:类型上补 +kubebuilder:subresource:status,代码里全部改用 r.Status().Patch。原因是 status 是控制器的私有输出,它不该有权限改动用户的输入。新行为是 spec 的写入和 status 的写入互不干扰,kubectl get dhp -w 时可以看到 generation 只在 spec 变化时增长,observedGeneration 只在控制器完成一次收敛后跟上。验证方式是在 envtest 里起一个真实 apiserver,并发执行一次 spec 修改和一次 Reconcile,断言 spec 未被回退。
根因三:全量 Update 造成无效写入。修改方式是统一使用 client.MergeFrom 差异 patch。原因是即使内容相同,全量写入也会携带完整对象,覆盖窗口更大;而 patch 只发送真实差异,冲突面小得多。新行为是同一次 Reconcile 若 status 未发生实质变化,apiserver 收到的是一份空 patch,不产生新的 resourceVersion 语义。验证方式是对同一个对象连续触发五次 Reconcile,观察 metadata.resourceVersion 与 objects 的事件流。
除根因修复之外,还补了两条防御。第一条是 Ensure 严格幂等:先 Describe 一次,如果实例已存在且参数一致,直接返回已有 providerID,不调用创建接口。第二条是在 status 里写 observedGeneration,让「控制器是否已经消化了当前这版 spec」变成可判定的,而不是靠时间猜。
这三条修改都不引入新的外部依赖,也不需要改 CRD 的 group/version,只影响控制器行为和 CRD 的一个子资源开关。
改的是根因处
不是症状表面
一次只改一处
改完必须复验
08、演进
[同一输入:一份供应商接入声明]
|
+--[V1] ConfigMap + CronJob
| 期望态:ConfigMap 里的一行数据
| 实际态:脚本回写的状态 ConfigMap
| 代价:没有回收语义、没有并发保护、状态最长滞后一个调度周期
|
+--[V2] CRD + Controller
| 期望态:spec(带 schema 校验)
| 实际态:status(带 conditions 与 observedGeneration)
| 代价:控制器本身要部署、要 RBAC、要监控、要跟着版本升级
|
[Trade-off]
得到:声明式收敛、两阶段删除、可观测状态、事件驱动的秒级响应
失去:不再有「改个配置就行」的轻量感,控制器本身成为一个新的运维对象
边界:只有当期望态能被明确定义、且收敛动作可以幂等执行时,这套东西才划算
正确性上,V1 是「尽力而为」,脚本跑成功一次算一次;V2 是「最终一致」,任何时刻重启都能从当前状态重新收敛。这是两者最本质的差别。
稳定性上,V1 的故障面是脚本本身,重启即恢复;V2 的故障面多了 apiserver 的写限流、webhook 可用性、控制器的 leader election。控制器不会让你的系统更稳,它只是把不稳定从一个地方挪到了另一个地方,同时让这个新的地方可被观测。
复杂度上,V1 是一个 Python 文件;V2 是一个 Go 工程加一套 CRD 版本策略。如果团队里没有人愿意长期维护这个控制器,V2 的复杂度就是净亏损。
成本上,V1 只是几个 CronJob 的资源消耗;V2 多了一组常驻 Pod、一组 RBAC、一套 CRD,还要考虑 CRD 装不上时的降级路径。这些成本不高,但它是持续的。
适用范围上,V1 适合供应商数量少、变更低频、失败可以人工兜底的阶段;V2 适合供应商数量多、状态需要被上游实时消费、失败会直接影响在线业务的阶段。我们现在处在后者。
遗留问题有两个,都很明确:一是外部供应商实例如果被人为在控制台改坏,控制器只会在下一次 Reconcile 时纠正,中间存在窗口;二是 CRD 从 v1alpha1 升到 v1 需要 conversion,这个话题本文不展开,但必须在做之前想清楚。
旧解成本很低
新解责任更重
所得并非免费
取舍写在边界
09、洞见
9.1 CRD 给的是 API,不是自动化
把自定义资源写进集群,只意味着你多了一种可以被声明、被校验、被 watch 的对象。它不会替你创建任何外部资源。很多人第一次做 CRD 会觉得「我声明了它怎么没动」,原因就是只注册了类型,没有写控制器。API 和自动化是两件必须分开评估的事,前者是存储与契约,后者是行为与责任。
9.2 反直觉判断:控制器不保证顺序,只保证收敛
直觉上我们会希望控制器「按顺序做对每一步」。但控制器模式真正提供的保证是:给定当前的实际状态和期望状态,它能算出下一步该做什么。这意味着同一份状态可能被处理多次,可能被并发处理,甚至可能在处理过程中对象已经变了。所以 Reconcile 的第一原则是幂等,第二原则是从当前状态重新计算,而不是从上一次计算的结果继续。把所有「增量语义」从 Reconcile 里删掉,是这套模式的门槛。
9.3 反直觉判断:status 子资源是并发边界,不是可选装饰
大多数人把 subresources.status 当成一个让 kubectl get -o yaml 更好看的开关。它真正的作用是在 apiserver 层面把「用户写」和「控制器写」切成两个入口。没有它,控制器写状态就是一次全对象写入,你在代码里 Get 到的那份旧副本会覆盖用户在这之间做的修改。这不是极端并发场景下的偶发问题,这是默认行为下的必然结果。
9.4 Finalizer 把删除变成了两阶段提交
deletionTimestamp 一旦被设置,对象就进入待删状态并停止接受常规更新,控制器在这一刻获得最后一次收尾机会。理解这一点之后,写 finalizer 的代码会自然变成「先确认外部已清理,再摘标记」,而不是「删完之后再想办法补救」。同时它带来一个必须接受的代价:任何 finalizer 逻辑的缺陷,都会表现为资源永久无法删除,而不是一次性的报错。
9.5 值得做 Operator 的四个判断信号
第一,存在一个可以被写下来的期望态,且它是用户输入而不是控制器推断的。第二,从期望态到实际态的动作可以幂等重放,重复执行不产生额外副作用。第三,这个能力背后有长尾的领域知识,比如配额计算、多阶段就绪判断、跨系统对账,这些知识写死在脚本里会迅速腐化。第四,失败会直接影响在线业务,需要秒级感知而不是小时级对账。四个信号同时成立,做 Operator 是划算的;只成立前两个,一个 Job 可能就够了。
事件只是提醒
收敛才是答案
状态划清并发
删除分成两步
10、系统落地
原来我们有的是:一份供应商配置 ConfigMap、一个五分钟一次的 CronJob、一个由脚本写出来的状态 ConfigMap,以及一套靠人维护的对比表格。
本篇新增的是:一个 DigitalHumanProvider CRD、一个控制器、一套带 conditions 的 status 契约,以及删除时的两阶段清理路径。控制器只做三件事,读 spec、对账外部、写 status,不做任何业务判断。
现在能做的事情是具体的:平台可以通过 kubectl get dhp 直接看到所有供应商的 Ready 状态;上游内容中台可以 watch 这个资源类型,在供应商不可用时第一时间切换;下线一个供应商从「发消息给供应商 + 手工去控制台删 + 更新表格」变成一次 kubectl delete。
还缺的东西同样具体。第一是配额与计量,spec 里目前没有并发上限、调用额度这类字段,也就没有对应的收敛逻辑。第二是多集群分发,现在每个集群各管各的,缺少一个上层的汇总视图。第三是 CRD 的版本演进策略,v1alpha1 迟早要动。第四是控制器自身的可观测性,目前只有日志,没有把 Reconcile 的次数、失败率、耗时暴露成指标。
下一步的演进顺序,我倾向于先把控制器的指标补上,再做配额,最后才考虑版本升级。理由是:配额是对账逻辑的延伸,风险可控;而版本升级会动到已经落库的对象,一旦做错,回滚代价远高于前两者。
原来只有配置
现在有了状态
还缺计量配额
下一步再接续
11、小结
Q1 → 声明式 API 提供的是可校验、可观测、可收敛的对象;ConfigMap 只提供了存储
Q2 → 责任由归属决定:控制器只对 status 和收尾负责,spec 永远属于用户
Q3 → 判据是期望态可定义、动作可幂等、知识有长尾、失败影响在线业务
状态 → 一个跑通创建、收敛、观测、回收全链路的 DigitalHumanProvider
这一轮真正学到的不是怎么写 CRD,而是怎么在写之前先回答「这件事的期望态是什么」。如果答不上来,写出来的控制器只会变成一个有 API 的脚本。
一问落在接口
二问落在责任
三问落在边界
状态落在交付
12、作业
12.1 理解题:Reconcile 里为什么必须处理 client.IgnoreNotFound(err)?
参考答案:Reconcile 的触发来自队列,队列里的 key 可能在控制器取出之前对应的对象就已经被删掉了。此时 Get 返回 NotFound,这是正常情况而不是异常。如果不忽略,控制器会持续报错并重试一个永远不会存在的对象。正确做法是忽略 NotFound 并返回成功,让这次 Reconcile 干净结束。
12.2 实战题:给 DigitalHumanProvider 增加一条 Ready=False 的 status 路径,并验证最终一致。
参考答案:在 Ensure 返回错误时用 meta.SetStatusCondition 写入 Ready=False 和 Reason=EnsureFailed,同时写入 ObservedGeneration,然后 Status().Patch 回写。验证时把 spec.endpoint 改成一个不可达地址,用 kubectl get dhp -w 观察 conditions 从 True 翻成 False;再改回可达地址,观察它翻回 True。断言点是每次翻转都伴随 observedGeneration 追上 metadata.generation。
12.3 排障题:某个 DHP 删除后卡在 Terminating,外部实例已经不存在,如何定位并修复?
参考答案:先看 metadata.finalizers 是否有值,再看控制器日志里的删除分支报了什么错。如果外部实例确实已不存在而代码仍在报错,说明删除接口返回的错误没有被正确映射成「已不存在」这一正常完成态。修复方向是在外部 API 封装层把 404 翻译成哨兵错误,并在 Reconcile 里把它当作成功处理。注意不要用 kubectl patch 手动摘 finalizer,那会绕过清理逻辑并掩盖真正的缺陷。
12.4 架构判断题:平台想加一个「当某供应商当日调用量达到阈值就自动新建实例」的能力,值得做 Operator 吗?
参考答案:先看四个信号。期望态是否可明确定义:可以,但阈值来自业务指标而不是用户输入,属于控制器推断,需要额外引入指标来源和防风控逻辑。动作是否可幂等:可以,Ensure 天然幂等。知识是否有长尾:有,容量模型和成本约束属于领域知识。失败是否影响在线:是。四个信号看似都成立,但真正的风险点在第一条之外的隐含条件:自动新建实例意味着系统能自主产生成本,这需要一个明确的人工闸门,例如把控制器降级为「只输出建议,写进 status.conditions」,由人决定是否扩容。因此更合理的形态是先做观测与控制建议,等容量模型稳定后再放开自动执行。
理解先摸语义
实战再走一遍
排障回到证据
判断留在取舍
13、思考
这一轮改造的核心冲突,从头到尾只有一个:我们想用一个声明式对象去表达一件发生在集群外部的事情。
CRD 本身解决不了这个问题,它只负责把这个对象存好、校验好、广播好。真正让冲突落地的,是控制器里那三个决定:期望态写在哪里、实际态写在哪里、两者之间的差异由谁负责消除。
写到这里我得到一个可以复用的判断:当你在犹豫某个能力该不该做 Operator 时,不要问「它复不复杂」,要问「它的期望态能不能被一行 spec 写清楚」。能写清楚,控制器就只是一个收敛循环;写不清楚,再精巧的框架也只是把人的判断藏进了代码里。
声明不是目的
收敛才是能力
边界决定取舍
取舍决定长期

浙公网安备 33010602011771号