Kubernetes 调度器 local-dynamic 容量检查 Bug 深度实证分析:双重竞态与日志验证

在对 local-dynamic StorageClass 调度问题进行深入排查时,通过在关键函数中添加诊断日志并复现压测,我们从实际日志中捕获到了两个独立的竞态 Bug 的精确发生时刻。本文基于真实日志数据,完整还原从 NodeInfo 生命周期到 informer 传播链的全貌,并对每个 Bug 的成因给出毫秒级证明。

问题背景

在节点上同时创建 11 个 Pod + PVC(local-dynamic StorageClass),节点容量恰好容纳 10 个 PVC,复现出以下两类现象:

  1. 过量调度 → Provisioner 报 "Not enough free space"(调度器允许了本不该允许的调度)
  2. FailedScheduling + PVC 残留 volume.kubernetes.io/selected-node 注解 + "context deadline exceeded"

这两个现象看似矛盾(一个太宽松、一个太严格),实际上来自两个不同方向的竞态 Bug,都在 getUsedCapacity 函数中。


调度容量检查架构

local-dynamic 通过节点注解描述磁盘容量:

1
2
Node Annotation: csi.volume.kubernetes.io/kubernetes.io.csi.local
Value: {"pool-ssd": "107374182400"}

Filter 阶段容量检查调用链:

1
2
3
4
FindPodVolumes
└─ checkVolumeSizeEnough(inlineVolumes, node, claimsToProvision, pods)
└─ checkNodeCapacity(driverVolumeSize, node, pods)
└─ getUsedCapacity(className, driverName, node, pods)

原始 getUsedCapacity 的两个数据来源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 来源1:pvCache - 仅含已写入 API 的 PV
pvs := b.pvCache.ListPVs(className)
for _, pv := range pvs {
if pv.Labels["kubernetes.io/hostname"] == node.Name {
usedCapacity += pv.Spec.Capacity[...]
}
}

// 来源2:pod-walk - 仅含当前在 NodeInfo 中的 Pod 的未绑定 PVC
for _, pod := range pods {
pvc, _ := b.pvcCache.GetPVC(...)
if pvc.Spec.VolumeName == "" { // 仅统计未绑定 PVC
usedCapacity += pvcRequestSize
}
}

这两个来源存在两个相反方向的缺陷,日志实证了这两个缺陷都真实发生。


NodeInfo 生命周期:理解竞态的基础

要理解容量计算为何会出错,必须先理解 Pod 在调度器缓存中的完整生命周期。

Pod 的四个状态阶段

1
2
3
4
[调度周期]       [绑定周期]          [绑定完成]           [Informer 追上]
AssumePod() → FinishBinding() → cleanupAssumedPods() → AddPod()
NodeInfo: ✓ NodeInfo: ✓ NodeInfo: ✗ ← 移除! NodeInfo: ✓ (committed)
pvCache: ✗ pvCache: ✗ pvCache: ✗ (延迟) pvCache: ✓ (PV 到达)

Phase 1 - AssumePod:Filter/Reserve 通过后,Pod 乐观加入 NodeInfo:

1
2
3
4
5
assumed.Spec.NodeName = host
Cache.AssumePod(logger, assumed)
// → NodeInfo.AddPod(pod) — pod 进入 NodeInfo
// → assumedPods.Insert(key)
// → podStates[key].bindingFinished = false

Phase 2 - FinishBinding:绑定 goroutine 完成后(无论成功失败):

1
2
3
4
5
// defer 保证必然调用
Cache.FinishBinding(logger, assumed)
// → podStates[key].bindingFinished = true
// → podStates[key].deadline = now + ttl(30s)
// Pod 仍在 NodeInfo!仅标记"可被过期"

Phase 3 - cleanupAssumedPods:后台 goroutine 每秒扫描:

1
2
3
4
5
// cache.go:754
if cache.ttl != 0 && now.After(*ps.deadline) {
logger.Info("Pod expired", ...)
cache.removePod(logger, ps.pod) // ← Pod 从 NodeInfo 消失!
}

