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

在排查一起 local-dynamic StorageClass 的 Pod 调度异常时,通过提升调度器日志级别并结合实际压测复现,深入剖析了 kube-scheduler volumebinding 插件中存在的容量检查 Bug。本文完整记录从现象、根因、日志验证到修复的全过程。

问题背景

在节点上同时创建 11 个 Pod + PVC(使用 local-dynamic StorageClass)时,出现以下现象:

1
2
3
4
5
6
7
8
9
Events:
Warning FailedScheduling default-scheduler
0/1111 nodes are available: 1 node(s) didn't find available
persistent volumes to bind, 1110 node(s) didn't match Pod's
node affinity/selector.

Warning FailedScheduling default-scheduler
running PreBind plugin "VolumeBinding":
binding volumes: context deadline exceeded

调度失败后,PVC 上仍然保留了 volume.kubernetes.io/selected-node 注解:

1
Annotations: volume.kubernetes.io/selected-node: tess-node-2dq2t-tess908.sddz.ebay.com

问题有两个层面:

  1. 为什么容量检查在某些时刻会错误地拒绝调度?
  2. 为什么调度失败后 PVC 上的 selected-node 注解仍然保留?

调度器容量检查流程

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

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

Filter 阶段的容量检查调用链为:

1
2
3
4
FindPodVolumes
└─ checkVolumeSizeEnough(inlineVolumes, node, claimsToProvision, pods)
└─ checkNodeCapacity(driverVolumeSize, node, pods)
└─ getUsedCapacity(className, driverName, node, pods)

checkNodeCapacity 的核心逻辑:

1
2
3
4
5
usedCapacity := b.getUsedCapacity(className, driverName, node, pods)
if OversellRatio*cap < totalReqSize+usedCapacity {
// 容量不足,拒绝调度
return false, nil
}

原始 getUsedCapacity 的实现

原始代码从两个来源统计节点已用容量:

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
func (b *volumeBinder) getUsedCapacity(className, driverName string, node *v1.Node, pods []fwk.PodInfo) (usedCapacity int64) {
// 来源1:pvCache 中已 Provision 的 PV
pvs := b.pvCache.ListPVs(className)
for _, pv := range pvs {
if pv.Labels["kubernetes.io/hostname"] == node.Name {
cap := pv.Spec.Capacity[v1.ResourceStorage]
usedCapacity += cap.Value()
}
}

// 来源2:nodeInfo.GetPods() 中待 Provision 的 PVC
for _, pod := range pods { // pods = nodeInfo.GetPods()
for _, v := range pod.GetPod().Spec.Volumes {
if v.PersistentVolumeClaim != nil {
pvc, _ := b.pvcCache.GetPVC(...)
if pvc.Spec.VolumeName == "" { // 仅统计未绑定 PVC
usedCapacity += pvc请求量
}
}
}
}

// 来源3:CSI Inline Volume
// ...
}

这里存在两个根本性缺陷。


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
2
3
4
5
6
7
8
9
10
11
12
13
14
PreBind: bindAPIUpdate()    → pvc 写入 AnnSelectedNode=nodeX(不可回滚)
checkBindings() → 轮询等待 PVC bound
→ 超时: "context deadline exceeded"

scheduler:
RevertAssumedPodVolumes() → 仅恢复内存 cache,不回滚 API
pod 移出 NodeInfo
pod 重新放入调度队列

此时:
pvc.AnnSelectedNode = nodeX(API 中仍存在)
pvCache: 无对应 PV(provisioner 可能未完成)
NodeInfo(nodeX): 无此 Pod
→ getUsedCapacity 完全漏计 pvc 占用的容量

NodeInfo 生命周期:Pod 为什么在绑定后从 NodeInfo 中移除

要理解竞态窗口的成因,必须先理解调度器缓存(cacheImpl)如何管理 NodeInfo 中的 Pod 状态。

Pod 的五个状态阶段

1
2
3
4
[调度周期]      [绑定周期]         [绑定完成]          [Informer 追上]
AssumePod() → FinishBinding() → cleanupAssumedPods() → AddPod()
NodeInfo: ✓ NodeInfo: ✓ NodeInfo: ✗ ← 移除! NodeInfo: ✓ (committed)
pvCache: ✗ pvCache: ✗ pvCache: ✗ (延迟) pvCache: ✓ (PV 到达)

Phase 1:调度周期 — AssumePod

在 Filter/Reserve 阶段通过后,调度器乐观地将 Pod 加入缓存:

