状态:草稿 (2026-10-17),源码 + 只读实测。 基准为 KubeVirt
v1.9.0(提交79d34c17)。实测是通过读取安装了 v1.9.0 的小型集群的日志、对象和 Pod 规格完成的,并未创建或修改新的 VM。
KubeVirt 源码阅读 3:virt-controller
目录
- 1. 直觉:队列中只有名称,判断永远从头开始
- 2. 图示
- 3. 代码路径:只有成为领导者才能工作
- 4. VM 控制器:pkg/virt-controller/watch/vm/vm.go
- 5. VMI 控制器:创建并移交 Pod
- 6. Pod 模板:pkg/virt-controller/services/template.go
- 7. 实测
- 8. 局限性
- 参考
1. 直觉:队列中只有名称,判断永远从头开始
Kubernetes 控制器的运作依据不是“发生了什么”,而是“现在的状态是什么”。事件处理器只会将变更对象的
名称(네임스페이스/이름)放入工作队列中,由工作线程取出该名称,从缓存中读取最新对象,然后从头重新计算“期望状态与当前状态的
差距”。即使相同的名称被传入十次也只会处理一次,如果失败则会延迟并将名称重新放回队列。
virt-controller 中的一个 VM 会经历这两个环节。
- VM 控制器:观察 VM 并创建或删除 VMI(运行策略)。
- VMI 控制器:观察 VMI 并创建 virt-launcher Pod,当 Pod 准备就绪后将 VMI 移交给节点。
2. 图示
3. 代码路径:只有成为领导者才能工作
即使部署了两个 Pod,virt-controller 也只会让 单个领导者 运行控制器。在 application.go 的领导者选举回调
onStartedLeading()(:609)中
启动 Informer 并像 go vca.vmController.Run(...) 那样拉起控制器。工作线程数的默认值为:VMI 控制器 10
(:128),包括 VM 控制器在内的多数控制器为
3(:125)。
4. VM 控制器: pkg/virt-controller/watch/vm/vm.go
4.1 Execute — 从队列中取出 一个
vm.go:336 中的 Execute() 对所有控制器来说结构都是相同的。
if err := c.execute(key); err != nil {
log.Log.Reason(err).Infof("re-enqueuing VirtualMachine %v", key)
c.Queue.AddRateLimited(key)
} else {
log.Log.V(4).Infof("processed VirtualMachine %v", key)
c.Queue.Forget(key)
}
来源:kubevirt/kubevirt v1.9.0 vm.go 第 347–353 行(Apache-2.0)。失败时会通过 AddRateLimited 逐渐延长重试间隔,成功时则通过 Forget 清除退避记录。
4.2 execute → sync
execute()(:357)会从缓存(Index)中读取 VM 并创建
副本,查找同名的 VMI 并验证所有权(ownerReference)(ClaimVirtualMachineInstanceByName),收集 DataVolume 列表后调用 sync() 和 updateStatus() 。如果直接修改缓存中的对象会污染其他工作线程看到的缓存,因此 KubeVirt 代码几乎总是
从 DeepCopy() 开始。这是代码审查中经常被指出的点。
sync()(:3215)的顺序如下。
- 检查期望值。
satisfiedExpectations()如果 为假,则什么都不做直接返回。这是因为刚刚请求创建了 VMI,但该 VMI 尚未在 Informer 中可见。如果没有这个机制,当缓存延迟时会重复创建 VMI。 - 如果正在删除, 则停止 VMI 并移除终结器(finalizer)。否则附加终结器
kubevirt.io/virtualMachineControllerFinalize。 vm.RunStrategy()(第 2 篇)将运行策略折叠为单一策略,同步 instancetype,并检查 DataVolume 的创建与就绪状态。syncRunStrategy()使用 创建或删除 VMI。
4.3 syncRunStrategy — Always 分支
vm.go:970 中的 switch runStrategy 是该控制器的核心。用自然语言描述 Always(:979分支如下:
- 如果存在 VMI 且 存在停止请求或处于结束状态(Succeeded·Failed) ,则通过
stopVMI()(:1658将其删除。创建新工作由 下一次 reconcile 完成(注释为:“删除并返回,以便在下一阶段开始”)。 - 如果存在 VMI 且处于存活状态,则什么都不做。
- 如果不存在 VMI,对于启动失败重复的 VM 遵守退避时间(backoff),否则调用
startVMI()。
RerunOnFailure 中指出, 仅在 Failed 时重新拉起,而 Succeeded 时则保持不动; Halted 在存在 VMI 时仅执行删除; Manual 仅遵循子资源引入的
请求(第 2 篇),这些差异在各个分支中都有明确记录。
4.4 startVMI — 在创建 VMI 之前要做的事
startVMI()(:1257)不会立即创建 VMI,而是先做五件事。
SetupVMIFromVM()(:1868) — 复制 VM 的模板,填入名称、命名空间以及所有者(VM),如果模板中没有固件 UUID,则根据 VM 名称计算并填入(CalculateLegacyUUID)。这就是为什么重启 VM 后客户机看到的 UUID 保持不变。createVMRevision()— 将用于本次启动的 VM 规格保存为ControllerRevision。名称为revision-start-vm-<VM UID>-<generation>(getVMRevisionName, :1698)。这成为日后当 VM 规格变更时计算“需要重启”(RestartRequired)条件的基准。- 为 VMI 附加终结器。这是为了确保在 VMI 被删除之前,VM 控制器必定能看到它一次。
- 应用 instancetype、默认网络接口并重新验证网络。
c.expectations.ExpectCreations(vmKey, 1)(:1333)记录“将创建一个”并创建 VMI。当 Informer 看到新的 VMI 时,addVirtualMachineInstance()会将期望值减CreationObserved。
5. VMI 控制器:创建并移交 Pod
5.1 lifecycle.go 中的 sync
VMI 控制器的判断逻辑位于 pkg/virt-controller/watch/vmi/lifecycle.go 的 sync()
(:66)中。如果 VMI 正在被删除,则删除 Pod;如果已结束,则清理热插拔专用的 Pod。如果尚无 Pod 且 VMI 正在经历首次处理,则在确认若干条件(DataVolume 准备就绪、无进行中的迁移、后端存储就绪)后,通过 RenderLaunchManifest()
(:156绘制 Pod 并通过 createPod()
(:1076进行创建。如果因 Pod 安全标准受阻而被拒绝,则会将其转换为“需要 privileged 命名空间”的友好错误并记录在事件中。
5.2 updateStatus — 移交的那一刻
updateStatus()(:256)负责迁移阶段(phase)。创建 Pod 后为 Pending → Scheduling,接着在 Scheduling 中,当 Pod 变为 Ready 时,填充卷与网络 status,将阶段更改为 Scheduled ,同时在 VMI 上写入标签 kubevirt.io/nodeName=<노드> 和 status.nodeName (:421–426)。这个标签正是第 4 篇中 virt-handler 用来选择自己工作内容的标准。正如第 2 篇中看到的 Scheduled 的注释一样,接力棒正是在这里完成交接的。
6. Pod 模板: pkg/virt-controller/services/template.go
RenderLaunchManifest()(:355)首先计算内存开销,实际组装则交由 renderLaunchManifest()
(:395)负责。在这个近乎 400 行的函数中,有五个重点需要关注。
- 用户。 如果是非 root VMI,则以 UID 107(
qemu)运行。是否为非 root 取决于 VMI 的status.runtimeUser(vmitrait.IsNonRoot),实测的 VMI 全部为 107。 - 宽限期。 将 VMI 的宽限期(默认 30 秒)加上 15 秒后传递给 virt-launcher,而 Pod 本身在此基础上再增加 15 秒(:411)。注释的含义是:“关闭 VM 的工作应交由 virt-handler 处理,防止 Kubernetes 提前杀死 Pod 导致 VM 被强制关闭”。
- 命令。 容器
compute的命令是virt-launcher-monitor及标志合集(:460).--qemu-timeout默认 240 秒 (application.go:115)外加 0–119 秒的随机值(qemuTimeoutJitterRange = 120, :101)。这样可以错开多个 VM,防止它们同时因超时而结束。 - Sidecar。 负责输出串行控制台日志的
guest-console-log以restartPolicy: Always形式进入 initContainers(serialconsolelog.go:45)。由于它是 Kubernetes 的 原生 Sidecar,因此会比主容器先启动、后退出。使用容器磁盘的 VM 会附加更多的初始化容器来预先拉取磁盘镜像(第 5 篇)。 - 资源。 CPU 请求数为 vCPU 数量 ÷
cpuAllocationRatio(默认 10),内存请求数为客户机内存 + 开销。KVM 的开销估算公式位于pkg/hypervisor/kvm/hypervisorbackend.go的GetMemoryOverhead()(:69)中。在五个固定值(virt-launcher-monitor 25Mi、virt-launcher 100Mi、virtlogd 25Mi、virtqemud 40Mi、QEMU 30Mi — :38)的基础上,加上客户机内存的 1/512(页表)、每个 vCPU 8Mi、IO 线程 8Mi、图形设备 32Mi,若存在 VFIO 设备则额外增加 1Gi(第 7 篇)。
7. 实测
在安装了 v1.9.0 的小型集群中通过读取进行了验证(2026-10-17)。
领导者。 租约(lease) kubevirt/virt-controller 的持有者是两个 Pod 中的一个(租约期限 15 秒),另一个 Pod 的日志停留在启动时的“attempting to acquire leader lease”这一行。处于等待状态的 Pod 只盯着租约,不运行任何控制器。
单次重启日志。 2026-10-13,某个 VM(运行策略 Always)的 VMI 以 Failed 结束(结束原因超出本文范围)。如果在领导者日志中只提取该 VM 的行,4.3 节的分支将一目了然。 pos 的行号与 v1.9.0 源码完全一致。
| 时间(UTC) | 位置 | 消息 |
|---|---|---|
| 00:42:32.011 | vm.go:1005 |
Stopping VM with VMI in phase Failed and VM runStrategy: Always |
| 00:42:32.042 | vm.go:1685 |
Dispatching delete event for vmi … with phase Failed |
| 00:42:32.179 | vm.go:1032 |
Starting VM due to runStrategy: Always |
| 00:42:32.196 | vm.go:1341 |
Started VM by creating the new virtual machine instance … |
从初次判断到创建新 VMI 耗时 0.19 秒。还可以看出删除与创建发生在 不同的 reconcile 中(期间有另一次相同 VM 的 Stopping 行)。
新 VMI 中留下的痕迹。 该 VMI 的 status.virtualMachineRevisionName 为 revision-start-vm-<VM UID>-1 (4.4 节的 2),
metadata.finalizers 是 kubevirt.io/virtualMachineControllerFinalize 和 kubevirt.io/foregroundDeleteVirtualMachine, 注释
kubevirt.io/vm-generation: "1", 标签 kubevirt.io/nodeName 。阶段记录(phaseTransitionTimestamps)为:Pending·Scheduling 00:42:32 → Scheduled 00:42:35 → Running 00:42:36。
Pod 规格与计算公式。 读取了类似 VMI 的 virt-launcher Pod(2 vCPU·Guest 2Gi,根目录使用 PVC)以及一个用于实操的 VM(8 vCPU·8Gi,容器磁盘)。
| 项目 | 2 vCPU · 2Gi | 8 vCPU · 8Gi | 代码 |
|---|---|---|---|
| CPU 请求 | 200m | 800m | vCPU ÷ 10 |
| 内存请求 | 2328Mi | 8532Mi | Guest + 开销 |
开销(注释 kubevirt.io/memory-overhead-bytes) |
293,601,280 B = 280Mi | 356,515,840 B = 340Mi | 下方计算 |
--grace-period-seconds / Pod 宽限期 |
45 / 60 | 45 / 60 | 30+15, +15 |
--qemu-timeout |
283s | 309s | 240 + 0–119 |
| 用户 | runAsUser 107, runAsNonRoot | 相同 | 非 root 默认 |
用公式重新计算开销:2Gi VM 为固定值 220Mi + 页表 4Mi + vCPU 16Mi + IO 线程 8Mi + 图形 32Mi = 280Mi, 8Gi VM 为 220 + 16 + 64 + 8 + 32 = 340Mi ,与实测注释在字节级别完全一致。“学生 VM 启动过程”第 2 篇中的“约 280Mi”正是来源于此。
此外,Pod 还包含就绪门槛(readiness gate kubevirt.io/virtual-machine-unpaused(暂停 VM 会使 Pod 退出 Ready 状态))、设备资源
devices.kubevirt.io/kvm·tun·vhost-net 请求,以及 guest-console-log 初始化容器(restartPolicy: Always, CPU 5m·内存 35M)。
8. 局限性
- 开销公式是 请求值。实际使用的内存已在第 5 篇中单独测量(按进程统计的 RSS)。公式中的固定值 220Mi 与实际情况的差距请参考该篇。
- 重启日志是一次性事件。未亲自复现期望值所阻止的“重复创建”(未实测).
- )。迁移、热插拔、instancetype 同步虽然在同一个文件中,但未包含在本文的探讨范围内。
Titi 的小提示。 阅读控制器代码时,请不断自问:“如果这个函数失败了会怎么样?”通常答案总是:“将名称重新放回队列,下次从头开始处理。”因此,即使函数稍有瑕疵,最终也会收敛到正确状态!
参考资料
均于 2026-10-17 访问。
vm.go: https://github.com/kubevirt/kubevirt/blob/v1.9.0/pkg/virt-controller/watch/vm/vm.golifecycle.go: https://github.com/kubevirt/kubevirt/blob/v1.9.0/pkg/virt-controller/watch/vmi/lifecycle.gotemplate.go: https://github.com/kubevirt/kubevirt/blob/v1.9.0/pkg/virt-controller/services/template.go- KubeVirt 用户指南,运行策略: https://kubevirt.io/user-guide/compute/run_strategies/
- Kubernetes 文档,原生 Sidecar 容器: https://kubernetes.io/docs/concepts/workloads/pods/sidecar-containers/
- 姊妹系列“学生 VM 启动过程”第 2 篇: KubeVirt 内部原理,第 5 篇: 生命周期与回收