Kubernetes volumebinding 调度插件:className 非确定性查找与 Provisioner 范围盲区修复

在对 local-dynamic StorageClass 进行压测时,发现 volumebinding 插件中一个独立的容量检查 Bug:volInCal(计入容量的 PVC/PV 列表)在 5 秒内从 18 个条目骤降至 0,usedCapacity 归零,导致调度器再次允许过量调度。本文记录根因分析、日志实证与修复方案。

问题背景

在节点上同时创建多个 Pod + PVC(使用 local-dynamic StorageClass,节点容量恰好满足 N 个 PVC),出现 Provisioner 报空间不足的现象:

1
Warning  ProvisioningFailed  node(s) didn't have enough free storage

调度器认为节点有空余容量并将 Pod 调度上去,但 Provisioner 实际分配时发现空间已耗尽。

容量检查架构

local-dynamic 通过节点注解(而非标准 CSIStorageCapacity 对象)描述可用磁盘容量:

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(driverName, node, pods)

根因一:continue 而非 break 导致 className 非确定性

原始 checkNodeCapacity 查找 StorageClass 的代码:

1
2
3
4
5
6
for _, class := range classList {
if class.Provisioner == driverName {
className = class.Name
continue // ← 错误!应该是 break
}
}

使用 continue 而非 break,每次匹配都会覆盖 className,最终 className 取决于 StorageClass 在列表中的最后位置——非确定性,随 etcd watch 顺序变化。

根因二:多 StorageClass 共享同一 Provisioner

1
2
local-dynamic-ssd   → Provisioner: kubernetes.io.csi.local
local-dynamic-hdd → Provisioner: kubernetes.io.csi.local

两个 StorageClass 共享同一个 Provisioner,从同一个物理节点的容量池分配空间。原始代码只用单个 className 查 pvCache:

1
pvs := b.pvCache.ListPVs(className)  // 只查一个 className!

如果 className 被错误地选为 local-dynamic-hdd,而实际的 PVC 使用 local-dynamic-ssd,则:

  • pvCache.ListPVs("local-dynamic-hdd") 返回空
  • getUsedCapacity 返回 0
  • 调度器认为节点空闲 → 过量调度

压测日志实证:volInCal 5 秒内骤降至 0

1
2
3
4
5
6
7
8
9
10
T+0s   volInCal=["pvc-1/ns/stress-pvc-1", ..., "ns/stress-pvc-9"]  (18 entries)
usedCapacity=900GiB(正确)

T+5s delete_all 删除 stress deployments + PVCs
→ NodeInfo 清空(stress Pod 移出)
→ className 被错误选为 local-dynamic-hdd

T+5s volInCal=[] (0 entries)
usedCapacity=0(错误!PV 仍在节点上)
→ 新 Pod 被允许调度 → Provisioner 报 "Not enough free space"

两个触发因素同时发生:

  1. stress Pod 被删除 → NodeInfo 清空 → pod-walk 贡献归零
  2. className 翻转为错误的 StorageClass → pvCache.ListPVs(wrongClass) 返回 0

修复:以 Provisioner 为范围的 classNamesForDriver 集合

不再尝试确定一个 className,而是收集该 Provisioner 的所有 StorageClass:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
func (b *volumeBinder) getUsedCapacity(driverName string, node *v1.Node,
pods []fwk.PodInfo) (usedCapacity int64) {

// 收集此 Provisioner 的所有 StorageClass
classList, err := b.classLister.List(labels.Everything())
classNamesForDriver := sets.New[string]()
for _, class := range classList {
if class.Provisioner == driverName {
classNamesForDriver.Insert(class.Name)
}
}
klog.V(5).InfoS("getUsedCapacity: collected StorageClasses for driver",
"driver", driverName, "node", node.Name,
"classNames", classNamesForDriver.UnsortedList())

// 来源1:此 Provisioner 所有 StorageClass 的 PV
for className := range classNamesForDriver {
for _, pv := range b.pvCache.ListPVs(className) {
if pv.Labels["kubernetes.io/hostname"] != node.Name { continue }
cap := pv.Spec.Capacity[v1.ResourceStorage]
usedCapacity += cap.Value()
klog.V(5).InfoS("getUsedCapacity: counted provisioned PV",
"driver", driverName, "node", node.Name,
"pv", pv.Name, "cap", cap.String(), "runningTotal", usedCapacity)
}
}

// 来源2:NodeInfo pod-walk — 未绑定 PVC(任意 className 属于该 Provisioner)
for _, pod := range pods {
if pod.GetPod() == nil { continue }
for _, v := range pod.GetPod().Spec.Volumes {
if v.PersistentVolumeClaim != nil {
pvc, err := b.pvcCache.GetPVC(...)
if err != nil { continue }
pvcClassName := volume.GetPersistentVolumeClaimClass(pvc)
if !classNamesForDriver.Has(pvcClassName) { continue } // 按 Provisioner 过滤
if pvc.Spec.VolumeName == "" {
usedCapacity += roundedCapacity
klog.V(5).InfoS("getUsedCapacity: counted unbound PVC",
"driver", driverName, "node", node.Name,
"pvc", klog.KObj(pvc), "runningTotal", usedCapacity)
}
} else if v.CSI != nil && v.CSI.Driver == driverName {
// 来源3:CSI Inline Volume
usedCapacity += inlineVolumeSize
klog.V(5).InfoS("getUsedCapacity: counted CSI inline volume", ...)
}
}
}

klog.V(5).InfoS("getUsedCapacity: final result",
"driver", driverName, "node", node.Name,
"usedCapacity", usedCapacity, "volsInCalc", volInCal)
return
}

