[
https://issues.apache.org/jira/browse/YUNIKORN-3356?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Dale Richardson updated YUNIKORN-3356:
--------------------------------------
Description:
h2. Summary
The shim builds two general-purpose clientsets whose groupings follow code
structure rather than traffic type: one serves both the informers and the
events sink, the other serves writes, the volume binder, and predicate lookups.
Because each clientset gets its own rate limiter from the same
{{kubernetes.qps}} setting, (a) unrelated traffic classes share failure domains
— an event burst can delay informer relists during recovery — and (b) the
effective request ceiling is 2× the configured value, which is unlikely to be
what operators expect.
Proposal: restructure into purpose-built clients — {*}writes/binds{*},
{*}informers{*}, *events* - each with its own rate limiter, sensible
per-concern defaults, and a distinct User-Agent.
Scope note: this ticket is entirely client-side (shim code + config). It ships
no FlowSchema/PriorityLevelConfiguration objects and requires none - all
server-side APF work is proposed separately in a later proposal. APF is
referenced below only as rationale for where client-side limits are and are not
appropriate.
h2. Current state (master, {{pkg/client}} / {{{}pkg/shim{}}})
* Bootstrap client ({{{}pkg/client/kubeclient.go:43{}}},
{{{}interfaces.go:58{}}}, called from {{{}bootstrap.go:30{}}}): two ConfigMap
GETs at startup. Sets no QPS, so no clientset-level limiter is created and it
runs on client-go's rest-layer defaults (5 QPS / 10 burst).
* Clientset 2 ({{{}pkg/shim/scheduler.go:67{}}}): serves *both* the
cluster-wide SharedInformerFactory ({{{}scheduler.go:70{}}}) *and* the
events/v1 broadcaster sink ({{{}scheduler.go:84-85{}}}).
* Clientset 3 ({{{}pkg/client/apifactory.go:93{}}}): serves *all writes*
(Bind/Create/Delete/UpdateStatus), the namespaced ConfigMap informer factory,
the volume binder ({{{}apifactory.go:122{}}}), and the predicate framework
handle.
Consequences:
# *{{kubernetes.qps}} is a per-clientset limit, so the effective ceiling is 2×
the configured value.* Each {{NewKubeClient}} call builds a fresh
{{rest.Config}} ({{{}kubeclient.go:55-69{}}}) and client-go creates a new token
bucket per clientset ({{{}kubernetes/clientset.go{}}},
{{{}NewForConfigAndClient{}}}). An operator setting {{kubernetes.qps: 500}}
expecting to cap the scheduler at 500 req/s actually allows ~1,000. Any further
client splitting silently multiplies this again unless the config surface is
redesigned — so this restructuring and the config semantics must land together.
# *An event flood competes with informer relists on the same token bucket*
(clientset 2). Watch calls themselves bypass the client rate limiter entirely
({{{}rest/request.go:763-764{}}} — watches are deliberately not throttled), but
the *relist / initial LIST* after a watch break does pay a token — and the
events/v1 broadcaster spawns a goroutine per event
({{{}tools/events/event_broadcaster.go:170-173{}}}), so a mass-failure burst
can park thousands of concurrent token waits ahead of a recovery relist on the
shared FIFO bucket. Order-of-magnitude impact (assuming a burst of a few
thousand events with the bucket drained): seconds of relist delay at the
1000/1000 default, minutes at operator-lowered values (e.g. 50). Recovery
correctness is sensitive to relist timeliness (see YUNIKORN-3355). Splitting
removes the coupling at every setting.
# {*}Events compete with nothing they should{*}: events are droppable by
design, but today the only way to limit them is to limit everything else too.
Note the broadcaster's bounded 1000-entry drop-on-full queue bounds only the
broadcast side; in-flight sink recordings are per-event goroutines with *no*
bound — a client-side event bucket introduces a real bound that does not exist
today.
# No {{UserAgent}} is set on any client (zero occurrences in non-test code),
so apiserver-side attribution (audit logs, per-client debugging) cannot
distinguish the shim's write path from its watch path.
h2. Proposed design
||Client||Serves||Limiter||Config||
|writes|Bind, Create/Delete, status updates, volume binder|*none by default* —
APF governs this path on every supported Kubernetes version (on by default
since 1.20, GA 1.29); {{{}kubernetes.qps{}}}/{{{}kubernetes.burst{}}} retained
as an *opt-in* cap|Values <= 0 (the new default) install an explicit no-op
limiter — note this must be {{{}flowcontrol.NewFakeAlwaysRateLimiter(){}}},
because leaving {{rest.Config.QPS}} at 0 silently applies client-go's 5/10
defaults. Positive values install a token bucket for operators with a
deliberate policy reason to cap the scheduler (fleet limits, fragile downstream
admission/audit infrastructure). Rationale: the limiter's only measured
production effect is harm — 52 binds/s at qps 50, and even the 1000 default
clips sustained peak (~1,170 binds/s measured only as a burst-window average) —
while APF provides the actual protection. Method: 3,000–5,000-pod bind bursts
on a kind+KWOK rig, 500 nodes; harness offered below.|
|informers|all SharedInformerFactory watch/list traffic|*none* (unlimited)|—
steady-state request rate is intrinsically tiny (watches are long-lived and
bypass the limiter; the limiter charges per request, not per event); relist
bursts are better governed server-side by APF than by a client limiter that
would slow recovery|
|events|events/v1 broadcaster sink|token bucket|new {{kubernetes.eventQPS}} /
{{kubernetes.eventBurst}} (suggested default 200/400 — higher than kubelet's
{{eventRecordQPS}} 50/100 because a batch scheduler's event volume tracks
pods/s, roughly one event per scheduled pod; 50 would drop events in normal
operation at moderate throughput, 200/400 is lossless in steady state while
bounding storms). Bounded loss under storm is acceptable and now *possible
without capping binds*|
|bootstrap|2 startup ConfigMap GETs|n/a|fold into the writes client; the
dedicated 5/10-QPS clientset is vestigial|
Each client sets a distinct {{UserAgent}} ({{{}yunikorn-scheduler/writes{}}},
{{{}/informers{}}}, {{{}/events{}}}) for apiserver-side attribution (audit
logs, APF debug endpoints). ({{{}rest_client_*{}}} metrics are not
User-Agent-labelled; the client split itself is what would let per-client
metrics registries be distinguished, if metrics are ever registered.)
Design principle: server-side APF arbitrates all must-complete traffic (binds,
status updates). Client-side limiting is used only for *discardable* traffic
(events), where the terminal state under overload is "dropped" regardless of
who decides. Shedding at the source is free; sending an event to be rejected
costs a full round trip and APF seat-time on an already-stressed server, after
which the events/v1 broadcaster abandons it anyway (server rejections —
{{StatusError}} — are not retried: "Server rejected event (will not retry!)";
the 12-attempt retry loop applies only to network errors). APF *can* route the
shim's events to a separate cheap priority level via a resource-matching
FlowSchema rule (same ServiceAccount, {{{}resources: events{}}}), and the
companion APF proposal may ship one as defence in depth — but it complements
source-shedding rather than replacing it. The write-path knob is retained as an
opt-in policy cap, not a default: APF is present on every supported Kubernetes
version, and the rare deliberately-APF-disabled cluster
({{{}--enable-priority-and-fairness=false{}}}) is exactly the operator who can
be expected to set an explicit value.
h2. Risks / compatibility
* Connection overhead: ~nil — client-go caches transports keyed on connection
config, so additional clientsets with identical TLS/dial settings share the
underlying connection pool.
* Event delivery under sustained storm becomes explicitly and predictably
bounded (today the bounds are accidental: the broadcaster's 1000-entry
broadcast queue plus unbounded in-flight sink goroutines).
h2. Validation
* Unit: config plumbing (defaults, back-compat of {{{}kubernetes.qps{}}}).
* e2e: mass-failure event storm concurrent with a bind burst — assert bind
throughput is unaffected by event-path saturation (today they share a bucket);
assert informer relist latency is unaffected by an event backlog. A kind+KWOK
harness that produces both conditions exists from the API-server load
characterisation work and can be contributed.
was:
h2. Summary
The shim builds two general-purpose clientsets whose groupings follow code
structure rather than traffic type: one serves both the informers and the
events sink, the other serves writes, the volume binder, and predicate lookups.
Because each clientset gets its own rate limiter from the same
{{kubernetes.qps}} setting, (a) unrelated traffic classes share failure domains
— an event burst can starve informer watch re-establishment during recovery —
and (b) the effective request ceiling is 2× the configured value, which is
unlikely to be what operators expect.
Proposal: restructure into purpose-built clients — {*}writes/binds{*},
{*}informers{*}, *events* (and later *leader election* if it is ever added) —
each with its own rate limiter, sensible per-concern defaults, and a distinct
User-Agent.
h2. Current state (master, {{pkg/client}} / {{{}pkg/shim{}}})
* Bootstrap client ({{{}pkg/client/kubeclient.go:43{}}},
{{{}interfaces.go:58{}}}, called from {{{}bootstrap.go:30{}}}): two ConfigMap
GETs at startup. Sets no QPS, so no clientset-level limiter is created and it
runs on client-go's rest-layer defaults (5 QPS / 10 burst).
* Clientset 2 ({{{}pkg/shim/scheduler.go:67{}}}): serves *both* the
cluster-wide SharedInformerFactory ({{{}scheduler.go:70{}}}) *and* the
events/v1 broadcaster sink ({{{}scheduler.go:84-85{}}}).
* Clientset 3 ({{{}pkg/client/apifactory.go:93{}}}): serves *all writes*
(Bind/Create/Delete/UpdateStatus), the namespaced ConfigMap informer factory,
the volume binder ({{{}apifactory.go:122{}}}), and the predicate framework
handle.
Consequences:
# *{{kubernetes.qps}} is a per-clientset limit, so the effective ceiling is 2×
the configured value.* Each {{NewKubeClient}} call builds a fresh
{{rest.Config}} ({{{}kubeclient.go:55-69{}}}) and client-go creates a new token
bucket per clientset ({{{}client-go kubernetes/clientset.go{}}},
{{{}NewForConfigAndClient{}}}). An operator setting {{kubernetes.qps: 500}}
expecting to cap the scheduler at 500 req/s actually allows ~1,000. Any further
client splitting silently multiplies this again unless the config surface is
redesigned — so this restructuring and the config semantics must land together.
# *An event flood competes with watch re-establishment on the same token
bucket* (clientset 2). Mass-failure scenarios emit events in bursts precisely
when informers need tokens to reconnect and relist — the worst possible
coupling (see YUNIKORN-3355 for how much recovery correctness depends on timely
informer delivery). Severity scales inversely with the configured QPS: at the
1000/1000 default a mass-failure event burst (thousands of events) delays
informer re-establishment by seconds; at operator-lowered values (e.g. 50,
kube-scheduler parity) the same burst starves the watch path for minutes.
Splitting removes the coupling at every setting.
# {*}Events compete with nothing they should{*}: events are droppable by
design (the events/v1 broadcaster already has a bounded 1000-entry queue with
drop-on-full), yet today they can only be limited by limiting everything else
too.
# No {{UserAgent}} is set on any client (zero occurrences in non-test code),
so apiserver-side attribution (audit logs, per-client debugging) cannot
distinguish the shim's write path from its watch path.
h2. Proposed design
||Client||Serves||Limiter||Config||
|writes|Bind, Create/Delete, status updates, volume binder|token
bucket|{{kubernetes.qps}} / {{kubernetes.burst}} — {*}semantics preserved{*}:
this is the knob that caps how fast YuniKorn acts on the cluster (binds are
~1:1 with QPS tokens; measured 52 vs 1,170 binds/s at 50 vs 1000)|
|informers|all SharedInformerFactory watch/list traffic|*none* (unlimited)|—
steady-state request rate is intrinsically tiny (watches are long-lived; the
limiter charges per request, not per event); relist bursts are better governed
server-side by APF than by a client limiter that would slow recovery|
|events|events/v1 broadcaster sink|token bucket|new {{kubernetes.eventQPS}} /
{{kubernetes.eventBurst}} (suggested default 200/400; kubelet precedent
{{{}eventRecordQPS{}}}). Bounded loss under storm is acceptable and now
*possible without capping binds*|
|bootstrap|2 startup ConfigMap GETs|n/a|fold into the writes client; the
dedicated 5/10-QPS clientset is vestigial|
Each client sets a distinct {{UserAgent}} ({{{}yunikorn-scheduler/writes{}}},
{{{}/informers{}}}, {{{}/events{}}}) for apiserver-side attribution and cleaner
{{rest_client_*}} metrics if/when client-go metrics are registered.
h2. Interactions
* {*}Config semantics{*}: this issue narrows {{kubernetes.qps}} from "per
clientset, effectively ×2" to "write path only". Release notes must state both
the old effective behaviour and the new meaning. The documentation work for QPS
guidance should land as part of this change (one table: client / purpose / knob
/ default) rather than separately.
* {*}Hot-reload{*}: with per-concern defaults there is little reason left to
make QPS hot-reloadable; recommend dropping that idea rather than building
config-rebuild machinery for multiple clients. Server-side APF is the correct
dynamic control.
* {*}APF (future, optional phase 2){*}: APF distinguishes flows by {*}user{*},
so split clients on one ServiceAccount still share one queue position. Running
the events client under a separate identity would allow events to be placed in
a cheap priority level while the write path keeps a protected one. Not required
for this issue, but the client boundary drawn here is what makes it possible
later — worth keeping in mind during review.
* {*}Write dispatcher (KEP-5229-style, future){*}: a bounded write-dispatch
worker pool would sit naturally on the writes client; clean separation here
simplifies that later work.
h2. Risks / compatibility
* Connection count rises by ~2 HTTP/2 connections — negligible.
* Operators who relied (knowingly or not) on the 2× effective ceiling will see
the informer path become unlimited and the write path unchanged; net behaviour
change in normal operation is nil, but it should be called out.
* Event delivery under sustained storm becomes explicitly bounded (today it is
implicitly bounded by the broadcaster's 1000-entry drop-on-full queue anyway).
h2. Validation
* Unit: config plumbing (defaults, back-compat of {{{}kubernetes.qps{}}}).
* e2e: mass-failure event storm concurrent with a bind burst — assert bind
throughput is unaffected by event-path saturation (today they share a bucket);
assert informer watch re-establishment is unaffected by an event backlog. A
kind+KWOK harness that produces both conditions exists from the API-server load
characterisation work and can be contributed.
> Split the shim's Kubernetes clients by concern (writes / informers / events)
> ----------------------------------------------------------------------------
>
> Key: YUNIKORN-3356
> URL: https://issues.apache.org/jira/browse/YUNIKORN-3356
> Project: Apache YuniKorn
> Issue Type: Improvement
> Components: shim - kubernetes
> Reporter: Dale Richardson
> Priority: Major
>
> h2. Summary
> The shim builds two general-purpose clientsets whose groupings follow code
> structure rather than traffic type: one serves both the informers and the
> events sink, the other serves writes, the volume binder, and predicate
> lookups. Because each clientset gets its own rate limiter from the same
> {{kubernetes.qps}} setting, (a) unrelated traffic classes share failure
> domains — an event burst can delay informer relists during recovery — and (b)
> the effective request ceiling is 2× the configured value, which is unlikely
> to be what operators expect.
> Proposal: restructure into purpose-built clients — {*}writes/binds{*},
> {*}informers{*}, *events* - each with its own rate limiter, sensible
> per-concern defaults, and a distinct User-Agent.
> Scope note: this ticket is entirely client-side (shim code + config). It
> ships no FlowSchema/PriorityLevelConfiguration objects and requires none -
> all server-side APF work is proposed separately in a later proposal. APF is
> referenced below only as rationale for where client-side limits are and are
> not appropriate.
> h2. Current state (master, {{pkg/client}} / {{{}pkg/shim{}}})
> * Bootstrap client ({{{}pkg/client/kubeclient.go:43{}}},
> {{{}interfaces.go:58{}}}, called from {{{}bootstrap.go:30{}}}): two ConfigMap
> GETs at startup. Sets no QPS, so no clientset-level limiter is created and it
> runs on client-go's rest-layer defaults (5 QPS / 10 burst).
> * Clientset 2 ({{{}pkg/shim/scheduler.go:67{}}}): serves *both* the
> cluster-wide SharedInformerFactory ({{{}scheduler.go:70{}}}) *and* the
> events/v1 broadcaster sink ({{{}scheduler.go:84-85{}}}).
> * Clientset 3 ({{{}pkg/client/apifactory.go:93{}}}): serves *all writes*
> (Bind/Create/Delete/UpdateStatus), the namespaced ConfigMap informer factory,
> the volume binder ({{{}apifactory.go:122{}}}), and the predicate framework
> handle.
> Consequences:
> # *{{kubernetes.qps}} is a per-clientset limit, so the effective ceiling is
> 2× the configured value.* Each {{NewKubeClient}} call builds a fresh
> {{rest.Config}} ({{{}kubeclient.go:55-69{}}}) and client-go creates a new
> token bucket per clientset ({{{}kubernetes/clientset.go{}}},
> {{{}NewForConfigAndClient{}}}). An operator setting {{kubernetes.qps: 500}}
> expecting to cap the scheduler at 500 req/s actually allows ~1,000. Any
> further client splitting silently multiplies this again unless the config
> surface is redesigned — so this restructuring and the config semantics must
> land together.
> # *An event flood competes with informer relists on the same token bucket*
> (clientset 2). Watch calls themselves bypass the client rate limiter entirely
> ({{{}rest/request.go:763-764{}}} — watches are deliberately not throttled),
> but the *relist / initial LIST* after a watch break does pay a token — and
> the events/v1 broadcaster spawns a goroutine per event
> ({{{}tools/events/event_broadcaster.go:170-173{}}}), so a mass-failure burst
> can park thousands of concurrent token waits ahead of a recovery relist on
> the shared FIFO bucket. Order-of-magnitude impact (assuming a burst of a few
> thousand events with the bucket drained): seconds of relist delay at the
> 1000/1000 default, minutes at operator-lowered values (e.g. 50). Recovery
> correctness is sensitive to relist timeliness (see YUNIKORN-3355). Splitting
> removes the coupling at every setting.
> # {*}Events compete with nothing they should{*}: events are droppable by
> design, but today the only way to limit them is to limit everything else too.
> Note the broadcaster's bounded 1000-entry drop-on-full queue bounds only the
> broadcast side; in-flight sink recordings are per-event goroutines with *no*
> bound — a client-side event bucket introduces a real bound that does not
> exist today.
> # No {{UserAgent}} is set on any client (zero occurrences in non-test code),
> so apiserver-side attribution (audit logs, per-client debugging) cannot
> distinguish the shim's write path from its watch path.
> h2. Proposed design
> ||Client||Serves||Limiter||Config||
> |writes|Bind, Create/Delete, status updates, volume binder|*none by default*
> — APF governs this path on every supported Kubernetes version (on by default
> since 1.20, GA 1.29); {{{}kubernetes.qps{}}}/{{{}kubernetes.burst{}}}
> retained as an *opt-in* cap|Values <= 0 (the new default) install an explicit
> no-op limiter — note this must be
> {{{}flowcontrol.NewFakeAlwaysRateLimiter(){}}}, because leaving
> {{rest.Config.QPS}} at 0 silently applies client-go's 5/10 defaults. Positive
> values install a token bucket for operators with a deliberate policy reason
> to cap the scheduler (fleet limits, fragile downstream admission/audit
> infrastructure). Rationale: the limiter's only measured production effect is
> harm — 52 binds/s at qps 50, and even the 1000 default clips sustained peak
> (~1,170 binds/s measured only as a burst-window average) — while APF provides
> the actual protection. Method: 3,000–5,000-pod bind bursts on a kind+KWOK
> rig, 500 nodes; harness offered below.|
> |informers|all SharedInformerFactory watch/list traffic|*none* (unlimited)|—
> steady-state request rate is intrinsically tiny (watches are long-lived and
> bypass the limiter; the limiter charges per request, not per event); relist
> bursts are better governed server-side by APF than by a client limiter that
> would slow recovery|
> |events|events/v1 broadcaster sink|token bucket|new {{kubernetes.eventQPS}} /
> {{kubernetes.eventBurst}} (suggested default 200/400 — higher than kubelet's
> {{eventRecordQPS}} 50/100 because a batch scheduler's event volume tracks
> pods/s, roughly one event per scheduled pod; 50 would drop events in normal
> operation at moderate throughput, 200/400 is lossless in steady state while
> bounding storms). Bounded loss under storm is acceptable and now *possible
> without capping binds*|
> |bootstrap|2 startup ConfigMap GETs|n/a|fold into the writes client; the
> dedicated 5/10-QPS clientset is vestigial|
> Each client sets a distinct {{UserAgent}} ({{{}yunikorn-scheduler/writes{}}},
> {{{}/informers{}}}, {{{}/events{}}}) for apiserver-side attribution (audit
> logs, APF debug endpoints). ({{{}rest_client_*{}}} metrics are not
> User-Agent-labelled; the client split itself is what would let per-client
> metrics registries be distinguished, if metrics are ever registered.)
> Design principle: server-side APF arbitrates all must-complete traffic
> (binds, status updates). Client-side limiting is used only for *discardable*
> traffic (events), where the terminal state under overload is "dropped"
> regardless of who decides. Shedding at the source is free; sending an event
> to be rejected costs a full round trip and APF seat-time on an
> already-stressed server, after which the events/v1 broadcaster abandons it
> anyway (server rejections — {{StatusError}} — are not retried: "Server
> rejected event (will not retry!)"; the 12-attempt retry loop applies only to
> network errors). APF *can* route the shim's events to a separate cheap
> priority level via a resource-matching FlowSchema rule (same ServiceAccount,
> {{{}resources: events{}}}), and the companion APF proposal may ship one as
> defence in depth — but it complements source-shedding rather than replacing
> it. The write-path knob is retained as an opt-in policy cap, not a default:
> APF is present on every supported Kubernetes version, and the rare
> deliberately-APF-disabled cluster
> ({{{}--enable-priority-and-fairness=false{}}}) is exactly the operator who
> can be expected to set an explicit value.
> h2. Risks / compatibility
> * Connection overhead: ~nil — client-go caches transports keyed on
> connection config, so additional clientsets with identical TLS/dial settings
> share the underlying connection pool.
> * Event delivery under sustained storm becomes explicitly and predictably
> bounded (today the bounds are accidental: the broadcaster's 1000-entry
> broadcast queue plus unbounded in-flight sink goroutines).
> h2. Validation
> * Unit: config plumbing (defaults, back-compat of {{{}kubernetes.qps{}}}).
> * e2e: mass-failure event storm concurrent with a bind burst — assert bind
> throughput is unaffected by event-path saturation (today they share a
> bucket); assert informer relist latency is unaffected by an event backlog. A
> kind+KWOK harness that produces both conditions exists from the API-server
> load characterisation work and can be contributed.
>
--
This message was sent by Atlassian Jira
(v8.20.10#820010)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]