Repository navigation
Replace engine copies that aren't serving when a rollout stalls - #572
Draft
dennis-upbound wants to merge 1 commit into
Draft
dennis-upbound wants to merge 1 commit into
dennis-upbound wants to merge 1 commit into
Conversation
4 tasks done
dennis-upbound
force-pushed
the
dennis/rollout-replace-unavailable
branch
2 times, most recently
from
October 26, 2026 20:40
0bce2f1 to
406a438
Compare
A Standalone engine's Deployment kept Kubernetes' default rolling update: surge 25% rounded up, and 25% unavailable rounded down. For an engine of fewer than four copies that's one surge pod and no copy allowed down. The controller doesn't remove an old pod until the new one is available. It cleans up unhealthy old pods only inside that budget, which is zero while the surge pod is Pending. On a pool with no free devices the surge pod can't schedule until an old pod releases its claim. In both incidents in modelplaneai#496 the old pod was restarting and not Ready while it held the GPUs the new pod needed, so a fix to the engine's config never rolled out and nothing served. This sets the Deployment's strategy from its observed status. Once the Deployment controller marks a rollout Progressing False with reason ProgressDeadlineExceeded (at least 600 seconds after its last progress by default), maxUnavailable becomes the number of copies that aren't available, when that's more than the default allows. The controller then removes the old pods that aren't serving, and their devices go to the new pods. Surge stays at 25%. Waiting for the deadline means a copy that's briefly unready while a rollout still progresses isn't removed. The controller marks the rollout Progressing True again once it moves, and the strategy goes back to the defaults. Until a rollout stalls, the strategy is the one the API server defaults to. Existing Deployments keep their spec on upgrade. A strategy change doesn't start a new ReplicaSet. A rollout of a healthy engine on a full pool still stalls with the old pod serving, as before. Every copy is available, so none counts as down even past the deadline. Backend.build takes the observed resources as a keyword argument. The llm-d and Grove backends ignore them, because their workloads replace a gang before starting its successor by default. Fixes modelplaneai#496. Signed-off-by: Dennis Ramdass <dennis@upbound.io> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dennis-upbound
force-pushed
the
dennis/rollout-replace-unavailable
branch
from
October 27, 2026 03:34
406a438 to
424fd05
Compare
This branch has not been deployed
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 of your changes
Fixes #496.
A Standalone engine's Deployment used Kubernetes' default rolling update. With fewer than four copies, the controller adds one surge pod and waits for it to be available before it removes an old pod. It cleans up unhealthy old pods only inside that budget (
maxScaledDown = allPods - (copies - maxUnavailable) - newRSUnavailableinrolling.go), which is zero while the surge pod is Pending. On a pool with no free device the surge pod can't schedule until an old pod releases its claim. In both #496 incidents the old pod was restarting and not Ready, so the config fix never rolled out and nothing served.This sets
spec.strategyfrom the Deployment's observed status, which compose-model-replica already reads from the Object'sstatus.atProvider.manifest:maxSurgeis25%, as today.ProgressingFalse with reasonProgressDeadlineExceeded,maxUnavailableis the number of copies that aren't available, when that's more than the25%default allows. Otherwise it's25%.The controller sets that condition once
progressDeadlineSeconds(600 by default) have passed with no progress, using its own clock and requeue (progress.go). It checks the deadline when it next syncs the Deployment, so the condition can come later. On the kind e2e it took 10 to 18 minutes. Any progress, such as a pod becoming ready or an old pod going away, sets it back to True, and the strategy returns to the defaults. So a crash-looping engine's fix rolls out at least ten minutes after it's applied: the controller removes the old pods that aren't serving, and their devices go to the new pods.Until a rollout stalls, a Deployment renders
{type: RollingUpdate, rollingUpdate: {maxSurge: 25%, maxUnavailable: 25%}}. That's exactly whatSetDefaults_Deploymentinpkg/apis/apps/v1/defaults.gofills in, so on upgrade the patch matches the live spec, and the Deployment's generation and pods don't change. The controller hashes onlyspec.templatefor a ReplicaSet (sync.go), so a strategy change doesn't start a new ReplicaSet either.Backend.buildtakes the observed resources as a keyword-only argument. The llm-d and Grove backends ignore it. LeaderWorkerSet (maxSurge: 0, maxUnavailable: 1) and Grove (RollingRecreate) replace a gang before starting its successor by default, so they can't deadlock this way.Decisions:
main. Every copy is available, so none counts as down even past the deadline. That avoids a surprise outage on a config change, at the cost of a rollout that waits for room. Replacing first, with an opt-inrollout: Surge | Replaceor a strategy picked from Schedule replicas by free GPUs rather than free nodes #542's free-device histogram, would trade that stall for an outage. Picking surge or replace from the histogram is future work once Schedule replicas by free GPUs rather than free nodes #542 merges. A ModelReady that reports the stall is ModelDeployment and ModelEndpoint report Ready while the engine has no ready pods #497.I ran this on the kind e2e with the fake DRA driver and a pod that claimed every free device:
ProgressDeadlineExceededappeared 10 to 18 minutes after the fix, and the function setmaxUnavailable: 1within 2 seconds of it. The controller deleted the old pod, the new pod became ready, and the strategy returned to25%. The strategy changes didn't create a ReplicaSet, so the rollout ended with the old one and the fix's.maxUnavailable: 1cleared it in 3 seconds.25%.I have:
nix flake check(or./nix.sh flake check) and made sure it passes.git commit -s.🤖 Generated with Claude Code