KubeVirt CDI HonorWaitForFirstConsumer 特性分析
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,并把 DataVolume 标记成
WaitForFirstConsumer。 - KubeVirt 负责识别这个状态,用带有 VMI 调度约束的临时 Pod 触发 PVC 绑定。
- PVC Bound 后,CDI 再继续数据导入、上传或克隆。
- 数据完成后,KubeVirt 再启动真正的 VM Pod。
这个特性最适合节点或拓扑敏感的虚拟机磁盘场景,尤其是 local PV、HostPath、zone 级块存储、多磁盘 VM 和带强调度约束的 VMI。它解决的不是「如何导入数据」,而是「让谁先决定磁盘应该落在哪个节点或拓扑域」。
参考链接:
- Kubernetes StorageClass
volumeBindingMode官方文档:Storage Classes - CDI WFFC 设计说明:waitforfirstconsumer-storage-handling.md
- CDI 源码版本:
420f86a0a68a96188338f33b043c0078a1704d3f - KubeVirt 源码版本:
357a5c9d62d5dc354f8927137144c9d76f6dcd6c