Skip to content

kvm: fix RBD exclusive-lock leak that breaks revertSnapshot on Ceph - #13835

Open
calvix wants to merge 3 commits into
apache:4.22from
calvix:fix/rbd-snapshot-exclusive-lock-leak
Open

kvm: fix RBD exclusive-lock leak that breaks revertSnapshot on Ceph#13835
calvix wants to merge 3 commits into
apache:4.22from
calvix:fix/rbd-snapshot-exclusive-lock-leak

Conversation

@calvix

@calvix calvix commented Aug 10, 2026

Copy link
Copy Markdown

Description

This PR fixes snapshot operations for KVM + ceph/rbd

takeRbdVolumeSnapshotOfStoppedVm() calls image.snapCreate(snapshotName)
twice. The first call creates the RBD snapshot, the second one always
throws RbdException ("Failed to create snapshot ") because the
snapshot already exists.

The duplicate happened in a merge artifact: 30d3066 ("Merge branch '4.20' into
4.22")

30d3066#diff-8a24835eeac038da0df1229615ce6ce564f2e49273abb55f4c9a90dedd29c222L2318-R2333

Because there was no finally block, that exception skipped rbd.close(image)
and r.ioCtxDestroy(io), so the agent kept the image open and held its RBD
exclusive-lock indefinitely. The exception is only logged, so the snapshot
job still reported success and the fault stayed invisible.

Consequences observed on a KVM + Ceph/RBD cluster:

  • revertSnapshot fails with "com.ceph.rbd.RbdException: Failed to rollback
    snapshot ". librbd returns EROFS because a live peer holds the
    exclusive-lock; 'rbd snap rollback' only succeeds once that client dies
    and librbd can break the lock, which makes the failure look intermittent.
  • getRbdSnapshotSize() is never reached, so every snapshot is reported with
    physical size 0 when snapshot.backup.to.secondary is false.
  • The leaked watchers keep the image busy, so 'rbd rm' fails and the volume
    cannot be expunged - it stays stuck in state Destroy.

The fix removes the duplicated call and move the image cleanup into a
finally block so the lock is released even if the snapshot itself fails.

Types of changes

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

Feature/Enhancement Scale or Bug Severity

Feature/Enhancement Scale

  • Major
  • Minor

Bug Severity

  • BLOCKER
  • Critical
  • Major
  • Minor
  • Trivial

How Has This Been Tested?

Manual testing on a KVM + Ceph/RBD cluster (CloudStack 4.22.1.0, Ceph 20.2.2, single RBD pool, tested with snapshot.backup.to.secondary both true and false):

  • createSnapshot on a DATADISK, stop the VM, revertSnapshotRbdException: Failed to rollback snapshot.
  • The RBD snapshot itself exists and is correctly named, so the rollback target was never the problem — rbd snap ls returned exactly the name passed to snapRollBack.
  • Correlated success/failure with the lock owner: when the lock holder still appears in rbd status (live client) the revert fails; when it does not (dead client, librbd breaks the lock), it succeeds.
  • Confirmed the mechanism by clearing the lock by hand:
# rbd lock rm ....
  • After clearing the lock, CloudStack revertSnapshot API job also returns success.

calvix added 3 commits August 10, 2026 07:41
takeRbdVolumeSnapshotOfStoppedVm() called image.snapCreate(snapshotName)
twice. The first call creates the RBD snapshot, the second one always
throws RbdException ("Failed to create snapshot <uuid>") because the
snapshot already exists.

The duplicate is a merge artifact: 30d3066 ("Merge branch '4.20' into
4.22") resolved a conflict by keeping the call from both sides - each
parent had exactly one.

Because there was no finally block, that exception skipped rbd.close(image)
and r.ioCtxDestroy(io), so the agent kept the image open and held its RBD
exclusive-lock indefinitely. The exception is only logged, so the snapshot
job still reported success and the fault stayed invisible.

Consequences observed on a KVM + Ceph/RBD cluster:

- revertSnapshot fails with "com.ceph.rbd.RbdException: Failed to rollback
  snapshot <uuid>". librbd returns EROFS because a live peer holds the
  exclusive-lock; 'rbd snap rollback' only succeeds once that client dies
  and librbd can break the lock, which makes the failure look intermittent.
- getRbdSnapshotSize() is never reached, so every snapshot is reported with
  physical size 0 when snapshot.backup.to.secondary is false.
- The leaked watchers keep the image busy, so 'rbd rm' fails and the volume
  cannot be expunged - it stays stuck in state Destroy.

Note the method also runs for RUNNING VMs: createSnapshot() branches on
"RUNNING && !primaryPool.isExternalSnapshot()", and RBD is an
external-snapshot pool, so every RBD volume snapshot took this path.

Remove the duplicated call and move the image/IO-context cleanup into a
finally block so the lock is released even if the snapshot itself fails.
…napshot

createRBDvolumeFromRBDSnapshot() closed the source image, the cloned image
and the RADOS IO context only on the success path, and called snapUnprotect()
only there too. Two paths escaped that cleanup:

- the early "Could not find snapshot ... on RBD" return, and
- any RadosException/RbdException from clone(), resize() or flatten(), which
  is caught and turned into a null disk.

Both leave the images open, so this client keeps the RBD exclusive-lock. That
later makes 'rbd snap rollback' (revertSnapshot) fail with EROFS from another
host, and keeps the image busy so 'rbd rm' cannot remove it - the volume then
stays stuck in state Destroy.

The failure paths after snapProtect() are worse: the snapshot stays protected,
and a protected snapshot can be deleted neither on its own nor together with
its volume.

Move the cleanup into a finally block, tracking whether the snapshot was
actually protected so it is unprotected exactly when it needs to be. Failures
during cleanup are logged and never mask the original outcome; a failed
snapUnprotect is logged at ERROR since it needs manual intervention.

This is the same class of defect as the leak fixed in
takeRbdVolumeSnapshotOfStoppedVm(); no behaviour changes on the success path.
Two tests around takeRbdVolumeSnapshotOfStoppedVm, using the MockedConstruction
pattern already used in this test class (the Rbd instance is created inside the
method under test, so it cannot be injected):

- createsSnapshotExactlyOnce guards the duplicated snapCreate call from coming
  back, and checks the image and IO context are released.
- releasesHandlesWhenSnapshotFails makes snapCreate throw and asserts the image
  is still closed and the IO context destroyed, so a future failure cannot leak
  the RBD exclusive-lock again.

takeRbdVolumeSnapshotOfStoppedVm, radosConnect and getRbdSnapshotSize widened
from private to protected so the test can stub the Ceph interactions.
Comment on lines +2351 to +2353
io = r.ioCtxCreate(primaryPool.getSourceDir());
rbd = new Rbd(io);
image = rbd.open(disk.getName());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

io and image are torn down in nested try-catch constructions in a finally clause of the parent try-catch clause. I think this should be re-structured to be in called methods.

In addition the same pattern happens below. a lot of re-use can be gained,

@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 25.00000% with 45 lines in your changes missing coverage. Please review.
✅ Project coverage is 17.69%. Comparing base (5328528) to head (48b5fda).

Files with missing lines Patch % Lines
...ud/hypervisor/kvm/storage/KVMStorageProcessor.java 25.00% 43 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               4.22   #13835      +/-   ##
============================================
- Coverage     17.69%   17.69%   -0.01%     
+ Complexity    15835    15833       -2     
============================================
  Files          5925     5925              
  Lines        533539   533578      +39     
  Branches      65274    65280       +6     
============================================
- Hits          94427    94424       -3     
- Misses       428435   428476      +41     
- Partials      10677    10678       +1     
Flag Coverage Δ
uitests 3.69% <ø> (ø)
unittests 18.77% <25.00%> (-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.

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.

2 participants