1
2
3
4
5
6
7
8
9
// schedule_one.go:967
func (sched *Scheduler) assume(logger klog.Logger, assumed *v1.Pod, host string) error {
assumed.Spec.NodeName = host
sched.Cache.AssumePod(logger, assumed)
// → addPod(pod, assumePod=true)
// → NodeInfo.AddPod(pod) // Pod 进入 NodeInfo
// → assumedPods.Insert(key) // 标记为"已假设"
// → podStates[key] = {bindingFinished: false, deadline: nil}
}

此时 Pod 在 NodeInfo 中,getUsedCapacity 的 pod-walk 可以计入它的 PVC。

Phase 2:绑定周期 — 异步 goroutine

绑定在单独的 goroutine 中异步执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// schedule_one.go:989
func (sched *Scheduler) bind(...) (status *fwk.Status) {
defer func() {
sched.finishBinding(logger, schedFramework, assumed, targetNode, status)
// defer 确保无论成功失败都会调用 FinishBinding
}()
return schedFramework.RunBindPlugins(ctx, state, assumed, targetNode)
// volumebinding PreBind: bindAPIUpdate() + checkBindings()
}

func (sched *Scheduler) finishBinding(...) {
sched.Cache.FinishBinding(logger, assumed)
// → podStates[key].bindingFinished = true
// → podStates[key].deadline = now + ttl (30s)
// Pod 仍在 NodeInfo!仅标记"可被过期"
}

FinishBinding 后 Pod 依然在 NodeInfo — 只是被标记为”可以在 TTL 到期后清除”。

Phase 3:TTL 过期清除 — 后台 goroutine

调度器有一个后台 goroutine 周期性(默认每秒)扫描已完成绑定的 assumed pods:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// cache.go:737
func (cache *cacheImpl) cleanupAssumedPods(logger klog.Logger, now time.Time) {
for key := range cache.assumedPods {
ps := cache.podStates[key]
if !ps.bindingFinished {
continue // 绑定未完成,跳过
}
if now.After(*ps.deadline) {
// TTL 到期!
cache.removePod(logger, ps.pod)
// → NodeInfo.RemovePod(pod) ← pod-8 从 NodeInfo 消失!
// → delete podStates[key]
// → delete assumedPods[key]
logger.V(5).Info("Finished binding for pod, can be expired", ...)
}
}
}

