Skip to content

Commit 3c8049c

Browse files
Stop concurrent CKS operations clobbering each other, recover stuck states
A Kubernetes cluster's state was written by id alone, so two operations started on the same cluster at once both got past their first transition. Each then ran its steps against VMs the other was changing, and whichever finished last decided the final state. The status scanner could also overwrite the state of an operation that started after it read the cluster. The state is now compared and set in one update, as the VM and volume states are. Each operation moves the cluster into its own state before it changes anything, and fails without touching the cluster when another operation got there first. From then on it only moves the cluster while the cluster is in a state the operation moved it to. Starting a cluster in Alert now moves it to Starting like any other start. A start from Error, a stop from Created or Error, and a retried destroy still go on without a state change, as they did before; the start and stop then leave the state alone when they end. From any other state with no transition for it, an operation is now refused instead of running alongside the one that holds the cluster. The scanner only completes a stop or a recovery that it started itself. A management server that stopped during an operation left the cluster in that operation's state. Nothing moved it on, and every later operation on it was refused until the database was edited by hand. The scanner now fails the operation of a cluster in an operation's state when no job is in progress for it on any management server. The existing Alert reconciliation then returns a healthy cluster to Running. To find those jobs, the start, stop, scale and upgrade jobs now record their cluster, as the create, delete and node jobs already did. A cluster named by a job in progress on a management server of an older version, which does not record it, is left alone. This replaces the scanner's first-run handling of Starting clusters, which tried a transition that does not exist. No schema or API change. Signed-off-by: Brad House <bhouse@nexthop.ai> (cherry picked from commit fee9834)
1 parent fb60564 commit 3c8049c

21 files changed

Lines changed: 653 additions & 63 deletions

‎api/src/main/java/com/cloud/kubernetes/cluster/KubernetesCluster.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ enum State {
8888
s_fsm.addTransition(State.Stopping, Event.OperationFailed, State.Alert);
8989

9090
s_fsm.addTransition(State.Stopped, Event.StartRequested, State.Starting);
91+
s_fsm.addTransition(State.Alert, Event.StartRequested, State.Starting);
9192
s_fsm.addTransition(State.Stopped, Event.OperationSucceeded, State.Stopped);
9293
s_fsm.addTransition(State.Running, Event.OperationSucceeded, State.Running);
9394

‎plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/KubernetesClusterManagerImpl.java‎

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
import java.util.ArrayList;
3030
import java.util.Arrays;
3131
import java.util.Collections;
32-
import java.util.Date;
3332
import java.util.EnumSet;
3433
import java.util.HashMap;
3534
import java.util.HashSet;
@@ -122,6 +121,8 @@
122121
import org.apache.cloudstack.context.CallContext;
123122
import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService;
124123
import org.apache.cloudstack.framework.config.ConfigKey;
124+
import org.apache.cloudstack.framework.jobs.dao.AsyncJobDao;
125+
import org.apache.cloudstack.framework.jobs.impl.AsyncJobVO;
125126
import org.apache.cloudstack.managed.context.ManagedContextRunnable;
126127
import org.apache.cloudstack.network.RoutedIpv4Manager;
127128
import org.apache.commons.beanutils.BeanUtils;
@@ -360,6 +361,8 @@ public class KubernetesClusterManagerImpl extends ManagerBase implements Kuberne
360361
@Inject
361362
protected VMInstanceDao vmInstanceDao;
362363
@Inject
364+
protected AsyncJobDao asyncJobDao;
365+
@Inject
363366
protected UserVmJoinDao userVmJoinDao;
364367
@Inject
365368
protected NetworkOfferingDao networkOfferingDao;
@@ -2909,35 +2912,27 @@ public void reallyRun() {
29092912
startWorker = ComponentContext.inject(startWorker);
29102913
startWorker.reconcileAlertCluster();
29112914
} else if (isClusterVMsInDesiredState(kubernetesCluster, VirtualMachine.State.Stopped)) {
2912-
stateTransitTo(kubernetesCluster.getId(), KubernetesCluster.Event.StopRequested);
2913-
stateTransitTo(kubernetesCluster.getId(), KubernetesCluster.Event.OperationSucceeded);
2915+
// move the cluster as listed, so that an operation started on it since then, which may
2916+
// also allow a stop, is left alone
2917+
if (_stateMachine.transitTo(kubernetesCluster, KubernetesCluster.Event.StopRequested, null, kubernetesClusterDao)) {
2918+
_stateMachine.transitTo(kubernetesCluster, KubernetesCluster.Event.OperationSucceeded, null, kubernetesClusterDao);
2919+
}
29142920
}
29152921
} catch (Exception e) {
29162922
logger.warn("Failed to run Kubernetes cluster Alert state scanner on Kubernetes cluster: {} status scanner", kubernetesCluster, e);
29172923
}
29182924
}
29192925

