scheduler.node_resource_limit is silently unenforced for most nodes when the scheduler runs more than one replica
Summary
FilterByResourceLimit decides admission from per-replica in-memory heartbeat
state, but the candidate node set is global. Each runtime node's heartbeat is
pinned to exactly one scheduler replica, so any given replica has snapshots for
only a subset of the nodes it can schedule onto — and the filter keeps nodes it
has no snapshot for. With more than one replica the configured per-node
ceilings therefore apply to a shifting minority of nodes, with no error, no
rejection, and no log line indicating a limit was skipped.
Measured on an 8-node cluster with 3 scheduler replicas: every node exceeded
max_sandbox_count, worst case 3.0x over, with zero rejections and zero
errors.
Mechanism
Three facts combine:
-
The candidate set is global. Schedule builds candidates from
nodes.Snapshot(false) (services/scheduler/internal/service.go:86), which
reads AtomicNodeRegistry.nodesByID. In kubernetes discovery mode that map
is populated from an EndpointSlice watch, so every replica knows every node
and may schedule onto any of them.
-
The load metrics are per-replica. The snapshot attached to each candidate
comes from PeekObserved (service.go:91), backed by
AtomicNodeRegistry.observed (node_registry.go:49) — a plain in-process map
written only by Heartbeat. Configuring scheduler.redis_addr does not help:
Redis is used for sandbox-to-node bindings only, never for observed.
-
Each node heartbeats to exactly one replica. The runtime's reporter opens
a single lazily-connected gRPC channel to the scheduler Service and reuses it,
so every node's heartbeats land on whichever replica that one connection
resolved to. The distribution is arbitrary and re-randomizes whenever a
replica restarts.
FilterByResourceLimit then fails open on anything it cannot evaluate
(services/scheduler/internal/filter.go:16-19):
if n.Snapshot == nil {
// No heartbeat yet — cannot evaluate limits; keep the node.
result = append(result, n)
continue
}
That branch is correct in isolation — a node that has genuinely not reported yet
should not be excluded — but under sharded heartbeats it is the common case
rather than the rare one, so the ceiling quietly evaporates for most of the
fleet.
The same sharding also affects gateways: each gateway pod holds one channel to
one replica, so which replica (and therefore which subset of enforced nodes) a
request is decided by depends on which gateway pod received it.
Reproduction
- Deploy the scheduler with
discovery.mode: kubernetes and 2+ replicas.
- Set a small ceiling so it is cheap to observe:
{ "scheduler": { "node_resource_limit": { "max_sandbox_count": 10 } } }
- Create sandboxes through the gateway until well past
nodes x 10.
- Read each runtime node's own
GET /nodes (per-node ground truth). Do not
use the gateway's aggregated /nodes — it answers from whichever replica that
gateway is pinned to and so reports only that replica's observed subset, which
is the very thing under test.
Expected: creates start failing with Unavailable: no nodes available once every
node is at the ceiling. Actual: creates keep succeeding and nodes climb past it.
Observed behaviour
8 nodes, 3 replicas, max_sandbox_count set to 10 (a small value so ~100
sandboxes suffice; the code path is identical at any value).
| run |
result |
| 200 sandboxes, full speed, through all gateways |
8/8 nodes over the ceiling, worst 30 = 3.0x, 0 rejections, 0 errors |
| 120 sandboxes, full speed, through one gateway pinned to a replica observing 4 nodes |
8/8 over, worst 16 = 1.6x |
| 96 sandboxes, paced (batches of 8, 12 s apart — longer than the 5 s heartbeat interval) |
the 4 observed nodes froze at 11; the 4 unobserved nodes climbed to 13 |
The paced run separates two distinct effects:
- For nodes the deciding replica does observe, enforcement is exactly correct.
11 is the expected first blocked value because withinLimit compares strictly
greater-than, so a node reporting exactly the ceiling is still admitted.
- For nodes it does not observe, there is no enforcement at all. This is the
actual defect; heartbeat staleness only adds burst-sized overshoot on top.
Supporting observations:
- The heartbeat split across the 3 replicas was
6/0/2, then 0/4/4 after a
restart — measured via agentenv_scheduler_observed_nodes{status="ready"}.
- 2 of the 3 gateway pods were pinned to a replica observing zero nodes, so
roughly two thirds of scheduling traffic had no ceiling applied whatsoever.
This is why the unpaced run reached 3.0x.
- Dropping to a single replica fixes it: that replica observes 8/8 nodes and
the ceiling holds. This is what we deployed, at the cost of control-plane
availability during scheduler restarts.
Fail-closed is not a fix
Excluding nodes without a snapshot would be worse: a replica observing one node
would reject the other seven and pile every sandbox onto that one node. A
correct fix needs the deciding replica to have the state, not to guess
pessimistically without it.
Possible directions
- Document single-replica as a requirement for resource-aware scheduling.
The docs currently recommend a single replica only when using the in-memory
binding store; nothing states that node_resource_limit also depends on it.
This is the zero-code fix and would at least stop the silent failure.
- Share observed state (e.g. Redis, alongside bindings). Restores hard
ceilings with multiple replicas, at the cost of putting a network read on the
scheduling path.
- Leader-elected single active scheduler, non-leaders idle — the
kube-scheduler shape. Preserves the current in-memory fast path and gives HA
without sharding node state.
- Node-side admission. Have the runtime refuse a create when it is already
at its own ceiling and let the caller retry elsewhere — the kubelet
--max-pods shape. This is a hard cap regardless of replica count and is
immune to heartbeat staleness, but needs a retry path in the gateway so a
caller does not see a failure while other nodes still have room.
Happy to send a PR for whichever direction maintainers prefer, (1) included.
Two smaller adjacent findings
- Unknown keys are silently dropped. The scheduler config is decoded with a
plain json.Unmarshal and no DisallowUnknownFields, so a misspelled key such
as max_sandbox_kount degrades to "no limit for that dimension" with no
warning.
- The effective limit is not observable. Nothing logs the decoded
node_resource_limit at startup, so combined with the above there is no way
for an operator to confirm from outside the process that the limit they wrote
is the limit in force. We added the decoded value to the existing
scheduler gRPC server listening startup line locally; glad to upstream it.
Also, the bundled Helm chart has no passthrough for this setting — values.yaml
exposes the other scheduler.* knobs but the ConfigMap template never renders
node_resource_limit, so a chart-based deployment cannot set it at all without
overriding the whole config blob. Can include that in the same PR.
scheduler.node_resource_limitis silently unenforced for most nodes when the scheduler runs more than one replicaSummary
FilterByResourceLimitdecides admission from per-replica in-memory heartbeatstate, but the candidate node set is global. Each runtime node's heartbeat is
pinned to exactly one scheduler replica, so any given replica has snapshots for
only a subset of the nodes it can schedule onto — and the filter keeps nodes it
has no snapshot for. With more than one replica the configured per-node
ceilings therefore apply to a shifting minority of nodes, with no error, no
rejection, and no log line indicating a limit was skipped.
Measured on an 8-node cluster with 3 scheduler replicas: every node exceeded
max_sandbox_count, worst case 3.0x over, with zero rejections and zeroerrors.
Mechanism
Three facts combine:
The candidate set is global.
Schedulebuilds candidates fromnodes.Snapshot(false)(services/scheduler/internal/service.go:86), whichreads
AtomicNodeRegistry.nodesByID. Inkubernetesdiscovery mode that mapis populated from an EndpointSlice watch, so every replica knows every node
and may schedule onto any of them.
The load metrics are per-replica. The snapshot attached to each candidate
comes from
PeekObserved(service.go:91), backed byAtomicNodeRegistry.observed(node_registry.go:49) — a plain in-process mapwritten only by
Heartbeat. Configuringscheduler.redis_addrdoes not help:Redis is used for sandbox-to-node bindings only, never for
observed.Each node heartbeats to exactly one replica. The runtime's reporter opens
a single lazily-connected gRPC channel to the scheduler Service and reuses it,
so every node's heartbeats land on whichever replica that one connection
resolved to. The distribution is arbitrary and re-randomizes whenever a
replica restarts.
FilterByResourceLimitthen fails open on anything it cannot evaluate(
services/scheduler/internal/filter.go:16-19):That branch is correct in isolation — a node that has genuinely not reported yet
should not be excluded — but under sharded heartbeats it is the common case
rather than the rare one, so the ceiling quietly evaporates for most of the
fleet.
The same sharding also affects gateways: each gateway pod holds one channel to
one replica, so which replica (and therefore which subset of enforced nodes) a
request is decided by depends on which gateway pod received it.
Reproduction
discovery.mode: kubernetesand 2+ replicas.{ "scheduler": { "node_resource_limit": { "max_sandbox_count": 10 } } }nodes x 10.GET /nodes(per-node ground truth). Do notuse the gateway's aggregated
/nodes— it answers from whichever replica thatgateway is pinned to and so reports only that replica's observed subset, which
is the very thing under test.
Expected: creates start failing with
Unavailable: no nodes availableonce everynode is at the ceiling. Actual: creates keep succeeding and nodes climb past it.
Observed behaviour
8 nodes, 3 replicas,
max_sandbox_countset to 10 (a small value so ~100sandboxes suffice; the code path is identical at any value).
The paced run separates two distinct effects:
11 is the expected first blocked value because
withinLimitcompares strictlygreater-than, so a node reporting exactly the ceiling is still admitted.
actual defect; heartbeat staleness only adds burst-sized overshoot on top.
Supporting observations:
6/0/2, then0/4/4after arestart — measured via
agentenv_scheduler_observed_nodes{status="ready"}.roughly two thirds of scheduling traffic had no ceiling applied whatsoever.
This is why the unpaced run reached 3.0x.
the ceiling holds. This is what we deployed, at the cost of control-plane
availability during scheduler restarts.
Fail-closed is not a fix
Excluding nodes without a snapshot would be worse: a replica observing one node
would reject the other seven and pile every sandbox onto that one node. A
correct fix needs the deciding replica to have the state, not to guess
pessimistically without it.
Possible directions
The docs currently recommend a single replica only when using the in-memory
binding store; nothing states that
node_resource_limitalso depends on it.This is the zero-code fix and would at least stop the silent failure.
ceilings with multiple replicas, at the cost of putting a network read on the
scheduling path.
kube-scheduler shape. Preserves the current in-memory fast path and gives HA
without sharding node state.
at its own ceiling and let the caller retry elsewhere — the kubelet
--max-podsshape. This is a hard cap regardless of replica count and isimmune to heartbeat staleness, but needs a retry path in the gateway so a
caller does not see a failure while other nodes still have room.
Happy to send a PR for whichever direction maintainers prefer, (1) included.
Two smaller adjacent findings
plain
json.Unmarshaland noDisallowUnknownFields, so a misspelled key suchas
max_sandbox_kountdegrades to "no limit for that dimension" with nowarning.
node_resource_limitat startup, so combined with the above there is no wayfor an operator to confirm from outside the process that the limit they wrote
is the limit in force. We added the decoded value to the existing
scheduler gRPC server listeningstartup line locally; glad to upstream it.Also, the bundled Helm chart has no passthrough for this setting —
values.yamlexposes the other
scheduler.*knobs but the ConfigMap template never rendersnode_resource_limit, so a chart-based deployment cannot set it at all withoutoverriding the whole config blob. Can include that in the same PR.