Run virtual machines with KubeVirt
A KubeVirt VM is the heaviest stateful workload you can put on this platform, and this platform is built to replace the ground it stands on. Nodes are treated as cattle: drained and rebuilt on upgrades, rollouts, and repairs. A container tolerates that. A virtual machine holding 8Gi of memory and a disk on local NVMe does not. So before you reach for the manifest, understand what happens the first time its node goes away.
What happens to your VM when the node drains#
When a bare-metal node is drained, the VM's pod (the virt-launcher) is evicted like any other, and KubeVirt reschedules the VM onto another node, but as a fresh VM, from its disk image. If that disk sat on the node's local NVMe , the copy went with the old node: local NVMe is fast but node-pinned. Treat a local-NVMe VM disk as scratch; keep anything you cannot lose on network-backed storage or outside the VM.
The setting that gives a heavy VM a graceful exit is the drain timeout: each worker pool waits up to 180 seconds by default before a node is torn down. Raise it per pool via spec.topology.workers.machineDeployments[].deletion.nodeDrainTimeoutSeconds so a large VM can flush and shut down cleanly instead of being cut off mid-write.
Note
KubeVirt is user-managed upstream software running in your cluster, not a platform component. Pin a specific release rather than installing from releases/latest/download, and design around the fact that node churn will reschedule your VMs.
It runs on bare metal only#
KubeVirt needs hardware virtualization (KVM). Hetzner bare-metal servers have it; Hetzner Cloud instances lack nested virtualization, so VMs will not run there. If your cluster has no bare-metal nodes, add a bare-metal pool first. It is the same KVM requirement as the secure runtime , and the two pair cleanly: KubeVirt for full virtual machines, the secure runtime for pod-level VM isolation.
Install and define a VM#
Install KubeVirt and CDI (the Containerized Data Importer for disk images), pinning a version:
$ VERSION=v1.4.0
$ kubectl apply -f https://github.com/kubevirt/kubevirt/releases/download/$VERSION/kubevirt-operator.yaml
$ kubectl apply -f https://github.com/kubevirt/kubevirt/releases/download/$VERSION/kubevirt-cr.yaml
Then declare a VirtualMachine, backing its disk with local NVMe for speed (scratch storage, per the caveat above):
apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
name: builder
spec:
running: true
template:
spec:
domain:
cpu: {cores: 4}
resources:
requests: {memory: 8Gi}
devices:
disks:
- name: root
disk: {bus: virtio}
volumes:
- name: root
dataVolume: {name: builder-root}
Where it fits and what to reserve#
Where a container is not enough, a VM earns its weight: isolated CI/CD runners where each build gets a clean machine sealed off from the host and the next build (a common first use); legacy applications that need a full OS or a specific kernel and cannot be containerized; and multi-tenant platforms that give each tenant a strongly isolated VM.
A running VM reserves its CPU and memory like a Guaranteed pod, so size the bare-metal pool for the VMs you intend to run plus headroom; otherwise a VM and your containers end up competing for the same cores. For latency-sensitive VMs on a multi-socket server, the same NUMA alignment rules apply.
Run NUMA-aligned workloads
Pin a pod's CPUs and memory to one socket on multi-socket bare metal so latency-sensitive databases and real-time services stop paying the cross-socket tax.
Run a workload in a user namespace
Map container root onto an unprivileged host UID with one field, so a container escape does not land as root on the node.