feat(upstream): slow start for newly observed upstream nodes - #13941
Open
AlinsRan wants to merge 2 commits into
Open
feat(upstream): slow start for newly observed upstream nodes#13941AlinsRan wants to merge 2 commits into
AlinsRan wants to merge 2 commits into
Conversation
A node that joins an upstream takes its full share of traffic immediately, which is exactly what a JVM that has not JIT-compiled yet, or a service with empty caches and connection pools, cannot handle. `warm_up_conf` makes a node the gateway observes for the first time take a reduced share and ramp back to its configured weight over `slow_start_time_seconds`. Whether a node is new is decided by the data plane alone. `apisix/slow_start.lua` compares the node set of each picker build against the one the previous build recorded in the `upstream-slow-start` shared dict, and generates every ramp start locally with `ngx.now()`. Unlike the approach in apache#12991, nothing is read from or written to the node configuration, so the ramp does not depend on who writes the config or on their clock, and it applies to nodes from service discovery as well. - The node set an upstream has when the first picker is built is mature, which covers a cold start, a resync, and enabling `warm_up_conf` on a running upstream; `startup_grace_period_seconds` extends that to nodes that arrive late during a restart. - A node added later ramps from `min_weight_percent`; one a health check keeps out of the picker starts its ramp when it first becomes pickable. - A node that leaves and comes back within one window resumes its ramp, one that comes back later or was kept out for longer starts over. - The picker cache key carries the `interval` bucket while any node ramps, so the picker is rebuilt once per bucket per worker, and settles once every node is mature. Presence is tracked from the configuration and eligibility from each worker's health view, so one worker's transient opinion cannot drop a node the others serve. A ramp start is only created with `add` and only replaced through an `add`-based election, and refreshed with `expire`, so parallel reconciles never write back state another worker has just changed - without a lock, which the balancer phase could not take. The first step supports HTTP roundrobin upstreams with a single node priority. Other balancer types, mixed priorities, an interval longer than the window, stream routes reaching such an upstream (directly or through a service) and traffic-split upstreams are rejected at the Admin API. The data plane fails open instead: it keeps the configured weights and logs once.
…ation TEST 25 printed the checker's error message after a separating space even when there was none, so its expected output ended in a space and eclint rejected the file. It now prints a word for each outcome.
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
A node that joins an Upstream today takes its full share of traffic immediately. For a JVM that has not JIT-compiled yet, a service with an empty local cache, or one still filling its connection pools, the scale-out meant to add capacity is what drives its latency up or knocks it over. Tuning node weights by hand does not survive autoscaling or rolling releases.
This adds
warm_up_confto the Upstream: a node the gateway observes for the first time takes a reduced share of the traffic and ramps back to its configured weight overslow_start_time_seconds.{ "type": "roundrobin", "nodes": [{"host": "10.0.0.10", "port": 8080, "weight": 100}], "warm_up_conf": { "slow_start_time_seconds": 300, "min_weight_percent": 1, "interval": 1, "aggression": 1, "startup_grace_period_seconds": 180 } }Without
warm_up_confnothing changes: no state is created, the picker cache key is untouched, and no shared dict is written.Relation to #12991
#12991 proposed the same feature with the same four core fields and the same weight curve, which this keeps. What it does not keep is where the start of a ramp comes from. There, the Admin API wrote
update_timeonto each node and persisted it to etcd. That makes the ramp depend on the clock and the honesty of whoever writes the configuration, pollutes the declarative config with runtime state, and does nothing for nodes that never pass through the Admin API - every node from service discovery.Here the data plane decides on its own.
apisix/slow_start.luacompares the node set of each picker build against the one the previous build recorded in a newupstream-slow-startshared dict, and generates every ramp start locally withngx.now(). Nothing is read from the node configuration, and nothing is written back to etcd.Lifecycle
warm_up_confon for an Upstream that is already serving.min_weight_percentoverslow_start_time_seconds. A node an active health check keeps out of the picker starts its ramp when it first becomes pickable, not when the configuration first mentioned it.startup_grace_period_secondskeeps nodes that arrive late during a restart mature, so start-up ordering does not manufacture new nodes.Weight curve
Same as #12991 and Envoy's
SlowStartConfig:Request path
While any node ramps, the picker cache key carries the current
intervalbucket, so a worker rebuilds the picker once per bucket rather than doing per-node work per request. Once every node is mature the key settles on a stable suffix, and the rebuild that causes is the one that restores the full weights. The hot path does a singleshdict:getof an aggregate deadline. A request and its retries keep the same picker.Consistency across workers
Workers reconcile in parallel and the shared dict has no compare-and-set, so state is never read and then written back blindly:
add, and only replaced through an election - anaddon a key naming the start being replaced, whose winner every other worker adopts. Refreshes useexpire, which leaves the value alone. This avoids a lock on purpose:create_server_pickercan be reached from the balancer phase on a retry, where a sleeping lock is not allowed.Scope
This first step ramps HTTP
roundrobinUpstreams whose nodes share one priority. Anything else is rejected at the Admin API instead of being accepted and silently ignored:intervalgreater thanslow_start_time_seconds;warm_up_confis added to an Upstream a stream route already reaches;traffic-splitplugin, which are rebuilt per request and have no stable scope.These checks run on the configuration entry points only. A configuration written to etcd directly, or embedded in a route or a service, never runs them, and must not take a whole Upstream out of service over a field that only shapes a ramp. At runtime the module therefore fails open: in the stream subsystem, with mixed priorities, or without the shared dict, nodes keep their configured weights and one error is logged per Upstream.
Worth knowing
interval, a ramping node can get even less than its weight asks for.upstream-slow-startdefaults to 10m. Its size is bounded by the nodes of Upstreams that enable slow start, and eviction fails open: a node whose state is lost keeps its configured weight.Which issue(s) this PR fixes:
Related to #7992 and #10832, and supersedes #12991.
Checklist
Tests
t/node/upstream-slow-start.t: the weight curve and its clamping; a node added to a running Upstream ramping while the existing one stays at full weight; the ramp ending at the configured weights; an unrelated change not restarting it; the tombstone resuming a ramp; a route-embedded Upstream as its own scope; the picker key moving once perintervaland then settling; a node an active health check sidelines keeping its lifecycle; a sidelined node starting its window on first entry; and two workers racing on the same state, replayed deterministically by injecting another worker's write between a read and the write that follows it.t/admin/upstream-slow-start.t: field ranges, defaults, every rejection above in both directions, an unreadable etcd reference failing validation rather than the request, declarative validation through/apisix/admin/configs/validate, the data plane keeping an Upstream whosewarm_up_confit cannot honour, and enablingwarm_up_confin a cluster with no stream route at all.