Kubernetes 调度器 local-dynamic PVC 容量检查竞态 Bug 分析与修复

在排查一起 local-dynamic StorageClass 的 PVC 调度成功但 Provisioning 失败(Not enough free space)的问题时,我发现了 kube-scheduler 的 volumebinding 插件中存在一个容量检查竞态 Bug。本文详细分析 Bug 2:**getUsedCapacity 遗漏了 assume cache 中已预约到该节点、但尚未体现在 NodeInfo 的 PVC**。

问题背景

PVC 描述如下:

1
2
3
4
5
6
7
StorageClass:  local-dynamic
Status: Pending
Annotations: volume.kubernetes.io/selected-node: tess-node-9k5ws-tess908.sddz.ebay.com
Events:
Warning ProvisioningFailed kubernetes.io.csi.local
failed to provision volume with StorageClass "local-dynamic":
rpc error: code = OutOfRange desc = Not enough free space

调度器已经把该 PVC 所在的 Pod 绑定到某节点(selected-node 注解已写入),但 CSI 外部 Provisioner 在实际创建卷时报告空间不足。这说明调度器的容量预检通过了,但实际容量不够


调度器容量检查流程

local-dynamic 使用 Tess 自定义的节点注解方式(而非标准 CSIStorageCapacity 对象)来描述节点磁盘容量:

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

调度流程中,FindPodVolumescheckVolumeSizeEnoughcheckNodeCapacitygetUsedCapacity

1
2
3
4
5
6
7
// checkNodeCapacity 伪代码
for _, poolCap := range driverCapacityMap { // 遍历每个磁盘池的总容量
usedCapacity := b.getUsedCapacity(className, driverName, node, pods)
if poolCap < totalReqSize + usedCapacity {
return false, nil // 容量不足,拒绝调度
}
}

getUsedCapacity 负责计算节点上已消耗的容量,逻辑分三部分:

  1. 已 Provision 的 PV:从 pvCache 中列出该 StorageClass 的 PV,筛选 label["kubernetes.io/hostname"] == node.Name 的,累加容量
  2. 待 Provision 的 PVC(来自 NodeInfo 中的 Pod):遍历 nodeInfo.GetPods() 返回的每个 Pod 的 Volume,对 Spec.VolumeName == "" 的 PVC 累加请求量
  3. CSI Inline Volume:从 Pod Volume 的 CSI 属性读取 size

Bug 2:pvcCache 中已 Assumed 但尚未进入 NodeInfo 的 PVC 被遗漏

时序分析

Kubernetes 调度器对每个 Pod 的处理流程是:

1
PreFilter → Filter (并行, 多节点) → Score → Reserve → Permit → PreBind → Bind

Reserve 阶段,AssumePodVolumes 被调用:

1
2
3
4
// binder.go - AssumePodVolumes
claimClone := dynamicProvision.PVC.DeepCopy()
metav1.SetMetaDataAnnotation(&claimClone.ObjectMeta, volume.AnnSelectedNode, nodeName)
err = b.pvcCache.Assume(claimClone) // PVC 写入 assume cache,带 AnnSelectedNode

Reserve 阶段完成后,调度器主循环调用 assumePod,将 Pod 加入 NodeInfo 缓存。这两步是顺序执行的:先 pvcCache.Assume,再 assumePod

对于下一个 Pod 的调度,Filter 阶段调用:

1
pl.Binder.FindPodVolumes(logger, pod, state.podVolumeClaims, node, nodeInfo.GetPods())

nodeInfo.GetPods() 返回节点上已 assume 的 Pod,理论上包含上一个 Pod。但存在以下场景使得 Pod 未出现在 NodeInfo 中,而 PVC 已经在 pvcCache 中带有 AnnSelectedNode

场景一:Permit 阶段等待(WaitOnPermit)

如果某个 Permit 插件返回 Wait,Pod 会在 WaitOnPermit 状态挂起。此时:

  • Pod 已经被 assume 到节点(在 NodeInfo 中),AssumePodVolumes 已执行
  • 若之后 Permit 超时或被拒绝,调度器会调用 RevertAssumedPodVolumes
  • PVC 的 assume 状态被回滚到 API 版本
  • 但如果此时 BindPodVolumes 已经通过 API 写入了 AnnSelectedNode 注解,API 版本的 PVC 依然带着 AnnSelectedNodeVolumeName 仍为空
  • Pod 被重新放回调度队列,从 NodeInfo 中移除
  • 此时该 PVC 已不在任何 NodeInfo 的 Pod 列表中,但它的 AnnSelectedNode 仍指向该节点

场景二:Bind 失败后的重调度

  1. Pod A 调度到节点 X,BindPodVolumes 向 API 写入 AnnSelectedNode=nodeX
  2. 外部 Provisioner 尝试 Provision,失败(如临时错误)
  3. BindPodVolumes 超时,Pod A 被重新放入调度队列
  4. Pod A 从 NodeInfo(X) 中移除
  5. PVC-A 在 API Server 中仍有 AnnSelectedNode=nodeXVolumeName 为空
  6. 此时 Pod B 的调度 Filter 阶段检查节点 X 的容量:getUsedCapacity 遍历 NodeInfo(X) 的 Pod,没有 Pod A,PVC-A 的容量被遗漏
  7. Pod B 被调度到节点 X,PVC-B 也标记了 AnnSelectedNode=nodeX
  8. 当 Pod A 重新调度也选到节点 X 时,两个 PVC 的总容量超过节点实际空间

