Skip to content

kvm: restore backups into the volume they were taken from - #13922

Open
calvix wants to merge 2 commits into
apache:4.22from
calvix:fix/nas-backup-restore-volume-mapping
Open

kvm: restore backups into the volume they were taken from#13922
calvix wants to merge 2 commits into
apache:4.22from
calvix:fix/nas-backup-restore-volume-mapping

Conversation

@calvix

@calvix calvix commented Aug 19, 2026

Copy link
Copy Markdown

Description

This is an issue I noticed when using the Recovery and backup plugin on KVM + CEPH, but might be related to all KVM volume storage.

restoreBackup of a KVM instance with more than one data disk can write a
backed up disk into a different volume than the one it was taken from. This happens only on a second or further restore of a VM from a backup that was taken. ie: create backup of VM, restore 1, then restore 2 again.

  • equal-sized data disks → the volume contents are silently exchanged, and the API returns success: true, but the appearance of the bug is not 100% because the enumeration can randomly be the same as before. I recommend trying instances with 5 or 7 data disks for testing where occurrence is very likely.
  • differently sized data disks → the smaller image is written over the larger volume, the restore then
    fails converting the larger image into the smaller volume, and returns an error after the data is already
    destroyed
    , with no rollback
  • the ROOT disk is never affected (always re-attached at device id 0, so it sorts first in both lists)
  • createVMFromBackup and restoreVolumeFromBackupAndAttachToVM are not affected

Reproduced on 4.22.1.0 with KVM + Ceph/RBD primary storage and the NAS backup provider.

At first, this can be a very silent error, especially if the VM uses mount by LABEL/UUID, as the VM will mount the data to the right mount paths, but volume A now contains the data of volume B ( including LABELs) and volume B contains data for volume A.

Logs

case 1 - different disk sizes

The logs show output showing this bug on the VM, with 2 data volumes diska, diskb - all stored on Ceph primary storage and backed up via backup and recovery plugin to cephfs storage. diskcheck is a simple bash util that prints useful data from the disks on a remote machine and connects via ssh.

 #:   diskcheck 10.3.77.212
DEVICE SIZE   LABEL   MOUNT        FS-SIZE  FILE
vdb    1G     DISKA   /mnt/diska   974M     DISKA=DISKA
vdc    2G     DISKB   /mnt/diskb   2.0G     DISKB=DISKB

 #: cmk -p stable list volumes virtualmachineid=3d602d8a-c00c-4f49-a432-8c8f82237b3d filter=name,deviceid,size
{
  "count": 3,
  "volume": [
    {
      "deviceid": 0,
      "name": "ROOT-959",
      "size": 630159872
    },
    {
      "deviceid": 1,
      "name": "demo-disk-a",
      "size": 1073741824
    },
    {
      "deviceid": 2,
      "name": "demo-disk-b",
      "size": 2147483648
    }
  ]
}

 #: cmk -p stable stop virtualmachine id=3d602d8a-c00c-4f49-a432-8c8f82237b3d
{
 ...... redacted
}

 #: cmk -p stable restore backup id=557b755b-6426-4daa-9c96-871281a4112f
{
  "success": true
}
 # Below we can see the deviceID changed for both data disks
 #: cmk -p stable list volumes virtualmachineid=3d602d8a-c00c-4f49-a432-8c8f82237b3d filter=name,deviceid
{
  "count": 3,
  "volume": [
    {
      "deviceid": 0,
      "name": "ROOT-959"
    },
    {
      "deviceid": 4,
      "name": "demo-disk-a"
    },
    {
      "deviceid": 1,
      "name": "demo-disk-b"
    }
  ]
}
 #: cmk -p stable start virtualmachine id=3d602d8a-c00c-4f49-a432-8c8f82237b3d
{
...
}
 # Here we can see that disk ordering changed - this will basically break any further restore 
 #: diskcheck 10.3.77.212
