Backing up and Restoring Kubevirt VMs with Velero

Since OpenShift Virtualization (Kubevirt) runs VMs inside a container, these can be backed up using Velero just like any other containerized workload.

Pre-checks

Use snapshottable storage

You can skip this section if your VMs are already backed by snapshotable storage like Synology, Ceph/ODF, etc.

The assumption is that your disks are iSCSI backed by a snapshot capable CSI driver. This sections talks about converting QCOW images to raw iscsi disks.

  1. Create a peristent volume claim large enough to hold the non-sparse disk contents:

    apiVersion: v1
    kind: PersistentVolumeClaim
    metadata:
      name: radius02-iscsi
      namespace: virtual-machines
    spec:
      accessModes:
        - ReadWriteOnce
      volumeMode: Block
      resources:
        requests:
          storage: 25Gi
      storageClassName: synology-iscsi
    

  2. Create a small utility pod that you can mount both filesystems in order to transfer the data:

    apiVersion: v1
    kind: Pod
    metadata:
      name: disk-migrator
      namespace: virtual-machines
    spec:
      restartPolicy: Never
      containers:
        - name: mover
          image: registry.access.redhat.com/ubi9/ubi
          command: ["/bin/bash", "-c", "sleep infinity"]
          securityContext:
            privileged: true
          volumeDevices:
          - name: target
            devicePath: /dev/target
          volumeMounts:
          - name: source
            mountPath: /source
        volumes:
        - name: source
          persistentVolumeClaim:
            claimName: radius02        # NFS PVC
        - name: target
          persistentVolumeClaim:
            claimName: radius02-iscsi  # iSCSI PVC
    

  3. Connect to the pod and copy the data:

    oc rsh disk-migrator
    dd if=/source/disk.img of=/dev/target bs=4M status=progress conv=fsync
    

  4. Edit the VM and replace the dataVolume with a persistentVolumeClaim:

    spec:
      template:
        spec:
          volumes:
          - name: rootdisk
            persistentVolumeClaim:
              claimName: radius02-iscsi
    

Backup

There are two methods to backup Kubevirt VMs. Either backup the whole namespace (lazy man's way), or label each resource for a particular VM and backup using labelSelectors. The second gives the most flexability so we'll cover it here.

Create the following configmap since you don't want your VM starting as soon as they are restored:

apiVersion: v1
kind: ConfigMap
metadata:
  name: velero-restore-modifiers-kubevirt
  namespace: openshift-adp
data:
  resource-modifiers.yaml: |
    version: v1
    resourceModifierRules:
    - conditions:
        groupResource: virtualmachines.kubevirt.io
      patches:
      - operation: remove
        path: "/spec/running"
      - operation: add
        path: "/spec/runStrategy"
        value: "Halted"

The following script will identify resources that require labels for each VM:

#!/usr/bin/env bash
set -euo pipefail

NS="${1:?namespace required}"
VM="${2:?vm name required}"

echo "VM:"
oc -n "$NS" get vm "$VM" -o name

echo
echo "DataVolumes:"
DVS=$(oc -n "$NS" get vm "$VM" -o json | jq -r '
  .spec.template.spec.volumes[]
  | select(.dataVolume != null)
  | .dataVolume.name
' | sort -u)
if [ -n "${DVS:-}" ]; then
  while read -r dv; do
    [ -z "$dv" ] && continue
    oc -n "$NS" get dv "$dv" -o name
  done <<< "$DVS"
fi

echo
echo "PVCs:"
PVCS=$(
  {
    oc -n "$NS" get vm "$VM" -o json | jq -r '
      .spec.template.spec.volumes[]
      | select(.persistentVolumeClaim != null)
      | .persistentVolumeClaim.claimName
    '
    if [ -n "${DVS:-}" ]; then
      while read -r dv; do
        [ -z "$dv" ] && continue
        oc -n "$NS" get dv "$dv" -o json | jq -r '.status.claimName // empty'
      done <<< "$DVS"
    fi
  } | sort -u
)
if [ -n "${PVCS:-}" ]; then
  while read -r pvc; do
    [ -z "$pvc" ] && continue
    oc -n "$NS" get pvc "$pvc" -o name
  done <<< "$PVCS"
fi

echo
echo "PVs:"
if [ -n "${PVCS:-}" ]; then
  while read -r pvc; do
    [ -z "$pvc" ] && continue
    pv=$(oc -n "$NS" get pvc "$pvc" -o jsonpath='{.spec.volumeName}')
    [ -n "$pv" ] && echo "persistentvolume/$pv"
  done <<< "$PVCS" | sort -u
fi

echo
echo "Cloud-init / secret refs:"
oc -n "$NS" get vm "$VM" -o json | jq -r '
  .spec.template.spec.volumes[] |
  if .cloudInitNoCloud?.secretRef then
    "secret/" + .cloudInitNoCloud.secretRef
  elif .cloudInitConfigDrive?.secretRef then
    "secret/" + .cloudInitConfigDrive.secretRef
  else
    empty
  end
' | sort -u

The output will look something like:

[randal@ws02 velero]$ ./vm-resource-list.sh virtual-machines radius02
VM:
virtualmachine.kubevirt.io/radius02

DataVolumes:
datavolume.cdi.kubevirt.io/radius02

PVCs:
persistentvolumeclaim/radius02

PVs:
persistentvolume/pvc-6f3bf32d-e18c-4e6e-bd86-54d7695f465b

Cloud-init / secret refs:
[randal@ws02 velero]$

Label the VM itself:
oc -n virtual-machines label vm radius02 backup.vm/name=radius02 --overwrite

Label the storage:
oc -n virtual-machines label dv radius02 backup.vm/name=radius02 --overwrite
oc -n virtual-machines label pvc radius02 backup.vm/name=radius02 --overwrite
oc label pv pvc-6f3bf32d-e18c-4e6e-bd86-54d7695f465b backup.vm/name=radius02 --overwrite

Label any cloud-init / secret refs identified in a similar fashion.

Create the backup CR using a labelSelector to target the VMs resources:

apiVersion: velero.io/v1
kind: Backup
metadata:
  name: radius02-adhoc-20260420-0905
  namespace: openshift-adp
spec:
  hooks: {}
  includedNamespaces:
  - virtual-machines
  labelSelector:
    matchLabels:
      backup.vm/name: radius02
  includedResources: []
  excludedResources: []
  storageLocation: synologyonprem
  ttl: 720h00m00s
  snapshotVolumes: false
  defaultVolumesToFsBackup: true

Restore

Restoring a VM into a running state will cause issues. The following ConfigMap on the restore target will mutate the restore and prevent the restored VM from automatically starting:

apiVersion: v1
kind: ConfigMap
metadata:
  name: velero-restore-modifiers-kubevirt
  namespace: openshift-adp
data:
  resource-modifiers.yaml: |
    version: v1
    resourceModifierRules:
    - conditions:
        groupResource: virtualmachines.kubevirt.io
      patches:
      - operation: remove
        path: "/spec/running"
      - operation: add
        path: "/spec/runStrategy"
        value: "Halted"

Then you can restore using:

velero restore create radius02-restore-test \
  --from-backup <backup-name> \
  --exclude-resources datavolumes.cdi.kubevirt.io \
  --resource-modifier-configmap velero-restore-modifiers-kubevirt