背景

线上某控制器负责管理 WorkItem 资源。删除流程里偶发出现:

  • 日志显示已经把状态写成 Deleting
  • 紧接着同一轮 reconcile 又读到旧状态 Failed
  • 状态机落入 default 分支,提前 return nil
  • 在特定 predicate 组合下,不会被 status 变更再次触发,最终表现为“删除卡死”

现象与日志

下面是一次典型时间线(省略无关字段):

1
2
3
10:14:07.447 entering delete flow, setting phase to Deleting
10:14:07.460 delete admission check passed
10:14:07.460 unexpected phase during deletion: Failed

注意第三行和第一行间隔只有十几毫秒。

关键代码路径

删除状态机(简化后)大致是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (r *Controller) reconcileDelete(ctx context.Context, item *v1.WorkItem) (ctrl.Result, error) {
if item.Status.Phase != PhaseDeleting && item.Status.Phase != PhaseDeleted {
if err := r.updateStatus(ctx, item, PhaseDeleting); err != nil {
return ctrl.Result{}, err
}
}

if err := r.syncDryRunCondition(ctx, item); err != nil {
return ctrl.Result{}, err
}

switch item.Status.Phase {
case PhaseDeleting:
return r.executeUninstall(ctx, item)
case PhaseDeleted:
return ctrl.Result{}, nil
default:
return ctrl.Result{}, nil
}
}

updateStatus 内部采用了 RetryOnConflict,每轮会先 Get 再 patch:

1
2
3
4
5
6
7
8
9
10
11
func (r *Controller) updateStatus(ctx context.Context, item *v1.WorkItem, phase Phase) error {
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
if err := r.Get(ctx, client.ObjectKeyFromObject(item), item); err != nil {
return err
}

before := item.DeepCopy()
item.Status.Phase = phase
return r.Status().Patch(ctx, item, client.MergeFrom(before))
})
}

根因分析

问题不是“patch 失败”,而是同一轮 reconcile 内对象指针被二次覆盖

时序如下:

  1. updateStatus(..., PhaseDeleting) 执行成功,内存对象 item.Status.Phase = Deleting
  2. 紧接着进入 syncDryRunCondition(...)
  3. 该函数内部再次调用通用 updateStatus/update 逻辑
  4. 逻辑开头的 r.Get(ctx, key, item) 会把当前 item 指针覆盖为 informer cache 的快照
  5. 在高负载下,cache 还没及时消费到刚才 Deleting 的 watch event,返回旧值 Failed
  6. switch item.Status.Phase 命中 default,提前 return nil

核心点:

  • controller 只能写 API server,不会直接写 informer cache
  • Status().Patch 成功返回,不代表本地 cache 已经完成更新
  • 请求量暴涨时,watch 传播与 informer 队列延迟会显著放大这个窗口

为什么之前不明显

该竞态窗口一直存在,但在低负载时窗口很窄,通常“撞不上”。

当以下条件叠加时会明显暴露:

  • reconcile 请求量突增(例如 30 倍)
  • API server 与 informer 处理延迟升高
  • 同一轮 reconcile 内多次复用同一个对象指针,并且每次更新前都 Get
  • 状态机 default 分支直接 return nil

修复方案

1) 兜底修复:default 分支固定 RequeueAfter

先避免“无事件永久沉默”:

1
2
3
default:
cfg := r.ConfigManager.Get()
return ctrl.Result{RequeueAfter: cfg.SyncStatusCheckInterval}, nil

这能把“卡死”降级为“延迟恢复”。

2) 根因修复:用局部变量固定有效 phase

不要在 switch 里依赖后续可能被 Get 覆盖的对象字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
effectivePhase := item.Status.Phase
if effectivePhase != PhaseDeleting && effectivePhase != PhaseDeleted {
if err := r.updateStatus(ctx, item, PhaseDeleting); err != nil {
return ctrl.Result{}, err
}
effectivePhase = PhaseDeleting
}

if err := r.syncDryRunCondition(ctx, item); err != nil {
return ctrl.Result{}, err
}

switch effectivePhase {
case PhaseDeleting:
return r.executeUninstall(ctx, item)
case PhaseDeleted:
return ctrl.Result{}, nil
default:
cfg := r.ConfigManager.Get()
return ctrl.Result{RequeueAfter: cfg.SyncStatusCheckInterval}, nil
}

额外排查点

这次排查还发现另一个容易被忽略的问题:

  • PhaseDeleted 分支注释写“remove finalizer”
  • 但分支里如果没有真正执行移除 finalizer,重启后可能落到“已 Deleted 但对象无法 GC”的状态

建议把“已 Deleted 时确保 finalizer 被移除”做成幂等逻辑,而不是只依赖某一次成功路径。

经验总结

  1. 对于 controller-runtime:Patch success != cache updated
  2. 同一轮 reconcile 里,谨慎复用同一对象指针并多次 Get
  3. 状态机 default 分支尽量提供可恢复路径(requeue 或 error)。
  4. 终态资源要做最终幂等收口(比如 finalizer 清理)。

这类问题在低压下很难复现,但一旦流量上来,会从“偶发”迅速变成“稳定踩坑”。