Skip to content

fix(k8s): reduce client-side throttling and centralize Kubernetes client rate limits - #438

Open
dmitsh wants to merge 2 commits into
mainfrom
ds-qps
Open

fix(k8s): reduce client-side throttling and centralize Kubernetes client rate limits#438
dmitsh wants to merge 2 commits into
mainfrom
ds-qps

Conversation

@dmitsh

@dmitsh dmitsh commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Description

Large-cluster topology reconciliation could spend several minutes waiting on client-go’s default rate limiter. The Kubernetes engine performed a per-node GET followed by a full Node UPDATE, generating roughly two API requests per node even when topology labels were already current.

This PR reduces Kubernetes API traffic and adds consistent rate-limit configuration across Kubernetes-backed components.

Changes

  • Rework Kubernetes engine label reconciliation to:
    • Reuse the Node list fetched during topology discovery.
    • Skip nodes whose topology labels are already current.
    • PATCH only changed labels instead of performing a GET and full UPDATE.
    • Continue removing stale Topograph-managed labels.
    • Preserve existing nvidia.com/gpu.clique behavior.
  • Change Kubernetes engine RBAC from Node update to patch.
  • Add kubeQPS and kubeBurst support to the Kubernetes and NFD engines.
  • Configure NFD’s typed and dynamic Kubernetes clients with one shared token-bucket limiter, making QPS an aggregate limit across both clients.
  • Centralize client rate-limit normalization for the DRA provider and Kubernetes, NFD, and Slinky engines.
    • When only QPS or burst is configured, the other value uses the client-go default.
  • Add shared Helm configuration:
    kubeClient:
      qps: 50
      burst: 100
  • Update Helm values, schema, NOTES, examples, API documentation, engine/provider documentation, and the changelog.

closes #437

@dmitsh
dmitsh requested a review from ravisoundar as a code owner July 28, 2026 11:46
@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR reduces Kubernetes API traffic and centralizes client rate-limit configuration.

  • Reuses listed Nodes, skips unchanged topology labels, and patches only label differences in the Kubernetes engine.
  • Replaces Kubernetes-engine Node update permission with patch permission.
  • Adds shared Helm-level QPS and burst settings across Kubernetes-backed providers and engines.
  • Gives the NFD typed and dynamic clients one aggregate token-bucket limiter.
  • Keeps node-observer running until shutdown and retriggers generation when an API-server pod is deleted.
  • Updates tests, examples, schema, documentation, and release notes.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
internal/k8s/client.go Adds centralized normalization and configuration helpers for independent and shared client-go rate limiters.
pkg/engines/k8s/kubernetes.go Reuses the request-scoped Node list, compares desired labels, and applies strategic merge patches only when needed.
pkg/engines/k8s/engine.go Adds Kubernetes client rate-limit parameters and request-scoped Node cache state.
pkg/engines/nfd/engine.go Configures the typed and dynamic NFD clients with one aggregate rate limiter.
charts/topograph/templates/configmap.yml Publishes chart-wide Kubernetes client limits into provider and engine defaults.
charts/topograph/templates/rbac.yaml Narrows the Kubernetes engine's Node write permission from update to patch.
pkg/node_observer/status_informer.go Makes informer startup remain active until shutdown and triggers regeneration after API-server pod deletion.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    H[Helm kubeClient settings] --> C[Provider and engine parameters]
    C --> K[Kubernetes engine client]
    C --> D[DRA provider client]
    C --> S[Slinky engine client]
    C --> N[NFD shared token bucket]
    N --> T[NFD typed client]
    N --> Y[NFD dynamic client]
    K --> L[List selected Nodes once]
    L --> M{Topology labels changed?}
    M -- No --> O[Skip Node]
    M -- Yes --> P[Patch changed labels]
    T --> A[Kubernetes API]
    Y --> A
    D --> A
    S --> A
    O --> A
    P --> A
Loading

Reviews (6): Last reviewed commit: "addressed comments" | Re-trigger Greptile

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.91589% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.95%. Comparing base (8468abe) to head (a68c870).
⚠️ Report is 37 commits behind head on main.