绑定失败路径(ForgetPod):当 PreBind 返回错误(如 context deadline exceeded):

1
2
3
handleBindingCycleError()
→ RevertAssumedPodVolumes() // 仅恢复内存 pvcCache,不回滚 API
→ sched.Cache.ForgetPod(pod) // 立即从 NodeInfo 移除(不等 30s TTL)

这条路径是 Bug B(漏计)的核心:ForgetPod 后 Pod 立即不在 NodeInfo,而 PV 可能还未创建。


Bug A:双重计算(Double Count)

竞态条件

Provisioner 创建 PV 后,PV controller 随即更新 PVC(设置 VolumeName)。这产生两个独立的 informer 事件,由两条独立 watch 流传播到调度器:

1
2
3
4
5
6
7
API Server 写入顺序(有序):
Step 1: 创建 PV → PV 写入 etcd
Step 2: 更新 PVC.VolumeName → PVC 写入 etcd

调度器两个独立 watch 流(各自延迟,无顺序保证):
PV informer: → pvCache.latestObj 更新
PVC informer: → pvcCache.latestObj.VolumeName 更新

PV informer 先到,PVC VolumeName 更新未到 时:

1
2
3
pvCache: 已有 PV(ListPVs 能计入)
pod-walk: pvc.VolumeName="" 仍满足,还会再计一次
→ 同一个 PVC 贡献了两倍容量!

日志实证:161ms 的双重计算窗口

通过在 getUsedCapacity 中添加日志,以及打印 pvCache 中所有 PV 的状态,得到 pvc-8 的精确时序:

pvCache(PV informer)的更新时刻:

1
2
I0617 10:05:21.711718  getUsedCapacity: cached PV=pvc-d75e0ee8-..., phase=Pending, claimRef=stress-pvc-8
I0617 10:05:21.835589 getUsedCapacity: cached PV=pvc-d75e0ee8-..., phase=Bound, claimRef=stress-pvc-8

pvcCache(PVC informer)的更新时刻:

1
2
I0617 10:05:21.716  pvc=stress-pvc-8, VolumeName=""          ← PVC 还未更新
I0617 10:05:21.872 pvc=stress-pvc-8, VolumeName="pvc-d75e0ee8-..." ← 更新到达

时序对齐:

1
2
3
4
5
6
7
8
9
10
11
10:05:21.711  PV 进入 pvCache (phase=Pending)
pvCache: ListPVs() 能看到 pvc-8 的 PV → 计入 100G
10:05:21.716 pvc-8.VolumeName="" (PVC informer 未到)
pod-walk: VolumeName=="" → 再计入 100G
╔══════════════════════════════════╗
║ 双重计算窗口(161ms):+200G ║
║ 实际只应计 100G ║
╚══════════════════════════════════╝
10:05:21.872 pvc-8.VolumeName="pvc-d75e0ee8-..." (PVC informer 到达)
pod-walk: VolumeName!="" → 不再计
→ 恢复正确(单次计数)

这 161ms 的窗口(PV informer 先于 PVC informer 到达)使节点在调度器眼中比实际多用了 100G。


Bug B:漏计(Under Count)

竞态条件

1
2
3
4
5
6
7
8
9
10
11
12
PreBind:
bindAPIUpdate() → pvc.AnnSelectedNode = nodeX (写入 API,不可回滚)
checkBindings() → 轮询 60s → "context deadline exceeded"

handleBindingCycleError():
RevertAssumedPodVolumes() → 仅恢复内存 pvcCache
ForgetPod() → Pod 立即移出 NodeInfo

重调度时:
pod-walk: Pod 不在 NodeInfo → pvc 不计入
pvCache: PV 从未创建(Provisioner 失败)→ 不计入
→ 旧代码完全看不到这个 PVC 的 100G 预约!

日志实证:pvc-9 被占用 2 分钟但完全不可见