DEVICE SIZE   LABEL   MOUNT        FS-SIZE  FILE
vdb    2G     DISKB   /mnt/diskb   2.0G     DISKB=DISKB
vdc    1G     DISKA   /mnt/diska   974M     DISKA=DISKA



 #: cmk -p stable stop virtualmachine id=3d602d8a-c00c-4f49-a432-8c8f82237b3d
{
  ...
  
}
 # The second restore fails because it cannot restore 2GB disk into 1 GB volume
 #: cmk -p stable restore backup id=557b755b-6426-4daa-9c96-871281a4112f
{
  "account": "admin",
  "accountid": "65fed4ca-48b6-11f1-a321-bc24119cb31e",
  "cmd": "org.apache.cloudstack.api.command.user.backup.RestoreBackupCmd",
  "completed": "2026-08-19T11:35:55+0200",
  "created": "2026-08-19T11:35:44+0200",
  "domainid": "176895fd-48b6-11f1-a321-bc24119cb31e",
  "domainpath": "ROOT",
  "jobid": "3c7c45dc-68f1-4152-8276-a1f4cf03678f",
  "jobprocstatus": 0,
  "jobresult": {
    "errorcode": 530,
    "errortext": "Error restoring VM from backup [{\"externalId\":\"i-2-959-VM\\/2026.08.19.11.28.52\",\"name\":\"demo\",\"uuid\":\"557b755b-6426-4daa-9c96-871281a4112f\",\"vmId\":959}]."
  },
  "jobresultcode": 530,
  "jobresulttype": "object",
  "jobstatus": 2,
  "userid": "65ff40f2-48b6-11f1-a321-bc24119cb31e"
}

If the disk sizes are the same, the second restore works but disk can be swapped depending on the scenario how disk are renumbered.

Root cause

NASBackupProvider.restoreVMBackup builds two lists the backed up volumes and the instance's current
volumes, each sorted by device id, and LibvirtRestoreBackupCommandWrapper.restoreVolumesOfExistingVM
consumes them index by index:

BackupManagerImpl.restoreBackup - importRestoredVM - KVMGuru.importVirtualMachineFromBackup
re-attaches every data disk with getNextAvailableDeviceId(). The volumes are never detached first, so
their current ids still count as "in use" and the helper can never return the id a volume already holds —
every data disk is shifted by one slot on every restore.

A restore is therefore only correct while the instance's device ids still match the ones recorded in the
backup. The first restore satisfies that and then breaks it for the next one.

Behaviour, restore by restore

The first restore is always correct. It is the restore itself that can plant the fault, so the damage only
appears on second restore.

Restore #1 — succeeds, but renumbers the disks

The device ids recorded in the backup still match the instance, so the two sorted lists line up and every
backup lands in its own volume. Verified on a 2-data-disk instance by reading the volumes directly from
primary storage — each still held its own filesystem.

But on completion KVMGuru.importVirtualMachineFromBackup re-attaches the data disks with
getNextAvailableDeviceId():

t

before restore #1:   ROOT=0   demo-disk-a=1   demo-disk-b=2
after  restore #1:   ROOT=0    ddemo-disk-a=4  emo-disk-b=1         <-- renumbered
after  restore #2:   ROOT=0   demo-disk-a=2   demo-disk-b=4      <-- renumbered again

After the first restore, diskA gets id 4 because ids 1 and 2 are taken; id 3 is cdrom, so the next free id is 4. diskb gets id 1 because the id was just freed.

Restore #2 — the damage, and what it looks like depends on the disk sizes

Sorted by device id the instance is now [ROOT, demo-disk-b, demo-disk-a] while the backup is
[ROOT, demo-disk-a, demo-disk-b], so index 1 and 2 point at each other's volumes.

Equal-sized data disks (1 GB + 1 GB) — silent corruption. Both images fit their new targets, so
qemu-img succeeds and the API returns success: true. The volume contents are simply exchanged:

demo-disk-a  contains ext4 label: DISKB      <-- swapped
demo-disk-b  contains ext4 label: DISKA

A guest that mounts by LABEL=/UUID= still mounts everything at the right paths, because the labels
travel with the filesystems — so nothing looks wrong from inside the instance, while CloudStack's
volume-to-content mapping is now wrong for every per-volume operation (detach, delete, snapshot, resize,
restore single volume).