Files with missing lines Patch % Lines
pkg/engines/k8s/kubernetes.go 90.47% 3 Missing and 3 partials ⚠️
pkg/node_observer/status_informer.go 69.23% 4 Missing ⚠️
pkg/engines/k8s/engine.go 80.00% 1 Missing ⚠️
pkg/engines/nfd/engine.go 80.00% 1 Missing ⚠️
pkg/engines/slinky/engine.go 0.00% 1 Missing ⚠️
pkg/providers/dra/provider.go 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #438      +/-   ##
==========================================
+ Coverage   72.15%   74.95%   +2.79%     
==========================================
  Files          89       95       +6     
  Lines        5689     6547     +858     
==========================================
+ Hits         4105     4907     +802     
+ Misses       1382     1378       -4     
- Partials      202      262      +60     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

Copy link
Copy Markdown
Contributor

@ArangoGutierrez ArangoGutierrez left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 6 issues. The rate-limit centralization and the PATCH-only reconciliation both look right; the notes below are mostly about guards that stopped discriminating, plus one default that shifts.

  1. kubeBurst: 10 is an unanchored substring that also matches kubeBurst: 100, so this case cannot pin the client-go burst default it exists to prove. Changing the template to default 100 renders kubeBurst: 100 here and all 120 chart tests still pass; anchoring the value would catch it. (charts/topograph/tests/configmap_test.yaml:80)
  2. This PR adds kubeQPS and kubeBurst as request parameters on the k8s and NFD engines, but the API reference loses its four existing entries rather than gaining two more. They stay live request parameters - the server applies config providerParams/engineParams only when the payload omits the key - so direct API callers have nothing documenting them. (docs/api.md:105)
  3. This only calls the function with (0, 0), so nothing checks that a configured qps/burst actually reaches the shared token bucket. Rewriting the body to normalizedClientRateLimits(0, 0), which discards both arguments and would make kubeClient.qps a no-op for NFD, leaves this package and pkg/engines/nfd green. (internal/k8s/client_test.go:60)
  4. Flipping this case to a positive assertion for k8s leaves slurm, nfd, and graph uncovered, since the by default case above already renders with the chart's default k8s engine. Widening the template guard to ne .Values.engine.name "slinky" grants those engines node patch and the suite stays green; a notContains case with engine.name: slurm would restore it. (charts/topograph/tests/rbac_test.yaml:152)
  5. The comment documents that this always installs a bucket, but the NFD loader calls it unconditionally, so an unconfigured deployment now shares one 5 QPS bucket where the typed and dynamic clients each built their own before - roughly 10 QPS aggregate down to 5, in a PR aimed at reducing throttling. An early return on zero/zero would keep the old default, or a CHANGELOG note if the lower aggregate is intended. (internal/k8s/client.go:26)
  6. A node in the graph but outside the engine nodeSelector was labeled through the old GET path; it now errors, and ApplyNodeLabels stops on the first error, so the request fails with a 502 after some nodes are already patched. Logging and skipping would let the pass finish for the nodes in scope. (pkg/engines/k8s/kubernetes.go:43)

Comment thread charts/topograph/tests/configmap_test.yaml Outdated
Comment thread docs/api.md
Comment thread internal/k8s/client_test.go Outdated
Comment thread charts/topograph/tests/rbac_test.yaml
Comment thread internal/k8s/client.go
Comment thread pkg/engines/k8s/kubernetes.go
…ent rate limits

Signed-off-by: Dmitry Shmulevich <dshmulevich@nvidia.com>
Signed-off-by: Dmitry Shmulevich <dshmulevich@nvidia.com>

