Make node retirement fit inside the AWS spot notice - #31
Open
harshit-anyscale wants to merge 5 commits into
Open
Make node retirement fit inside the AWS spot notice#31harshit-anyscale wants to merge 5 commits into
harshit-anyscale wants to merge 5 commits into
Conversation
AWS gives 120 seconds of warning before it reclaims a spot machine. Serve does
not get 120 seconds: detection costs up to 10s (5s spot-notice poll, then a 5s
node-manager loop) and the drain action itself is capped at 60s. Everything
below is derived from that budget.
Timeout ladder (anyscale_service.yaml)
Four timeouts nest, each guarding the one below it:
long-runner ceiling 20s
request_timeout_s 26
HAPROXY_TIMEOUT_SERVER_S 28
HAPROXY_HARD_STOP_AFTER_S 30
Read top down. Hard-stop must be at or under 60s to fit the drain action, and
should equal RAY_SERVE_DIRECT_INGRESS_MIN_DRAINING_PERIOD_S (30, the default)
so an old HAProxy worker can never outlive the replica it is routing to --
closing the 30s/400s mismatch. Subtract the two ordering guards and the request
ceiling lands at 20s. request_timeout_s sits below TIMEOUT_SERVER_S so an
overrun surfaces as a clean replica 408 rather than a generic HAProxy 500.
6s of headroom over the ceiling is generous: measured transport and queueing
overhead above the deliberate sleep was p50 0.3s / p99 0.1s (n=408, 2026-08-28).
Workload (long_runner.py, traffic_model.py)
- long-runner 30-120s -> 8-20s, clamp 125 -> 20. This is what lets hard-stop
reach 30s. It narrows what the service validates; revert both together.
- Docstring corrected to match the code.
Graceful shutdown (7 deployments)
1200s / 6000s -> 15s / 20s / 25s, sized just above each deployment's own
request time. The machine is gone 120s after the notice regardless, so a
20-minute grace period only means the drain never terminates on its own.
Warm floors (config.py + 4 apps)
New _with_floor() applies min_replicas per deployment rather than on the shared
presets, since AUTOSCALE_SPIKY_T2 is also used by batch-infer. nlp-chain,
image-dag, cpu-fanout and long-runner get a floor of 2: a run with all four at
zero took 29 cold-start 408s in its first 100 seconds, and the compressed
ladder leaves far less room to survive that queue. Trade-off: min_replicas=0
was deliberate, and this removes scale-from-zero coverage for those four.
Placement (anyscale_service.yaml)
cpu-gpu-sim moves to ON_DEMAND while cpu-general stays PREFER_SPOT. The mux
workers schedule there, and mux recovers slowest from losing warm replicas --
each MuxModelWorker blocks readiness until all 20 models are pre-loaded.
Latency buckets (anyscale_service.yaml)
130000/150000 -> 70000/90000. Those two bounds existed to stop a legitimate
~120s long-runner request interpolating P99 to ~282s and firing a false alert
~0.7x/day (RCA 2026-08-03). With the ceiling at 20s nothing reaches them.
ACTION REQUIRED: the Grafana rule and the Notion acceptance table still carry
the old 130s SLO, which is ~6x too high against a 20s cap -- the alert cannot
fire until the rule's quantile boundary moves to 30000.
Verification
Five consecutive load tests at ~15,000 RPS: 69,224,241 requests, 1 failure
(0.0000014%, against a 0.01% threshold), 5/5 passed. Cold-start timeouts went
from 29 to zero. Note that no spot instance was reclaimed during any of the
five runs, so these establish that the ladder is stable under sustained load
and that the warm floors work -- they do not yet demonstrate a live preemption
being handled cleanly.
Not closed by this change: an old HAProxy worker can still accept one final
request at any point up to its deadline, so the last 20s of a 30s worker life
stay exposed. No hard-stop value fixes that; it needs server-template plus
runtime membership so no reload, and no old worker, ever exists.
Co-Authored-By: Claude <noreply@anthropic.com>
main moved both worker groups to ON_DEMAND. Merging that in cleanly would have
quietly defeated this branch: git took main's cpu-general block without
conflict, leaving the whole fleet on-demand while the branch still claimed to
be spot support. The only reported conflict was cpu-gpu-sim, which is the one
line that genuinely agrees on both sides.
Resolution:
cpu-general ON_DEMAND -> PREFER_SPOT, now with a comment that explains it as
a re-enable and points at the work in this branch that earns it
cpu-gpu-sim stays ON_DEMAND, keeping this branch's mux rationale
Co-Authored-By: Claude <noreply@anthropic.com>
This group was held on ON_DEMAND earlier in the branch because MuxModelWorker
blocks readiness until all 20 models are pre-loaded, which was taken to mean
mux recovers slowly from losing a warm replica. Checked, and it does not:
- _prewarm() gathers all 20 loads concurrently and each is a 10-40ms sleep
over a 1-8 KiB bytearray, so the preload is ~40ms of work.
- measured mean replica startup over 7d (n=477): mux-model-worker 30s vs
mux-ingress 27s, and the ingress loads no models at all. The 20-model
preload is ~3s of a ~30s startup, and three deployments start slower --
two of them already on spot via cpu-general.
The exclusion was also expensive. cpu-gpu-sim carries 3,014 of 3,226 measured
worker node-hours/month: the simulated_gpu: 8 cap puts 8 replicas on a node
that CPU alone would fill with 32, and those nodes absorb the CPU-only work as
well, leaving cpu-general at 7% of the fleet. Holding 93% of worker hours on
on-demand to protect mux was worth roughly $1,000/month.
What is left is not mux-specific: any min_replicas=1 deployment on a spot group
pays a full cold start when its only warm replica is reclaimed. That applies to
mux-model-worker, stream-chat and mixed-preprocess here, and is a floors
decision rather than a market_type one.
Co-Authored-By: Claude <noreply@anthropic.com>
With cpu-gpu-sim on spot, a floor of 1 is a single point of failure: the reclaim takes the only warm replica and the next request waits out a full cold start. Measured mean replica startup over 7d makes that concrete -- stream-chat 43s (n=3108), mux-model-worker 30s (n=477), mixed-preprocess-gpu 27s (n=152). At a floor of 2 a reclaim costs capacity instead of the whole deployment. _with_floor already existed for the min_replicas=0 apps; its docstring now covers this second, different starting point rather than pretending it is the same case. Deliberately not included: mixed-preprocess-cpu, mux-ingress, echo, grpc-canary and heavy-payload are still at 1. cpu-general is on spot too, so they carry the same exposure -- this change was scoped to cpu-gpu-sim. mixed-preprocess-cpu is the strongest of the five if the floors are widened, since it is the HTTP entry point of a chain whose second hop is now floored. A comment there says so. Cost: roughly one extra baseline node against the ~$1,000/month the previous commit recovers. Co-Authored-By: Claude <noreply@anthropic.com>
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.
AWS gives 120 seconds of warning before it reclaims a spot machine. Serve does not get 120 seconds: detection costs up to 10s (a 5s spot-notice poll, then a 5s node-manager loop) and the drain action itself is capped at 60s. Every value below is derived from that budget.
The timeout ladder
Four timeouts nest, each guarding the one below it:
request_timeout_s408past thisHAPROXY_TIMEOUT_SERVER_S500HAPROXY_HARD_STOP_AFTER_SRead it top down. Hard-stop must be at or under 60s to fit inside the drain action, and it should equal
RAY_SERVE_DIRECT_INGRESS_MIN_DRAINING_PERIOD_S(30, the default) so an old HAProxy worker can never outlive the replica it is routing to — closing the previous 30s/400s mismatch. Subtract the two ordering guards and the request ceiling lands at 20s.request_timeout_sdeliberately sits belowTIMEOUT_SERVER_Sso an overrun surfaces as a clean replica 408 rather than a generic HAProxy 500 — the difference between a diagnosable failure and a mystery.6s of headroom over the ceiling is generous. Measured transport and queueing overhead above the deliberate sleep was p50 0.3s / p99 0.1s (n=408, 2026-08-28).
What changed
Workload —
long_runner.py,traffic_model.pyGraceful shutdown — 7 deployments
Warm floors —
config.py+ 4 apps_with_floor()appliesmin_replicasper deployment rather than on the shared presets, sinceAUTOSCALE_SPIKY_T2is also used by batch-infer.min_replicas=0was deliberate — this removes scale-from-zero coverage for those four.Placement —
anyscale_service.yamlPREFER_SPOT. Main sits onON_DEMANDprecisely because retirement did not fit the notice; everything above is what earns spot back, so these lines should never be reverted on their own.cpu-gpu-simwas held back at first, on the grounds thatMuxModelWorkerblocks readiness until all 20 models are pre-loaded and so recovers slowly. That does not hold up:_prewarm()gathers all 20 loads concurrently and each is a 10–40ms sleep over a 1–8 KiB bytearray. Measured mean replica startup over 7 days (n=477) is 30s for mux-model-worker vs 27s for mux-ingress, which loads no models at all — the preload is ~3s of a ~30s startup, and three deployments start slower.cpu-gpu-simcarries 3,014 of 3,226 measured worker node-hours/month (93%):simulated_gpu: 8packs 8 replicas onto a node CPU alone would fill with 32, and those nodes absorb the CPU-only work too, leavingcpu-generalat 7% of the fleet.Latency buckets —
anyscale_service.yaml130000/150000→70000/90000. Those two bounds existed to stop a legitimate ~120s long-runner request interpolating P99 to ~282s and firing a false alert ~0.7×/day (RCA 2026-08-03). With the ceiling at 20s, nothing reaches them.Important
The Grafana rule and the Notion acceptance table still carry long-runner's old 130s SLO. Against a 20s cap that is ~6× too high and the alert cannot fire until the rule's quantile boundary moves to
30000.Verification
Five consecutive load tests at ~15,000 RPS:
One failure in 69.2 million requests — 0.0000014%, against a 0.01% acceptance threshold. Cold-start timeouts went from 29 to zero.
What these runs do not show: no spot instance was reclaimed during any of the five, so they establish that the ladder is stable under sustained load and that the warm floors work. They do not yet demonstrate a live preemption being handled cleanly.
What this does not close
An old HAProxy worker can still accept one final request at any point up to its deadline, so the last 20s of a 30s worker life stay exposed. No value of hard-stop fixes that — raise it and the trailing window moves with it, lower it and the exposed fraction grows.
Closing it properly needs
server-templateplus runtime membership, so that changing the replica list never requires a reload and no old worker exists at all.