1
2
3
4
I0617 10:05:20.433  pvc=stress-pvc-9, VolumeName=""  ← AnnSelectedNode 已设置
I0617 10:05:21.248 pvc=stress-pvc-9, VolumeName="" ← Provisioner 未创建 PV
I0617 10:05:30.147 pvc=stress-pvc-9, VolumeName="" ← 持续...
I0617 10:07:34.157 pvc=stress-pvc-9, VolumeName="" ← 2 分钟后仍然如此

pvc-9 的 AnnSelectedNode 写入后,Provisioner 从未成功创建 PV(返回了 context deadline exceeded)。对于旧代码:

  • pod-walk:stress-deploy-9 已 ForgetPod,不在 NodeInfo → 不计
  • pvCache:无 PV → 不计
  • 旧代码认为这 100G 空余,可能允许另一个 Pod 占用 → 过量调度

pod-10 三次调度的完整证据

pod-10 的三次调度尝试直接观测到了两个 Bug 的效果:

三次尝试对比

Attempt 1 (10:05:21.662) Attempt 2 (10:05:21.818) Attempt 3 (10:05:21.994)
NodeVolumeLimits unbound pvc-2,4,5,6,7,9,11 (8个) pvc-2,4,5,6,7,9,11 (8个) pvc-9,pvc-11 (2个)
pvc-8 状态 VolumeName=”” + PV=Pending VolumeName=”” + PV=Pending VolumeName=set + PV=Bound
双重计算 是(在161ms窗口内)
binder.go:1228 未出现 未出现 出现!
结果 FAIL FAIL PASS → PreBind

关键转折点

Attempt 2 (10:05:21.818) 精确落在双重计算窗口内(10:05:21.711 ~ 10:05:21.872),pvc-8 被计了两次(+100G),使 usedCapacity 超过节点容量,pod-10 被拒绝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
10:05:21.763: PersistentVolumeUpdate → pod-10 移入 Backoff queue
10:05:21.815: 新一轮调度开始
10:05:21.818: FindPodVolumes
csi.go:464 "Persistent volume had no name for claim" PVC=stress-pvc-2,4,5,6,7,9,11
← 仍然看到 8 个 unbound PVC
← 双重计算窗口内 → FAIL
10:05:21.873: ScheduleAttemptFailure → Unschedulable
10:05:21.878: PersistentVolumeUpdate → Backoff(pvc-2,4,5,6,7 的 PV 事件触发)

10:05:21.991: 新一轮调度开始
10:05:21.994: FindPodVolumes
csi.go:464 "Persistent volume had no name for claim" PVC=stress-pvc-9,11
← 只剩 2 个 unbound PVC!(pvc-2,4,5,6,7 已 bound)
← 双重计算窗口已关闭
binder.go:1228 "Provisioning for claims..." ← 容量检查通过!
10:05:21.995: AssumePodVolumes → Reserve
10:05:21.998: PUT stress-pvc-10 → 200 OK(AnnSelectedNode 写入)
10:05:22+: "Could not expire cache for pod as binding is still in progress"(checkBindings 轮询)

NodeVolumeLimits 变化的含义

Attempt 2 → Attempt 3 期间(176ms),NodeVolumeLimits 看到的 unbound PVC 从 8 个降到 2 个:pvc-2, pvc-4, pvc-5, pvc-6, pvc-7 全部变为 bound(VolumeName 设置)。这说明这 5 个 PVC 对应的 Provisioner 在这 176ms 内完成了 PV 创建并更新了 pvcCache,随之触发 PersistentVolumeUpdate 事件激活了 pod-10 的重试。


Informer 延迟的三条影响路径

