Kubernetes 调度器 local-dynamic 容量检查 Bug 深度实证分析:双重竞态与日志验证
在对 local-dynamic StorageClass 调度问题进行深入排查时,通过在关键函数中添加诊断日志并复现压测,我们从实际日志中捕获到了两个独立的竞态 Bug 的精确发生时刻。本文基于真实日志数据,完整还原从 NodeInfo 生命周期到 informer 传播链的全貌,并对每个 Bug 的成因给出毫秒级证明。
问题背景
在节点上同时创建 11 个 Pod + PVC(local-dynamic StorageClass),节点容量恰好容纳 10 个 PVC,复现出以下两类现象:
- 过量调度 → Provisioner 报
"Not enough free space"(调度器允许了本不该允许的调度) - FailedScheduling + PVC 残留
volume.kubernetes.io/selected-node注解 +"context deadline exceeded"
这两个现象看似矛盾(一个太宽松、一个太严格),实际上来自两个不同方向的竞态 Bug,都在 getUsedCapacity 函数中。
调度容量检查架构
local-dynamic 通过节点注解描述磁盘容量:
1 | Node Annotation: csi.volume.kubernetes.io/kubernetes.io.csi.local |
Filter 阶段容量检查调用链:
1 | FindPodVolumes |
原始 getUsedCapacity 的两个数据来源
1 | // 来源1:pvCache - 仅含已写入 API 的 PV |
这两个来源存在两个相反方向的缺陷,日志实证了这两个缺陷都真实发生。
NodeInfo 生命周期:理解竞态的基础
要理解容量计算为何会出错,必须先理解 Pod 在调度器缓存中的完整生命周期。
Pod 的四个状态阶段
1 | [调度周期] [绑定周期] [绑定完成] [Informer 追上] |
Phase 1 - AssumePod:Filter/Reserve 通过后,Pod 乐观加入 NodeInfo:
1 | assumed.Spec.NodeName = host |
Phase 2 - FinishBinding:绑定 goroutine 完成后(无论成功失败):
1 | // defer 保证必然调用 |
Phase 3 - cleanupAssumedPods:后台 goroutine 每秒扫描:
1 | // cache.go:754 |
绑定失败路径(ForgetPod):当 PreBind 返回错误(如 context deadline exceeded):
1 | handleBindingCycleError() |
这条路径是 Bug B(漏计)的核心:ForgetPod 后 Pod 立即不在 NodeInfo,而 PV 可能还未创建。
Bug A:双重计算(Double Count)
竞态条件
Provisioner 创建 PV 后,PV controller 随即更新 PVC(设置 VolumeName)。这产生两个独立的 informer 事件,由两条独立 watch 流传播到调度器:
1 | API Server 写入顺序(有序): |
当 PV informer 先到,PVC VolumeName 更新未到 时:
1 | pvCache: 已有 PV(ListPVs 能计入) |
日志实证:161ms 的双重计算窗口
通过在 getUsedCapacity 中添加日志,以及打印 pvCache 中所有 PV 的状态,得到 pvc-8 的精确时序:
pvCache(PV informer)的更新时刻:
1 | I0617 10:05:21.711718 getUsedCapacity: cached PV=pvc-d75e0ee8-..., phase=Pending, claimRef=stress-pvc-8 |
pvcCache(PVC informer)的更新时刻:
1 | I0617 10:05:21.716 pvc=stress-pvc-8, VolumeName="" ← PVC 还未更新 |
时序对齐:
1 | 10:05:21.711 PV 进入 pvCache (phase=Pending) |
这 161ms 的窗口(PV informer 先于 PVC informer 到达)使节点在调度器眼中比实际多用了 100G。
Bug B:漏计(Under Count)
竞态条件
1 | PreBind: |
日志实证:pvc-9 被占用 2 分钟但完全不可见
1 | I0617 10:05:20.433 pvc=stress-pvc-9, VolumeName="" ← AnnSelectedNode 已设置 |
pvc-9 的 AnnSelectedNode 写入后,Provisioner 从未成功创建 PV(返回了 context deadline exceeded)。对于旧代码:
- pod-walk:stress-deploy-9 已 ForgetPod,不在 NodeInfo → 不计
- pvCache:无 PV → 不计
- 旧代码认为这 100G 空余,可能允许另一个 Pod 占用 → 过量调度
pod-10 三次调度的完整证据
pod-10 的三次调度尝试直接观测到了两个 Bug 的效果:
三次尝试对比
| Attempt 1 (10:05:21.662) | Attempt 2 (10:05:21.818) | Attempt 3 (10:05:21.994) | |
|---|---|---|---|
| NodeVolumeLimits unbound | pvc-2,4,5,6,7,9,11 (8个) | pvc-2,4,5,6,7,9,11 (8个) | pvc-9,pvc-11 (2个) |
| pvc-8 状态 | VolumeName=”” + PV=Pending | VolumeName=”” + PV=Pending | VolumeName=set + PV=Bound |
| 双重计算 | 是 | 是(在161ms窗口内) | 否 |
| binder.go:1228 | 未出现 | 未出现 | 出现! |
| 结果 | FAIL | FAIL | PASS → PreBind |
关键转折点
Attempt 2 (10:05:21.818) 精确落在双重计算窗口内(10:05:21.711 ~ 10:05:21.872),pvc-8 被计了两次(+100G),使 usedCapacity 超过节点容量,pod-10 被拒绝。
1 | 10:05:21.763: PersistentVolumeUpdate → pod-10 移入 Backoff queue |
NodeVolumeLimits 变化的含义
Attempt 2 → Attempt 3 期间(176ms),NodeVolumeLimits 看到的 unbound PVC 从 8 个降到 2 个:pvc-2, pvc-4, pvc-5, pvc-6, pvc-7 全部变为 bound(VolumeName 设置)。这说明这 5 个 PVC 对应的 Provisioner 在这 176ms 内完成了 PV 创建并更新了 pvcCache,随之触发 PersistentVolumeUpdate 事件激活了 pod-10 的重试。
Informer 延迟的三条影响路径
路径 1:PVC informer → pvcCache.apiObj(影响 checkBindings)
1 | // checkBindings 只读 apiObj(informer 版本) |
若 informer 延迟 > bindTimeout(默认 60s),即使 Provisioner 已成功创建 PV,checkBindings 也会超时 → "context deadline exceeded"。这是 PVC 残留 AnnSelectedNode 的直接原因:bindAPIUpdate 已写入 API 但 Restore() 只恢复内存。
路径 2:PV informer → pvCache(影响容量计算 - Bug A 来源)
PV informer 与 PVC informer 是独立 watch 流。在本次测试中,PV informer 比 PVC VolumeName 更新早 161ms 到达(实测数据),触发了双重计算。
路径 3:PVC informer → pvcCache.latestObj(修复后无影响)
修复方案在 PreBind 的 bindAPIUpdate 后立即调用 pvcCache.Assume(pvc),将 latestObj 更新为含 AnnSelectedNode 的版本。ListAllPVCs() 读取 latestObj,不依赖 informer 速度。
两种 Bug 的完整对比
| 场景 | pvCache | pod-walk | usedCapacity | 实际效果 |
|---|---|---|---|---|
| PV 未到,VolumeName=”” | ✗ | ✓ (1次) | 正确 | 正常 |
| PV 已到,VolumeName=””(Bug A) | ✓ | ✓(再计1次) | 多算 | 过度拒绝(false negative) |
| PV 已到,VolumeName!=”” | ✓ (1次) | ✗ | 正确 | 正常 |
| Provisioner 失败 + ForgetPod(Bug B) | ✗ | ✗ | 少算 | 过量调度(false positive) |
Bug A 导致 pod-10 在 Attempt 2 被错误拒绝,Bug B 在 pod-9/11 失败后允许了过量调度。两个 Bug 在毫秒级时间内同时在同一个集群上发生。
修复方案
核心思路
AnnSelectedNode 是 PVC 与节点之间容量预约关系的唯一可靠来源:无论 PV 是否存在、Pod 是否在 NodeInfo、informer 是否追上,只要 PVC 有 AnnSelectedNode=nodeX,该 PVC 就在此节点上预约了容量。
Step 1:AssumeCache.ListAll()
1 | // assume_cache.go |
原 List(indexObj) 通过 namespace index 查询,无法跨 namespace 枚举所有 PVC。
Step 2:PVCAssumeCache.ListAllPVCs()
1 | // volumebinding/assume_cache.go |
Step 3:重写 getUsedCapacity
1 | func (b *volumeBinder) getUsedCapacity(className, driverName string, node *v1.Node, |
Step 4:excludePVCKeys 防止重复计算
1 | // checkVolumeSizeEnough 末尾 |
修复效果
| 场景 | 修复前 | 修复后 |
|---|---|---|
| PV 已到,VolumeName=”” | 双重计算 | 单次计算(AnnSelectedNode扫描) |
| Provisioner 失败,ForgetPod 后 | 漏计 | 正确计入(AnnSelectedNode残留) |
| 正常 bound PVC | 正确(pvCache) | 正确(AnnSelectedNode,若仍存在) |
| 当前调度请求的 PVC | 正确(totalReqSize) | 正确(excludePVCKeys 排除) |
两个 Bug 被同一个修复方案同时消除:不再依赖 pvCache(消除双重计算)和 pod-walk(消除漏计),统一使用 AnnSelectedNode 作为容量预约的单一信源。
AnnSelectedNode 为何不会被回滚
BindPodVolumes 分两步执行:
1 | // Step 1: 写入 API —— 不可逆 |
超时后 RevertAssumedPodVolumes → pvcCache.Restore():
1 | // 仅恢复内存中的 latestObj = apiObj(已含 AnnSelectedNode 的版本) |
AnnSelectedNode 永久残留在 API Server 中,直到外部 Provisioner 清除或 PVC 被删除。修复方案正是利用了这一永久性:即使在失败和重调度场景下,AnnSelectedNode 依然是可靠的容量预约标记。
总结
通过对 getUsedCapacity 函数添加精确诊断日志,我们从实际运行数据中捕获到了两个竞态 Bug 的毫秒级证据:
Bug A(双重计算):PV informer 比 PVC informer 早 161ms 到达调度器,导致同一个 PVC 同时被 pvCache 和 pod-walk 各计一次。pod-10 的 Attempt 2 (10:05:21.818) 恰好落在这个 161ms 窗口内,被错误拒绝。
Bug B(漏计):Provisioner 失败后,Pod ForgetPod 移出 NodeInfo,PV 从未创建,旧代码对 pvc-9 的 100G 容量预约完全不可见,持续 2 分钟。
两个 Bug 都源于同一个根本问题:getUsedCapacity 使用了两个有各自可见性盲区的数据来源(pvCache + pod-walk),而 AnnSelectedNode 才是跨越所有场景的唯一可靠来源。
修复后,pvcCache 的 AnnSelectedNode 扫描同时解决了:
- 双重计算:不再从 pvCache 计(只从 AnnSelectedNode 计一次)
- 漏计:AnnSelectedNode 在 ForgetPod、PV 未创建、informer 延迟等所有场景下仍然可见
- **
excludePVCKeys**:精确排除当前调度请求的 PVC,避免与totalReqSize重复