KubeVirt is a CNCF project that adds virtual machines to Kubernetes. Each VM runs inside a pod with QEMU/KVM, so you manage it with kubectl, schedule it like any workload and put it behind normal Services. In this tutorial you will install KubeVirt and the Containerized Data Importer (CDI), create an Ubuntu 24.04 VM from a container disk, create a second VM with a persistent disk imported from the Ubuntu cloud image, and reach both with virtctl.
Prerequisites
To follow this tutorial, you will need:
- A Kubernetes cluster (one of the last three minor releases) with worker nodes running Ubuntu 24.04 and at least 4 vCPUs and 8 GB of RAM each, so there is room for a 2 GB VM next to the system pods.
- Hardware virtualization on the worker nodes. On bare metal this is enabled in the BIOS (VT-x or AMD-V). If the nodes are themselves virtual machines, the hypervisor must expose nested virtualization. Step 1 shows how to check.
- A default StorageClass for Step 5, such as Longhorn or OpenEBS.
kubectlon the machine you manage the cluster from, and an SSH key pair (~/.ssh/id_ed25519.pub) to log in to the VMs.
Step 1 - Checking virtualization support on the nodes
On each worker node, count the CPU flags for Intel VT-x (vmx) or AMD-V (svm). Any number above zero means the CPU exposes virtualization:
grep -cE 'vmx|svm' /proc/cpuinfo
4
Then confirm that the KVM device exists:
ls -l /dev/kvm
crw-rw---- 1 root kvm 10, 232 Sep 25 09:12 /dev/kvm
If the count is 0 or /dev/kvm is missing, KubeVirt cannot use hardware acceleration. For a test cluster you can still continue and enable software emulation in Step 2, but VMs will be very slow. Do not use emulation in production.
Step 2 - Installing KubeVirt
KubeVirt is installed with an operator. Read the latest stable version, then create the operator and the KubeVirt custom resource that tells it to deploy the components:
export KUBEVIRT_VERSION=$(curl -s https://storage.googleapis.com/kubevirt-prow/release/kubevirt/kubevirt/stable.txt)
echo "$KUBEVIRT_VERSION"
kubectl create -f "https://github.com/kubevirt/kubevirt/releases/download/${KUBEVIRT_VERSION}/kubevirt-operator.yaml"
kubectl create -f "https://github.com/kubevirt/kubevirt/releases/download/${KUBEVIRT_VERSION}/kubevirt-cr.yaml"
Only if Step 1 showed no hardware virtualization, enable emulation:
kubectl -n kubevirt patch kubevirt kubevirt --type=merge \
--patch '{"spec":{"configuration":{"developerConfiguration":{"useEmulation":true}}}}'
Wait until KubeVirt reports itself available. This can take a few minutes:
kubectl -n kubevirt wait kv kubevirt --for condition=Available --timeout=10m
kubectl -n kubevirt get pods
kubevirt.kubevirt.io/kubevirt condition met
NAME READY STATUS RESTARTS AGE
virt-api-7c9d7b8f5d-2xk4p 1/1 Running 0 3m
virt-controller-6d8f7c9b4f-9mzq7 1/1 Running 0 2m
virt-controller-6d8f7c9b4f-tl2wb 1/1 Running 0 2m
virt-handler-8qfxn 1/1 Running 0 2m
virt-handler-zk7tp 1/1 Running 0 2m
virt-operator-5b6f9d7c8d-4ptrx 1/1 Running 0 5m
virt-operator-5b6f9d7c8d-kcw2m 1/1 Running 0 5m
There is one virt-handler per node; it manages the VMs on that node.
Step 3 - Installing virtctl
virtctl starts and stops VMs and opens consoles and SSH sessions to them. Download the release that matches your KubeVirt version and install it to /usr/local/bin:
ARCH=amd64
if [ "$(uname -m)" = "aarch64" ]; then ARCH=arm64; fi
curl -L -o virtctl "https://github.com/kubevirt/kubevirt/releases/download/${KUBEVIRT_VERSION}/virtctl-${KUBEVIRT_VERSION}-linux-${ARCH}"
sudo install -m 0755 virtctl /usr/local/bin/virtctl
rm virtctl
virtctl version
Client Version: version.Info{GitVersion:"v1.6.1", ...}
Server Version: version.Info{GitVersion:"v1.6.1", ...}
Step 4 - Creating a VM from a container disk
The quickest way to boot a VM is a container disk: a disk image packaged in an OCI image and pulled from a registry. The quay.io/containerdisks/ubuntu images contain the official Ubuntu cloud images. Container disks are ephemeral: changes are lost when the VM stops, which is fine for tests and stateless workers.
Create the manifest. Paste your public key (the content of ~/.ssh/id_ed25519.pub) in place of your_public_ssh_key:
nano ubuntu-vm.yaml
apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
name: ubuntu-vm
spec:
runStrategy: Halted
template:
metadata:
labels:
kubevirt.io/vm: ubuntu-vm
spec:
domain:
cpu:
cores: 2
resources:
requests:
memory: 2Gi
devices:
disks:
- name: rootdisk
disk:
bus: virtio
- name: cloudinitdisk
disk:
bus: virtio
interfaces:
- name: default
masquerade: {}
networks:
- name: default
pod: {}
volumes:
- name: rootdisk
containerDisk:
image: quay.io/containerdisks/ubuntu:24.04
- name: cloudinitdisk
cloudInitNoCloud:
userData: |
#cloud-config
users:
- name: ubuntu
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh_authorized_keys:
- your_public_ssh_key
Key parts of the spec:
runStrategy: Haltedcreates the VM definition without starting it.Alwayswould start it and restart it if it stops.masqueradeconnects the VM to the pod network through NAT, so it gets pod networking and works with Services and network policies.cloudInitNoCloudpasses cloud-init user data, here a user with your SSH key.
Create the VM and start it:
kubectl apply -f ubuntu-vm.yaml
virtctl start ubuntu-vm
A running VM is represented by a VirtualMachineInstance (VMI):
kubectl get vm,vmi
NAME AGE STATUS READY
virtualmachine.kubevirt.io/ubuntu-vm 1m Running True
NAME AGE PHASE IP NODENAME READY
virtualmachineinstance.kubevirt.io/ubuntu-vm 45s Running 10.0.1.87 k8s-w1 True
Watch the boot on the serial console. Press Ctrl+] to leave it:
virtctl console ubuntu-vm
Once cloud-init has finished, log in over SSH. virtctl ssh tunnels through the Kubernetes API, so the VM does not need a public address:
virtctl ssh ubuntu@vm/ubuntu-vm --identity-file ~/.ssh/id_ed25519
Inside the VM, confirm the release:
lsb_release -d
Description: Ubuntu 24.04.3 LTS
Type exit to leave. Stop the VM when you no longer need it:
virtctl stop ubuntu-vm
Step 5 - Creating a VM with a persistent disk
For VMs whose data must survive restarts, use a PersistentVolumeClaim as the root disk. CDI imports disk images from a URL or registry into PVCs. Install it with its operator:
export CDI_VERSION=$(basename "$(curl -s -w '%{redirect_url}' https://github.com/kubevirt/containerized-data-importer/releases/latest)")
echo "$CDI_VERSION"
kubectl create -f "https://github.com/kubevirt/containerized-data-importer/releases/download/${CDI_VERSION}/cdi-operator.yaml"
kubectl create -f "https://github.com/kubevirt/containerized-data-importer/releases/download/${CDI_VERSION}/cdi-cr.yaml"
kubectl wait cdi cdi --for condition=Available --timeout=10m
cdi.cdi.kubevirt.io/cdi condition met
Now define a VM with a dataVolumeTemplates section. When the VM is created, CDI creates a 20 GiB PVC on the default StorageClass, downloads the Ubuntu 24.04 cloud image into it and resizes it:
nano ubuntu-persistent.yaml
apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
name: ubuntu-persistent
spec:
runStrategy: Always
dataVolumeTemplates:
- metadata:
name: ubuntu-persistent-root
spec:
storage:
resources:
requests:
storage: 20Gi
source:
http:
url: https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
template:
metadata:
labels:
kubevirt.io/vm: ubuntu-persistent
spec:
domain:
cpu:
cores: 2
resources:
requests:
memory: 2Gi
devices:
disks:
- name: rootdisk
disk:
bus: virtio
- name: cloudinitdisk
disk:
bus: virtio
interfaces:
- name: default
masquerade: {}
networks:
- name: default
pod: {}
volumes:
- name: rootdisk
dataVolume:
name: ubuntu-persistent-root
- name: cloudinitdisk
cloudInitNoCloud:
userData: |
#cloud-config
users:
- name: ubuntu
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh_authorized_keys:
- your_public_ssh_key
Apply it and follow the import. The VM starts automatically when the DataVolume reaches Succeeded:
kubectl apply -f ubuntu-persistent.yaml
kubectl get dv ubuntu-persistent-root -w
NAME PHASE PROGRESS RESTARTS AGE
ubuntu-persistent-root ImportInProgress 37.52% 40s
ubuntu-persistent-root ImportInProgress 81.03% 1m10s
ubuntu-persistent-root Succeeded 100.0% 1m45s
Press Ctrl+C, then connect as before:
virtctl ssh ubuntu@vm/ubuntu-persistent --identity-file ~/.ssh/id_ed25519
Create a file inside the VM, restart it with virtctl restart ubuntu-persistent, and log in again: the file is still there.
Step 6 - Exposing a VM with a Service
VMs can be reached through normal Kubernetes Services. virtctl expose creates one that selects the VM's pod. To reach SSH from outside the cluster through a NodePort:
virtctl expose vm ubuntu-persistent --name ubuntu-persistent-ssh --type NodePort --port 22
kubectl get svc ubuntu-persistent-ssh
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
ubuntu-persistent-ssh NodePort 10.96.141.212 <none> 22:31522/TCP 5s
Connect using any node IP and the assigned port:
ssh -p 31522 ubuntu@your_node_ip
For web applications inside a VM, expose the application port with --type ClusterIP instead and route it through your Ingress controller.
Step 7 - Live migrating a VM (optional)
KubeVirt can move a running VM to another node without stopping it, for example before draining a node for maintenance. This requires every disk of the VM to be on storage with the ReadWriteMany access mode, such as Longhorn RWX volumes or Ceph RBD. Check whether a VM can be migrated:
kubectl get vmi ubuntu-persistent -o jsonpath='{.status.conditions[?(@.type=="LiveMigratable")].status}{"\n"}'
If it prints True, trigger the migration and watch it:
virtctl migrate ubuntu-persistent
kubectl get vmim -w
When the migration object reaches Succeeded, kubectl get vmi ubuntu-persistent shows the new node. If it prints False, kubectl describe vmi ubuntu-persistent explains the reason, usually a ReadWriteOnce disk.
Troubleshooting
The VMI stays in Scheduling with Insufficient devices.kubevirt.io/kvm. No node exposes /dev/kvm. Enable virtualization in the BIOS or nested virtualization on the hypervisor, or enable emulation for testing as shown in Step 2. Check what each node offers with kubectl describe node your_node | grep devices.kubevirt.io.
The VM boots but SSH is refused. cloud-init has not finished or the key is wrong. Watch the boot with virtctl console and look for the Cloud-init ... finished line. The launcher pod logs also help: kubectl logs -l kubevirt.io/vm=ubuntu-vm -c compute.
The DataVolume stays Pending. There is no default StorageClass, or it cannot provision the requested size. Check kubectl get storageclass and kubectl describe pvc ubuntu-persistent-root.
Conclusion
You installed KubeVirt and CDI, booted an ephemeral Ubuntu 24.04 VM from a container disk, created a persistent VM from the official cloud image, reached both with virtctl and exposed SSH through a Service. From here you can add a second network interface with Multus to connect VMs to a VLAN, take VM snapshots with the VirtualMachineSnapshot API on a CSI driver that supports snapshots, or import existing VMs from other hypervisors with CDI.