路径 1:PVC informer → pvcCache.apiObj(影响 checkBindings

1
2
3
// checkBindings 只读 apiObj(informer 版本)
pvc, err := b.pvcCache.GetAPIPVC(getPVCName(claim))
// → return objInfo.apiObj // 不读 latestObj,等待 informer 更新

若 informer 延迟 > bindTimeout(默认 60s),即使 Provisioner 已成功创建 PV,checkBindings 也会超时 → "context deadline exceeded"。这是 PVC 残留 AnnSelectedNode 的直接原因:bindAPIUpdate 已写入 API 但 Restore() 只恢复内存。

路径 2:PV informer → pvCache(影响容量计算 - Bug A 来源)

PV informer 与 PVC informer 是独立 watch 流。在本次测试中,PV informer 比 PVC VolumeName 更新早 161ms 到达(实测数据),触发了双重计算。

路径 3:PVC informer → pvcCache.latestObj(修复后无影响)

修复方案在 PreBind 的 bindAPIUpdate 后立即调用 pvcCache.Assume(pvc),将 latestObj 更新为含 AnnSelectedNode 的版本。ListAllPVCs() 读取 latestObj不依赖 informer 速度


两种 Bug 的完整对比

场景 pvCache pod-walk usedCapacity 实际效果
PV 未到,VolumeName=”” ✓ (1次) 正确 正常
PV 已到,VolumeName=””(Bug A) ✓(再计1次) 多算 过度拒绝(false negative)
PV 已到,VolumeName!=”” ✓ (1次) 正确 正常
Provisioner 失败 + ForgetPod(Bug B) 少算 过量调度(false positive)

Bug A 导致 pod-10 在 Attempt 2 被错误拒绝,Bug B 在 pod-9/11 失败后允许了过量调度。两个 Bug 在毫秒级时间内同时在同一个集群上发生。


修复方案

核心思路

AnnSelectedNode 是 PVC 与节点之间容量预约关系的唯一可靠来源:无论 PV 是否存在、Pod 是否在 NodeInfo、informer 是否追上,只要 PVC 有 AnnSelectedNode=nodeX,该 PVC 就在此节点上预约了容量。

Step 1:AssumeCache.ListAll()

1
2
3
4
5
6
7
8
9
10
11
12
// assume_cache.go
func (c *AssumeCache) ListAll() []interface{} {
c.rwMutex.RLock()
defer c.rwMutex.RUnlock()
allObjs := []interface{}{}
for _, obj := range c.store.List() {
objInfo, ok := obj.(*objInfo)
if !ok { continue }
allObjs = append(allObjs, objInfo.latestObj)
}
return allObjs
}

List(indexObj) 通过 namespace index 查询,无法跨 namespace 枚举所有 PVC。

Step 2:PVCAssumeCache.ListAllPVCs()

1
2
3
4
5
6
7
8
9
10
11
// volumebinding/assume_cache.go
func (c *PVCAssumeCache) ListAllPVCs() []*v1.PersistentVolumeClaim {
objs := c.ListAll()
pvcs := make([]*v1.PersistentVolumeClaim, 0, len(objs))
for _, obj := range objs {
pvc, ok := obj.(*v1.PersistentVolumeClaim)
if !ok { continue }
pvcs = append(pvcs, pvc)
}
return pvcs
}

Step 3:重写 getUsedCapacity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func (b *volumeBinder) getUsedCapacity(className, driverName string, node *v1.Node,
pods []fwk.PodInfo, excludePVCKeys sets.Set[string]) (usedCapacity int64) {

// 唯一来源:扫描 pvcCache,找所有 AnnSelectedNode=node 的 PVC
for _, pvc := range b.pvcCache.ListAllPVCs() {
if volume.GetPersistentVolumeClaimClass(pvc) != className { continue }
if pvc.Annotations[volume.AnnSelectedNode] != node.Name { continue }
if excludePVCKeys.Has(getPVCName(pvc)) { continue } // 避免与 totalReqSize 重复
capacity := pvc.Spec.Resources.Requests[v1.ResourceStorage]
capacityGiB, err := volumehelpers.RoundUpToGiB(capacity)
if err != nil { continue }
usedCapacity += capacityGiB * volumehelpers.GiB
}

// CSI Inline Volume 保留 pod-walk(无 PVC/AnnSelectedNode)
for _, pod := range pods {
if pod.GetPod() == nil { continue }
for _, v := range pod.GetPod().Spec.Volumes {
if v.CSI != nil && v.CSI.Driver == driverName {
// ... 处理 inline volume
}
}
}
return
}

Step 4:excludePVCKeys 防止重复计算

1
2
3
4
5
6
// checkVolumeSizeEnough 末尾
excludePVCKeys := sets.New[string]()
for _, pvc := range pvcs { // 当前调度请求的 PVC
excludePVCKeys.Insert(getPVCName(pvc))
}
return b.checkNodeCapacity(driverVolumeSize, node, pods, excludePVCKeys)

修复效果

场景 修复前 修复后
PV 已到,VolumeName=”” 双重计算 单次计算(AnnSelectedNode扫描)
Provisioner 失败,ForgetPod 后 漏计 正确计入(AnnSelectedNode残留)
正常 bound PVC 正确(pvCache) 正确(AnnSelectedNode,若仍存在)
当前调度请求的 PVC 正确(totalReqSize) 正确(excludePVCKeys 排除)

两个 Bug 被同一个修复方案同时消除:不再依赖 pvCache(消除双重计算)和 pod-walk(消除漏计),统一使用 AnnSelectedNode 作为容量预约的单一信源。


AnnSelectedNode 为何不会被回滚

BindPodVolumes 分两步执行:

1
2
3
4
5
6
7
8
9
10
// Step 1: 写入 API —— 不可逆
err = b.bindAPIUpdate(ctx, assumedPod, bindings, claimsToProvision)
// → PersistentVolumeClaims.Update() 写入 AnnSelectedNode

// Step 2: 轮询等待 PV 创建完成(60s 超时)
err = wait.PollUntilContextTimeout(ctx, time.Second, b.bindTimeout, false,
func(ctx context.Context) (bool, error) {
return b.checkBindings(logger, assumedPod, bindings, claimsToProvision)
})
// 超时 → "binding volumes: context deadline exceeded"

超时后 RevertAssumedPodVolumespvcCache.Restore()

1
2
3
// 仅恢复内存中的 latestObj = apiObj(已含 AnnSelectedNode 的版本)
// 不向 API Server 发送任何请求
objInfo.latestObj = objInfo.apiObj

AnnSelectedNode 永久残留在 API Server 中,直到外部 Provisioner 清除或 PVC 被删除。修复方案正是利用了这一永久性:即使在失败和重调度场景下,AnnSelectedNode 依然是可靠的容量预约标记。


总结

通过对 getUsedCapacity 函数添加精确诊断日志,我们从实际运行数据中捕获到了两个竞态 Bug 的毫秒级证据:

Bug A(双重计算):PV informer 比 PVC informer 早 161ms 到达调度器,导致同一个 PVC 同时被 pvCache 和 pod-walk 各计一次。pod-10 的 Attempt 2 (10:05:21.818) 恰好落在这个 161ms 窗口内,被错误拒绝。

Bug B(漏计):Provisioner 失败后,Pod ForgetPod 移出 NodeInfo,PV 从未创建,旧代码对 pvc-9 的 100G 容量预约完全不可见,持续 2 分钟。

两个 Bug 都源于同一个根本问题:getUsedCapacity 使用了两个有各自可见性盲区的数据来源(pvCache + pod-walk),而 AnnSelectedNode 才是跨越所有场景的唯一可靠来源。

修复后,pvcCache 的 AnnSelectedNode 扫描同时解决了:

  • 双重计算:不再从 pvCache 计(只从 AnnSelectedNode 计一次)
  • 漏计:AnnSelectedNode 在 ForgetPod、PV 未创建、informer 延迟等所有场景下仍然可见
  • **excludePVCKeys**:精确排除当前调度请求的 PVC,避免与 totalReqSize 重复