Kubernetes 调度器 local-dynamic PVC 容量检查竞态 Bug 分析与修复
在排查一起 local-dynamic StorageClass 的 Pod 调度异常时,通过提升调度器日志级别并结合实际压测复现,深入剖析了 kube-scheduler volumebinding 插件中存在的容量检查 Bug。本文完整记录从现象、根因、日志验证到修复的全过程。
问题背景
在节点上同时创建 11 个 Pod + PVC(使用 local-dynamic StorageClass)时,出现以下现象:
1 | Events: |
调度失败后,PVC 上仍然保留了 volume.kubernetes.io/selected-node 注解:
1 | Annotations: volume.kubernetes.io/selected-node: tess-node-2dq2t-tess908.sddz.ebay.com |
问题有两个层面:
- 为什么容量检查在某些时刻会错误地拒绝调度?
- 为什么调度失败后 PVC 上的
selected-node注解仍然保留?
调度器容量检查流程
local-dynamic 使用节点注解(而非标准 CSIStorageCapacity 对象)来描述可用磁盘容量:
1 | Node Annotation: csi.volume.kubernetes.io/kubernetes.io.csi.local |
Filter 阶段的容量检查调用链为:
1 | FindPodVolumes |
checkNodeCapacity 的核心逻辑:
1 | usedCapacity := b.getUsedCapacity(className, driverName, node, pods) |
原始 getUsedCapacity 的实现
原始代码从两个来源统计节点已用容量:
1 | func (b *volumeBinder) getUsedCapacity(className, driverName string, node *v1.Node, pods []fwk.PodInfo) (usedCapacity int64) { |
这里存在两个根本性缺陷。
Bug 根因分析
缺陷一:pvCache 无法看到创建中的 PV
pvCache.ListPVs(className) 依赖 informer,只能列出已写入 API Server 并传播到本地缓存的 PV。外部 Provisioner(如 kubernetes.io/csi.local)从收到 PVC 的 AnnSelectedNode 注解到实际创建 PV 并写入 API Server,需要一段时间。在这段时间内:
- Provisioner 正在创建 PV(in-flight)
- pvCache 中看不到该 PV
getUsedCapacity漏计了这部分空间
缺陷二:pod-walk 依赖 NodeInfo,存在一致性窗口
nodeInfo.GetPods() 只返回当前在调度器缓存中与该节点关联的 Pod。以下场景会导致 PVC 既不在 pvCache(PV 尚未创建),也不在 NodeInfo pod-walk 中:
场景:Pod 已从 NodeInfo 移除,但 PVC 的 AnnSelectedNode 仍存在
1 | PreBind: bindAPIUpdate() → pvc 写入 AnnSelectedNode=nodeX(不可回滚) |
NodeInfo 生命周期:Pod 为什么在绑定后从 NodeInfo 中移除
要理解竞态窗口的成因,必须先理解调度器缓存(cacheImpl)如何管理 NodeInfo 中的 Pod 状态。
Pod 的五个状态阶段
1 | [调度周期] [绑定周期] [绑定完成] [Informer 追上] |
Phase 1:调度周期 — AssumePod
在 Filter/Reserve 阶段通过后,调度器乐观地将 Pod 加入缓存:
1 | // schedule_one.go:967 |
此时 Pod 在 NodeInfo 中,getUsedCapacity 的 pod-walk 可以计入它的 PVC。
Phase 2:绑定周期 — 异步 goroutine
绑定在单独的 goroutine 中异步执行:
1 | // schedule_one.go:989 |
FinishBinding 后 Pod 依然在 NodeInfo — 只是被标记为”可以在 TTL 到期后清除”。
Phase 3:TTL 过期清除 — 后台 goroutine
调度器有一个后台 goroutine 周期性(默认每秒)扫描已完成绑定的 assumed pods:
1 | // cache.go:737 |
这就是日志中看到 “can be expired” 的时刻。Pod 被从 NodeInfo 移除,但此时:
- PVC 的
AnnSelectedNode已写入 API(不可回滚) - Provisioner 可能还未创建 PV(informer 未收到
PersistentVolumeAdd)
Phase 4:Informer 追上 — addPodToCache
API Server 上 pod.spec.nodeName 的变更最终通过 watch/informer 传播回调度器:
1 | // eventhandlers.go:249 |
竞态窗口的精确位置
1 | 时间线(以 pod-8 为例): |
原始代码在危险窗口 [T1, T2] 内对 pod-8 的容量完全不可见,这是 Provisioner 报 “Not enough free space” 的根本原因。
日志实证:11 Pod 压测时序复现
以下是 11 个 stress-deploy Pod 同时创建时的实际日志(节点容量恰好允许 9 个 PVC):
阶段一:前 8 个 Pod 依次调度(06:19:46 之前已完成 1-7,06:19:46.531 开始 pod-8)
1 | 06:19:46.253: "All PVCs for pod are bound" pod=stress-deploy-6 ← pod-6 完成 |
pod-8 进入 PreBind 轮询状态(provisioner 正在创建 PV)。
阶段二:pod-9 首次调度失败(06:19:47.672)
1 | 06:19:47.679: "Attempting to schedule pod" pod=stress-deploy-9 |
关键细节:FilterWithNominatedPods 不仅考虑当前调度的 pod-9,还把 pod-10 作为候选者计入统计,导致 used = 9,加上 pvc-9 的请求超出节点容量。日志中 binder.go:1219(”Provisioning for claims…”)不出现,印证了 checkVolumeSizeEnough 在进入 checkVolumeProvisions 之前就已返回 false。
阶段三:pod-8 完成,pod-10 被调度(06:19:48)
1 | 06:19:48.543: "All PVCs for pod are bound" pod=stress-deploy-8 ← pod-8 PreBind 完成 |
pod-8 完成后 pvc-8 已 bound(VolumeName 设置),pod walk 不再计入,腾出一个容量槽。
阶段四:pod-9 第二次调度成功(06:19:51.923)
触发事件:**PersistentVolumeAdd** — pod-8 的 PV 终于写入 API 并传播到 informer。
1 | 06:19:51.923: "Pod moved to an internal scheduling queue" event="PersistentVolumeAdd" |
此时 getUsedCapacity 的状态:
1 | pvCache: PV-1 ~ PV-8(pod-8 的 PV 刚进入 informer)= 8 个 PV |
致命竞态窗口
上述日志展示的是 pod-8 仍在 NodeInfo 时 pod-9 失败的场景,此时原始代码实际上工作正确(pod-8 被 pod-walk 计入)。然而原始代码存在一个致命的不可见窗口:
1 | 时间轴: |
在 T2 窗口内,若下一个 Pod 的调度触发 Filter:
- pvCache 无 pvc-8 的 PV
- pod-walk 无 pod-8(已移除)
getUsedCapacity少算一个 PVC 的容量- 过量调度!→ Provisioner 实际创建时报 “Not enough free space”
这正是最初观察到的 CSI Provisioner 报错的根本原因。
为什么 PVC 的 selected-node 注解不会被回滚
BindPodVolumes 分两步执行,第一步不可回滚:
1 | // Step 1: 写入 API — 不可逆 |
超时后调度器调用 RevertAssumedPodVolumes → pvcCache.Restore():
1 | // Restore 只恢复内存中的 assume cache: |
因此 API Server 中 PVC 的 AnnSelectedNode 永久保留,直到:
- 外部 Provisioner 失败后主动清除此注解(这是标准信号机制),或者
- PVC 被删除
checkBindings 与 Informer 延迟
checkBindings 使用 GetAPIPVC 读取 apiObj(informer-backed),而非直接访问 API Server:
1 | pvc, err := b.pvcCache.GetAPIPVC(getPVCName(claim)) |
isPVCFullyBound 需要:
1 | return pvc.Spec.VolumeName != "" && metav1.HasAnnotation(pvc.ObjectMeta, volume.AnnBindCompleted) |
两个字段均由 PV Controller 在绑定完成后写入 API。若 informer 传播延迟超过 BindTimeoutSeconds(默认 60s),调度器会超时,即使 PV 实际上已经创建成功。这在 1100+ 节点的大规模集群中是真实的风险。
修复方案
核心思路
用 pvcCache 扫描替换两个有缺陷的来源(pvCache.ListPVs + pod-walk):
任何携带
AnnSelectedNode=nodeX的 PVC 都代表该节点上已预约的容量,无论 PV 是否已创建,无论对应 Pod 是否还在 NodeInfo 中。
Step 1:AssumeCache 新增 ListAll()
在 pkg/scheduler/util/assumecache/assume_cache.go 中添加跨 namespace 枚举方法:
1 | func (c *AssumeCache) ListAll() []interface{} { |
原有 List(indexObj) 通过 namespace index 查询,无法跨 namespace 枚举所有 PVC。
Step 2:PVCAssumeCache 新增 ListAllPVCs()
在 pkg/scheduler/framework/plugins/volumebinding/assume_cache.go 中:
1 | func (c *PVCAssumeCache) ListAllPVCs() []*v1.PersistentVolumeClaim { |
Step 3:重写 getUsedCapacity(核心修复)
1 | func (b *volumeBinder) getUsedCapacity(className, driverName string, node *v1.Node, |
Step 4:调用链传递 excludePVCKeys
checkVolumeSizeEnough 构建排除集,通过 checkNodeCapacity 传递给 getUsedCapacity:
1 | // checkVolumeSizeEnough 末尾 |
修复效果对比
| 场景 | 修复前 | 修复后 |
|---|---|---|
| PV 创建中(in-flight),Pod 仍在 NodeInfo | 正确计入(pod-walk) | 正确计入(pvcCache scan) |
| PV 创建中,Pod 已移出 NodeInfo(危险窗口) | 漏计!→ 过量调度 | 正确计入(pvcCache scan,AnnSelectedNode 仍存在) |
| Bind 超时重调度,PVC AnnSelectedNode 残留 | 漏计! | 正确计入 |
| Permit 被拒,PVC 回滚后 AnnSelectedNode 仍在 API | 漏计! | 正确计入 |
| 正常情形(PV 已在 pvCache,Pod 在 NodeInfo) | 正确 | 正确(excludePVCKeys 防止重复计算) |
修复验证:日志对比
修复前(pod-9 第一次调度,06:19:47):
1 | binder.go:321: FindPodVolumes |
修复后(预期):
getUsedCapacity扫描 pvcCache,正确计入所有AnnSelectedNode=node的 PVC- 若总量超过节点容量,拒绝调度(**正确防止 Provisioner 报 “Not enough free space”**)
- 若容量允许,通过检查,直接进入 provisioning 流程
遗留问题
context deadline exceeded与 informer 延迟:checkBindings通过GetAPIPVC(informer-backed)判断 PVC 是否已 bound。在大集群(1100+ 节点)高负载场景下,informer 传播延迟可能导致 60s 超时,即使 PV 实际已创建成功。可通过检查 Provisioner 日志与PersistentVolumeAdd事件的时间差来确认是否属于此场景。per-pool 容量计算:
checkNodeCapacity遍历多个磁盘池,但getUsedCapacity返回所有池的总 used,用单池 cap 与总 used 比较在多池场景下逻辑有误(TODO 注释保留,尚未修复)。
总结
local-dynamic PVC 过量调度(Provisioner 报 “Not enough free space”)的根本原因是 getUsedCapacity 的两个数据来源均存在可见性盲区:
- **
pvCache.ListPVs()**:只能看到 API Server 中已持久化的 PV,看不到创建中的 PV nodeInfo.GetPods()pod-walk:只能看到调度器缓存中当前与节点关联的 Pod,Pod 离开 NodeInfo 后其 PVC 的预约容量即消失
修复的核心洞察:**AnnSelectedNode 注解才是 PVC 与节点之间容量预约关系的唯一可靠来源**。只要 PVC 携带 AnnSelectedNode=nodeX,无论 PV 是否存在、Pod 是否在 NodeInfo,该 PVC 的容量都应被计入节点的已用容量。通过 pvcCache.ListAllPVCs() 扫描替换两个有缺陷的来源,彻底消除了竞态窗口。