Add scale-up circuit breaker for unfulfilled ASG scale-ups - #294
Conversation
55b5a74 to
6689b06
Compare
When an ASG cannot fulfil a desired-capacity increase (e.g. an instance type is temporarily out of capacity in an AZ), running instances stop appearing while pod utilisation stays high. Escalator kept raising the desired count every scan interval, driving it up to max_nodes with no new nodes launching and leaving a wide desired-vs-running gap. Introduce an opt-in per-nodegroup circuit breaker. A scale-up is counted as failed when the running node count has not reached the desired count requested by the previous scale-up by the time the next one is evaluated. After scale_up_failure_threshold consecutive failures the breaker opens and stops increasing the desired count; after scale_up_failure_cooldown it allows a single half-open probe, re-opening on failure or resuming normal scaling once capacity recovers. Untaint of existing nodes is unaffected. Disabled by default (threshold 0), so existing configs are unchanged. Exposes node_group_scale_up_circuit_breaker_open and node_group_scale_up_failed_scale_events metrics.
6689b06 to
26f3146
Compare
| scale_up_cool_down_period: 2m | ||
| scale_up_cool_down_timeout: 10m | ||
| scale_up_failure_threshold: 5 | ||
| scale_up_failure_cooldown: 30m |
There was a problem hiding this comment.
That's a long time (I know this is just example config). Short cooldown isn't an issue, because we still half-probe and fail out if we still haven't caught up to desired
| scale-up. If the cooldown is shorter than typical instance launch latency, a healthy-but-slow node group could be | ||
| mistaken for a stuck one. | ||
|
|
||
| `scale_up_failure_cooldown` is how long the breaker stays open before allowing a single half-open probe scale-up. If |
There was a problem hiding this comment.
We might not even need the cooldown... The load from checking isn't hurting us, and introducing a cooldown will only introduce potential latency during scaling
We just want to open the circuit breaker, keep looping as normal, but only do scale activities when actual matches desired again
Drop the cooldown/half-open-probe mechanism from the scale-up circuit breaker. Instead of waiting out a timer and probing, the breaker holds the desired count frozen while open and closes as soon as the running count reaches that frozen target, so recovery is picked up on the next scan without adding scaling latency. Removes the scale_up_failure_cooldown config option and its validation; scale_up_failure_threshold is now the only knob.
awprice
left a comment
There was a problem hiding this comment.
Looking good - just some tweaks needed
| b.sawScaleUp = true | ||
| } | ||
|
|
||
| func (b scaleUpCircuitBreaker) String() string { |
There was a problem hiding this comment.
String() doesn't look used anywhere. It's also the only value receiver on the type (allow, recordScaleUp and enabled are all pointer receivers), so dropping it also makes the receivers consistent.
There was a problem hiding this comment.
Removed in 06ed774. It had no callers and was the only value receiver on the type, so dropping it also lets the fmt import go and keeps every method on a pointer receiver.
| log.Errorf("failed to set cloud provider node group size: %v", err) | ||
| return 0, err | ||
| } | ||
| opts.nodeGroup.scaleUpCircuitBreaker.recordScaleUp(cloudProviderNodeGroup.TargetSize()) |
There was a problem hiding this comment.
This reads the target back out of the cache after IncreaseSize, but the AWS provider's IncreaseSize only calls SetDesiredCapacity (aws.go:359); it never updates the local asg, which is only refreshed on the next Refresh/DescribeAutoScalingGroups. So on real AWS this records the previous desired count, not the one we just asked for. Tests don't catch it because the mock's IncreaseSize updates targetSize inline (cloud_provider.go:139).
Two effects: the breaker opens a cycle late (threshold + 1 stuck cooldowns instead of threshold), and the frozen target ends up one step behind the real DesiredCapacity, so a partial recovery can close it too early. I confirmed both by running the real provider against a stub: with threshold: 3 it opened after 4 scale-ups, and froze at 14 while AWS desired was already 18.
Grabbing the target before the increase fixes it for both mock and AWS:
requestedTarget := cloudProviderNodeGroup.TargetSize() + nodesToAdd
err := cloudProviderNodeGroup.IncreaseSize(nodesToAdd)
...
opts.nodeGroup.scaleUpCircuitBreaker.recordScaleUp(requestedTarget)There was a problem hiding this comment.
Good catch, thanks for running it against the real provider.
I reworked this so it no longer reads the target back after IncreaseSize. The failure signal is now based on whether the running count grew rather than whether it reached the requested target, so recordScaleUp records Size() captured at the start of the scan, and Size() is not touched by IncreaseSize. That takes the stale read you described out of the picture.
Size() and TargetSize() are both read once up front now, which on AWS reflects the values from the scan's refresh.
| for i := 0; i < threshold; i++ { | ||
| added, err := controller.scaleUpCloudProviderNodeGroup(opts) | ||
| assert.NoError(t, err) | ||
| assert.Greater(t, added, 0, "iteration %d should be permitted", i) |
There was a problem hiding this comment.
This only passes because the mock updates targetSize synchronously in IncreaseSize, which AWS doesn't; desired isn't visible until the next Refresh. If the mock only exposed the new desired after a Refresh (a SetDecoupleTarget knob alongside SetDecoupleActual would do it), it'd have caught the stale read above.
There was a problem hiding this comment.
Tied to the change on the stale read above. Since recordScaleUp no longer reads TargetSize() back after the increase, the stale value that knob would have exposed can no longer occur, so I left SetDecoupleTarget out rather than add scaffolding for a path that isn't there anymore.
SetDecoupleActual still drives this test: the running count stays pinned, so it never grows and the breaker trips after the threshold. If you'd still like the mock to model deferred desired visibility for future tests I'm happy to add it.
| // we froze at when the breaker tripped. No probing or waiting required: | ||
| // while open the desired count is held steady, so once the cloud provider | ||
| // delivers that capacity the running count catches up and we resume. | ||
| if currentSize >= b.lastRequestedTarget { |
There was a problem hiding this comment.
lastRequestedTarget is frozen when we trip and we only close once running reaches it. If desired drops below that number while we're open (a scale-down when demand falls off during a long outage, or someone resetting desired by hand, which is what we did in the incident), running can never get back to the frozen value and we're stuck open until a restart. That's the manual intervention we're trying to remove.
Could we compare against the live TargetSize() instead, closing once running has caught up to whatever desired currently is? While open we're not raising desired, so it's the same number in the normal case; it only differs when something external lowers it, which is exactly when we want to recover.
There was a problem hiding this comment.
Agreed. Switched the recovery check to the live TargetSize().
allow now takes both the running and desired counts and closes once running reaches the current desired, not the value frozen at trip time. So if desired is lowered while the breaker is open, whether by a scale-down as demand falls or the manual reset we did in the incident, it recovers on the next scan. Added TestCircuitBreakerRecoversWhenDesiredDropsWhileOpen to cover that.
| // The running count is expected to reach the desired count we requested | ||
| // within the scale-up cooldown. If it has not, the cloud provider could | ||
| // not deliver the capacity (e.g. AZ out of capacity), so count a failure. | ||
| if currentSize >= b.lastRequestedTarget { |
There was a problem hiding this comment.
Want to make sure we're happy with this: we count a failure whenever running hasn't hit the previous target yet. If an ASG is genuinely launching but always a step behind a rising desired (busy cluster, steady-but-slow launches), running never catches the previous target and we trip on a healthy group. The docs cover slow launches vs cooldown, but this is slightly different: it's the target moving every round. Would keying off "did running go up at all since last time" be safer? Not a blocker since it's opt-in.
There was a problem hiding this comment.
Went with your suggestion. A scale-up now counts as failed only when the running count did not grow since the previous one, rather than when it fell short of the previous target. A group whose desired count climbs faster than instances launch keeps making progress each scan and no longer accrues failures. The out of capacity case this targets still trips because the running count stays flat.
One tradeoff worth naming: a group that delivers a slow trickle while wanting a lot more will not trip. My read is that degrades to today's behaviour rather than under provisioning a healthy group, which felt like the safer default for an opt-in feature. Covered by TestCircuitBreakerDoesNotTripWhenRunningKeepsGrowing.
| if nodesToAdd > 0 { | ||
| // The breaker logs its own state transitions; keep this at debug to avoid | ||
| // repeating a warning on every scan while it stays open. | ||
| if !opts.nodeGroup.scaleUpCircuitBreaker.allow(cloudProviderNodeGroup.Size()) { |
There was a problem hiding this comment.
Flagging that Size() is len(asg.Instances) (aws.go:240), i.e. instances the ASG has, not Ready k8s nodes. If instances come up but never join (bad bootstrap), we'll think capacity arrived and never trip. Fine for the out-of-capacity case this targets, but the "running node count" wording in the docs/metric help is a bit optimistic.
There was a problem hiding this comment.
You're right that Size() is len(asg.Instances) and not Ready k8s nodes. I kept it as the signal since this targets the out of capacity case where no instances launch at all, but updated the docs to say instance count rather than running node count, and called out the failure mode you described: if instances launch but never join, the count grows and the breaker reads that as capacity arriving, so it won't trip on a broken bootstrap.
| // allowed in the nodegroup at any given time. | ||
| MaxUnhealthyNodesPercent int `json:"max_unhealthy_nodes_percent,omitempty" yaml:"max_unhealthy_nodes_percent,omitempty"` | ||
|
|
||
| ScaleUpFailureThreshold int `json:"scale_up_failure_threshold,omitempty" yaml:"scale_up_failure_threshold,omitempty"` |
There was a problem hiding this comment.
worth a checkThat(nodegroup.ScaleUpFailureThreshold >= 0, ...) to match the rest of the validation; a negative value just silently disables it right now.
There was a problem hiding this comment.
Added checkThat(nodegroup.ScaleUpFailureThreshold >= 0, ...) alongside the rest of the validation, so a negative value now errors rather than silently disabling.
| case circuitClosed: | ||
| if b.consecutiveFailures >= b.failureThreshold { | ||
| b.state = circuitOpen | ||
| metrics.NodeGroupScaleUpCircuitBreakerOpen.WithLabelValues(b.nodegroup).Set(1) |
There was a problem hiding this comment.
this gauge only gets Set on a transition, so an enabled group that never trips never emits it and there's no 0 line for dashboards to sit on. A Set(0) when we build the breaker gives it a baseline.
There was a problem hiding this comment.
Added a newScaleUpCircuitBreaker constructor that sets the gauge to 0 when the breaker is enabled, so an enabled group emits a 0 baseline from startup even if it never trips. Wired it into both construction sites.
- Judge a scale-up failed when the running count did not grow since the previous scale-up, rather than when it fell short of the previous target. Keeps a busy-but-healthy group whose desired count rises faster than instances launch from tripping. - Record the running count at scale-up time instead of reading TargetSize() back after IncreaseSize, which returns a stale value on AWS until the next refresh. - Recover against the live desired count so an external scale-down or manual reset of the desired count closes the breaker instead of leaving it stuck. - Add newScaleUpCircuitBreaker constructor that seeds the open gauge to 0 for a baseline, validate scale_up_failure_threshold >= 0, drop unused String(). - Update docs and worked example.
Problem
When an ASG can't fulfil a desired-count increase, because an instance type is temporarily out of capacity in an AZ, no instances launch but pod utilisation stays high. Escalator keeps raising the desired count every scan, so it climbs to
max_nodeswhile the running count stays flat, leaving a large and persistent gap between desired and running.We hit this in production. An ASG sat at desired 600 against an AZ with no capacity, running stayed near 100, and we reset the desired count by hand.
What this does
An opt-in, per-nodegroup circuit breaker that stops raising the ASG desired count once repeated scale-ups fail to add running nodes. Disabled by default.
scale_up_failure_thresholdconsecutive failures the breaker opens and stops raising the desired count, holding it steady rather than ratcheting it up further.The gate sits only around the desired-count increase in
scaleUpCloudProviderNodeGroup. Note that "running count" here is the ASG instance count (Size()), not the number of Ready Kubernetes nodes. This targets the out of capacity case where no instances launch at all. If instances launch but never join, for example a broken bootstrap, the count grows and the breaker reads that as capacity arriving, so it will not trip on that failure mode.Worked example
Node group with
max_nodes: 600, running 100,scale_up_cool_down_period: 5m, each loop wanting roughly +50 nodes to clear a backlog of pending pods. An instance type is out of capacity in the AZ, so nothing launches and running stays at 100. Breaker set toscale_up_failure_threshold: 3.With the breaker disabled the desired count climbs to the max of 600 while only 100 nodes run, a gap of 500 nodes that all try to launch at once when the AZ recovers and overshoot real demand. With the breaker it caps at 250 and holds there, with no cooldown to wait out. When the AZ recovers, the 250 requested nodes launch, the running count catches up to the held desired count of 250, the breaker closes, and scaling resumes against real demand.
Configuration
The default
scale_up_failure_thresholdof 0 disables the breaker, so existing configs are unchanged.Judging on whether the running count grows means a group that launches instances steadily, even slowly, keeps making progress and does not accrue failures, so slow launch latency does not by itself trip the breaker.
Metrics
node_group_scale_up_circuit_breaker_open, a gauge that is 1 when open and 0 when closed. It is seeded to 0 when an enabled breaker is built, so an enabled group that never trips still emits a baseline.node_group_scale_up_failed_scale_events, a counter of scale-ups whose running count did not grow.Verification
go test ./pkg/...passes. Unit tests cover the state machine: disabled, reset when running grows, no trip on a healthy group whose desired count keeps rising while running climbs, trip after threshold consecutive no-growth scale-ups, stays open until running reaches desired, recovery once running catches up, recovery when desired is lowered externally while open, and failed-event counting. An integration test runsscaleUpCloudProviderNodeGroupagainst a mock ASG that raises the desired count but never delivers nodes, asserts the desired count freezes once the breaker trips, and then that scaling resumes once the running count reaches the frozen target.