29202926

2927+
// move clusters left in an operation's state by a management server that stopped to a state the
2928+
// reconciliation above can work from
2929+
try {
2930+
recoverOrphanedOperationStates();
2931+
} catch (Exception e) {
2932+
logger.warn("Failed to recover Kubernetes clusters left in an operation's state", e);
2933+
}
2934+
29212935
if (firstRun) {
2922-
// run through Kubernetes clusters in 'Starting' state and reconcile state as 'Alert' or 'Error' if the VM's are running
2923-
List<KubernetesClusterVO> startingKubernetesClusters = kubernetesClusterDao.findManagedKubernetesClustersInState(KubernetesCluster.State.Starting);
2924-
for (KubernetesCluster kubernetesCluster : startingKubernetesClusters) {
2925-
if ((new Date()).getTime() - kubernetesCluster.getCreated().getTime() < 10*60*1000) {
2926-
continue;
2927-
}
2928-
if (logger.isInfoEnabled()) {
2929-
logger.info("Running Kubernetes cluster state scanner on Kubernetes cluster: {} for state: {}", kubernetesCluster, KubernetesCluster.State.Starting.toString());
2930-
}
2931-
try {
2932-
if (isClusterVMsInDesiredState(kubernetesCluster, VirtualMachine.State.Running)) {
2933-
stateTransitTo(kubernetesCluster.getId(), KubernetesCluster.Event.FaultsDetected);
2934-
} else {
2935-
stateTransitTo(kubernetesCluster.getId(), KubernetesCluster.Event.OperationFailed);
2936-
}
2937-
} catch (Exception e) {
2938-
logger.warn("Failed to run Kubernetes cluster Starting state scanner on Kubernetes cluster: {} status scanner", kubernetesCluster, e);
2939-
}
2940-
}
29412936
List<KubernetesClusterVO> destroyingKubernetesClusters = kubernetesClusterDao.findManagedKubernetesClustersInState(KubernetesCluster.State.Destroying);
29422937
for (KubernetesCluster kubernetesCluster : destroyingKubernetesClusters) {
29432938
if (logger.isInfoEnabled()) {
@@ -2957,6 +2952,65 @@ public void reallyRun() {
29572952
}
29582953
}
29592954

2955+
/**
2956+
* States which a cluster holds only while an operation runs on it, and which nothing else moves it out of.
2957+
*/
2958+
protected static final List<KubernetesCluster.State> OPERATION_STATES = List.of(KubernetesCluster.State.Starting,
2959+
KubernetesCluster.State.Stopping, KubernetesCluster.State.Scaling, KubernetesCluster.State.ScalingStoppedCluster,
2960+
KubernetesCluster.State.Upgrading, KubernetesCluster.State.Importing, KubernetesCluster.State.RemovingNodes,
2961+
KubernetesCluster.State.Recovering);
2962+
2963+
/**
2964+
* Fails the operation of each cluster in an operation's state which has no job in progress on any management
2965+
* server. Such a cluster was left there by a management server that stopped during the operation: the jobs of a
2966+
* stopped management server are failed when it starts again or when another one sees it leave, but nothing moved
2967+
* the cluster on, and every later operation on it was refused.
2968+
*/
2969+
protected void recoverOrphanedOperationStates() {
2970+
List<KubernetesClusterVO> clusters = kubernetesClusterDao.findManagedKubernetesClustersInStates(OPERATION_STATES);
2971+
if (clusters.isEmpty()) {
2972+
return;
2973+
}
2974+
// A management server of an older version does not record the cluster of its start, stop, scale and upgrade
2975+
// jobs, but their parameters name it by its UUID. A cluster that such a job may be acting on is left alone.
2976+
List<String> untaggedJobsCmdInfo = new ArrayList<>();
2977+
for (AsyncJobVO job : asyncJobDao.findInstancePendingAsyncJobs(ApiCommandResourceType.KubernetesCluster.toString(), null)) {
2978+
if (job.getInstanceId() == null) {
2979+
untaggedJobsCmdInfo.add(StringUtils.defaultString(job.getCmdInfo()));
2980+
}
2981+
}
2982+
for (KubernetesClusterVO cluster : clusters) {
2983+
if (untaggedJobsCmdInfo.stream().anyMatch(cmdInfo -> cmdInfo.isEmpty() || cmdInfo.contains(cluster.getUuid()))) {
2984+
logger.debug("Not recovering Kubernetes cluster {} from state {} while a job of an older management server may be acting on it", cluster, cluster.getState());
2985+
continue;
2986+
}
2987+
try {
2988+
recoverOrphanedOperationState(cluster.getId());
2989+
} catch (Exception e) {
2990+
logger.warn("Failed to recover Kubernetes cluster {} from state {}", cluster, cluster.getState(), e);
2991+
}
2992+
}
2993+
}
2994+
2995+
/**
2996+
* The cluster is read before its jobs. An operation that ends between the two reads moves the cluster to another
2997+
* state, so the compare-and-set of the state as read misses. For it to hit a live operation instead, one operation
2998+
* would have to end and another start and reach the same state between the two reads and the update right after.
2999+
*/
3000+
protected void recoverOrphanedOperationState(long clusterId) throws NoTransitionException {
3001+
KubernetesClusterVO cluster = kubernetesClusterDao.findById(clusterId);
3002+
if (cluster == null || !OPERATION_STATES.contains(cluster.getState())) {
3003+
return;
3004+
}
3005+
if (asyncJobDao.findInstancePendingAsyncJob(ApiCommandResourceType.KubernetesCluster.toString(), clusterId) != null) {
3006+
return;
3007+
}
3008+
final KubernetesCluster.State state = cluster.getState();
3009+
if (_stateMachine.transitTo(cluster, KubernetesCluster.Event.OperationFailed, null, kubernetesClusterDao)) {
3010+
logger.warn("Kubernetes cluster {} was in state {} with no operation in progress, as its operation ended without moving it on, for example because the management server running it stopped; marked the operation as failed", cluster, state);
3011+
}
3012+
}
3013+
29603014
// checks if Kubernetes cluster is in desired state
29613015
private boolean isClusterVMsInDesiredState(KubernetesCluster kubernetesCluster, VirtualMachine.State state) {
29623016
List<KubernetesClusterVmMapVO> clusterVMs = kubernetesClusterVmMapDao.listByClusterId(kubernetesCluster.getId());

‎plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterActionWorker.java‎

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,10 @@ public class KubernetesClusterActionWorker {
164164
protected final static List<KubernetesClusterNodeType> CLUSTER_NODES_TYPES_LIST = Arrays.asList(WORKER, CONTROL, ETCD);
165165

166166
protected StateMachine2<KubernetesCluster.State, KubernetesCluster.Event, KubernetesCluster> _stateMachine = KubernetesCluster.State.getStateMachine();
167+
// whether this worker has started its operation with transitToOperationState, and the state the operation moved
168+
// the cluster to and has not moved it out of yet
169+
private boolean operationStarted = false;
170+
private KubernetesCluster.State ownedState;
167171

168172
@Inject
169173
protected CAManager caManager;
@@ -770,17 +774,96 @@ protected void updateLoginUserDetails(List<Long> clusterVMs) {
770774
}
771775
}
772776

777+
/**
778+
* Moves the cluster on with the given event. Once this worker has started its operation with
779+
* {@link #transitToOperationState}, it only moves the cluster out of the state that the operation last moved it
780+
* to: a cluster it never moved, or that has moved on since, may be held by another operation by now.
781+
*/
773782
protected boolean stateTransitTo(long kubernetesClusterId, KubernetesCluster.Event e) {
774783
KubernetesClusterVO kubernetesCluster = kubernetesClusterDao.findById(kubernetesClusterId);
784+
if (kubernetesCluster == null) {
785+
logger.warn("Unable to find Kubernetes cluster with ID {} to apply event {}", kubernetesClusterId, e);
786+
return false;
787+
}
788+
if (operationStarted) {
789+
if (ownedState == null || kubernetesCluster.getState() != ownedState) {
790+
logger.debug("Not applying event {} to Kubernetes cluster {} in state {}, which its operation does not hold", e, kubernetesCluster, kubernetesCluster.getState());
791+
return false;
792+
}
793+
}
775794
try {
776-
return _stateMachine.transitTo(kubernetesCluster, e, null, kubernetesClusterDao);
795+
boolean transitioned = _stateMachine.transitTo(kubernetesCluster, e, null, kubernetesClusterDao);
796+
if (operationStarted) {
797+
// the operation holds the state it moved the cluster to, until another operation moves it on; on a
798+
// miss, another operation already has
799+
ownedState = transitioned ? kubernetesCluster.getState() : null;
800+
}
801+
return transitioned;
777802
} catch (NoTransitionException nte) {
778803
logger.warn(String.format("Failed to transition state of the Kubernetes cluster : %s in state %s on event %s",
779804
kubernetesCluster.getName(), kubernetesCluster.getState().toString(), e.toString()), nte);
780805
return false;
781806
}
782807
}
783808

809+
/**
810+
* Moves the cluster into the state of the operation that is starting, failing if another operation got there first.
811+
* The state is compared and set in one update, so of two operations started on the cluster at once only one gets
812+
* past this point. The other fails without changing the state, and without undoing anything that the operation in
813+
* progress may still need.
814+
* <p>
815+
* From then on the worker only moves the cluster out of that state, see {@link #stateTransitTo}.
816+
*
817+
* @param statesToGoOnWithout the states with no transition for the event in which the operation still goes on,
818+
* without a state change, as it did before; in any other such state it fails. Going on
819+
* this way, the operation holds no state, and does not move the cluster when it ends.
820+
* @return whether the cluster was moved to the operation's state
821+
*/
822+
protected boolean transitToOperationState(final KubernetesCluster.Event event, final KubernetesCluster.State... statesToGoOnWithout) throws CloudRuntimeException {
823+
operationStarted = true;
824+
KubernetesClusterVO cluster = kubernetesClusterDao.findById(kubernetesCluster.getId());
825+
if (cluster == null) {
826+
throw new CloudRuntimeException(String.format("Unable to find Kubernetes cluster %s", kubernetesCluster.getName()));
827+
}
828+
final KubernetesCluster.State state = cluster.getState();
829+
boolean transitioned;
830+
try {
831+
transitioned = _stateMachine.transitTo(cluster, event, null, kubernetesClusterDao);
832+
} catch (NoTransitionException e) {
833+
if (Arrays.asList(statesToGoOnWithout).contains(state)) {
834+
return false;
835+
}
836+
String message = String.format("Kubernetes cluster %s is in state %s, which does not allow %s", kubernetesCluster.getName(), state, event);
837+
logger.warn(message);
838+
throw new CloudRuntimeException(message);
839+
}
840+
if (!transitioned) {
841+
KubernetesClusterVO current = kubernetesClusterDao.findById(cluster.getId());
842+
throw operationRaced(state, current == null ? null : current.getState(), event);
843+
}
844+
ownedState = cluster.getState();
845+
return true;
846+
}
847+
848+
/**
849+
* Makes the operation hold a state it went on in without moving the cluster, so that it moves the cluster out of
850+
* it when it ends: a retried destroy completes the Destroying state.
851+
*/
852+
protected void holdOperationState(final KubernetesCluster.State state) {
853+
ownedState = state;
854+
}
855+
856+
protected boolean isOperationStarted() {
857+
return operationStarted;
858+
}
859+
860+
protected CloudRuntimeException operationRaced(final KubernetesCluster.State from, final KubernetesCluster.State to, final KubernetesCluster.Event event) {
861+
String message = String.format("Kubernetes cluster %s changed from state %s to %s before %s could start; another operation may be in progress, please retry once it has finished",
862+
kubernetesCluster.getName(), from, to, event);
863+
logger.warn(message);
864+
return new CloudRuntimeException(message);
865+
}
866+
784867
protected boolean createCloudStackSecret(String[] keys) {
785868
File pkFile = getManagementServerSshPublicKeyFile();
786869
Pair<String, Integer> publicIpSshPort = getKubernetesClusterServerIpSshPort(null);

‎plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterAddWorker.java‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,9 @@ public boolean addNodesToCluster(List<Long> nodeIds, boolean mountCksIsoOnVr, bo
8686
} catch (ManagementServerException e) {
8787
throw new CloudRuntimeException(String.format("Failed to retrieve public IP for the network: %s ", network.getName()));
8888
}
89+
// take the cluster before attaching the ISO, so that an add which is refused leaves nothing behind
90+
transitToOperationState(KubernetesCluster.Event.AddNodeRequested);
8991
attachCksIsoForNodesAdditionToCluster(nodeIds, kubernetesCluster.getId(), mountCksIsoOnVr);
90-
stateTransitTo(kubernetesCluster.getId(), KubernetesCluster.Event.AddNodeRequested);
9192
String controlNodeGuestIp = getControlVmPrivateIp();
9293
Ternary<Integer, Long, Long> nodesAddedAndMemory = importNodeToCluster(nodeIds, network, publicIp, controlNodeGuestIp, mountCksIsoOnVr);
9394
int nodesAdded = nodesAddedAndMemory.first();
@@ -113,7 +114,14 @@ public boolean addNodesToCluster(List<Long> nodeIds, boolean mountCksIsoOnVr, bo
113114
description, kubernetesCluster.getId(), ApiCommandResourceType.KubernetesCluster.toString(), 0);
114115
return true;
115116
} catch (Exception e) {
116-
stateTransitTo(kubernetesCluster.getId(), KubernetesCluster.Event.OperationFailed);
117+
// a failure before this operation took the cluster must not end the operation that holds it, and after,
118+
// the cluster is only moved while this operation still holds it, see stateTransitTo
119+
if (isOperationStarted()) {
120+
stateTransitTo(kubernetesCluster.getId(), KubernetesCluster.Event.OperationFailed);
121+
}
122+
if (e instanceof CloudRuntimeException) {
123+
throw (CloudRuntimeException) e;
124+
}
117125
throw new CloudRuntimeException(e);
118126
}
119127
}

‎plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterDestroyWorker.java‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,10 @@ public boolean destroy() throws CloudRuntimeException {
328328
if (logger.isInfoEnabled()) {
329329
logger.info("Destroying Kubernetes cluster : {}", kubernetesCluster);
330330
}
331-
stateTransitTo(kubernetesCluster.getId(), KubernetesCluster.Event.DestroyRequested);
331+
// a cluster already being destroyed is having its destroy retried, which completes it
332+
if (!transitToOperationState(KubernetesCluster.Event.DestroyRequested, KubernetesCluster.State.Destroying)) {
333+
holdOperationState(KubernetesCluster.State.Destroying);
334+
}
332335
boolean vmsDestroyed = destroyClusterVMs();
333336
// if there are VM's that were not expunged, we can not delete the network
334337
if (vmsDestroyed) {

‎plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterRemoveWorker.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ public boolean removeNodesFromCluster(List<Long> nodeIds) {
7070
} catch (ManagementServerException e) {
7171
throw new CloudRuntimeException(String.format("Failed to retrieve public IP for the network: %s ", network.getName()));
7272
}
73-
stateTransitTo(kubernetesCluster.getId(), KubernetesCluster.Event.RemoveNodeRequested);
73+
transitToOperationState(KubernetesCluster.Event.RemoveNodeRequested);
7474
boolean result = removeNodesFromCluster(nodeIds, network, publicIp);
7575
if (!result) {
7676
stateTransitTo(kubernetesCluster.getId(), KubernetesCluster.Event.OperationFailed);

0 commit comments

Comments
 (0)