函数签名清理

原始代码为了输出调试日志,将 *v1.Pod 参数贯穿整个调用链:

1
2
3
4
5
6
7
8
// 修复前(debug 残留参数)
func (b *volumeBinder) getUsedCapacity(driverName string, node *v1.Node,
requestedPod *v1.Pod, pods []fwk.PodInfo) (usedCapacity int64)
func (b *volumeBinder) checkNodeCapacity(totalReqSizeMap map[string]int64,
node *v1.Node, pod *v1.Pod, pods []fwk.PodInfo) (bool, error)
func (b *volumeBinder) checkVolumeSizeEnough(inlineVolumes []v1.Volume,
node *v1.Node, pvcs []*v1.PersistentVolumeClaim,
pod *v1.Pod, pods []fwk.PodInfo) (bool, error)

这些 pod 参数仅用于硬编码的条件日志。修复后移除所有 pod 参数,改为无条件 klog.V(5).InfoS(...) 日志:

1
2
3
4
5
6
7
8
// 修复后(清洁签名 + 结构化日志)
func (b *volumeBinder) getUsedCapacity(driverName string, node *v1.Node,
pods []fwk.PodInfo) (usedCapacity int64)
func (b *volumeBinder) checkNodeCapacity(totalReqSizeMap map[string]int64,
node *v1.Node, pods []fwk.PodInfo) (bool, error)
func (b *volumeBinder) checkVolumeSizeEnough(inlineVolumes []v1.Volume,
node *v1.Node, pvcs []*v1.PersistentVolumeClaim,
pods []fwk.PodInfo) (bool, error)

checkNodeCapacity 也加入了完整的容量决策日志:

1
2
3
4
5
6
7
klog.V(5).InfoS("checkNodeCapacity: capacity check",
"driver", driverName, "node", node.Name,
"nodeCapacity", cap,
"oversellCapacity", OversellRatio*cap,
"usedCapacity", usedCapacity,
"requestedSize", totalReqSize,
"totalRequestAndUsed", totalReqSize+usedCapacity)

修复后的关键日志(klog.V(5)

开启 --v=5 后,可在调度器日志中观察到完整的容量计算过程:

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
26
27
28
29
30
# getUsedCapacity 入口
getUsedCapacity: collected StorageClasses for driver
driver=kubernetes.io.csi.local node=worker-1
classNames=[local-dynamic-ssd local-dynamic-hdd]

# 逐 PV 计入
getUsedCapacity: counted provisioned PV
driver=kubernetes.io.csi.local node=worker-1
pv=pvc-abc123 cap=100Gi runningTotal=107374182400

# 逐 unbound PVC 计入
getUsedCapacity: counted unbound PVC
driver=kubernetes.io.csi.local node=worker-1
pvc=default/stress-pvc-9 runningTotal=214748364800

# 最终汇总
getUsedCapacity: final result
driver=kubernetes.io.csi.local node=worker-1
usedCapacity=858993459200
volsInCalc=[pvc-abc123/default/stress-pvc-1 default/stress-pvc-9]

# checkNodeCapacity 容量决策
checkNodeCapacity: capacity check
driver=kubernetes.io.csi.local node=worker-1
nodeCapacity=1073741824000 oversellCapacity=1073741824000
usedCapacity=858993459200 requestedSize=107374182400
totalRequestAndUsed=966367641600

checkNodeCapacity: node accepted, capacity sufficient
node=worker-1

总结

容量预约的真实范围是 Provisioner,而非 StorageClass。多个 StorageClass 可以共享同一个 Provisioner 和同一块物理存储池,必须将它们整体视为一个容量域。

原始代码的两个缺陷共同导致 usedCapacity 出现非确定性归零:

  1. **continue 而非 break**:StorageClass 查找循环每次匹配都覆盖 className,最终结果取决于 etcd watch 顺序,不可预期。
  2. 单 className 查询盲区:只查一个 StorageClass 的 PV,无法覆盖共享同一 Provisioner 的所有 StorageClass。

修复:收集 classNamesForDriver 集合,所有来源(pvCache、pod-walk、inline volume)统一按 Provisioner 范围过滤,消除 className 非确定性,同时覆盖多 StorageClass 共享 Provisioner 的场景。