状态:草稿(2026-10-17),源码 + 只读实测。 基准为 KubeVirt
v1.9.0(提交79d34c17)。实测是通过读取小型集群的 virt-handler·virt-launcher 日志以及 VMI status 得出的。
KubeVirt 源码阅读 4:virt-handler
目录
1. 直觉:在节点上弥合期望与实际的差距
第 3 篇中的 VMI 控制器在 Pod 准备就绪时将节点名称写入 VMI 并放手。此后,实际启动和关闭 VM 的工作就落在了该节点的 virt-handler 身上。virt-handler 也是一个控制器,但它的对比对象很独特。
- 期望状态:集群中的 VMI 对象(Spec)。
- 实际状态:virt-launcher Pod 内的 libvirt 所持有的 Domain(Status)。
通过对比两者,从中选择“关闭·删除·同步”之一,并将结果写回 VMI 的 status 中。并且负责只能在 Pod 外部进行的工作(挂载其他容器的磁盘、在 Pod 的网络命名空间中创建设备)。这是为了尽量降低 virt-launcher 特权的架构设计,审查指南中也有“节点级别的特权操作应该在 virt-handler 中,而不是在 virt-launcher 中”的原则(第 8 篇)。
2. 架构图
3. 代码路径
3.1 两个 Informer
cmd/virt-handler/virt-handler.go 的 Run()(:209)进行组装。
- VMI Informer。
factory.VMISourceHost(app.HostOverride)(:268)通过标签选择器kubevirt.io/nodeName in (<내 노드>)来观察 VMI (virtinformers.go:492)。在拥有 1,000 个节点的集群中,如果 1,000 个 virt-handler 观察所有的 VMI,API Server 将无法承受。第 3 篇末尾 VMI 控制器附加的那个标签就是在这里使用的。 - Domain Informer。
virtcache.NewSharedInformer(...)(:274)包装的不是 Kubernetes 对象,而是 本节点的 virt-launcher 们发送的 Domain 事件,使其看起来像一个 Informer。为了防止漏掉事件,worker()(domain-watcher.go:73)会定期通过handleResync()(:142向各个 launcher 的 socket 查询GetDomain并进行补充。周期由标志位--domain-resync-period-seconds默认的 300 秒 (virt-handler.go:118)决定。
由于两个 Informer 会将相同的键(네임스페이스/이름)放入队列,因此无论是 VMI 发生变化还是 Domain 发生变化,都会运行相同的 execute() 。
3.2 事件是如何离开 Pod 的
virt-launcher 内的 notify 客户端会连接到其文件系统中的 /var/run/kubevirt/domain-notify-pipe.sock 。这个 socket 不是由 launcher 创建的,而是 virt-handler 预先植入 Pod 中的。 pipe.InjectNotify()
(notify-server/pipe/pipe.go:54)会在 Pod 的挂载目录中创建一个监听 socket,并将传入的连接转发给 handler 自己的 notify 服务(Proxy(), :104)。
notify 服务只有 HandleDomainEvent 和 HandleK8sEvent 这两个 RPC
(notify.proto:6)。
launcher 不需要知道 handler 的 socket 路径,也没有权限查看。
反方向(handler → launcher)则是 launcher 打开的 cmd 服务。handler 将 kubelet 的 Pod 目录作为 /pods 挂载并使用 gRPC 连接到
/pods/<파드 UID>/volumes/kubernetes.io~empty-dir/sockets/launcher-sock
(cmd-client/client.go:191)。
Cmd 服务(cmd.proto:6)包含
SyncVirtualMachine·ShutdownVirtualMachine·KillVirtualMachine·GetDomain·Ping 等 36 个 RPC。
由于 handler 和 launcher 的版本可能不一致(在升级过程中),修改此 proto 的 PR 必定会检查
是否与旧 launcher 兼容 是否与旧 launcher 兼容。
3.3 execute — 是否接管
execute()(vm.go:321)主要用来挑选“不予处理的理由”。
- 如果在本地 Informer 中找不到 VMI,则会在全局 Informer 中再找一次。这是因为 Domain Informer 的速度可能会比 VMI Informer 快。
- 如果 VMI 没有变化,但 Domain 的 UID 不同(用相同名称重新创建的 VM),则会先清理旧的 Domain,并在 5 秒后重新检查。
- 如果在迁移中,或者不属于该节点(
isVMIOwnedByNode, :1829),或者作为迁移的源节点,则不予干涉。源节点和目标节点由migration-source.go·migration-target.go的单独控制器负责。
只有在剩余情况下才会进入 sync() 。
3.4 sync — 三个分支
sync()(vm.go:1433)首先会打印一行 VMI 和 Domain 的状态(:1459).
c.logger.Object(vmi).Infof("VMI is in phase: %v | Domain status: %v, reason: %v", vmi.Status.Phase, domain.Status.Status, domain.Status.Reason)
来源:kubevirt/kubevirt v1.9.0 pkg/virt-handler/vm.go 第 1459 行 (Apache-2.0)。然后计算 shouldShutdown·shouldDelete·shouldUpdate ,并按照注释中指定的顺序选择一个。顺序是
删除导致的关闭工作 → 清理已经关闭的虚拟机 → 规格变更与首次启动 删除导致的关闭工作 → 清理已经关闭的虚拟机 → 规格变更与首次启动(:1571 附近的 switch)。最后总是通过 updateVMIStatus()
(:1097将从 Domain 读取的内容写入 VMI status。
阶段(phase)、接口列表、Guest OS 信息、卷状态、 AgentConnected 等条件都是在这里被填入的。
3.5 同步 — 只能在 Pod 外部进行的准备工作
shouldUpdate 分支将进入 processVmUpdate()(:2256) →
vmUpdateHelperDefault()(:1933。如果 VMI 尚未处于 Running
状态,则 handleStartingVMI()(:2043会执行启动前的准备工作。只要有一项准备工作未完成,就会将键延迟 1 秒后重新处理。
- Container Disk。 检查包含磁盘镜像的容器(
volumeroot)是否准备就绪(ContainerDisksReady),并将该容器的文件挂载到 launcher 的container-disks目录中(MountAndVerify, container-disk/mount.go:246)。因为这是连接不同容器文件系统的工作,所以在 Pod 内部是无法完成的。 - 热插拔卷并等待准备就绪。
- 网络。
setupNetwork()(controller.go:363)进入 Pod 的网络命名空间,创建网桥和 tap 设备。以 nmstate 格式绘制期望的形态后进行应用(第 5 篇架构图)。 - 设备所有者。
setupDevicesOwnerships()(controller.go:320)将/dev/kvm等设备的所有者调整为非 root QEMU(107)可以使用的状态。
准备工作完成后, syncVirtualMachine()(:2123会将 SMBIOS、内存气球周期等集群配置包含在选项中,并调用 client.SyncVirtualMachine(vmi, options)
(cmd-client/client.go:332)。VMI 会被完整地序列化为 JSON 传递过去,之后的流程就交给第 5 篇中的 virt-launcher 了。
4. 实测:从被接管到变为 Running
我们将 2026-10-13 重新创建的一个 VMI(第 3 篇中的那个 VM)的启动过程与 virt-handler 和 virt-launcher 的日志进行了对齐。时间为日志的 UTC 时间,“+”表示从 virt-handler 首次接管的那一行开始计算的时间。
| +秒 | 组件 | pos | 内容 |
|---|---|---|---|
| −0.337 | launcher | virt-launcher.go:85 |
Marked as ready(cmd 服务启动) |
| 0.000 | handler | vm.go:1461 |
VMI is in phase: Scheduled | Domain does not exist |
| +0.006 | handler | netpod.go:187·244 |
当前与期望的 Pod 网络(网桥 k6t-eth0) |
| +0.863 | handler | pipe.go:116 |
Accepted new notify pipe connection |
| +0.864 | launcher | manager.go:1651 |
Domain defined. |
| +1.057 | launcher | manager.go:1612 |
Domain started. |
| +1.060 | launcher | server.go:269 |
Synced vmi |
| +1.103 | handler | vm.go:1459 |
VMI is in phase: Scheduled | Domain status: Running |
| +1.136 | handler | vm.go:1459 |
VMI is in phase: Running | Domain status: Running |
在 handler 接管后仅 1.1 秒,VMI 就变为了 Running 状态。从网络准备到建立 notify 管道连接耗时 0.86 秒,是历时最长的环节。 launcher 侧的 Domain 定义和启动合计耗时 0.2 秒(第 5 篇)。
5 分钟 resync。 在同一个 VM 的 handler 日志中,后续的行都像 …:03:25, …:08:25, …:13:25 那样精确地每 300 秒打印一次
(读取了 2026-10-16 全天的日志)。这就是 3.1 节提到的即使没有事件也会重新查询 Domain 的机制。 defaultDomainResyncPeriodSeconds = 300 。
status 中记录的内容。 该 VMI 的 status 中包含 interfaces[0].infoSource: domain, guest-agent, guestOSInfo(Guest 报告的操作系统名称和内核)、条件 AgentConnected(启动 8 秒后)。前两项是 handler 从 Domain 和 Guest Agent 中读取并写入的。
5. 局限性
- 这是单个节点、单次 VM 启动的时间线。根据节点和 VM 规模、是否存在 container disk 的分布情况属于 未实测 状态。这个 VM 因为其根目录已经是填充好的 PVC,所以没有经历 container disk 阶段。 未实测。
- 关闭和删除分支(
processVmShutdown·processVmDelete)这次没有通过日志进行确认。 - 迁移控制器(
migration-source.go·migration-target.go)和设备管理器(第 7 篇)虽然在同一个进程中,但被排除在本文的讨论路径之外。
Titi 的总结。 在阅读 virt-handler 时,请多问自己一句:“为什么这件事不由 launcher 做,而要由 handler 做?”答案几乎总是“因为需要触及 Pod 外部”。将特权集中到一个节点守护进程中,让每个 VM 运行的 Pod 保持轻量!
参考
均于 2026-10-17 访问。
pkg/virt-handler/vm.go: https://github.com/kubevirt/kubevirt/blob/v1.9.0/pkg/virt-handler/vm.gocmd.proto·notify.proto: https://github.com/kubevirt/kubevirt/tree/v1.9.0/pkg/handler-launcher-com- 审查指南(节点组件的 Informer、特权操作的位置): https://github.com/kubevirt/kubevirt/blob/v1.9.0/docs/reviewer-guide.md
- 姊妹系列“学生 VM 是如何启动的”第 1 篇: 从四个层级看整体架构