Repository navigation
NAS backup: run take-backup commands concurrently on the KVM host, bounded per host #12847
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 4.20
Are you sure you want to change the base?
Changes from all commits
81f5dd2
b2cd0bf
4338c9c
b166bfd
e38c5c0
8514c35
da82917
f0096a6
3340496
1ad9593
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,7 @@ public class TakeBackupCommand extends Command { | |
| private List<String> volumePaths; | ||
| @LogLevel(LogLevel.Log4jLevel.Off) | ||
| private String mountOptions; | ||
| private boolean executeInSequence = false; | ||
|
|
||
| public TakeBackupCommand(String vmName, String backupPath) { | ||
| super(); | ||
|
|
@@ -89,6 +90,13 @@ public void setVolumePaths(List<String> volumePaths) { | |
|
|
||
| @Override | ||
| public boolean executeInSequence() { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the agent has a small pool of worker threads and everything the mgmt server sends goes through it. if long backups fill it up, does anything else sent to that host wait behind them?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That was the real gap, thank you. The cap is deliberately below the agent's default of 5 workers, so at least 3 workers stay free for start, stop, reboot and migrate at all times; an operator who raises the agent's
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is that cap per management server? with two of them a host could still get 4 backups at once and leave only one worker free
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, it is per management server. The count is kept in memory on each one, so with two management servers a host can see up to 2 x the cap, which is 4 at the default and leaves one of the agent's 5 workers free. Making it cluster-wide would mean either a So in 3340496 I have documented it instead. The setting's description now says the cap applies per management server and gives the sizing rule: cap x number of management servers must stay below the agent's
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ok, the sizing rule in the setting covers it for now. fine to leave a cluster wide count for a follow up |
||
| return true; | ||
| // Parallel by default: each backup uses its own per-VM on-NAS path, so concurrent | ||
| // runs do not contend. Operators can force sequential execution per zone via the | ||
| // backup.nas.parallel.execution.enabled setting. | ||
| return executeInSequence; | ||
| } | ||
|
|
||
| public void setExecuteInSequence(boolean executeInSequence) { | ||
| this.executeInSequence = executeInSequence; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| // Licensed to the Apache Software Foundation (ASF) under one | ||
| // or more contributor license agreements. See the NOTICE file | ||
| // distributed with this work for additional information | ||
| // regarding copyright ownership. The ASF licenses this file | ||
| // to you under the Apache License, Version 2.0 (the | ||
| // "License"); you may not use this file except in compliance | ||
| // with the License. You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, | ||
| // software distributed under the License is distributed on an | ||
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| // KIND, either express or implied. See the License for the | ||
| // specific language governing permissions and limitations | ||
| // under the License. | ||
| package org.apache.cloudstack.backup; | ||
|
|
||
| import org.junit.Assert; | ||
| import org.junit.Test; | ||
|
|
||
| public class TakeBackupCommandTest { | ||
|
|
||
| @Test | ||
| public void testExecuteInSequenceDefaultsToParallel() { | ||
| TakeBackupCommand command = new TakeBackupCommand("vm-1", "/backups/vm-1"); | ||
| // Default: run in parallel with other backup/delete commands. | ||
| Assert.assertFalse(command.executeInSequence()); | ||
| } | ||
|
|
||
| @Test | ||
| public void testExecuteInSequenceIsSettable() { | ||
|
DaanHoogland marked this conversation as resolved.
|
||
| TakeBackupCommand command = new TakeBackupCommand("vm-1", "/backups/vm-1"); | ||
|
|
||
| command.setExecuteInSequence(true); | ||
| Assert.assertTrue(command.executeInSequence()); | ||
|
|
||
| command.setExecuteInSequence(false); | ||
| Assert.assertFalse(command.executeInSequence()); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,7 @@ | |
| import com.cloud.storage.dao.VolumeDao; | ||
| import com.cloud.utils.Pair; | ||
| import com.cloud.utils.component.AdapterBase; | ||
| import com.cloud.utils.db.GlobalLock; | ||
| import com.cloud.utils.exception.CloudRuntimeException; | ||
| import com.cloud.vm.VirtualMachine; | ||
| import com.cloud.vm.dao.VMInstanceDao; | ||
|
|
@@ -59,11 +60,52 @@ | |
| import java.util.HashMap; | ||
| import java.util.Objects; | ||
| import java.util.UUID; | ||
| import java.util.function.Supplier; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| public class NASBackupProvider extends AdapterBase implements BackupProvider, Configurable { | ||
| private static final Logger LOG = LogManager.getLogger(NASBackupProvider.class); | ||
|
|
||
| public static final ConfigKey<Boolean> NASBackupParallelExecution = new ConfigKey<>("Advanced", Boolean.class, | ||
| "backup.nas.parallel.execution.enabled", | ||
| "true", | ||
| "Let NAS take-backup commands run concurrently on a KVM host instead of queueing behind every earlier command on that host " | ||
| + "and holding up every later one. Concurrency is bounded per host by backup.nas.parallel.max.per.host. " | ||
| + "Disable to restore strictly sequential execution.", | ||
| true, ConfigKey.Scope.Zone); | ||
|
|
||
| public static final ConfigKey<Integer> NASBackupParallelMaxPerHost = new ConfigKey<>("Advanced", Integer.class, | ||
| "backup.nas.parallel.max.per.host", | ||
| "2", | ||
| "Maximum number of NAS take-backup commands in flight on one KVM host when parallel execution is enabled; further " | ||
| + "backups for that host wait on the management server that received them. The limit holds across all management " | ||
| + "servers: in-flight backups are counted from the database under a cluster-wide lock per host. Keep it below the " | ||
| + "agent's worker thread count (workers in agent.properties, default 5) so start, stop, reboot and migrate commands " | ||
| + "are never queued behind backups.", | ||
| true, ConfigKey.Scope.Zone); | ||
|
|
||
| public static final ConfigKey<Integer> NASBackupParallelQueueTimeout = new ConfigKey<>("Advanced", Integer.class, | ||
| "backup.nas.parallel.queue.timeout", | ||
| "7200", | ||
| "Seconds a NAS take-backup may wait for a free per-host slot before it fails.", | ||
| true, ConfigKey.Scope.Zone); | ||
|
|
||
| /** Seconds to wait for a host's cluster-wide lock; it is only held for the count-and-reserve step. */ | ||
| private static final int HOST_SLOT_LOCK_WAIT_SECONDS = 30; | ||
|
|
||
| /** | ||
| * A BackingUp row older than the take-backup command timeout plus this margin is no longer awaited by any | ||
| * management server (for example its management server stopped mid-backup), so it does not hold a slot. | ||
| */ | ||
| private static final int STALE_RESERVATION_MARGIN_SECONDS = 300; | ||
|
|
||
| /** Bounds of the back-off between two attempts to reserve a slot on a full host. */ | ||
| protected long slotPollMinMillis = 1000L; | ||
| protected long slotPollMaxMillis = 15000L; | ||
|
|
||
| /** Wakes the waiters on this management server as soon as one of its own backups finishes. */ | ||
| private final Object localSlotRelease = new Object(); | ||
|
|
||
| @Inject | ||
| private BackupDao backupDao; | ||
|
|
||
|
|
@@ -154,11 +196,12 @@ public boolean takeBackup(final VirtualMachine vm) { | |
| final String backupPath = String.format("%s/%s", vm.getInstanceName(), | ||
| new SimpleDateFormat("yyyy.MM.dd.HH.mm.ss").format(creationDate)); | ||
|
|
||
| BackupVO backupVO = createBackupObject(vm, backupPath); | ||
| TakeBackupCommand command = new TakeBackupCommand(vm.getInstanceName(), backupPath); | ||
| command.setBackupRepoType(backupRepository.getType()); | ||
| command.setBackupRepoAddress(backupRepository.getAddress()); | ||
| command.setMountOptions(backupRepository.getMountOptions()); | ||
| final long zoneId = vm.getDataCenterId(); | ||
| final boolean parallel = applyExecutionPolicy(command, zoneId); | ||
|
|
||
| if (VirtualMachine.State.Stopped.equals(vm.getState())) { | ||
| List<VolumeVO> vmVolumes = volumeDao.findByInstance(vm.getId()); | ||
|
|
@@ -167,26 +210,188 @@ public boolean takeBackup(final VirtualMachine vm) { | |
| command.setVolumePaths(volumePaths); | ||
| } | ||
|
|
||
| BackupAnswer answer = null; | ||
| // Concurrent backups are bounded per host across all management servers: the BackingUp row of this | ||
| // backup is the reservation of one of the host's slots, created only while the host has a free slot. | ||
| // The wait happens here, on the management server, never on the agent's worker threads. | ||
| final BackupVO backupVO = parallel | ||
| ? reserveHostBackupSlot(vm, host.getId(), backupPath, NASBackupParallelMaxPerHost.valueIn(zoneId), NASBackupParallelQueueTimeout.valueIn(zoneId)) | ||
| : createBackupObject(vm, backupPath); | ||
| boolean succeeded = false; | ||
| try { | ||
| answer = (BackupAnswer) agentManager.send(host.getId(), command); | ||
| } catch (AgentUnavailableException e) { | ||
| throw new CloudRuntimeException("Unable to contact backend control plane to initiate backup"); | ||
| } catch (OperationTimedoutException e) { | ||
| throw new CloudRuntimeException("Operation to initiate backup timed out, please try again"); | ||
| } | ||
|
|
||
| if (answer != null && answer.getResult()) { | ||
| final BackupAnswer answer; | ||
| try { | ||
| answer = (BackupAnswer) agentManager.send(host.getId(), command); | ||
| } catch (AgentUnavailableException e) { | ||
| throw new CloudRuntimeException("Unable to contact backend control plane to initiate backup"); | ||
| } catch (OperationTimedoutException e) { | ||
| throw new CloudRuntimeException("Operation to initiate backup timed out, please try again"); | ||
| } | ||
| if (answer == null || !answer.getResult()) { | ||
| return false; | ||
| } | ||
| succeeded = true; | ||
| backupVO.setDate(new Date()); | ||
| backupVO.setSize(answer.getSize()); | ||
| backupVO.setStatus(Backup.Status.BackedUp); | ||
| backupVO.setBackedUpVolumes(BackupManagerImpl.createVolumeInfoFromVolumes(volumeDao.findByInstance(vm.getId()))); | ||
| return backupDao.update(backupVO.getId(), backupVO); | ||
| } else { | ||
| backupVO.setStatus(Backup.Status.Failed); | ||
| backupDao.remove(backupVO.getId()); | ||
| } finally { | ||
| if (!succeeded) { | ||
| // Releases the slot: a failed or interrupted backup must never stay BackingUp. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what happens when a backup runs past the 30 min wait? the host keeps going but the record is removed here, so the slot frees up early and the backup goes missing from the list |
||
| backupVO.setStatus(Backup.Status.Failed); | ||
| backupDao.update(backupVO.getId(), backupVO); | ||
| backupDao.remove(backupVO.getId()); | ||
| } | ||
| if (parallel) { | ||
| wakeLocalSlotWaiters(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Applies the zone's execution policy to the command and returns true when the command will run | ||
| * concurrently with other commands on the host (and so must be gated per host). | ||
| */ | ||
| protected boolean applyExecutionPolicy(final TakeBackupCommand command, final long zoneId) { | ||
| final boolean parallel = Boolean.TRUE.equals(NASBackupParallelExecution.valueIn(zoneId)); | ||
| command.setExecuteInSequence(!parallel); | ||
| return parallel; | ||
| } | ||
|
|
||
| static String hostBackupLockName(final long hostId) { | ||
| return "nas-backup-host-" + hostId; | ||
| } | ||
|
|
||
| /** | ||
| * Reserves one of the host's backup slots and returns the BackingUp backup row that holds it. The row is | ||
| * the reservation: it is created only while fewer than maxPerHost NAS backups are in flight on the host, | ||
| * counted from the database under the host's cluster-wide lock, so the limit holds across every management | ||
| * server. The lock is held only for the count-and-insert step, never for the backup itself. The slot is | ||
| * released when the row leaves BackingUp (BackedUp, or Failed and removed). When the host is full this | ||
| * waits with back-off, up to timeoutSeconds, then fails with CloudRuntimeException without leaving a row. | ||
| */ | ||
| protected BackupVO reserveHostBackupSlot(final VirtualMachine vm, final long hostId, final String backupPath, | ||
| final Integer maxPerHost, final Integer timeoutSeconds) { | ||
| final int max = Math.max(1, maxPerHost == null ? 1 : maxPerHost); | ||
| final int timeout = Math.max(0, timeoutSeconds == null ? 0 : timeoutSeconds); | ||
| final long deadline = System.currentTimeMillis() + timeout * 1000L; | ||
| final int[] lastCount = {-1}; | ||
| long pause = slotPollMinMillis; | ||
| while (true) { | ||
| final BackupVO reserved = runUnderHostLock(hostId, () -> { | ||
| final int inFlight = countInFlightBackups(hostId, vm.getDataCenterId()); | ||
| lastCount[0] = inFlight; | ||
| if (inFlight >= max) { | ||
| return null; | ||
| } | ||
| final BackupVO backup = createBackupObject(vm, backupPath); | ||
| LOG.debug("Host {} now has {} of {} NAS backups in flight", hostId, inFlight + 1, max); | ||
| return backup; | ||
| }); | ||
| if (reserved != null) { | ||
| return reserved; | ||
| } | ||
| final long remaining = deadline - System.currentTimeMillis(); | ||
| if (remaining <= 0) { | ||
| throw new CloudRuntimeException(String.format( | ||
| "Timed out after %d seconds waiting for a NAS backup slot on host %d (%d of %d in flight); " | ||
| + "raise %s or %s, or retry when the host's backups finish", | ||
| timeout, hostId, Math.max(lastCount[0], 0), max, NASBackupParallelMaxPerHost.key(), NASBackupParallelQueueTimeout.key())); | ||
| } | ||
| synchronized (localSlotRelease) { | ||
| try { | ||
| localSlotRelease.wait(Math.max(1L, Math.min(pause, remaining))); | ||
| } catch (InterruptedException e) { | ||
| Thread.currentThread().interrupt(); | ||
| throw new CloudRuntimeException("Interrupted while waiting for a NAS backup slot on host " + hostId); | ||
| } | ||
| } | ||
| pause = Math.min(pause * 2, slotPollMaxMillis); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Runs the action while holding the host's cluster-wide database lock and returns its result, or null when | ||
| * the lock could not be taken in time (the caller then retries like it does for a full host). | ||
| */ | ||
| protected <T> T runUnderHostLock(final long hostId, final Supplier<T> action) { | ||
| final GlobalLock lock = GlobalLock.getInternLock(hostBackupLockName(hostId)); | ||
| try { | ||
| if (!lock.lock(HOST_SLOT_LOCK_WAIT_SECONDS)) { | ||
| LOG.debug("Could not take lock {} within {} seconds, will retry", lock.getName(), HOST_SLOT_LOCK_WAIT_SECONDS); | ||
| return null; | ||
| } | ||
| try { | ||
| return action.get(); | ||
| } finally { | ||
| lock.unlock(); | ||
| } | ||
| } finally { | ||
| lock.releaseRef(); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Counts the NAS backups in flight on the host, from the database, so every management server sees the same | ||
| * number. A backup belongs to the host its VM is on, or was last on when stopped, which is how | ||
| * getVMHypervisorHost picks the host the command is sent to. A running VM cannot migrate while libvirt runs | ||
| * its backup job, so the attribution does not drift for running VMs. Rows older than the take-backup command | ||
| * timeout are left out: no management server is waiting for them any more. | ||
| */ | ||
| protected int countInFlightBackups(final long hostId, final long zoneId) { | ||
| final List<BackupVO> backingUp = backupDao.listByZoneAndStatus(zoneId, Backup.Status.BackingUp); | ||
| if (CollectionUtils.isEmpty(backingUp)) { | ||
| return 0; | ||
| } | ||
| final long staleBefore = System.currentTimeMillis() - (takeBackupCommandWaitSeconds() + (long) STALE_RESERVATION_MARGIN_SECONDS) * 1000L; | ||
| int inFlight = 0; | ||
| for (final BackupVO backup : backingUp) { | ||
| if (backup.getDate() != null && backup.getDate().getTime() < staleBefore) { | ||
| LOG.debug("Not counting stale BackingUp backup {} (started {}) towards host {}", backup.getUuid(), backup.getDate(), hostId); | ||
| continue; | ||
| } | ||
| final BackupOffering offering = backupOfferingDao.findByIdIncludingRemoved(backup.getBackupOfferingId()); | ||
| if (offering != null && !getName().equalsIgnoreCase(offering.getProvider())) { | ||
| continue; | ||
| } | ||
| final VirtualMachine backupVm = vmInstanceDao.findByIdIncludingRemoved(backup.getVmId()); | ||
| if (backupVm == null) { | ||
| continue; | ||
| } | ||
| final Long vmHostId = backupVm.getHostId() != null ? backupVm.getHostId() : backupVm.getLastHostId(); | ||
| if (vmHostId != null && vmHostId == hostId) { | ||
| inFlight++; | ||
| } | ||
| } | ||
| return inFlight; | ||
| } | ||
|
|
||
| /** The longest a management server waits for a TakeBackupCommand answer: commands.timeout if set for it, else wait. */ | ||
| protected int takeBackupCommandWaitSeconds() { | ||
| int wait = Math.max(0, Objects.requireNonNullElse(AgentManager.Wait.value(), 0)); | ||
| final String granular = AgentManager.GranularWaitTimeForCommands.value(); | ||
| if (granular != null) { | ||
| for (final String entry : granular.split(",")) { | ||
| final String[] pair = entry.trim().split("="); | ||
| if (pair.length == 2 && TakeBackupCommand.class.getSimpleName().equals(pair[0].trim())) { | ||
| try { | ||
| final int value = Integer.parseInt(pair[1].trim()); | ||
| if (value > 0) { | ||
| wait = value; | ||
| } | ||
| } catch (NumberFormatException e) { | ||
| LOG.debug("Ignoring invalid commands.timeout entry {}", entry); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return wait; | ||
| } | ||
|
|
||
| protected void wakeLocalSlotWaiters() { | ||
| synchronized (localSlotRelease) { | ||
| localSlotRelease.notifyAll(); | ||
| } | ||
| return Objects.nonNull(answer) && answer.getResult(); | ||
| } | ||
|
|
||
| private BackupVO createBackupObject(VirtualMachine vm, String backupPath) { | ||
|
|
@@ -450,6 +655,9 @@ public boolean isValidProviderOffering(Long zoneId, String uuid) { | |
| @Override | ||
| public ConfigKey<?>[] getConfigKeys() { | ||
| return new ConfigKey[]{ | ||
| NASBackupParallelExecution, | ||
| NASBackupParallelMaxPerHost, | ||
| NASBackupParallelQueueTimeout | ||
| }; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if a host has 20 VMs on the same backup schedule, do all 20 get sent at once now? is there anything that caps how many run together on one host?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not any more. f0096a6 adds a per-host gate in the provider: at most
backup.nas.parallel.max.per.host(default 2) take-backup commands are in flight on a host, and the rest wait on the management server, in the async job thread, bounded bybackup.nas.parallel.queue.timeout(default 2 h). A wait that runs out fails cleanly without leaving a BackingUp row behind. So 20 VMs on one schedule on one host run two at a time.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok that covers it