-
Notifications
You must be signed in to change notification settings - Fork 898
RollingUpdate maxUnavailable should ensure to allow new allocations #3438
Description
What happened:
The way the maxUnavailable parameter is currently implemented, as part of the RollingUpdate strategy, is counterintuitive and can lead to situations where:
- new allocations are made on outdated game server sets
- ready replicas in outdated game server sets are not reaped
What you expected to happen:
maxUnavailableshould ensure to always be able to serve new allocations. The number of already allocated game servers should not influence this calculation, as they are not relevant to whether or not new allocations can be made.- New allocations should be prioritized on active game server sets over outdated ones.
- Outdated game server sets should allow their ready replicas to be reaped to let the active game server set reach its desired replica count and finish the rolling update.
How to reproduce it (as minimally and precisely as possible):
Install Agones
kubectl create namespace agones-systemkubectl apply --server-side -f https://raw.githubusercontent.com/googleforgames/agones/release-1.35.0/install/yaml/install.yaml
Create a Fleet
kubectl apply -f https://raw.githubusercontent.com/googleforgames/agones/release-1.35.0/examples/simple-game-server/fleet.yaml- Verify that the Fleet has 25%
maxSurgeandmaxUnavailableby runningk get fleets.agones.dev/simple-game-server -o json | jq ".spec.strategy"{ "rollingUpdate": { "maxSurge": "25%", "maxUnavailable": "25%" }, "type": "RollingUpdate" } - Set the number of replicas to
10:kubectl edit fleets.agones.dev/simple-game-server - Cause 3 allocations by running the following command:
kubectl create -f https://raw.githubusercontent.com/googleforgames/agones/release-1.35.0/examples/simple-game-server/gameserverallocation.yaml -o yaml
Trigger a RollingUpdate
- Run
watch kubectl get gameserversetsto make sure that the game servers are currently ready and not allocated.k get gameservers NAME SCHEDULING DESIRED CURRENT ALLOCATED READY AGE simple-game-server-1 Packed 10 10 3 7 1m
- Run
kubectl edit fleets.agones.dev/simple-game-serveragain, to change the game server version to 0.17 instead of 0.18. - Trigger 3 more allocations with
kubectl create -f https://raw.githubusercontent.com/googleforgames/agones/release-1.35.0/examples/simple-game-server/gameserverallocation.yaml -o yaml. The game server sets should look something like this:NAME SCHEDULING DESIRED CURRENT ALLOCATED READY AGE simple-game-server-1 Packed 0 3 3 0 2m simple-game-server-2 Packed 7 7 3 4 1m
- Run
kubectl edit fleets.agones.dev/simple-game-serverone last time, to change the game server version to 0.16 instead of 0.17 to trigger one more rolling update. - The game server sets now demonstrate the issue.
simple-game-server-2remains with a hanging ready replica, which never gets reaped, even though that would bring the fleet back to its intended replica count and finish the update.NAME SCHEDULING DESIRED CURRENT ALLOCATED READY AGE simple-game-server-1 Packed 0 3 3 0 3m simple-game-server-2 Packed 4 4 3 1 2m simple-game-server-3 Packed 4 4 0 4 1m
Fix the broken RollingUpdate by updating the maxUnavailable value
Edit the update strategy to allow the game server set to scale down.
kubectl edit fleets.agones.dev/simple-game-server- Change the value of
maxUnavailableto 80% - Observe that the rolling update now works successfully, and the old game server states see their
DESIREDreplica count get to zero, and all of theirREADYreplicas get reaped.NAME SCHEDULING DESIRED CURRENT ALLOCATED READY AGE simple-game-server-1 Packed 0 3 3 0 4m simple-game-server-2 Packed 0 3 3 0 3m simple-game-server-3 Packed 4 4 0 4 2m
Anything else we need to know?:
In the following code snippet, fleet refers to the Fleet specification we have been editing, while active refers to the GameServerSet currently being updated and rest refers to the other GameServerSets that are not being updated.
Let's consider the case where there are the following three game server sets:
NAME SCHEDULING DESIRED CURRENT ALLOCATED READY AGE
simple-game-server-1 Packed 0 3 3 0 3m
simple-game-server-2 Packed 4 4 3 1 2m
simple-game-server-3 Packed 4 4 0 4 1mWhen rollingUpdateRestFixedOnReady is called, simple-game-server-3 is the active game server set, and simple-game-server-1 and simple-game-server-2 as part of the rest, the following happens:
r, err := intstr.GetValueFromIntOrPercent(fleet.Spec.Strategy.RollingUpdate.MaxUnavailable, int(fleet.Spec.Replicas), false)returns.25 * 10rounded down, which is2. This is then stored in theunavailablevariable as an int32.readyReplicasCountis5, since there are a total of two ready replicas across game server sets.minAvailableis8(fleet.Spec.Replicas - unavailable, so10 - 2)cleanupUnhealthyReplicasis then called, which does nothing since the ready replicas on outdated game server sets aren't considered unhealthy.- Then, the statement
if readyReplicasCount <= minAvailable { return nil }causes the method to return without scaling down the ready replicas from the outdated game server set. - If
maxUnavailableis then set to 80%, this changes the equation, since nowminAvailableis2instead of 8, which makes thisifstatement no longer true.
Here is the relevant code snippet, with irrelevant code to this issue commented out.
package fleets
// from pkg/fleets/controller.go
func (c *Controller) rollingUpdateRestFixedOnReady(ctx context.Context, fleet *agonesv1.Fleet, active *agonesv1.GameServerSet, rest []*agonesv1.GameServerSet) error {
// if len(rest) == 0 {
// return nil
// }
r, err := intstr.GetValueFromIntOrPercent(fleet.Spec.Strategy.RollingUpdate.MaxUnavailable, int(fleet.Spec.Replicas), false)
if err != nil {
return errors.Wrapf(err, "error parsing MaxUnavailable value: %s", fleet.ObjectMeta.Name)
}
// if r == 0 {
// r = 1
// }
// if r > int(fleet.Spec.Replicas) {
// r = int(fleet.Spec.Replicas)
// }
unavailable := int32(r)
// totalAlreadyScaledDown := int32(0)
// totalScaleDownCount := int32(0)
allGSS := rest
allGSS = append(allGSS, active)
readyReplicasCount := agonesv1.GetReadyReplicaCountForGameServerSets(allGSS)
minAvailable := fleet.Spec.Replicas - unavailable
// allPodsCount := agonesv1.SumSpecReplicas(allGSS)
// newGSSUnavailablePodCount := active.Spec.Replicas - active.Status.ReadyReplicas - active.Status.AllocatedReplicas
// maxScaledDown := allPodsCount - minAvailable - newGSSUnavailablePodCount
// if maxScaledDown <= 0 {
// return nil
// }
rest, _, err = c.cleanupUnhealthyReplicas(ctx, rest, fleet, maxScaledDown)
if err != nil {
return nil
}
// Resulting value is readyReplicasCount + unavailable - fleet.Spec.Replicas
// totalScaleDownCount = readyReplicasCount - minAvailable
if readyReplicasCount <= minAvailable {
// Cannot scale down.
return nil
}
// The rest of this method is irrelevant since in the case of this issue we always return in the above statement.
return nil
}
func (c *Controller) cleanupUnhealthyReplicas(ctx context.Context, rest []*agonesv1.GameServerSet,
fleet *agonesv1.Fleet, maxCleanupCount int32) ([]*agonesv1.GameServerSet, int32, error) {
totalScaledDown := int32(0)
for i, gsSet := range rest {
if totalScaledDown >= maxCleanupCount {
break
}
if gsSet.Spec.Replicas == 0 {
continue
}
if gsSet.Spec.Replicas == gsSet.Status.ReadyReplicas {
continue
}
// scaledDownCount := int32(integer.IntMin(int(maxCleanupCount-totalScaledDown), int(gsSet.Spec.Replicas-gsSet.Status.ReadyReplicas)))
// newReplicasCount := gsSet.Spec.Replicas - scaledDownCount
// if newReplicasCount > gsSet.Spec.Replicas {
// return nil, 0, fmt.Errorf("when cleaning up unhealthy replicas, got invalid request to scale down %s/%s %d -> %d", gsSet.Namespace, gsSet.Name, gsSet.Spec.Replicas, newReplicasCount)
// }
//
// gsSetCopy := gsSet.DeepCopy()
// gsSetCopy.Spec.Replicas = newReplicasCount
// totalScaledDown += scaledDownCount
// if _, err := c.gameServerSetGetter.GameServerSets(gsSetCopy.ObjectMeta.Namespace).Update(ctx, gsSetCopy, metav1.UpdateOptions{}); err != nil {
// return nil, totalScaledDown, errors.Wrapf(err, "error updating gameserverset %s", gsSetCopy.ObjectMeta.Name)
// }
//
// rest[i] = gsSetCopy
}
return rest, totalScaledDown, nil
}When this happens, it is even possible that a new allocation selects the ready replica from the outdated game server set, then causing the following state to occur:
Before Allocation
NAME SCHEDULING DESIRED CURRENT ALLOCATED READY AGE
simple-game-server-1 Packed 0 3 3 0 3m
simple-game-server-2 Packed 4 4 3 1 2m
simple-game-server-3 Packed 4 4 0 4 1mAfter Allocation
NAME SCHEDULING DESIRED CURRENT ALLOCATED READY AGE
simple-game-server-1 Packed 0 3 3 0 3m
simple-game-server-2 Packed 4 4 4 0 2m
simple-game-server-3 Packed 3 3 0 3 1mThe latest game server state lost one of its desired replicas in the process! In cases where the old and new game server sets compete for the last available replica, this leads to a situation where the new game server set disappears in favor of the old one, like here:
NAME SCHEDULING DESIRED CURRENT ALLOCATED READY AGE
simple-game-server-1 Packed 3 6 6 0 12m
simple-game-server-2 Packed 4 4 4 0 11m
simple-game-server-3 Packed 0 0 0 0 10mThis issue can also be reproduced consistently with a Fleet Autoscaler, except that the case mentioned in the previous paragraph wouldn't happen, as long as the maxReplicas isn't reached.