Differently sized data disks (1 GB + 2 GB), data loss, reported as a failure.
The loop processes the disks in order and only aborts on the second pairing, so the first one has already been written.
The API returns:

"errorcode": 530,
"errortext": "Error restoring VM from backup [{\"externalId\":\"i-2-959-VM/2026.08.19.11.28.52\", ...}]"

and the agent log shows the real cause — the 2 GB backup being written into the 1 GB volume:

qemu-img convert -n -O raw --image-opts
  driver=qcow2,file.filename=.../datadisk.806fa24b-a2da-45b1-9bd4-5fd51d0cc78b.qcow2   <- backup of disk B (2 GB)
  rbd:cloudstack/da2029ad-8517-4f40-af03-bd5f3ec5d959                                   <- volume of disk A (1 GB)
encountered the error: [qemu-img: output file is smaller than input]

State afterwards — the restore is reported as failed, but one volume is already gone, with no rollback:

demo-disk-a (1 GiB)   ext4-label = DISKA     <- untouched, the write into it failed
demo-disk-b (2 GiB)   ext4-label = DISKA     <- OVERWRITTEN, its own data now exists only in the backup

Both volumes still report Ready in CloudStack. An operator seeing error 530 would reasonably assume
nothing happened.

How to reproduce

Instance with two data disks, a NAS backup offering, KVM primary storage. Use two different sizes to get
the loud failure, if you want to reproduce the disk swap with same disk sizes, I recommend using more data disks, as there is a chance that reumbering accdientaly get right and the bug does not occur. So trying like 5 or 7 data disks should most likely show the bug.

# 1. baseline: device ids 1 and 2
cmk list volumes virtualmachineid=$VM filter=name,deviceid
#   ROOT deviceid=0 | demo-disk-a deviceid=1 | demo-disk-b deviceid=2

# 2. label the filesystems so they can be told apart
#    mkfs.ext4 -L DISKA /dev/vdb ; mkfs.ext4 -L DISKB /dev/vdc

cmk create backup virtualmachineid=$VM name=demo
cmk stop virtualmachine id=$VM

# 3. FIRST restore - correct, but renumbers the disks
cmk restore backup id=$B
cmk list volumes virtualmachineid=$VM filter=name,deviceid
#   ROOT deviceid=0 | demo-disk-b deviceid=1 | demo-disk-a deviceid=4     <-- misaligned from here on

# 4. SECOND restore - silent swap (equal sizes) or error 530 after destroying a volume (different sizes)
cmk restore backup id=$B

# if you have the same sizes, you could try from UI detach diskA ( after either turning off teh safety config or disabling backups) and see that in OS the different disk vanishes.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Enhancement (improves an existing feature and functionality)
  • Cleanup (Code refactoring and cleanup, that may add test cases)
  • build/CI

How Has This Been Tested?

Unit tests — three added, all of which fail without the patch:

with the fix without the fix
KVMGuruTest 30 tests, 0 failures 30 tests, 1 error
LibvirtRestoreBackupCommandWrapperTest 13 tests, 0 failures 13 tests, 1 failure, 1 error

Every pre-existing test in both classes passes either way.

  • LibvirtRestoreBackupCommandWrapperTest.testRestoreOfExistingVmMapsBackupsToVolumesByUuid — the
    instance's volumes arrive in a different order than the backed up volumes; each backup must still be
    written into the volume it was taken from. Also asserts with never() that neither data disk's backup is
    written into the other one.
  • LibvirtRestoreBackupCommandWrapperTest.testRestoreOfExistingVmFailsWhenBackedUpVolumeIsNoLongerAttached
  • KVMGuruTest.testImportVirtualMachineFromBackupReinstatesRecordedDeviceIds — a data disk recorded on
    device id 5 must be re-attached on device id 5, and getNextAvailableDeviceId() must not be consulted.