这就是日志中看到 “can be expired” 的时刻。Pod 被从 NodeInfo 移除,但此时:

  • PVC 的 AnnSelectedNode 已写入 API(不可回滚)
  • Provisioner 可能还未创建 PV(informer 未收到 PersistentVolumeAdd

Phase 4:Informer 追上 — addPodToCache

API Server 上 pod.spec.nodeName 的变更最终通过 watch/informer 传播回调度器:

1
2
3
4
5
6
7
8
// eventhandlers.go:249
func (sched *Scheduler) addPodToCache(obj interface{}) {
sched.Cache.AddPod(logger, pod)
// case: assumedPods.Has(key) → updatePod() // 若仍是 assumed 状态则更新
// case: !ok → addPod(pod, false) // 若已过期则作为 committed pod 重新加入
// → NodeInfo.AddPod(pod)
// → MoveAllToActiveOrBackoffQueue(AssignedPodAdd)
}

竞态窗口的精确位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
时间线(以 pod-8 为例):

T1: cleanupAssumedPods TTL 到期
→ NodeInfo.RemovePod(pod-8) ← pod-8 消失
pvCache: pod-8 的 PV 尚不存在

┌─── 危险窗口 ───────────────────────────────┐
│ 此时调度 pod-9: │
│ - pod-walk: pod-8 不在 NodeInfo → 不计入 │
│ - pvCache: pod-8 的 PV 不存在 → 不计入 │
│ → usedCapacity 少算 1 个 PVC │
│ → 允许超量调度! │
└────────────────────────────────────────────┘

T2: PersistentVolumeAdd 事件到达
→ pvCache.Add(pv-8) ← pod-8 的 PV 进入 pvCache
→ MoveAllToActiveOrBackoffQueue("PersistentVolumeAdd")
→ pod-9 被重新激活调度

T3: Informer 更新 pod-8 的 spec.nodeName
→ addPodToCache(pod-8)
→ NodeInfo.AddPod(pod-8) (committed pod)

原始代码在危险窗口 [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
2
3
4
06:19:46.253: "All PVCs for pod are bound"  pod=stress-deploy-6  ← pod-6 完成
06:19:46.400: "All PVCs for pod are bound" pod=stress-deploy-7 ← pod-7 完成
06:19:46.539: "AssumePodVolumes" pod=stress-deploy-8 ← pod-8 进入 Reserve
06:19:46.542: PUT /pvcs/stress-pvc-8 → 200 OK ← pvc-8 AnnSelectedNode 写入 API

pod-8 进入 PreBind 轮询状态(provisioner 正在创建 PV)。

阶段二:pod-9 首次调度失败(06:19:47.672)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
06:19:47.679: "Attempting to schedule pod"   pod=stress-deploy-9
06:19:47.682: "FindPodVolumes" FilterWithNominatedPods
06:19:47.683: "No matching volumes for pod" PVC=stress-pvc-9 ← 无静态 PV

checkVolumeSizeEnough() 运行 getUsedCapacity:
pvCache: PV-1 ~ PV-7 = 7 个已 Provision 的 PV
pod-walk: pod-8 在 NodeInfo 中,pvc-8.VolumeName="" → 计入
nominated: pod-10(FilterWithNominatedPods 阶段),pvc-10 → 计入
usedCapacity = 7 + 1 + 1 = 9

totalReqSize(pvc-9) + usedCapacity(9) = 10 > 节点容量(9)

checkVolumeSizeEnough = false → 不到达 checkVolumeProvisions(line 1219)

06:19:47.687: FailedScheduling: "1 node(s) didn't find available persistent volumes to bind"

关键细节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
2
3
4
5
6
06:19:48.543: "All PVCs for pod are bound"  pod=stress-deploy-8  ← pod-8 PreBind 完成
06:19:48.816: stress-deploy-10 开始调度
06:19:48.988: "No matching volumes for pod" PVC=stress-pvc-10
06:19:48.988: binder.go:1219 "Provisioning for claims..." claimCount=1 ← 容量检查通过
06:19:48.990: "AssumePodVolumes" pod=stress-deploy-10
06:19:48.995: PUT /pvcs/stress-pvc-10 → 200 OK

pod-8 完成后 pvc-8 已 bound(VolumeName 设置),pod walk 不再计入,腾出一个容量槽。

阶段四:pod-9 第二次调度成功(06:19:51.923)

触发事件:**PersistentVolumeAdd** — pod-8 的 PV 终于写入 API 并传播到 informer。

1
2
3
4
06:19:51.923: "Pod moved to an internal scheduling queue" event="PersistentVolumeAdd"
06:19:51.927: "FindPodVolumes" FilterWithNominatedPods
06:19:51.928: "No matching volumes for pod" PVC=stress-pvc-9
06:19:51.928: binder.go:1219 "Provisioning for claims..." claimCount=1 ← 容量检查通过!

此时 getUsedCapacity 的状态:

1
2
3
4
5
6
7
pvCache:  PV-1 ~ PV-8(pod-8 的 PV 刚进入 informer)= 8 个 PV
pod-walk: pod-8 pvc-8.VolumeName != "" → 不计入(已 bound)
pod-10 pvc-10.VolumeName = "" → 计入(在 NodeInfo 中)
(06:19:48-49 期间 7 个 AssignedPodDelete 事件移除了其他 Pod)
usedCapacity = 8(pvCache) - N(被删除 Pod 的贡献) + 1(pvc-10) ≤ 9

totalReqSize(pvc-9) + usedCapacity ≤ 9 → 通过

致命竞态窗口

上述日志展示的是 pod-8 仍在 NodeInfo 时 pod-9 失败的场景,此时原始代码实际上工作正确(pod-8 被 pod-walk 计入)。然而原始代码存在一个致命的不可见窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
时间轴:

T1: pod-8 完成绑定 → "Finished binding for pod, can be expired"
pvc-8.VolumeName != "" (API 已更新)
pod-8 从 NodeInfo 移除

T2: ← 危险窗口开始 →
pvCache: pvc-8 的 PV 尚未出现(informer 传播延迟)
pod-walk: pod-8 不在 NodeInfo → pvc-8 不被计入
getUsedCapacity 漏计 pvc-8 的容量

T3: PersistentVolumeAdd 事件 → pvc-8 PV 进入 pvCache
← 危险窗口结束 →

在 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
2
3
4
5
6
7
8
9
10
// Step 1: 写入 API — 不可逆
bindAPIUpdate(ctx, assumedPod, bindings, claimsToProvision)
// → 调用 PersistentVolumeClaims.Update(),写入 AnnSelectedNode 到 API Server

// Step 2: 轮询等待 PV 创建完成
err = wait.PollUntilContextTimeout(ctx, time.Second, b.bindTimeout, false,
func(ctx context.Context) (bool, error) {
return b.checkBindings(logger, assumedPod, bindings, claimsToProvision)
})
// → 若超时:返回 "binding volumes: context deadline exceeded"

超时后调度器调用 RevertAssumedPodVolumespvcCache.Restore()

1
2
3
4
// Restore 只恢复内存中的 assume cache:
objInfo.latestObj = objInfo.apiObj
// apiObj = API Server 版本 = 已包含 AnnSelectedNode 的版本
// 不会向 API Server 发送任何请求!

因此 API Server 中 PVC 的 AnnSelectedNode 永久保留,直到:

  • 外部 Provisioner 失败后主动清除此注解(这是标准信号机制),或者
  • PVC 被删除

checkBindings 与 Informer 延迟

checkBindings 使用 GetAPIPVC 读取 apiObj(informer-backed),而非直接访问 API Server

1
2
pvc, err := b.pvcCache.GetAPIPVC(getPVCName(claim))
// GetAPIPVC 返回 objInfo.apiObj — 仅在 informer 接收到 watch 事件后才更新

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
2
3
4
5
6
7
8
9
10
11
12
13
14
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 {
continue
}
allObjs = append(allObjs, objInfo.latestObj)
}
return allObjs
}

