在对 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 } }
使用 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 被错误地选为 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"
两个触发因素同时发生:
stress Pod 被删除 → NodeInfo 清空 → pod-walk 贡献归零
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 ) { 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()) 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) } } 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 } 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 { 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 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 出现非确定性归零:
**continue 而非 break**:StorageClass 查找循环每次匹配都覆盖 className,最终结果取决于 etcd watch 顺序,不可预期。
单 className 查询盲区 :只查一个 StorageClass 的 PV,无法覆盖共享同一 Provisioner 的所有 StorageClass。
修复:收集 classNamesForDriver 集合,所有来源(pvCache、pod-walk、inline volume)统一按 Provisioner 范围过滤,消除 className 非确定性,同时覆盖多 StorageClass 共享 Provisioner 的场景。