linstor: incremental snapshots on secondary storage - #13746
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## 4.22 #13746 +/- ##
==========================================
Coverage 17.69% 17.70%
- Complexity 15833 15864 +31
==========================================
Files 5925 5925
Lines 533534 533635 +101
Branches 65273 65294 +21
==========================================
+ Hits 94421 94482 +61
- Misses 428434 428475 +41
+ Partials 10679 10678 -1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…se diff Linstor volume snapshots were always backed up to NFS secondary storage as full qcow2 copies, so every snapshot consumed full-volume space. The existing KVM incremental snapshot feature (kvm.incremental.snapshot) is limited to file-based primary storage because it relies on libvirt checkpoints backed by qcow2-persistent dirty bitmaps, which do not exist for raw DRBD devices. Add incremental snapshots for Linstor through a content diff instead: the agent creates an empty qcow2 overlay backed by the raw snapshot device (with an explicit virtual size, clipping the trailing DRBD metadata), then safe-rebases it onto the previous snapshot qcow2 on secondary storage. qemu writes only the clusters in which the two backing files differ, yielding a delta qcow2 chained to its parent by a relative backing name (portable across mount points). No bitmaps, no libvirt version gates, no LINSTOR snapshot retained on primary, and it works for running and stopped VMs alike. Because the diff is content-based, any ready previous snapshot is a correct diff base, which makes fallback-to-full trivial. The feature is gated by the existing cluster-scoped kvm.incremental.snapshot setting plus the pool being Linstor; chain length is capped by snapshot.delta.max and the end_of_chain rotation. Encrypted volumes stay full-only (a rebase would need the LUKS secret for delta and backing file). - SnapshotDataStoreDaoImpl.findParent: parent selection for Linstor volumes (non-encrypted) uses the plain search instead of requiring kvm_checkpoint_path, so chains can start; ObjectInDataStoreManagerImpl then links parent_snapshot_id on the image store ref as usual. - SnapshotObject.getCorrectIncrementalParent: fall back to the plain parent instead of throwing when no ref carries a checkpoint path. The TO constructor still early-returns for such parents, keeping parents[] null so the file-based agent paths are unaffected, while getParent() correctly drives convert-before-use and chain-aware deletion. - SnapshotManagerImpl: generalize the three checkpoint-keyed predicates (extract URL flattening, endLastChainIfNeeded, copySnapshotChainToZone) through isIncrementalChainRef(), which also recognizes parent-linked KVM chains; XenServer per-member chain copy semantics are preserved. - Linstor driver: copySnapshot passes fullSnapshot=false and the parent install path when the dest ref has a ready, same-sized parent on the same store; the parent link is cleared whenever a full copy was made (encrypted, parent missing/unready/resized or agent-side fallback) so the DB chain always matches what is on disk. - LinstorBackupSnapshotCommandWrapper: new incremental branch (qemu-img create -F raw -b <snapdev> + safe rebase onto the parent + rebase -u to the relative backing name); falls back to the full convert path if the parent file is missing. The answer TO reports parentSnapshotPath so SnapshotObject.processEvent keeps the parent link only for real deltas. Restore, volume/template-from-snapshot, extract and cross-zone copy all reuse the existing machinery: qemu-img follows the relative backing chain on the same mount, and chain members are flattened via ConvertSnapshotCommand before standalone use. Delete ordering is handled by the generic parent-link logic (children hide their parents, leaf-first physical deletion).
0a4822c to
8643df8
Compare
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Adds support for incremental (content-diff) snapshot backups to secondary storage for Linstor volumes, reusing the existing KVM incremental snapshot configuration semantics.
Changes:
- Introduces content-based incremental chaining for Linstor snapshot backups via parent snapshot install paths.
- Updates snapshot parent selection logic to avoid applying checkpoint-based (cross-role) handling to content-based chains.
- Adds/extends unit tests covering incremental-chain detection, parent-path resolution, and chain bookkeeping adjustments.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| server/src/main/java/com/cloud/storage/snapshot/SnapshotManagerImpl.java | Centralizes “is part of incremental chain” detection for KVM (checkpoint-based and content-based parent-link chains). |
| server/src/test/java/com/cloud/storage/snapshot/SnapshotManagerImplTest.java | Adds tests for incremental-chain detection behavior across hypervisors and ref states. |
| plugins/storage/volume/linstor/src/main/java/org/apache/cloudstack/storage/datastore/driver/LinstorPrimaryDataStoreDriverImpl.java | Implements incremental-parent resolution on secondary storage and clears chain parent when a full copy is taken. |
| plugins/storage/volume/linstor/src/test/java/org/apache/cloudstack/storage/datastore/driver/LinstorPrimaryDataStoreDriverImplTest.java | Adds tests for incremental parent path selection and parent-link clearing behavior. |
| plugins/storage/volume/linstor/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LinstorBackupSnapshotCommandWrapper.java | Adds agent-side incremental qcow2 creation using safe rebase onto the parent qcow2 backing file. |
| plugins/storage/volume/linstor/src/main/java/com/cloud/api/storage/LinstorBackupSnapshotCommand.java | Adds a new command option for passing the parent snapshot install path to the agent. |
| engine/storage/src/main/java/org/apache/cloudstack/storage/datastore/ObjectInDataStoreManagerImpl.java | Skips checkpoint-based parent handling for volumes using content-based chains. |
| engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/snapshot/SnapshotObject.java | Makes incremental-parent resolution tolerate chains without checkpoints (fallback to plain parent). |
| engine/storage/snapshot/src/test/java/org/apache/cloudstack/storage/snapshot/SnapshotObjectTest.java | New tests for incremental-parent selection logic with/without checkpoint-bearing refs. |
| engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/SnapshotDataStoreDao.java | Adds usesContentBasedChain(volumeId) API to distinguish chain types. |
| engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/SnapshotDataStoreDaoImpl.java | Implements chain-type detection and adjusts findParent behavior accordingly. |
| engine/schema/src/test/java/org/apache/cloudstack/storage/datastore/db/SnapshotDataStoreDaoImplTest.java | Adds tests for usesContentBasedChain behavior across volume/pool conditions. |
| plugins/storage/volume/linstor/CHANGELOG.md | Documents the new incremental snapshot backup support. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| boolean contentBasedChain = kvmIncrementalSnapshot && Hypervisor.HypervisorType.KVM.equals(hypervisorType) && usesContentBasedChain(volumeId); | ||
| if (contentBasedChain && (role == null || !role.isImageStore())) { | ||
| logger.trace("Content-based snapshot chains only exist on the image store. Returning null as parent for volume [{}] and role [{}].", volumeId, role); | ||
| return null; | ||
| } | ||
| boolean checkpointBasedChain = kvmIncrementalSnapshot && Hypervisor.HypervisorType.KVM.equals(hypervisorType) && !contentBasedChain; |
| SnapshotObject snapshotObject = (SnapshotObject)srcData; | ||
| Boolean snapshotFullBackup = snapshotObject.getFullBackup(); | ||
| final StoragePoolVO pool = _storagePoolDao.findById(srcData.getDataStore().getId()); | ||
| final DevelopersApi api = getLinstorAPI(pool); | ||
| boolean fullSnapshot = true; | ||
| if (snapshotFullBackup != null) { | ||
| fullSnapshot = snapshotFullBackup; | ||
| } | ||
|
|
||
| // For encrypted volumes Linstor adds a LUKS layer (DRBD -> LUKS -> STORAGE). The storage | ||
| // layer snapshot device (getSnapshotPath) therefore only exposes the raw LUKS ciphertext, | ||
| // while restore writes onto the decrypted DRBD device (/dev/drbd/by-res/.../0). Backing up | ||
| // the ciphertext and writing it back to the decrypted layer corrupts the volume (and the | ||
| // shrink to the net volume size would even truncate the ciphertext). So for encrypted | ||
| // volumes we never read the storage snapshot directly: restore the snapshot into a temporary | ||
| // resource and back up its decrypted DRBD device instead, symmetric to the restore path. | ||
| final boolean encrypted = snapshotObject.getBaseVolume().getPassphraseId() != null; | ||
|
|
||
| SnapshotDataStoreVO destRef = _snapshotStoreDao.findByStoreSnapshot( | ||
| destData.getDataStore().getRole(), destData.getDataStore().getId(), destData.getId()); | ||
| // encrypted volumes are always backed up as full copies: an incremental rebase would need | ||
| // the LUKS secret for both the delta and the backing file | ||
| String parentPath = encrypted ? null : getIncrementalParentPath(destRef); | ||
| boolean fullSnapshot = parentPath == null; |
| final Map<String, String> options = cmd.getOptions(); | ||
| final String parentInstallPath = options != null ? | ||
| options.get(LinstorBackupSnapshotCommand.OPTION_PARENT_PATH) : null; | ||
|
|
||
| boolean incremental = false; | ||
| String dstPath = null; | ||
| if (!encrypted && parentInstallPath != null && src.getVolume() != null) { | ||
| final File parentFile = new File(secondaryPool.getLocalPath() + File.separator + parentInstallPath); | ||
| if (parentFile.isFile()) { | ||
| dstPath = createIncrementalQCow2( | ||
| srcPath, dst, secondaryPool, parentFile, src.getVolume().getSize(), cmd.getWaitInMillSeconds()); | ||
| incremental = true; | ||
| } else { | ||
| LOGGER.warn("Parent snapshot file '{}' missing on secondary storage, taking a full backup instead", | ||
| parentFile.getAbsolutePath()); | ||
| } | ||
| } |
| @Override | ||
| public boolean usesContentBasedChain(long volumeId) { | ||
| VolumeVO volume = volumeDao.findByIdIncludingRemoved(volumeId); | ||
| if (volume == null || volume.getPoolId() == null || volume.getPassphraseId() != null) { | ||
| return false; | ||
| } | ||
| StoragePoolVO pool = storagePoolDao.findById(volume.getPoolId()); | ||
| return pool != null && Storage.StoragePoolType.Linstor.equals(pool.getPoolType()); | ||
| } |
|
I'll try to review this next week if I have the time |
| private String createIncrementalQCow2( | ||
| final String srcPath, | ||
| final SnapshotObjectTO dst, | ||
| final KVMStoragePool secondaryPool, | ||
| final File parentFile, | ||
| final long netSize, | ||
| int waitMilliSeconds) | ||
| throws LibvirtException, QemuImgException, IOException | ||
| { |
There was a problem hiding this comment.
This method definition does not follow the coding conventions. (see https://cwiki.apache.org/confluence/spaces/CLOUDSTACK/pages/29687985/Coding+conventions)
| private String createIncrementalQCow2( | |
| final String srcPath, | |
| final SnapshotObjectTO dst, | |
| final KVMStoragePool secondaryPool, | |
| final File parentFile, | |
| final long netSize, | |
| int waitMilliSeconds) | |
| throws LibvirtException, QemuImgException, IOException | |
| { | |
| private String createIncrementalQCow2 (final String srcPath, final SnapshotObjectTO dst, final KVMStoragePool secondaryPool, final File parentFile, | |
| final long netSize, int waitMilliSeconds) throws LibvirtException, QemuImgException, IOException { |
| int waitMilliSeconds) | ||
| throws LibvirtException, QemuImgException, IOException | ||
| { | ||
| final String dstDir = secondaryPool.getLocalPath() + File.separator + dst.getPath(); |
There was a problem hiding this comment.
| final String dstDir = secondaryPool.getLocalPath() + File.separator + dst.getPath(); | |
| final String dstDir = secondaryPool.getLocalPathFor(dst.getPath()); |
| FileUtils.forceMkdir(new File(dstDir)); | ||
| final String dstPath = dstDir + File.separator + dst.getName(); | ||
|
|
||
| final Script createOverlay = new Script("qemu-img", Duration.millis(waitMilliSeconds)); |
There was a problem hiding this comment.
Please use the QemuImg class, extend it if needed.
|
Could you share the test cases you made? Since I don't have access to a Linstor storage, I can't test it myself, but if you share your tests I might be able to see potential test cases that have not been covered yet :) |
- LinstorBackupSnapshotCommandWrapper: follow coding conventions for the createIncrementalQCow2 signature, use KVMStoragePool.getLocalPathFor() instead of manual path concatenation, and create the qcow2 overlay via QemuImg.create() instead of a hand-rolled qemu-img Script call. - QemuImg.create: pass the created file's format to -f instead of the backing file's format, so an overlay format can differ from its backing format (-f qcow2 -F raw). All existing callers use matching formats and are unaffected. - CHANGELOG.md: fix wording of the incremental snapshot entry.
getDiskfulStoragePools only ever returned the storage pool of a single node, so an endpoint was only looked for on that one node - if its cloudstack host was down or disabled the operation fell back to a temporary resource or a diskless attach although other diskful copies were usable. Return all diskful storage pools ordered by how suited the copy is (in use, then active, then inactive) and walk that order when picking a host. The order matters: thick LVM snapshots on shared storage pools must be read on the node the volume is active on, so the host selection no longer shuffles candidates.
endCurrentChainIfNeeded walks the snapshot chain through findOneBySnapshotId, whose FIND_SNAPSHOT_IN_ZONE query returns an arbitrary store reference of the snapshot. Content-based chains only link their image store reference - the primary one stays unlinked - so for Linstor volumes the walk always saw a chain length of one and snapshot.delta.max was never enforced: deltas were chained forever and no full snapshot was ever taken again. Checkpoint-based chains were unaffected because they carry the parent link on both references. Order references carrying a chain link (parent or checkpoint) first in FIND_SNAPSHOT_IN_ZONE, and stop the walk gracefully if a parent has no references left instead of throwing an NPE.
Cover the incremental (content-diff) snapshot feature end to end: full -> delta chaining with parent links, snapshot.delta.max chain rotation, revert to a mid-chain delta, template from a delta (chain flattening), fallback to a full backup when the parent file is missing on secondary storage, mid-chain deletion, and that encrypted volumes are always backed up as full copies. Chain bookkeeping (parent_snapshot_id, end_of_chain) is not exposed via the API, so the tests verify it in the DB when reachable and otherwise fall back to physical-size comparisons against the chain's full backup. Tests needing host SSH (secondary storage manipulation/inspection) self-skip without credentials. The ServiceReady helper the Linstor test modules used to copy around moves to a shared linstor_test_utils module instead of gaining a third copy.
|
@JoaoJandre Thanks for the review, I addressed your comments and also added the integration tests I ran. I found another issue, that the And I also added a fix for Linstors getDiskfulHost selector, that was always only using the first result, instead of all. |
JoaoJandre
left a comment
There was a problem hiding this comment.
Code overall looks good to me. I'll try to test the incremental snapshots on NFS to make sure no regressions were introduced.
| private static final String FIND_SNAPSHOT_IN_ZONE = "SELECT ssr.* FROM " + | ||
| "snapshot_store_ref ssr, snapshots s " + | ||
| "WHERE ssr.snapshot_id=? AND ssr.snapshot_id = s.id AND s.data_center_id=?;"; | ||
| "WHERE ssr.snapshot_id=? AND ssr.snapshot_id = s.id AND s.data_center_id=? " + | ||
| "ORDER BY (ssr.parent_snapshot_id > 0) DESC, (ssr.kvm_checkpoint_path IS NOT NULL) DESC LIMIT 1;"; |
There was a problem hiding this comment.
We should use a join here instead of Cartesian product, I have no idea why I did not do it with a join originally. Could you refactor this?
| Shouldn't this be -o backing_file=filename instead? | ||
| */ | ||
| s.add("-f"); | ||
| s.add(file.getFormat().toString()); |
There was a problem hiding this comment.
Are we sure that the format is passed every time for the file if it has a backing file? If so, I think this change is good.
Description
In short, this PR adds incremental snapshot support for Linstor backuped (secondary storage) volume snapshots.
It uses the same configuration keys as the file bases KVM incremental snapshots settings.
Types of changes
Feature/Enhancement Scale or Bug Severity
Feature/Enhancement Scale
Bug Severity
Screenshots (if appropriate):
How Has This Been Tested?
Tested on a Linstor cluster.