ᕕ( ᐛ )ᕗ Jimyag's Blog

KubeVirt CDI HonorWaitForFirstConsumer 特性分析

Kubernetes 的 StorageClass.volumeBindingMode 有两个常见取值:ImmediateWaitForFirstConsumer

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

这个场景典型发生在:

  1. 本地盘、HostPath、local PV 这类节点绑定存储。
  2. 单 AZ / 单 zone 块存储,卷一旦创建或绑定就有明确拓扑。
  3. 多盘 VM,每块磁盘都由 CDI 管理,如果不同 PVC 被不同 worker pod 提前绑定,后续 VMI 很难同时满足所有卷约束。

所以这里不是单纯让 PVC 更晚绑定,而是保证「第一个真正影响 PVC 位置的消费者」尽量代表最终 workload,而不是代表数据导入任务。

CDI 的特性开关

在 CDI 里,HonorWaitForFirstConsumer 是一个 feature gate:

1
2
// HonorWaitForFirstConsumer - if enabled will not schedule worker pods on a storage with WaitForFirstConsumer binding mode
HonorWaitForFirstConsumer = "HonorWaitForFirstConsumer"

源码位置:pkg/feature-gates/feature-gates.go#L16-L18

它的开关状态来自 CDIConfig.Spec.FeatureGatesHonorWaitForFirstConsumerEnabled() 最终只是检查配置里是否包含这个 feature gate:pkg/feature-gates/feature-gates.go#L50-L75

不过全局开关不是唯一条件。CDI 还有一个 PVC/DataVolume 级别的绕过方式:cdi.kubevirt.io/storage.bind.immediate.requestedIsWaitForFirstConsumerEnabled() 的逻辑是:

1
2
3
4
isImmediateBindingRequested := ImmediateBindingRequested(obj)
pvcHonorWaitForFirstConsumer := !isImmediateBindingRequested
globalHonorWaitForFirstConsumer, err := gates.HonorWaitForFirstConsumerEnabled()
return pvcHonorWaitForFirstConsumer && globalHonorWaitForFirstConsumer, nil

源码位置:pkg/controller/common/util.go#L1543-L1554

也就是说,最终是否 honor WFFC,需要同时满足:

  1. 全局 HonorWaitForFirstConsumer 开启。
  2. 当前对象没有请求 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:

1
2
return storageClassBindingMode != nil &&
    *storageClassBindingMode == storagev1.VolumeBindingWaitForFirstConsumer

源码位置:pkg/controller/datavolume/controller-base.go#L1240-L1248

如果全局 feature gate 开启、StorageClass 是 WFFC、PVC 仍是 Pending,DataVolume 会被标记为 WaitForFirstConsumer

1
2
res := honorWaitForFirstConsumerEnabled && wffc &&
    pvc.Status.Phase == corev1.ClaimPending

源码位置:pkg/controller/datavolume/controller-base.go#L1250-L1265,状态写入位置在 pkg/controller/datavolume/controller-base.go#L1005-L1013WaitForFirstConsumer 也是 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()

1
2
3
4
5
6
func shouldHandlePvc(pvc *corev1.PersistentVolumeClaim, honorWaitForFirstConsumerEnabled bool, log logr.Logger) bool {
    if honorWaitForFirstConsumerEnabled {
        return isBound(pvc, log)
    }
    return true
}

源码位置:pkg/controller/util.go#L98-L105。import controller 的调用在 pkg/controller/import-controller.go#L177-L192,upload controller 的调用在 pkg/controller/upload-controller.go#L172-L180

这段逻辑的效果很明确:

  1. honor WFFC 时,只处理已经 Bound 的 PVC。
  2. PVC 还在 Pending 时,CDI 不创建 worker pod。
  3. 不 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 时,把 SucceededWaitForFirstConsumerPendingPopulation 都视为可以继续往 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:

1
2
3
4
5
6
if isWaitForFirstConsumer {
    log.Log.V(3).Object(vmi).Infof("Scheduling temporary pod for WaitForFirstConsumer DV")
    templatePod, err = c.templateService.RenderLaunchManifestNoVm(vmi)
} else {
    templatePod, err = c.templateService.RenderLaunchManifest(vmi)
}

源码位置: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 的调度字段上。正确做法是:

  1. StorageClass 使用 volumeBindingMode: WaitForFirstConsumer
  2. CDI 开启 HonorWaitForFirstConsumer
  3. DataVolume 不加 cdi.kubevirt.io/storage.bind.immediate.requested,也不要使用 virtctl image-upload --force-bind
  4. 在 VM/VMI 上配置 nodeSelectoraffinitytolerationsschedulerNametopologySpreadConstraints 等调度约束。
  5. 让 DataVolume 通过 dataVolumeTemplates 或 VM volume 引用进入 KubeVirt 的 VMI 创建流程。

KubeVirt 的临时 Pod 是从 VMI 渲染出来的。RenderLaunchManifestNoVm() 是 WFFC 时使用的临时 Pod 渲染入口:pkg/virt-controller/services/template.go#L278-L285。PodSpec 里会写入 VMI 派生的 NodeSelectorSchedulerNameTolerationsTopologySpreadConstraints 等字段: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 会基于配置在节点本地路径上动态创建 hostPathlocal 类型 PV;它的 StorageClass 可以使用 provisioner: rancher.io/local-pathvolumeBindingMode: WaitForFirstConsumerrancher/local-path-provisioner README

 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
42
43
44
45
46
47
48
49
50
51
52
53
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: local-path-wffc
provisioner: rancher.io/local-path
parameters:
  nodePath: /data/vm-disks
  pathPattern: "{{ .PVC.Namespace }}/{{ .PVC.Name }}/"
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Delete
---
apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
  name: vm-with-wffc-disk
spec:
  running: true
  dataVolumeTemplates:
    - metadata:
        name: vm-with-wffc-disk-root
      spec:
        source:
          http:
            url: "https://example.com/images/root.qcow2"
        pvc:
          storageClassName: local-path-wffc
          accessModes:
            - ReadWriteOnce
          resources:
            requests:
              storage: 20Gi
  template:
    spec:
      nodeSelector:
        node-role.kubernetes.io/virt-local: "true"
      tolerations:
        - key: "virt-local"
          operator: "Equal"
          value: "true"
          effect: "NoSchedule"
      domain:
        resources:
          requests:
            memory: 2Gi
        devices:
          disks:
            - name: root
              disk:
                bus: virtio
      volumes:
        - name: root
          dataVolume:
            name: vm-with-wffc-disk-root

这个例子里,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 的限制也要接受:

  1. 它不是共享存储。 PV 会带节点亲和性,只能在创建它的节点上使用。local-path 官方文档也说明,默认会用 kubernetes.io/hostname 写入 PV node affinity,保证卷只能在创建节点访问。
  2. VM 后续基本被这个节点绑定。 如果节点宕机或你想把 VM 调度到别的节点,本地盘数据不会自动迁移。
  3. 更适合测试、边缘节点、单节点绑定或明确接受本地盘语义的场景。 如果你需要 VM 可迁移、高可用或多节点漂移,应该考虑 Ceph RBD、Longhorn、LVM/CSI、云盘 CSI 等更适合虚拟机工作负载的存储。
  4. 不要开启 immediate binding。 local-path 如果改成 Immediate,PVC 可能在没有 VM 调度上下文时就被 provision;这会绕开本文讨论的 WFFC 价值。
  5. 不要给 DataVolume/PVC 加 immediate binding annotation。 如果你给 DataVolume/PVC 加了 cdi.kubevirt.io/storage.bind.immediate.requested,或者上传时用了 --force-bind,就等于告诉 CDI 不要 honor WFFC,PVC 可能会被 CDI worker pod 先绑定。
  6. DataVolume 要通过 VM/VMI 消费。 如果直接创建 DataVolume 而不是通过 VM/VMI 消费它,KubeVirt 不会有机会用 VM 的 selector 创建临时 Pod;这时要么等待真正消费者出现,要么接受 immediate binding。

可以用来做什么

HonorWaitForFirstConsumer 不是一个提升导入速度的功能,也不是一个存储兼容性兜底。它主要解决「谁来触发第一次绑定」的问题。

适合的应用场景包括:

  1. KubeVirt VM 使用本地盘或 HostPath 存储

    本地盘 PV 天然绑定节点。让 CDI worker pod 先绑定,等于让导入任务替 VM 选节点。开启 HonorWaitForFirstConsumer 后,KubeVirt 的临时 Pod 先参与调度,PVC 位置更接近 VM 的真实运行位置。

  2. 有 node selector、affinity、taints/tolerations 的 VM

    例如 VM 只能跑在带 GPU、特定 CPU 型号、特定安全域或特定标签的节点。如果 CDI worker pod 没有完全一致的约束,提前绑定会制造不可调度风险。WFFC 的价值就是让 PVC 绑定时看到 VM 级别的调度约束。

  3. 多磁盘 VM

    多个 DataVolume 如果分别被 worker pod 提前绑定到不同节点,最终 VMI 可能找不到一个同时满足所有卷位置的节点。让 KubeVirt 先触发绑定,可以把多个 PVC 的拓扑选择放进同一个 VMI 调度上下文里。

  4. 拓扑受限块存储

    一些云盘、CSI 块存储或 zone 级存储不是全局可挂载。WaitForFirstConsumer 本来就是为这类场景设计的;CDI honor 这个行为后,不会用导入任务破坏 Kubernetes scheduler 的延迟绑定语义。

  5. 热插拔卷

    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 处于 WaitForFirstConsumerPendingPopulation,且没有 --force-bind,上传会报错提示先让 PVC Bound 或使用 force-bind:pkg/virtctl/imageupload/imageupload.go#L539-L540

所以可以按这个规则判断:

  1. 这个 DataVolume 最终要给某个有明确调度约束的 VM 使用,应该 honor WFFC。
  2. 这个 DataVolume 只是作为集群镜像、模板、通用数据源,不关心节点位置,可以 force bind。
  3. 如果存储本身全局可访问,WFFC 的收益不明显,但通常也不会造成错误;主要代价是流程上要等第一个消费者出现。

结论

HonorWaitForFirstConsumer 的核心作用是保护 Kubernetes WaitForFirstConsumer 的延迟绑定语义,避免 CDI 的 import/upload/clone worker pod 抢先成为 PVC 的第一个消费者。

在 KubeVirt 场景里,它把职责拆开了:

  1. CDI 负责识别 WFFC PVC,暂停 worker pod,并把 DataVolume 标记成 WaitForFirstConsumer
  2. KubeVirt 负责识别这个状态,用带有 VMI 调度约束的临时 Pod 触发 PVC 绑定。
  3. PVC Bound 后,CDI 再继续数据导入、上传或克隆。
  4. 数据完成后,KubeVirt 再启动真正的 VM Pod。

这个特性最适合节点或拓扑敏感的虚拟机磁盘场景,尤其是 local PV、HostPath、zone 级块存储、多磁盘 VM 和带强调度约束的 VMI。它解决的不是「如何导入数据」,而是「让谁先决定磁盘应该落在哪个节点或拓扑域」。

参考链接:

#KubeVirt #CDI #Kubernetes #Storage #Virtualization