Status: Draft (2026-10-17), Source + Read-only measurements. Based on KubeVirt
v1.9.0(commit79d34c17). Measurements read logs, objects, and pod specs from a small cluster running v1.9.0, without creating or modifying any VMs.
Reading KubeVirt Source Code 3: virt-controller
Table of Contents
- 1. Intuition: Names only in the queue, decisions always from scratch
- 2. Diagram
- 3. Code Path: Work only when becoming leader
- 4. VM Controller: pkg/virt-controller/watch/vm/vm.go
- 5. VMI Controller: Creating and handing over the pod
- 6. Pod Template: pkg/virt-controller/services/template.go
- 7. Measurements
- 8. Limitations
- References
1. Intuition: Only names in the queue, judgment always starts from scratch
Kubernetes controllers operate based on "what the current state is" rather than "what happened." The event handler only puts the
name(네임스페이스/이름) of the changed object into the work queue, and a worker takes that name, reads the latest object from the cache, and recalculates the "difference between the desired state and the current state" from scratch. Even if the same name enters ten times, it is processed only once, and if it fails, the name is delayed and re-queued.
A single VM in virt-controller goes through these two loops.
- VM Controller: Observes VMs and creates or deletes VMIs (execution strategy).
- VMI Controller: Observes VMIs, creates virt-launcher pods, and hands the VMI over to the node when the pod is ready.
2. Diagram
3. Code path: Must become the leader to work
Even if two pods are running, virt-controller runs the controller on only one leader . In application.go 's leader election callback
onStartedLeading()(:609), it starts informers and launches the controller like go vca.vmController.Run(...) . The default number of workers is 10 for the VMI controller
(:128), and 3 for most others including the VM controller
(:125).
4. VM Controller: pkg/virt-controller/watch/vm/vm.go
4.1 Execute — Dequeue an item
vm.go:336 of the Execute() has the same shape across all controllers.
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)
}
Source: kubevirt/kubevirt v1.9.0 vm.go lines 347–353 (Apache-2.0). Failures are retried with an increasing AddRateLimited , and upon success, Forget clears the backoff history.
4.2 execute → sync
execute()(:357) reads the VM from the cache (indexer), makes a
copy, finds a VMI with the same name, checks the ownership (ownerReference) (}, {ClaimVirtualMachineInstanceByName), gathers the list of DataVolumes, and calls sync() and updateStatus() . Directly modifying objects in the cache would pollute the cache seen by other workers, so KubeVirt code almost always starts with DeepCopy() . This is a frequent comment in code reviews.
sync()(:3215) is structured as follows.
- Check expectations.
satisfiedExpectations()If is false, it returns without doing anything. This is because a request to create a VMI was just made, but that VMI is not yet visible in the informer. Without this mechanism, a lagging cache would cause the VMI to be created twice. - If it is being deleted, stops the VMI and removes the finalizer. Otherwise, it adds the finalizer
kubevirt.io/virtualMachineControllerFinalize. vm.RunStrategy()(Part 2) folds the run strategy into a single option, synchronizes the instancetype, and verifies DataVolume creation and preparation.syncRunStrategy()creates or deletes the VMI using
4.3 syncRunStrategy — Always branch
vm.go:970 's switch runStrategy is the heart of this controller. The Always(:979) branch can be described in plain words as follows.
- If a VMI exists and there is a stop request or it is in a finished state (Succeeded or Failed) , delete it with
stopVMI()(:1658). Creation is handled by the next reconcile (following the comment: "Delete and return so that we start in the next step"). - If a VMI exists and is running, do nothing.
- If no VMI exists, respect the backoff time for VMs with repeated startup failures, or otherwise call
startVMI().
RerunOnFailure reflects the difference that Halted recreates only when Failed and leaves Succeeded as is, while Manual follows only requests injected by subresources (Part 2), as written in each branch.
4.4 startVMI — Things done before creating a VMI
startVMI()(:1257) does not immediately create a VMI, but instead performs five steps first.
SetupVMIFromVM()(:1868— Copies the VM template, populates the name, namespace, and owner (VM), and if the template lacks a firmware UUID, calculates and inserts one from the VM name (CalculateLegacyUUID). This is why the UUID seen by the guest remains the same even after restarting the VM.createVMRevision()— Saves the VM spec used for this startup asControllerRevision. The name isrevision-start-vm-<VM UID>-<generation>(getVMRevisionName, :1698). Later, when the VM spec changes, this serves as the baseline for calculating the "RestartRequired" condition.- Attaches a finalizer to the VMI. This ensures that the VM controller is guaranteed to inspect the VMI at least once before it is deleted.
- Applies the instancetype and default network interface, and re-validates the network.
c.expectations.ExpectCreations(vmKey, 1)(:1333) to record "create one" and creates the VMI. When the informer sees the new VMI,addVirtualMachineInstance()lowers its expected value toCreationObserved.
5. VMI Controller: Creating and Handing Over the Pod
5.1 sync in lifecycle.go
The VMI controller's decision-making logic is located in pkg/virt-controller/watch/vmi/lifecycle.go 's sync()
(:66). If the VMI is being deleted, it deletes the pod; if deletion is complete, it cleans up only the hotplug pods. If no pod exists yet and the VMI is being processed for the first time, it checks several conditions (DataVolume readiness, no ongoing migration, backend storage readiness), draws the pod using RenderLaunchManifest()
(:156), and creates it using createPod()
(:1076). If rejected due to Pod Security Standards, it translates the error into a user-friendly message stating that a "privileged namespace is required" and records it as an event.
5.2 updateStatus — The Handover Moment
updateStatus()(:256) transitions the phase. Once the pod is created, Pending → Scheduling, and in Scheduling , once the pod becomes Ready , it populates the volume and network statuses, changes the phase to Scheduled , and applies the labels kubevirt.io/nodeName=<노드> and status.nodeName to the VMI (:421–426). These labels are precisely what virt-handler uses in Part 4 to select its workload. As seen in the comment from Part 2 in Scheduled , this is where the baton is passed.
6. Pod Template: pkg/virt-controller/services/template.go
RenderLaunchManifest()(:355) first calculates the memory overhead, and delegates the actual assembly to renderLaunchManifest()
(:395). There are five key areas to look at in this nearly 400-line function.
- User. If it is a non-root VMI, it runs with UID 107 (
qemu). Whether it is non-root is determined by the VMI'sstatus.runtimeUser(vmitrait.IsNonRoot), and all practically measured VMIs were 107. - Grace Period. Adds 15 seconds to the VMI's grace period (default 30 seconds) and passes it to virt-launcher, while adding another 15 seconds to the pod itself (:411). The comment means: "Let virt-handler handle shutting down the VM, and prevent Kubernetes from killing the pod prematurely and forcibly turning off the VM."
- Command. The command for container
computeconsists ofvirt-launcher-monitorand a set of flags (:460).--qemu-timeoutadds a random value between 0 and 119 seconds to the default 240 seconds (application.go:115) (qemuTimeoutJitterRange = 120, :101). This staggers multiple VMs so they do not all time out simultaneously. - Sidecar. The sidecar streaming serial console logs
guest-console-logis included in initContainers asrestartPolicy: Always(serialconsolelog.go:45). Because it is a Kubernetes native sidecar, it starts before the main container and terminates after it. VMs using container disks have an additional init container attached to pre-fetch the disk images (Part 5). - Resources. CPU request is vCPU count ÷
cpuAllocationRatio(default 10), and memory request is guest memory + overhead. KVM's overhead estimation formula is located inpkg/hypervisor/kvm/hypervisorbackend.go'sGetMemoryOverhead()(:69). Adding five fixed values (virt-launcher-monitor 25Mi, virt-launcher 100Mi, virtlogd 25Mi, virtqemud 40Mi, QEMU 30Mi — :38) plus 1/512 of guest memory (page tables), 8Mi per vCPU, 8Mi for IO threads, 32Mi for graphics devices, and an extra 1Gi if a VFIO device is present (Part 7).
7. Measurements
Verified through inspection on a small cluster running v1.9.0 (2026-10-17).
Leader. The holder of the lease kubevirt/virt-controller was one of the two pods (lease duration 15serializers/seconds), while the other pod's logs stopped at a single line during startup: "attempting to acquire leader lease". The standby pod only targets the lease and runs no controllers.
Log of a Single Restart. On 2026-10-13, a VMI for a VM (runStrategy: Always) ended in Failed (the reason for termination is out of scope for this article). Extracting only the lines for that VM from the leader's logs clearly reveals the branching logic from Section 4.3. All line numbers for pos match the v1.9.0 source code.
| Time (UTC) | pos | Message |
|---|---|---|
| 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 … |
It took 0.19 seconds from the initial judgment to the creation of the new VMI. It is also visible that deletion and creation occurred in different reconciles (with a Stopping line for the same VM in between).
Traces Left on the New VMI. That VMI's status.virtualMachineRevisionName was revision-start-vm-<VM UID>-1 (Section 4.4, item 2), and
metadata.finalizers and kubevirt.io/virtualMachineControllerFinalize , kubevirt.io/foregroundDeleteVirtualMachine, annotations
kubevirt.io/vm-generation: "1", and labels kubevirt.io/nodeName were attached. Phase history (phaseTransitionTimestamps) was
Pending·Scheduling 00:42:32 → Scheduled 00:42:35 → Running 00:42:36.
Pod specs and formulas. We read a virt-launcher pod for the same VMI (2 vCPU, 2Gi guest, root on PVC) and a practical VM (8 vCPU, 8Gi, container disk).
| Item | 2 vCPU · 2Gi | 8 vCPU · 8Gi | Code |
|---|---|---|---|
| CPU Request | 200m | 800m | vCPU ÷ 10 |
| Memory Request | 2328Mi | 8532Mi | Guest + Overhead |
Overhead (Annotation kubevirt.io/memory-overhead-bytes) |
293,601,280 B = 280Mi | 356,515,840 B = 340Mi | Calculation below |
--grace-period-seconds / Pod Grace Period |
45 / 60 | 45 / 60 | 30+15, +15 |
--qemu-timeout |
283s | 309s | 240 + 0–119 |
| User | runAsUser 107, runAsNonRoot | Same | Non-root default |
Recalculating the overhead formula, a 2Gi VM has fixed 220Mi + page table 4Mi + vCPU 16Mi + IO thread 8Mi + graphics 32Mi = 280Mi, and an 8Gi VM has 220 + 16 + 64 + 8 + 32 = 340Mi , which matches the measured annotations down to the byte. This is where the "about 280Mi" from part 2 of "Until the Student VM Boots" comes from.
Additionally, the pod had a readiness gate kubevirt.io/virtual-machine-unpaused(pausing the VM removes the pod from Ready), device resource
devices.kubevirt.io/kvm·tun·vhost-net requests, and guest-console-log init container(restartPolicy: Always, CPU 5m, Memory 35M).
8. Limitations
- The overhead formula is a requested value. Actual memory usage was measured separately in part 5 (RSS per process). Refer to that part to see how much the fixed formula value of 220Mi differs from reality.
- Restart logs are a single event. We did not directly reproduce "creating twice" prevented by expected values (unmeasured).
- ). Migration, hotplug, and instancetype synchronization are in the same file but were left out of the scope of this post.
A word from Titi. When reading controller code, constantly ask, "What happens if this function fails?" The answer is almost always, "Put the name back in the queue and look at it from the beginning next time." That is why even if a function is slightly flawed, it eventually converges to the correct state!
References
All accessed on 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 User Guide, RunStrategy: https://kubevirt.io/user-guide/compute/run_strategies/
- Kubernetes Documentation, Native Sidecar Containers: https://kubernetes.io/docs/concepts/workloads/pods/sidecar-containers/
- Companion Series "Until the Student VM Boots" Part 2: Inside KubeVirt, Part 5: Lifespan and Reclamation