KVM: fix leaked RBD exclusive-lock breaking volume snapshot revert on Ceph [4.22] - #3
Closed
calvix wants to merge 3 commits into
Closed
KVM: fix leaked RBD exclusive-lock breaking volume snapshot revert on Ceph [4.22]#3calvix wants to merge 3 commits into
calvix wants to merge 3 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
This PR fixes a leaked RBD
exclusive-lockon KVM + Ceph/RBD that makesrevertSnapshotfail, and adds regression tests.Actual behaviour: on a KVM cluster with Ceph/RBD primary storage, reverting a volume snapshot fails, seemingly at random:
Expected behaviour: the snapshot is rolled back.
Root cause.
KVMStorageProcessor.takeRbdVolumeSnapshotOfStoppedVm()callsimage.snapCreate(snapshotName)twice:The duplicate looks like a merge-conflict resolution that kept the call from both sides —
4.20grew asnapCreatenext to the newgetRbdSnapshotSize()while4.22already had one:4.19and4.20have one call;4.22andmainhave two.Because there is no
finally, the exception from the second call skipsrbd.close(image)andr.ioCtxDestroy(io), so the agent keeps the image open and holds its RBDexclusive-lockindefinitely. The exception is only logged, so the snapshot job still reports success and nothing looks wrong.Note this method also runs for running VMs:
createSnapshot()branches onRUNNING && !primaryPool.isExternalSnapshot(), and RBD is an external-snapshot pool, so every RBD volume snapshot takes this path.Resulting symptoms:
revertSnapshotfails withEROFS. A live peer holds theexclusive-lock, so librbd refusessnap_rollback. It only succeeds once that client dies and librbd can break the lock, which is why it looks intermittent.physicalsize: 0whensnapshot.backup.to.secondary=false, becausegetRbdSnapshotSize()is never reached.Destroy. The leaked watchers keep the image busy, sorbd rmfails and the volume can never be expunged.The agent log shows the swallowed exception on every snapshot:
<snapshot-uuid>there is exactly the snapshot the nextrevertSnapshotthen failed to roll back.The fix
takeRbdVolumeSnapshotOfStoppedVm()— remove the duplicatedsnapCreate, and moverbd.close(image)/r.ioCtxDestroy(io)into afinallyso the exclusive-lock is released even when the snapshot itself fails.createRBDvolumeFromRBDSnapshot()— the same class of defect. It released its handles only on the success path, so the earlyCould not find snapshot ... on RBDreturn and any exception fromclone()/resize()/flatten()leaked the same lock. Worse, the failure paths aftersnapProtect()left the snapshot protected, and a protected snapshot can be deleted neither on its own nor with its volume. Cleanup moved into afinally, tracking whether the snapshot was actually protected sosnapUnprotect()runs exactly when it should. Cleanup failures are logged and never mask the original outcome; a failedsnapUnprotectis logged atERRORbecause it needs manual intervention.Regression tests in
KVMStorageProcessorTest(see below).No behaviour change on the success path in either method.
Types of changes
Feature/Enhancement Scale or Bug Severity
Bug Severity
Screenshots (if appropriate):
N/A
How Has This Been Tested?
Unit tests — two new tests in
KVMStorageProcessorTest, using theMockedConstructionpattern already used in that class (theRbdinstance is constructed inside the method under test):takeRbdVolumeSnapshotOfStoppedVmTestCreatesSnapshotExactlyOnce— assertssnapCreateis invoked exactly once, and that the image and IO context are released. This is the direct guard against the duplicated call reappearing.takeRbdVolumeSnapshotOfStoppedVmTestReleasesHandlesWhenSnapshotFails— makessnapCreatethrowRbdExceptionand assertsrbd.close(image)andioCtxDestroy(io)still run, so a future failure cannot leak the lock again.takeRbdVolumeSnapshotOfStoppedVm,radosConnectandgetRbdSnapshotSizewere widened fromprivatetoprotectedto make this testable.mvn -pl plugins/hypervisors/kvm -Dtest=KVMStorageProcessorTest teston this branch:The first test was also verified to actually catch the defect: reintroducing the duplicated
snapCreateon top of this branch makes it fail, and only it —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.secondarybothtrueandfalse):Reproduced end to end before the fix:
createSnapshoton a DATADISK, stop the VM,revertSnapshot→RbdException: Failed to rollback snapshot.Verified the RBD snapshot itself exists and is correctly named, so the rollback target was never the problem —
rbd snap lsreturned exactly the name passed tosnapRollBack.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:
After clearing the lock the CloudStack
revertSnapshotAPI job also returns success.Confirmed the duplicated call is reached on every snapshot via the agent log stack trace quoted above.
How did you try to break this feature and the system with this change?
rbd lock ls/rbd status, including predicting failures in advance from the lock state.snapshot.backup.to.secondary=true(snapshot backed up to secondary storage) andfalse(kept on primary), since they take different snapshot paths and only the latter reachesgetRbdSnapshotSize().createVolumefrom a snapshot), which is what exercisescreateRBDvolumeFromRBDSnapshot()— including its "snapshot not found" path, which previously returned without releasing anything.KVMStorageProcessorfor the same pattern:deleteSnapshot()already releases its handles in afinallyand is unchanged.