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 | StorageClass: local-dynamic |
调度器已经把该 PVC 所在的 Pod 绑定到某节点(selected-node 注解已写入),但 CSI 外部 Provisioner 在实际创建卷时报告空间不足。这说明调度器的容量预检通过了,但实际容量不够。
调度器容量检查流程
local-dynamic 使用 Tess 自定义的节点注解方式(而非标准 CSIStorageCapacity 对象)来描述节点磁盘容量:
1 | Node Annotation: csi.volume.kubernetes.io/kubernetes.io.csi.local |
调度流程中,FindPodVolumes → checkVolumeSizeEnough → checkNodeCapacity → getUsedCapacity:
1 | // checkNodeCapacity 伪代码 |
getUsedCapacity 负责计算节点上已消耗的容量,逻辑分三部分:
- 已 Provision 的 PV:从
pvCache中列出该 StorageClass 的 PV,筛选label["kubernetes.io/hostname"] == node.Name的,累加容量 - 待 Provision 的 PVC(来自 NodeInfo 中的 Pod):遍历
nodeInfo.GetPods()返回的每个 Pod 的 Volume,对Spec.VolumeName == ""的 PVC 累加请求量 - 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 | // binder.go - AssumePodVolumes |
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 依然带着AnnSelectedNode,VolumeName仍为空 - Pod 被重新放回调度队列,从 NodeInfo 中移除
- 此时该 PVC 已不在任何 NodeInfo 的 Pod 列表中,但它的
AnnSelectedNode仍指向该节点
场景二:Bind 失败后的重调度
- Pod A 调度到节点 X,
BindPodVolumes向 API 写入AnnSelectedNode=nodeX - 外部 Provisioner 尝试 Provision,失败(如临时错误)
BindPodVolumes超时,Pod A 被重新放入调度队列- Pod A 从 NodeInfo(X) 中移除
- PVC-A 在 API Server 中仍有
AnnSelectedNode=nodeX,VolumeName为空 - 此时 Pod B 的调度 Filter 阶段检查节点 X 的容量:
getUsedCapacity遍历 NodeInfo(X) 的 Pod,没有 Pod A,PVC-A 的容量被遗漏 - Pod B 被调度到节点 X,PVC-B 也标记了
AnnSelectedNode=nodeX - 当 Pod A 重新调度也选到节点 X 时,两个 PVC 的总容量超过节点实际空间
场景三:并发调度窗口(理论竞态)
在 AssumePodVolumes 完成(PVC 进入 assume cache)到 assumePod 完成(Pod 进入 NodeInfo)之间,若有另一个 goroutine 触发了对该节点的 Filter(实际上调度是串行的,但这说明 assume cache 和 NodeInfo 的一致性依赖于调用顺序)。
为什么 getUsedCapacity 只看 NodeInfo.GetPods() 是不够的
现有逻辑:
1 | func (b *volumeBinder) getUsedCapacity(className, driverName string, node *v1.Node, pods []fwk.PodInfo) int64 { |
问题:pods 只包含当前在 NodeInfo 中存在的 Pod。以下 PVC 会被遗漏:
- Pod 已从 NodeInfo 移除(重调度、Permit 被拒),但 PVC 的
AnnSelectedNode仍存在于 pvcCache - 这些 PVC 占用的节点空间并未被后续的容量检查计入
修复方案
思路
在 getUsedCapacity 中额外扫描 pvcCache 中所有带 AnnSelectedNode=node.Name 且 VolumeName="" 的同 StorageClass PVC,作为补充计算。使用集合去重,避免与 NodeInfo Pod 走读的 PVC 重复计算。
实现
Step 1:在 pkg/scheduler/util/assumecache/assume_cache.go 中新增 ListAll():
1 | // ListAll 返回 cache 中所有对象,不经过 index 过滤。 |
Step 2:在 volumebinding/assume_cache.go 中新增 ListAllPVCs():
1 | func (c *PVCAssumeCache) ListAllPVCs() []*v1.PersistentVolumeClaim { |
Step 3:修改 getUsedCapacity,补充 pvcCache 扫描(核心修复):
1 | // 2a. 来自 NodeInfo 的 Pod 中未绑定的 PVC(原有逻辑) |
修复的效果
| 场景 | 修复前 | 修复后 |
|---|---|---|
Bind 失败重调度,PVC AnnSelectedNode 残留 |
容量被低估,后续 Pod 可能超额调度 | 正确计入残留 PVC 的容量 |
Permit Wait 超时,PVC 回滚后 AnnSelectedNode 仍在 API |
容量被低估 | 正确统计 |
| 正常调度(Pod 在 NodeInfo 中) | 正确 | 通过 countedPVCs 去重,行为不变 |
其他相关 Bug(未在本文修复)
Bug 1(per-pool 问题):
checkNodeCapacity遍历多个磁盘池,但getUsedCapacity返回的是所有池的总 used,用单池 cap 与总 used 比较在多池场景下逻辑有误(有 TODO 注释但尚未修复)Bug 3(PV hostname label 不匹配):
getUsedCapacity用pv.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 中,其占用的容量也能被后续调度决策感知。