Status: Draft (2026-10-17), based on source code and read-only measurements. Based on KubeVirt
v1.9.0(commit79d34c17). Measurements are based on virt-handler and virt-launcher logs as well as VMI status from a small cluster.
Reading KubeVirt Source Code 4: virt-handler
Table of Contents
- 1. Intuition: Bridging the Gap Between Desired and Actual on the Node
- 2. Diagram
- 3. Code Paths
- 4. Measurement: From Handover to Running
- 5. Limitations
- References
1. Intuition: Bridging the Gap Between Desired and Actual on the Node
In Part 3, the VMI controller writes the node name to the VMI and steps back once the pod is ready. From then on, actually starting and stopping the VM is the responsibility of that node's virt-handler. virt-handler is also a controller, but it compares an unusual pair of states:
- Desired: The VMI object (spec) in the cluster.
- Actual: The domain (status) held by libvirt inside the virt-launcher pod.
It compares the two to choose one of "shut down · delete · reconcile", and writes the result back to the VMI status. It also handles tasks that can only be done outsidethe pod (attaching disks from other containers, creating devices in the pod's network namespace). This is designed to keep virt-launcher's privileges small, and the review guide also follows the principle that "node-level privileged tasks belong to virt-handler, not virt-launcher" (Part 8).
2. Diagram
3. Code Paths
3.1 Two Informers
cmd/virt-handler/virt-handler.go assembled by Run()(:209).
- VMI Informer.
factory.VMISourceHost(app.HostOverride)(:268) uses the label selectorkubevirt.io/nodeName in (<내 노드>)to watch VMIs (virtinformers.go:492). In a cluster with 1,000 nodes, if 1,000 virt-handlers watched all VMIs, the API server would collapse. The label attached by the VMI controller at the end of Part 3 is used here. - Domain Informer.
virtcache.NewSharedInformer(...)(:274) is not a Kubernetes object, but wraps domain events sent by the node's virt-launcherslike an informer. To guard against missed events,worker()(domain-watcher.go:73) periodically callshandleResync()(:142on each launcher's socket toGetDomainrefresh and refill them. The interval is governed by the flag--domain-resync-period-seconds, defaulting to 300 seconds (virt-handler.go:118).
Since both informers push the same key (네임스페이스/이름) into the queue, the same execute() runs whether the VMI changes or the domain changes.
3.2 How Events Escape the Pod
The notify client inside virt-launcher connects to /var/run/kubevirt/domain-notify-pipe.sock in its own filesystem. This socket is not created by the launcher, but planted inside the pod by virt-handler. pipe.InjectNotify()
(notify-server/pipe/pipe.go:54) creates a listening socket inside the pod's mount and bridges incoming connections to the handler's own notify server (Proxy(), :104). The notify service consists of only two RPCs: HandleDomainEvent and HandleK8sEvent (notify.proto:6). The launcher neither needs to know nor has permission to view the handler's socket path.
The reverse direction (handler → launcher) uses the cmd server opened by the launcher. The handler mounts the kubelet's pod directory as /pods and connects via gRPC to
/pods/<파드 UID>/volumes/kubernetes.io~empty-dir/sockets/launcher-sock
(cmd-client/client.go:191).
Cmd Theservice (cmd.proto:6
SyncVirtualMachine·ShutdownVirtualMachine·KillVirtualMachine·GetDomain·Ping ) contains 36 RPCs including retaining compatibility with older launchersbecause handler and launcher versions may differ during upgrades.
3.3 execute — Deciding Whether to Take Over
execute()(vm.go:321) mostly identifies "reasons not to process".
- If the VMI is not in the local informer, it looks it up once more in the global informer, as the domain informer can sometimes be faster than the VMI informer.
- If the VMI is unchanged but the domain's UID differs (a VM recreated with the same name), the old domain is cleared first and checked again after 5 seconds.
- If migration is underway, it is not owned by this node (
isVMIOwnedByNode, :1829), or it is the migration source, it is left alone. Sources and destinations are handled by separate controllers inmigration-source.go·migration-target.go.
Only the remaining cases proceed to sync() .
3.4 sync — Three Branches
sync()(vm.go:1433) first logs the status of the VMI and domain in a single line (: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)
Source: kubevirt/kubevirt v1.9.0 pkg/virt-handler/vm.go line 1459 (Apache-2.0). Next, it computes shouldShutdown·shouldDelete·shouldUpdate and selects one option in the order specified by the comments: shut down due to deletion → clean up already stopped ones → spec changes and initial start(:1571 near switch). Finally, it always uses updateVMIStatus()
(:1097) to write what was read from the domain into the VMI status. Phase, interface list, guest OS info, volume status, and conditions like AgentConnected are populated here.
3.5 Reconciling — Preparations Possible Only Outside the Pod
shouldUpdate branch leads to processVmUpdate()(:2256) →
vmUpdateHelperDefault()(:1933). If the VMI is not yet Running, handleStartingVMI()(:2043) performs pre-startup preparations. If any preparation is incomplete, it defers the key by 1 second and returns.
- Container Disks. Checks if the container holding the disk image (
volumeroot) is ready (ContainerDisksReady), and attaches its files to the launcher'scontainer-disksfolder (MountAndVerify, container-disk/mount.go:246). Because this bridges filesystems between different containers, it cannot be done inside the pod. - Hotplug Volumesare attached and wait for readiness.
- Networking.
setupNetwork()(controller.go:363) enters the pod's network namespace to create bridges and tap devices. It renders the desired layout in nmstate format and applies it (Part 5 diagram). - Device Ownership.
setupDevicesOwnerships()(controller.go:320) adjusts ownership of/dev/kvmso that they can be used by non-root QEMU (107).
Once preparations are complete, syncVirtualMachine()(:2123) packs cluster settings like SMBIOS and memory balloon intervals into options and calls client.SyncVirtualMachine(vmi, options)
(cmd-client/client.go:332). The VMI is passed entirely as JSON, and everything after that is handled by virt-launcher in Part 5.
4. Measurement: From Handover to Running
We aligned the startup of a VMI recreated on 2026-10-13 (the VM from Part 3) using virt-handler and virt-launcher logs. Times are in log UTC, and "+" represents elapsed time from the line where virt-handler first took over.
| +sec | Actor | pos | Content |
|---|---|---|---|
| −0.337 | launcher | virt-launcher.go:85 |
Marked as ready (cmd server started) |
| 0.000 | handler | vm.go:1461 |
VMI is in phase: Scheduled | Domain does not exist |
| +0.006 | handler | netpod.go:187·244 |
Current and desired pod network (bridge 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 |
The VMI became Running 1.1 seconds after virt-handler took over. The longest duration was 0.86 seconds, from network preparation to the notify pipe connection. Domain definition and startup on the launcher side combined took 0.2 seconds (Part 5).
5-Minute Resync. Subsequent lines in the same VM's handler logs were recorded at exactly 300-second intervals like …:03:25, …:08:25, …:13:25 (based on reading a full day of logs on 2026-10-16). This is the mechanism from Section 3.1 where defaultDomainResyncPeriodSeconds = 300 re-queries the domain even if no events occur.
Left in Status. The VMI's status included interfaces[0].infoSource: domain, guest-agent, guestOSInfo(OS name and kernel reported by the guest) and condition AgentConnected(8 seconds after startup). The former two were read and written by the handler from the domain and guest agent.
5. Limitations
- This is a timeline for a single node and a single VM startup. Distribution based on node and VM sizes, as well as the presence of container disks, remains unmeasured. This VM used a PVC with a pre-populated root, so it skipped the container disk phase.
- The shutdown and delete branches (
processVmShutdown·processVmDelete) were not verified through logs this time. - The migration controller (
migration-source.go·migration-target.go) and device manager (Part 7) reside in the same process but were omitted from this article's path.
Titi's Tip. When reading virt-handler, ask yourself: "Why does handler do this instead of launcher?" Almost always, the answer is "Because it needs to touch outside the pod." Privileges are concentrated in a single node daemon, keeping the pods spawned per VM lightweight!
References
All accessed on 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- Review Guide (Informers for node components, placement of privileged tasks): https://github.com/kubevirt/kubevirt/blob/v1.9.0/docs/reviewer-guide.md
- Companion Series "Until Student VMs Boot" Part 1: The Big Picture Across Four Layers