Live testing — a patched build was deployed to a KVM + Ceph/RBD zone (management server and KVM
agents). The same 2-data-disk instance was restored three times in place:

  • device ids stayed 0,1,2 on every restore (unpatched: 1,24,1 after the first)
  • both volumes kept their own filesystem, verified with rbd export and from inside the guest
  • a 5-data-disk instance backed up and restored with all checksums intact
  • createVMFromBackup verified unaffected

calvix added 2 commits August 19, 2026 11:40
An in-place restore of a KVM instance with more than one data disk can write
a backed up disk into a different volume than the one it came from.

NASBackupProvider.restoreVMBackup pairs the backed up volumes with the
instance's current volumes by position in two lists that are each sorted by
device id, and LibvirtRestoreBackupCommandWrapper.restoreVolumesOfExistingVM
consumes those lists index by index. The volume UUID is carried along but only
used to build error messages.

That pairing is only correct while the instance's device ids still match the
ones recorded in the backup, and the restore itself breaks that: on completion
KVMGuru.importVirtualMachineFromBackup re-attaches every data disk with
getNextAvailableDeviceId(). The volumes are never detached first, so their
current ids still count as in use and the helper cannot return the id a volume
already holds - every data disk is shifted by one slot on each restore. The
root disk is unaffected because it is always re-attached at device id 0.

A second restore therefore maps the backup files onto the wrong volumes. With
equally sized disks the contents are silently exchanged and the API reports
success. With disks of different sizes the first volume is overwritten and the
restore then fails converting an image into a smaller volume, leaving that
volume half rewritten with no rollback.

Three changes:

- KVMGuru: re-attach data disks with the device id recorded in the backup,
  falling back to the next free id only when the backup has none.
- LibvirtRestoreBackupCommandWrapper: select the target volume by UUID instead
  of by list position, and fail explicitly when a backed up volume is no longer
  attached to the instance.
- NASBackupProvider: derive the UUID list and the backup file list from the
  same sorted collection so the two cannot drift apart.

The create-instance-from-backup path (restoreVolumesOfDestroyedVMs) is left
alone: it provisions new volumes with new UUIDs, so it has nothing to match on.
LibvirtRestoreBackupCommandWrapperTest: restoring an instance whose volumes
arrive in a different order than the backed up volumes must still write each
backup into the volume it was taken from, and must fail explicitly when a
backed up volume is no longer attached to the instance.

KVMGuruTest: importing an instance from a backup must re-attach its data disks
on the device ids recorded in the backup rather than allocating new ones.
@weizhouapache

Copy link
Copy Markdown
Member

@blueorangutan package

@blueorangutan

Copy link
Copy Markdown

@weizhouapache a [SL] Jenkins job has been kicked to build packages. It will be bundled with KVM, XenServer and VMware SystemVM templates. I'll keep you posted as I make progress.

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 70.58824% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 17.78%. Comparing base (85bcdb1) to head (4722bd4).

Files with missing lines Patch % Lines
...rg/apache/cloudstack/backup/NASBackupProvider.java 0.00% 3 Missing ⚠️
...er/src/main/java/com/cloud/hypervisor/KVMGuru.java 50.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff            @@
##               4.22   #13922   +/-   ##
=========================================
  Coverage     17.77%   17.78%           
- Complexity    15982    15988    +6     
=========================================
  Files          5928     5928           
  Lines        534301   534310    +9     
  Branches      65382    65385    +3     
=========================================
+ Hits          94987    95021   +34     
+ Misses       428568   428539   -29     
- Partials      10746    10750    +4     
Flag Coverage Δ
uitests 3.69% <ø> (ø)
unittests 18.86% <70.58%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@blueorangutan

Copy link
Copy Markdown

Packaging result [SF]: ✔️ el8 ✔️ el9 ✔️ el10 ✔️ debian ✔️ suse15. SL-JID 18917

@weizhouapache

Copy link
Copy Markdown
Member

@blueorangutan test

@blueorangutan

Copy link
Copy Markdown

@weizhouapache a [SL] Trillian-Jenkins test job (ol8 mgmt + kvm-ol8) has been kicked to run smoke tests

@blueorangutan

Copy link
Copy Markdown

[SF] Trillian Build Failed (tid-16786)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants