Horizontal autoscaling, opt-in per deployment - #211
Merged
Conversation
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.
Ring can now pick a deployment's instance count from its observed CPU, instead of holding the number in the manifest.
Opt-in, and that is the point. A deployment without an
autoscaleblock never has its count changed by Ring. That is what lets an external controller keep owning the count for its own deployments while Ring autoscales only what was handed to it explicitly.Two columns, not one
replicaskeeps meaning "what the manifest declared" and is never written by the scheduler; the decision lives indesired_replicasnext to it. So re-runningring applydoes not fight the autoscaler, and both numbers stay readable:A rolling update carries the current capacity to the new deployment, so a redeploy under load does not drop back to the starting count.
How it decides
One instance at a time; a ±10 point dead band around the target; 60s before adding, 300s before removing. The asymmetry is deliberate — shedding capacity slowly is what stops a load oscillating around the target from driving the count up and down with it. Missing or stale (>120s) metrics mean no decision at all, never a guess.
The bug this nearly shipped with
stats_cachereports CPU as the sum across instances — correct for the Prometheus gauge, and the exact opposite of a setpoint. Three instances at 30% report 90%, "above" a 70% target, so the controller would add a fourth, see the sum rise, and climb tomaxwhile every instance idled.Rather than divide at the call site, the cache now exposes
cpu_usage_percent_per_instancebeside the sum, both documented with the trap. A future controller finds the right value and the warning together.Rejected combinations
kind: job(no steady-state CPU), host networking withmax > 1(instances would fight over the same ports), and the containerd runtime — it reports CPU as a hardcoded0, so a CPU target would read "idle" forever and walk the deployment down tomin.Single-node limit, documented not hidden
Autoscaling multiplies instances on one machine: it divides that machine's CPU more finely, it does not add capacity. It absorbs spikes on a host with headroom; it cannot rescue a saturated one. Per-instance memory admission still applies, so a deployment that outgrows the host stops with
insufficient_resourcesrather than taking the machine down.No CPU admission control was added, deliberately: #110 established that memory is gated because it kills workloads while CPU overcommit only degrades, and that reasoning still holds.
Testing
824 unit tests, clippy clean. Coverage includes the opt-in guarantee at
target_replicas(), the summed-CPU regression, anti-flap under an oscillating load, cooldown asymmetry, rollout inheritance, and policy re-clamping.Reviewed by codex over three rounds. It caught the summed-CPU runaway, the containerd zero metric, a Firecracker burst (it started the whole deficit in one pass), the rollout capacity reset, stale-snapshot decisions, and — last round — that
(namespace, name)is only unique among rows with noparent_id, so measurements are now keyed by deployment id and both sides of a rollout are left alone.