原有 List(indexObj) 通过 namespace index 查询,无法跨 namespace 枚举所有 PVC。

Step 2:PVCAssumeCache 新增 ListAllPVCs()

pkg/scheduler/framework/plugins/volumebinding/assume_cache.go 中:

1
2
3
4
5
6
7
8
9
10
11
12
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(核心修复)

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
func (b *volumeBinder) getUsedCapacity(className, driverName string, node *v1.Node,
pods []fwk.PodInfo, excludePVCKeys sets.Set[string]) (usedCapacity int64) {

// 1. 扫描 pvcCache:所有 AnnSelectedNode=node 的 PVC
// 覆盖已 Provision + in-flight + 重调度后残留的所有情形
for _, pvc := range b.pvcCache.ListAllPVCs() {
if volume.GetPersistentVolumeClaimClass(pvc) != className {
continue
}
if pvc.Annotations[volume.AnnSelectedNode] != node.Name {
continue
}
// 跳过当前调度请求的 PVC(已在 totalReqSize 中计入,避免重复)
if excludePVCKeys.Has(getPVCName(pvc)) {
continue
}
capacity := pvc.Spec.Resources.Requests[v1.ResourceName(v1.ResourceStorage)]
capacityGiB, err := volumehelpers.RoundUpToGiB(capacity)
if err != nil {
continue
}
usedCapacity += capacityGiB * volumehelpers.GiB
}

// 2. CSI Inline Volume(不通过 PVC,需保留 pod-walk)
for _, pod := range pods {
if pod.GetPod() == nil {
continue
}
for _, v := range pod.GetPod().Spec.Volumes {
if v.CSI != nil && v.CSI.Driver == driverName &&
v.CSI.VolumeAttributes != nil &&
v.CSI.VolumeAttributes["size"] != "" {
volSize := resource.MustParse(v.CSI.VolumeAttributes["size"])
volSizeGiB, _ := volumehelpers.RoundUpToGiB(volSize)
usedCapacity += volSizeGiB * volumehelpers.GiB
}
}
}
return
}

Step 4:调用链传递 excludePVCKeys

checkVolumeSizeEnough 构建排除集,通过 checkNodeCapacity 传递给 getUsedCapacity

1
2
3
4
5
6
// checkVolumeSizeEnough 末尾
excludePVCKeys := sets.New[string]()
for _, pvc := range pvcs { // pvcs = 当前调度请求的 PVC
excludePVCKeys.Insert(getPVCName(pvc))
}
return b.checkNodeCapacity(driverVolumeSize, node, pods, excludePVCKeys)

修复效果对比

场景 修复前 修复后
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
2
3
4
binder.go:321:  FindPodVolumes
binder.go:1045: "No matching volumes for pod" PVC=stress-pvc-9
← checkVolumeSizeEnough 返回 false(含 FilterWithNominatedPods 的 pod-10)
← 未到达 binder.go:1219(checkVolumeProvisions 未被调用)

修复后(预期)

  • getUsedCapacity 扫描 pvcCache,正确计入所有 AnnSelectedNode=node 的 PVC
  • 若总量超过节点容量,拒绝调度(**正确防止 Provisioner 报 “Not enough free space”**)
  • 若容量允许,通过检查,直接进入 provisioning 流程

遗留问题

  1. context deadline exceeded 与 informer 延迟checkBindings 通过 GetAPIPVC(informer-backed)判断 PVC 是否已 bound。在大集群(1100+ 节点)高负载场景下,informer 传播延迟可能导致 60s 超时,即使 PV 实际已创建成功。可通过检查 Provisioner 日志与 PersistentVolumeAdd 事件的时间差来确认是否属于此场景。

  2. 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() 扫描替换两个有缺陷的来源,彻底消除了竞态窗口。