@ArangoGutierrez ArangoGutierrez left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second pass over the changes since e5f17d0. The five points from the first round all landed, and I mutation-checked four of them: anchoring kubeBurst: 10$ now catches a default 100 template change, the shared-limiter test goes red when the function discards its arguments, and widening the RBAC guard to ne "slinky" now fails two cases. Five things left, one of them a test that does not discriminate.

  1. This 50 ms window is shorter than client-go's 100 ms WaitForCacheSync poll period, so Start has not returned yet either way and the case passes unchanged against the old go s.run(). Mutating the implementation back to go s.run() keeps this green; widening the window to 500 ms fails that mutant and still passes the real one. (pkg/node_observer/status_informer_test.go:267)
  2. With the per-node GET gone, this branch cannot tell a node outside the nodeSelector from one that does not exist, so a wrong node name in a request nodes payload is swallowed and GenerateOutput still answers OK. Counting the skipped nodes and surfacing the total would keep a genuine typo visible. (pkg/engines/k8s/kubernetes.go:44)
  3. kubeQPS and kubeBurst are still live request parameters: the mapstructure tags remain on the DRA provider and the k8s, NFD and Slinky engines, getParameters validates them with a 400, and config params apply only when the payload omits the key, so a payload value wins over kubeClient. This PR adds two more of them and drops the last entries documenting any, which the doc-impact table asks to keep in sync. (docs/api.md:105)
  4. Start now blocks on run, but run only re-checks stopCh between process calls and the new s.cancel() reaches only the informer factories. A SIGTERM landing mid-retry waits out DoRequestWithRetries, whose time.Sleep and timeout-less http.Client observe the root context that Stop never cancels, so shutdown stalls up to the 5 minute maxRetryAfter. (pkg/node_observer/status_informer.go:136)
  5. The only test for requestOnAPIServerDelete passes a live *corev1.Pod, so the new DeletedFinalStateUnknown branch never runs: deleting that whole case leaves the package green. Relists after a watch gap deliver tombstones, which is the rolling-deployment path this handler was added for. (pkg/node_observer/status_informer_test.go:169)

select {
case err := <-done:
require.Failf(t, "Start returned before Stop", "error: %v", err)
case <-time.After(50 * time.Millisecond):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This 50 ms window is shorter than client-go's 100 ms WaitForCacheSync poll period, so Start has not returned yet either way and the case passes unchanged against the old go s.run(). Mutating the implementation back to go s.run() keeps this green; widening the window to 500 ms fails that mutant and still passes the real one.

node, ok := eng.cachedNodeMap[nodeName]
if !ok {
if len(eng.params.NodeSelector) != 0 {
klog.Warningf("Skipping topology labels for node %q because it does not match the engine nodeSelector", nodeName)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the per-node GET gone, this branch cannot tell a node outside the nodeSelector from one that does not exist, so a wrong node name in a request nodes payload is swallowed and GenerateOutput still answers OK. Counting the skipped nodes and surfacing the total would keep a genuine typo visible.

Comment thread docs/api.md
- **configUpdateMode**: (optional) Used in: [`slinky`]. By default, the full topology YAML is written in the Slurm ConfigMap. `skeleton-only` overrides to include switches or blocks only (no node lines); `none` skips updating the topology key in the ConfigMap.
- **nodes**: (optional) Supplies the cluster nodes used for topology generation as an array of regions mapping instance IDs to node names.

For Helm deployments, configure Kubernetes client rate limits only through

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kubeQPS and kubeBurst are still live request parameters: the mapstructure tags remain on the DRA provider and the k8s, NFD and Slinky engines, getParameters validates them with a 400, and config params apply only when the payload omits the key, so a payload value wins over kubeClient. This PR adds two more of them and drops the last entries documenting any, which the doc-impact table asks to keep in sync.


go s.run()

s.run()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Start now blocks on run, but run only re-checks stopCh between process calls and the new s.cancel() reaches only the informer factories. A SIGTERM landing mid-retry waits out DoRequestWithRetries, whose time.Sleep and timeout-less http.Client observe the root context that Stop never cancels, so shutdown stalls up to the 5 minute maxRetryAfter.

))
}

func TestAPIServerPodDeleteQueuesRequest(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only test for requestOnAPIServerDelete passes a live *corev1.Pod, so the new DeletedFinalStateUnknown branch never runs: deleting that whole case leaves the package green. Relists after a watch gap deliver tombstones, which is the rolling-deployment path this handler was added for.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Kubernetes API throttling with the K8S and NFD providers

2 participants