场景三:并发调度窗口(理论竞态)

AssumePodVolumes 完成(PVC 进入 assume cache)到 assumePod 完成(Pod 进入 NodeInfo)之间,若有另一个 goroutine 触发了对该节点的 Filter(实际上调度是串行的,但这说明 assume cache 和 NodeInfo 的一致性依赖于调用顺序)。


为什么 getUsedCapacity 只看 NodeInfo.GetPods() 是不够的

现有逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
func (b *volumeBinder) getUsedCapacity(className, driverName string, node *v1.Node, pods []fwk.PodInfo) int64 {
// ...省略 PV 统计...
for _, pod := range pods { // pods = nodeInfo.GetPods()
for _, v := range pod.GetPod().Spec.Volumes {
if v.PersistentVolumeClaim != nil {
pvc, err := b.pvcCache.GetPVC(...)
if pvc.Spec.VolumeName == "" {
usedCapacity += pvc请求量
}
}
}
}
}

问题pods 只包含当前在 NodeInfo 中存在的 Pod。以下 PVC 会被遗漏:

  • Pod 已从 NodeInfo 移除(重调度、Permit 被拒),但 PVC 的 AnnSelectedNode 仍存在于 pvcCache
  • 这些 PVC 占用的节点空间并未被后续的容量检查计入

修复方案

思路

getUsedCapacity 中额外扫描 pvcCache所有带 AnnSelectedNode=node.NameVolumeName="" 的同 StorageClass PVC,作为补充计算。使用集合去重,避免与 NodeInfo Pod 走读的 PVC 重复计算。

实现

Step 1:在 pkg/scheduler/util/assumecache/assume_cache.go 中新增 ListAll()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ListAll 返回 cache 中所有对象,不经过 index 过滤。
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 {
utilruntime.HandleErrorWithLogger(c.logger, &WrongTypeError{...}, "ListAll error")
continue
}
allObjs = append(allObjs, objInfo.latestObj)
}
return allObjs
}

Step 2:在 volumebinding/assume_cache.go 中新增 ListAllPVCs()

1
2
3
4
5
6
7
8
9
10
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,补充 pvcCache 扫描(核心修复):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 2a. 来自 NodeInfo 的 Pod 中未绑定的 PVC(原有逻辑)
countedPVCs := sets.New[string]()
for _, pod := range pods {
// ...原有逻辑...
if pvc.Spec.VolumeName == "" {
usedCapacity += pvc请求量
countedPVCs.Insert(getPVCName(pvc)) // 记录已统计
}
}

// 2b. 补充:pvcCache 中已 assume/API确认 到该节点但不在任何 Pod 中的 PVC
for _, pvc := range b.pvcCache.ListAllPVCs() {
if volume.GetPersistentVolumeClaimClass(pvc) != className { continue }
if pvc.Annotations[volume.AnnSelectedNode] != node.Name { continue }
if pvc.Spec.VolumeName != "" { continue } // 已 provision
if countedPVCs.Has(getPVCName(pvc)) { continue } // 去重

capacityGiB, _ := volumehelpers.RoundUpToGiB(pvc请求量)
usedCapacity += capacityGiB * volumehelpers.GiB
}

修复的效果

场景 修复前 修复后
Bind 失败重调度,PVC AnnSelectedNode 残留 容量被低估,后续 Pod 可能超额调度 正确计入残留 PVC 的容量
Permit Wait 超时,PVC 回滚后 AnnSelectedNode 仍在 API 容量被低估 正确统计
正常调度(Pod 在 NodeInfo 中) 正确 通过 countedPVCs 去重,行为不变

其他相关 Bug(未在本文修复)

  1. Bug 1(per-pool 问题)checkNodeCapacity 遍历多个磁盘池,但 getUsedCapacity 返回的是所有池的总 used,用单池 cap 与总 used 比较在多池场景下逻辑有误(有 TODO 注释但尚未修复)

  2. Bug 3(PV hostname label 不匹配)getUsedCapacitypv.Labels["kubernetes.io/hostname"] == node.Name 匹配 PV,若节点用 FQDN(如 tess-node-9k5ws-tess908.sddz.ebay.com)而 PV label 用短名,会导致已 Provision 的 PV 容量被遗漏,进一步低估 usedCapacity


总结

local-dynamic PVC 调度成功但 Provision 失败的根因之一,是 getUsedCapacity 只依赖 nodeInfo.GetPods() 来统计待 Provision 的 PVC 容量,无法覆盖以下状态的 PVC:

  • Pod 已从 NodeInfo 移除(重调度),但 PVC 的 AnnSelectedNode 注解仍存在
  • assume cache 和 NodeInfo 更新之间的短暂窗口

修复方式:额外扫描 pvcCache 中所有指向该节点的未绑定 PVC,以补充统计。这是一个防御性措施,确保即使 Pod 不在 NodeInfo 中,其占用的容量也能被后续调度决策感知。