TT Lab
Blog

한국어 English 日本語 中文

Get started
Blog

Blog

Reading KubeVirt Source Code 4: virt-handler — Reconciling VMIs on Nodes

Status: Draft (2026-10-17), based on source code and read-only measurements. Based on KubeVirt v1.9.0(commit 79d34c17). 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

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:

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

virt-handler — 노드 한 대의 VMI 를 맞추는 데몬 두 가지를 본다: 클러스터의 VMI(바라는 것)와 파드 안의 도메인(실제) 노드 · virt-handler (DaemonSet) VMI 인포머 라벨 kubevirt.io/nodeName = 이 노드만 도메인 인포머 notify 서버로 받은 이벤트 + 300초마다 resync 작업 큐 → execute(key) → sync() shutdown 지울 때 delete 정리할 때 update 시작 · 변경 handleStartingVMI() — 파드 밖에서만 할 수 있는 일 컨테이너 디스크를 launcher 쪽에 붙이고 확인 파드 네트워크 네임스페이스에 브리지 · tap 만들기 /dev/kvm 같은 장치 파일 소유자 맞추기 virt-launcher 파드 cmd 서버 (gRPC) …/sockets/launcher-sock SyncVirtualMachine notify 클라이언트 도메인 이벤트를 handler 로 보낸다 syncVirtualMachine() 결과는 updateVMIStatus() 가 VMI status 에 적는다 (phase · interfaces · volumeStatus · conditions) 실측 타임라인(같은 VM, 로그의 시각): +0.000s 「Scheduled · Domain does not exist」 — handler 가 넘겨받음 +0.006s 파드 네트워크 설계(nmstate) — k6t-eth0 브리지 +0.863s launcher 의 notify 파이프 연결 +1.103s 「Scheduled · Domain status: Running」
Figure: virt-handler's two informers, the three branches of sync, and communication with virt-launcher

3. Code Paths

3.1 Two Informers

cmd/virt-handler/virt-handler.go assembled by Run()(:209).

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".

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.

  1. Container Disks. Checks if the container holding the disk image (volumeroot) is ready (ContainerDisksReady), and attaches its files to the launcher's container-disks folder (MountAndVerify, container-disk/mount.go:246). Because this bridges filesystems between different containers, it cannot be done inside the pod.
  2. Hotplug Volumesare attached and wait for readiness.
  3. 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).
  4. Device Ownership. setupDevicesOwnerships()(controller.go:320) adjusts ownership of /dev/kvm so 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

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.

Sign in to like

Comments

No comments yet.

Sign in to leave a comment