KubeVirt CDI HonorWaitForFirstConsumer 特性分析
Last modified:
Kubernetes 的 StorageClass.volumeBindingMode 有两个常见取值:Immediate 和 WaitForFirstConsumer。
Immediate 会在 PVC 创建后尽快完成 PV 绑定或动态 provisioning。WaitForFirstConsumer 则会把绑定或 provisioning 推迟到第一个使用该 PVC 的 Pod 被调度时,让 scheduler 可以同时考虑 Pod 的 node selector、affinity、taints、资源需求、拓扑约束和卷拓扑。Kubernetes 官方文档对这个字段的解释是:volumeBindingMode 控制卷绑定和动态 provisioning 发生的时机;对拓扑受限、不是所有节点都可访问的存储,Immediate 可能让 Pod 之后不可调度,而 WaitForFirstConsumer 会延迟到使用 PVC 的 Pod 出现后再处理。
这个机制放到 KubeVirt + CDI 里,会出现一个额外问题:DataVolume 的数据导入、上传、克隆不是由虚拟机 Pod 自己完成的,而是由 CDI 先创建 worker pod 把数据写入 PVC。如果 CDI worker pod 成为「第一个消费者」,PVC 就会按 worker pod 的调度结果绑定到某个节点或拓扑域;等真正的 KubeVirt VMI Pod 创建时,它可能需要被调度到另一个节点,最终虚拟机启动失败。
HonorWaitForFirstConsumer 的作用就是避免这个问题:当 DataVolume 使用 WaitForFirstConsumer 存储,并且 PVC 还在 Pending 时,CDI 不主动创建 import/upload/clone worker pod;它把 DataVolume 标成 WaitForFirstConsumer,让 KubeVirt 先用符合 VMI 约束的临时 Pod 触发 PVC 绑定,然后 CDI 再继续数据填充。
问题在哪里
CDI 官方文档已经把这个问题讲得很直接:CDI 会用 worker Pod 做 import/upload/clone;如果 worker Pod 触发了 WFFC PVC 的绑定,PVC 会绑定到 worker Pod 所在节点;但 worker Pod 的调度约束可能和 KubeVirt VM 不同,VM 调度到别的节点后,卷就不可用了。这个说明在 CDI 仓库的文档里也有永久链接:doc/waitforfirstconsumer-storage-handling.md#L7-L14。
这个场景典型发生在:
- 本地盘、HostPath、local PV 这类节点绑定存储。
- 单 AZ / 单 zone 块存储,卷一旦创建或绑定就有明确拓扑。
- 多盘 VM,每块磁盘都由 CDI 管理,如果不同 PVC 被不同 worker pod 提前绑定,后续 VMI 很难同时满足所有卷约束。
所以这里不是单纯让 PVC 更晚绑定,而是保证「第一个真正影响 PVC 位置的消费者」尽量代表最终 workload,而不是代表数据导入任务。
CDI 的特性开关
在 CDI 里,HonorWaitForFirstConsumer 是一个 feature gate:
| |
源码位置:pkg/feature-gates/feature-gates.go#L16-L18。
它的开关状态来自 CDIConfig.Spec.FeatureGates,HonorWaitForFirstConsumerEnabled() 最终只是检查配置里是否包含这个 feature gate:pkg/feature-gates/feature-gates.go#L50-L75。
不过全局开关不是唯一条件。CDI 还有一个 PVC/DataVolume 级别的绕过方式:cdi.kubevirt.io/storage.bind.immediate.requested。IsWaitForFirstConsumerEnabled() 的逻辑是:
| |
源码位置:pkg/controller/common/util.go#L1543-L1554。
也就是说,最终是否 honor WFFC,需要同时满足:
- 全局
HonorWaitForFirstConsumer开启。 - 当前对象没有请求 immediate binding。
如果全局 feature gate 关闭,DataVolume controller 在生成 PVC 时还会主动补上 immediate binding annotation:pkg/controller/common/util.go#L1556-L1565。import、upload、clone 的 DataVolume controller 都会调用这个函数,例如 import 路径在 pkg/controller/datavolume/import-controller.go#L120-L132,upload 路径在 pkg/controller/datavolume/upload-controller.go#L116-L126,clone 路径在 pkg/controller/datavolume/clone-controller-base.go#L195-L205。
CDI 如何暂停 worker pod
CDI DataVolume controller 会检查 PVC 对应 StorageClass 的 binding mode:
| |
源码位置:pkg/controller/datavolume/controller-base.go#L1240-L1248。
如果全局 feature gate 开启、StorageClass 是 WFFC、PVC 仍是 Pending,DataVolume 会被标记为 WaitForFirstConsumer:
| |
源码位置:pkg/controller/datavolume/controller-base.go#L1250-L1265,状态写入位置在 pkg/controller/datavolume/controller-base.go#L1005-L1013。WaitForFirstConsumer 也是 CDI API 里正式定义的 DataVolume phase:staging/src/kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1/types.go#L400-L404。
另一边,真正负责创建 worker pod 的 import/upload controller 会先调用 IsWaitForFirstConsumerEnabled(),然后进入统一的 shouldHandlePvc():
| |
源码位置:pkg/controller/util.go#L98-L105。import controller 的调用在 pkg/controller/import-controller.go#L177-L192,upload controller 的调用在 pkg/controller/upload-controller.go#L172-L180。
这段逻辑的效果很明确:
- honor WFFC 时,只处理已经
Bound的 PVC。 - PVC 还在
Pending时,CDI 不创建 worker pod。 - 不 honor WFFC 时,CDI 可以照旧创建 worker pod,worker pod 会触发 PVC 绑定。
使用 CSI volume populator 的路径也有类似判断。CDI 自己的 populator util 会在 WFFC 且还没有 volume.kubernetes.io/selected-node annotation 时等待:pkg/controller/populators/util.go#L125-L131。vendor 里的 lib-volume-populator controller 也会等待 selected node:vendor/github.com/kubernetes-csi/lib-volume-populator/populator-machinery/controller.go#L489-L493。
KubeVirt 如何接住这个状态
如果 CDI 只是停止 worker pod,事情还没完成。必须有另一个 controller 代表真正的 workload 去触发 PVC 绑定。KubeVirt 就是这个消费者。
KubeVirt VM controller 在检查 DataVolume 时,把 Succeeded、WaitForFirstConsumer、PendingPopulation 都视为可以继续往 VMI 侧推进的状态:pkg/virt-controller/watch/vm/vm.go#L556-L558。注释里也明确写了:WaitForFirstConsumer 只能由 VMI 处理:pkg/virt-controller/watch/vm/vm_test.go#L1245。
到了 VMI lifecycle,controller 会检查关联 DataVolume 是否 ready,同时得到 isWaitForFirstConsumer 标记:pkg/virt-controller/watch/vmi/lifecycle.go#L91-L113。如果是 WFFC,它不是直接创建完整 virt-launcher Pod,而是调用 RenderLaunchManifestNoVm(vmi) 渲染一个不真正启动 VM 的临时 Pod:
| |
源码位置:pkg/virt-controller/watch/vmi/lifecycle.go#L150-L156。等不再处于 WFFC 后,KubeVirt 会清理这些临时 Pod:pkg/virt-controller/watch/vmi/lifecycle.go#L187-L190。
KubeVirt 对 PVC readiness 的判断也会调用 CDI API 的辅助函数 IsWaitForFirstConsumerBeforePopulating(),当 PVC 属于 DataVolume 且 DV phase 是 WaitForFirstConsumer 时返回 true:CDI API utils.go#L45-L63,KubeVirt 调用位置在 pkg/storage/types/pvc.go#L232-L245。
对传统 import/upload/clone worker pod 路径,这个协作链路可以简化成:
flowchart TD
DV["DataVolume 创建 PVC"]
WFFC["StorageClass 是 WaitForFirstConsumer"]
Pause["CDI 不创建 worker pod\nDV phase = WaitForFirstConsumer"]
KV["KubeVirt 发现 DV 处于 WaitForFirstConsumer"]
TempPod["KubeVirt 创建带 VMI 调度约束的临时 Pod"]
Bind["scheduler 选择节点\nPVC/PV 确定拓扑"]
Bound["PVC Bound"]
Worker["CDI worker pod 导入、上传或克隆数据"]
Done["DataVolume Succeeded"]
Launcher["KubeVirt 创建真正的 virt-launcher Pod"]
DV --> WFFC --> Pause --> KV --> TempPod --> Bind --> Bound --> Worker --> Done --> Launcher
如果走 CSI volume populator,则状态可能表现为 PendingPopulation,KubeVirt 也会把它当成可以继续推进到 VMI 侧的状态;但本质仍然是让使用方先提供调度上下文,再由存储/populator 流程继续填充数据。
KubeVirt API 里也有对应的 VMI condition:当 VMI 依赖处于 Pending/WaitForFirstConsumer 的 DataVolume,并且正在采取动作 provision PVC 时,会进入 Provisioning condition:staging/src/kubevirt.io/api/core/v1/types.go#L655-L659。
如何让它按 VM 的 selector 执行
如果目标是「让 CDI 根据 VM 上的 selector 选择节点」,配置入口不在 CDI worker pod 上,而在 VM/VMI 的调度字段上。正确做法是:
- StorageClass 使用
volumeBindingMode: WaitForFirstConsumer。 - CDI 开启
HonorWaitForFirstConsumer。 - DataVolume 不加
cdi.kubevirt.io/storage.bind.immediate.requested,也不要使用virtctl image-upload --force-bind。 - 在 VM/VMI 上配置
nodeSelector、affinity、tolerations、schedulerName、topologySpreadConstraints等调度约束。 - 让 DataVolume 通过
dataVolumeTemplates或 VM volume 引用进入 KubeVirt 的 VMI 创建流程。
KubeVirt 的临时 Pod 是从 VMI 渲染出来的。RenderLaunchManifestNoVm() 是 WFFC 时使用的临时 Pod 渲染入口:pkg/virt-controller/services/template.go#L278-L285。PodSpec 里会写入 VMI 派生的 NodeSelector、SchedulerName、Tolerations、TopologySpreadConstraints 等字段:pkg/virt-controller/services/template.go#L686-L704。其中 nodeSelector renderer 会先放入 KubeVirt 节点可调度标签,再合并集群级 selector 和 VMI 自身 selector:pkg/virt-controller/services/nodeselectorrenderer.go#L33-L51。
下面用 rancher/local-path-provisioner 做一个最小例子。local-path provisioner 会基于配置在节点本地路径上动态创建 hostPath 或 local 类型 PV;它的 StorageClass 可以使用 provisioner: rancher.io/local-path 和 volumeBindingMode: WaitForFirstConsumer:rancher/local-path-provisioner README。
| |
这个例子里,CDI 会先创建 PVC,但不会让 import worker pod 抢先绑定。KubeVirt 看到 DV 处于 WaitForFirstConsumer 后,会创建带有 nodeSelector: node-role.kubernetes.io/virt-local=true 和对应 toleration 的临时 Pod。Kubernetes scheduler 根据这个临时 Pod 选择节点,local-path 在这个节点上创建本地 PV,PVC 绑定到符合 VM 调度约束的节点。PVC 绑定后,CDI 再继续导入数据。
flowchart TD
VM["VM/VMI 带 nodeSelector"]
DV["DataVolume 使用 local-path-wffc"]
CDI["CDI honor WFFC\n不抢先创建 worker pod"]
Temp["KubeVirt 创建临时 Pod"]
Scheduler["scheduler 根据 VM selector 选节点"]
LP["local-path 在该节点创建本地 PV"]
Import["PVC Bound 后 CDI 导入数据"]
Run["数据完成后 VM 启动"]
VM --> DV --> CDI --> Temp --> Scheduler --> LP --> Import --> Run
这个方案能工作,但 local-path 的限制也要接受:
- 它不是共享存储。 PV 会带节点亲和性,只能在创建它的节点上使用。local-path 官方文档也说明,默认会用
kubernetes.io/hostname写入 PV node affinity,保证卷只能在创建节点访问。 - VM 后续基本被这个节点绑定。 如果节点宕机或你想把 VM 调度到别的节点,本地盘数据不会自动迁移。
- 更适合测试、边缘节点、单节点绑定或明确接受本地盘语义的场景。 如果你需要 VM 可迁移、高可用或多节点漂移,应该考虑 Ceph RBD、Longhorn、LVM/CSI、云盘 CSI 等更适合虚拟机工作负载的存储。
- 不要开启 immediate binding。 local-path 如果改成
Immediate,PVC 可能在没有 VM 调度上下文时就被 provision;这会绕开本文讨论的 WFFC 价值。 - 不要给 DataVolume/PVC 加 immediate binding annotation。 如果你给 DataVolume/PVC 加了
cdi.kubevirt.io/storage.bind.immediate.requested,或者上传时用了--force-bind,就等于告诉 CDI 不要 honor WFFC,PVC 可能会被 CDI worker pod 先绑定。 - DataVolume 要通过 VM/VMI 消费。 如果直接创建 DataVolume 而不是通过 VM/VMI 消费它,KubeVirt 不会有机会用 VM 的 selector 创建临时 Pod;这时要么等待真正消费者出现,要么接受 immediate binding。
可以用来做什么
HonorWaitForFirstConsumer 不是一个提升导入速度的功能,也不是一个存储兼容性兜底。它主要解决「谁来触发第一次绑定」的问题。
适合的应用场景包括:
KubeVirt VM 使用本地盘或 HostPath 存储
本地盘 PV 天然绑定节点。让 CDI worker pod 先绑定,等于让导入任务替 VM 选节点。开启
HonorWaitForFirstConsumer后,KubeVirt 的临时 Pod 先参与调度,PVC 位置更接近 VM 的真实运行位置。有 node selector、affinity、taints/tolerations 的 VM
例如 VM 只能跑在带 GPU、特定 CPU 型号、特定安全域或特定标签的节点。如果 CDI worker pod 没有完全一致的约束,提前绑定会制造不可调度风险。WFFC 的价值就是让 PVC 绑定时看到 VM 级别的调度约束。
多磁盘 VM
多个 DataVolume 如果分别被 worker pod 提前绑定到不同节点,最终 VMI 可能找不到一个同时满足所有卷位置的节点。让 KubeVirt 先触发绑定,可以把多个 PVC 的拓扑选择放进同一个 VMI 调度上下文里。
拓扑受限块存储
一些云盘、CSI 块存储或 zone 级存储不是全局可挂载。
WaitForFirstConsumer本来就是为这类场景设计的;CDI honor 这个行为后,不会用导入任务破坏 Kubernetes scheduler 的延迟绑定语义。热插拔卷
KubeVirt hotplug 路径也会识别 WFFC 卷:当卷还没有被 CDI 填充时,会创建 dummy pod 触发 population:pkg/virt-controller/watch/vmi/volume-hotplug.go#L171-L181。
什么时候不该用
有些场景反而不需要 honor WFFC。
如果只是上传一个通用 golden image,目标 PVC 不需要绑定到特定 VM 节点,立即创建 CDI upload pod 反而更简单。CDI 文档也把这类场景列为 immediate binding 的用途:doc/waitforfirstconsumer-storage-handling.md#L28-L34。
KubeVirt 的 virtctl image-upload 也暴露了同样的语义:--force-bind 的说明是「忽略 WaitForFirstConsumer logic,强制绑定 PVC」:pkg/virtctl/imageupload/imageupload.go#L134-L139。如果 DataVolume 处于 WaitForFirstConsumer 或 PendingPopulation,且没有 --force-bind,上传会报错提示先让 PVC Bound 或使用 force-bind:pkg/virtctl/imageupload/imageupload.go#L539-L540。
所以可以按这个规则判断:
- 这个 DataVolume 最终要给某个有明确调度约束的 VM 使用,应该 honor WFFC。
- 这个 DataVolume 只是作为集群镜像、模板、通用数据源,不关心节点位置,可以 force bind。
- 如果存储本身全局可访问,WFFC 的收益不明显,但通常也不会造成错误;主要代价是流程上要等第一个消费者出现。
结论
HonorWaitForFirstConsumer 的核心作用是保护 Kubernetes WaitForFirstConsumer 的延迟绑定语义,避免 CDI 的 import/upload/clone worker pod 抢先成为 PVC 的第一个消费者。
在 KubeVirt 场景里,它把职责拆开:CDI 识别 WFFC PVC 并暂停 worker pod;KubeVirt 用带有 VMI 调度约束的临时 Pod 触发 PVC 绑定;PVC Bound 后,CDI 再继续数据导入、上传或克隆。
这个特性适合节点或拓扑敏感的虚拟机磁盘场景,尤其是 local PV、HostPath、zone 级块存储、多磁盘 VM 和带强调度约束的 VMI。它解决的不是「如何导入数据」,而是「让谁先决定磁盘应该落在哪个节点或拓扑域」。
参考链接:
- Kubernetes StorageClass
volumeBindingMode官方文档:Storage Classes - CDI WFFC 设计说明:waitforfirstconsumer-storage-handling.md
- CDI 源码版本:
420f86a0a68a96188338f33b043c0078a1704d3f - KubeVirt 源码版本:
357a5c9d62d5dc354f8927137144c9d76f6dcd6c