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.
  • kubectl on 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: Halted creates the VM definition without starting it. Always would start it and restart it if it stops.
  • masquerade connects the VM to the pod network through NAT, so it gets pod networking and works with Services and network policies.
  • cloudInitNoCloud passes 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.