feat(worker): expose current CPU/RAM/DISK/GPU utilization via Instance metrics subresource - #85
Conversation
Real-time per-instance CPU/RAM/DISK gauges via the node kubelet (apiserver node proxy, metrics.k8s.io fallback) plus the device manager's latest accelerator snapshot, exposed as an Instance metrics subresource on the aggregated worker API. No history, no Prometheus. Signed-off-by: thxCode <thxcode0824@gmail.com>
… snapshot - add datax.Snapshot, a race-free latest-value holder - store the newest accelerator sample per monitor tick; default period becomes 15s and the monitor-history retention option is removed - serve GET /monitor/snapshot with the latest sample on the secure webserver Task 1 of instance-utilization-metrics. Signed-off-by: thxCode <thxcode0824@gmail.com>
- InstanceMetrics carries one current sample with unit-bearing fields for pod CPU/memory/rootfs/ephemeral-storage and per-accelerator metrics - no options type: the subresource is a plain computed GET Task 2 of instance-utilization-metrics. Signed-off-by: thxCode <thxcode0824@gmail.com>
- read the backing pod's CPU/memory/rootfs/ephemeral-storage at request time from the node kubelet through the apiserver node proxy, matching on pod UID so a recreated instance never sees the previous one - fall back to metrics.k8s.io for CPU/memory when the kubelet read fails, rejecting entries measured before the pod existed - merge allocated accelerator metrics from the device manager's latest snapshot on a best-effort basis Task 3 of instance-utilization-metrics. Signed-off-by: thxCode <thxcode0824@gmail.com>
There was a problem hiding this comment.
Code Review
This pull request implements the instances/<name>/metrics subresource to serve real-time CPU, memory, disk, and GPU utilization metrics for Instances. The metrics are retrieved from the node kubelet stats summary via the API-server node proxy, with a fallback to metrics.k8s.io for CPU/memory, and merged with accelerator metrics from the DeviceManager's latest snapshot. The DeviceManager has been refactored to store only the latest monitor snapshot using a new atomic Snapshot utility, removing the previous ring-buffer history. Feedback on the E2E test script suggests a more robust way to terminate background load processes by using kill $(jobs -p) instead of hardcoded job numbers.
There was a problem hiding this comment.
Pull request overview
Adds an Instance metrics subresource to the worker aggregated API (worker.gpustack.ai/v1) to expose a single “current gauges” utilization sample (CPU/memory/disk from kubelet stats summary via apiserver node proxy, plus best-effort GPU metrics merged from the DeviceManager’s latest snapshot), along with supporting DeviceManager snapshot plumbing, API types/codegen, docs, and e2e coverage.
Changes:
- Implement
GET instances/<name>/metricssubresource handler + unit tests in the worker aggregated API. - Replace DeviceManager monitor history with a “latest snapshot” readout endpoint (
/monitor/snapshot) backed by an atomic snapshot holder. - Add API types + regenerated OpenAPI/proto artifacts, and document the contract + add an e2e case.
Reviewed changes
Copilot reviewed 18 out of 25 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| specs/2026-08-07-instance-utilization-metrics.md | Design/spec for the “current gauges” Instance utilization endpoint and data sources. |
| pkg/worker/extensionapis/worker/instance.metrics.go | Implements the Instance metrics subresource (kubelet summary + metrics.k8s.io fallback + DM snapshot merge). |
| pkg/worker/extensionapis/worker/instance.metrics_test.go | Unit tests for subresource scoping, kubelet/fallback behavior, and snapshot merge/filtering. |
| pkg/worker/extensionapis/worker/instance.go | Registers the new metrics subresource handler. |
| pkg/utils/datax/snapshot.go | Adds a generic atomic “latest snapshot” holder utility. |
| pkg/utils/datax/snapshot_test.go | Tests the snapshot holder’s basic and concurrent behavior. |
| pkg/devicemanager/snapshot.go | Adds the /monitor/snapshot HTTP handler and snapshot envelope type alias. |
| pkg/devicemanager/snapshot_test.go | Tests snapshot handler method restrictions and JSON envelope shape. |
| pkg/devicemanager/manager.go | Registers the snapshot endpoint on the device-manager webserver. |
| pkg/devicemanager/detector/snapshot_test.go | Ensures monitor ticks replace the stored snapshot over time. |
| pkg/devicemanager/detector/option.go | Drops history options; sets monitor period default to 15s and updates flag help/validation. |
| pkg/devicemanager/detector/detector.go | Stores latest monitor output into a snapshot envelope instead of a ring buffer. |
| pkg/devicemanager/detector/config.go | Removes monitor-history configuration from detector config. |
| docs/reference/instance-metrics.md | User-facing reference for fields, sources, scoping/RBAC, degradation, and limits. |
| docs/README.md | Adds the new reference doc to the docs index. |
| api/worker/zz_generated.openapi.go | Regenerates OpenAPI for new InstanceMetrics types. |
| api/worker/v1/zz_generated.register.go | Registers InstanceMetrics in the scheme. |
| api/worker/v1/zz_generated.model_name.go | Adds OpenAPI model names for new v1 types. |
| api/worker/v1/zz_generated.deepcopy.go | Adds deepcopy methods for new API types. |
| api/worker/v1/instance.metrics.go | Defines the public v1 API types for Instance metrics and accelerator metrics. |
| api/worker/v1/generated.protomessage.pb.go | Regenerates protomessage stubs for new types. |
| api/worker/v1/generated.proto | Regenerates protobuf schema with new message types. |
| api/worker/v1/generated.pb.go | Regenerates protobuf implementation for new messages. |
| .claude/skills/gpustack-operator-e2e/SKILL.md | Documents the new e2e case coverage mapping. |
| .claude/skills/gpustack-operator-e2e/cases/case-37.sh | Adds e2e case validating the new metrics subresource behavior and RBAC. |
Files not reviewed (7)
- api/worker/v1/generated.pb.go: Generated file
- api/worker/v1/generated.proto: Generated file
- api/worker/v1/generated.protomessage.pb.go: Generated file
- api/worker/v1/zz_generated.deepcopy.go: Generated file
- api/worker/v1/zz_generated.model_name.go: Generated file
- api/worker/v1/zz_generated.register.go: Generated file
- api/worker/zz_generated.openapi.go: Generated file
Suppressed comments (5)
.claude/skills/gpustack-operator-e2e/cases/case-37.sh:58
- The test Instance manifest hard-codes
namespace: default, ignoring the<NS>argument documented at the top of the script. This makes the case run against a different namespace than requested.
cat <<EOF | kubectl apply -f -
apiVersion: worker.gpustack.ai/v1
kind: Instance
metadata: { name: ${INST}, namespace: default }
spec:
.claude/skills/gpustack-operator-e2e/cases/case-37.sh:69
- Namespace is hard-coded to
defaultwhen waiting for readiness. If<NS>is notdefault, the script will wait on the wrong object and may report a false failure.
for _ in $(seq 1 40); do
phase=$(kubectl -n default get instance "$INST" -o jsonpath='{.status.phase}' 2>/dev/null)
[ "$phase" = "Ready" ] && break
sleep 3
.claude/skills/gpustack-operator-e2e/cases/case-37.sh:73
- The raw metrics path is hard-coded to
namespaces/default, so running the case with a non-default<NS>will query the wrong endpoint.
RAW="/apis/worker.gpustack.ai/v1/namespaces/default/instances/${INST}/metrics"
.claude/skills/gpustack-operator-e2e/cases/case-37.sh:108
- CPU burn uses
kubectl -n default exec ..., ignoring<NS>. On non-default namespaces the load is never applied to the test Instance, which can cause the “figures track the backing pod” check to fail spuriously.
kubectl -n default exec "$INST" -c main -- sh -c 'for i in 1 2 3 4 5 6 7 8; do (while :; do :; done) & done; sleep 25; kill %1 %2 %3 %4 %5 %6 %7 %8 2>/dev/null' >/dev/null 2>&1
.claude/skills/gpustack-operator-e2e/cases/case-37.sh:123
- Authorization check uses
-n default, ignoring<NS>. Subresource RBAC is namespace-scoped, so this can yield the wrong result when the test runs in a different namespace.
deny=$(kubectl auth can-i get instances.worker.gpustack.ai/metrics -n default \
--as system:serviceaccount:default:default 2>/dev/null)
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
- assert the current sample carries timestamp/cpu/memory/rootfs/ ephemeral-storage and tracks the backing pod under load - assert an unprivileged caller is denied instances/metrics - register the case in the skill's case table Verified on the k3s test cluster (dev-472e3af7): all checks passed. Task 4 of instance-utilization-metrics. Signed-off-by: thxCode <thxcode0824@gmail.com>
- keep vendor-native MiB in InstanceAcceleratorMetrics (memoryMiB / memoryUsageMiB) instead of converting to bytes - drop accelerator samples whose snapshot is older than three monitor periods instead of presenting them as current - resolve the device-manager secure port from its named container port so a chart-level override keeps working - share one keep-alive HTTP client for readouts instead of leaking a transport per request - record the accepted unauthenticated-snapshot decision in the spec Signed-off-by: thxCode <thxcode0824@gmail.com>
- report every memory and storage figure in MiB, rounding byte figures up: the kubelet measures in bytes and the vendor device libraries in MiB, one sample must never make a consumer mix two units, and an idle instance — whose working set and writable layer routinely sit under 1 MiB — must not read as no usage at all - read one device manager snapshot per allocated manufacturer and never substitute another manufacturer's, whose snapshot cannot carry the cards the request asks about - drop the device manager retry: both attempts shared the operation deadline and repeated a pod resolution that cannot change within it; log why the best-effort accelerator section came back empty instead - fall back to metrics.k8s.io when the kubelet answers without knowing the pod, instead of serving an empty sample stamped with a measurement that never happened - name the actual failure in the ServiceUnavailable message rather than printing a nil metrics API error - return ServiceUnavailable, not Conflict, for a backing pod of a previous incarnation: transient backing state, like the other two cases - clamp negative metrics.k8s.io quantities and treat an empty container list as unserved, instead of wrapping them into exabytes of usage - bound the unverified snapshot readout with io.LimitReader - inline the metrics handler's production calls and drop its unused Client Signed-off-by: thxCode <thxcode0824@gmail.com>
…riod - carry periodSeconds in the monitor snapshot envelope so consumers scale their staleness bound to the configured cadence - cover the handler-level accelerator merge through a loopback TLS server standing in for the device manager pod Signed-off-by: thxCode <thxcode0824@gmail.com>
Document the instances/<name>/metrics subresource: response fields and units, per-figure data sources (live kubelet read, metrics.k8s.io fallback, device-manager snapshot), scoping/RBAC rules, degradation and limits. Signed-off-by: thxCode <thxcode0824@gmail.com>
Signed-off-by: thxCode <thxcode0824@gmail.com>
e0d4ba1 to
0464b0e
Compare
|
Pushed a revision round: a unit-contract change, ten review findings, and a triage of the bot feedback. History was rewritten in place (fixups folded into the originating commits), so the branch is still ten commits. Unit contract: every memory and storage figure is now MiB, rounded up
Byte figures round up, decided after measuring an idle instance on a Kubernetes cluster:
Truncation reported all three as Review findings addressed
Bot feedback triageThree findings were real and are fixed (replied and resolved inline): the Two are false positives and their threads are left open for a human call:
VerificationImage built on a build host and deployed to a Kubernetes cluster with a real accelerator:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 27 changed files in this pull request and generated no new comments.
Files not reviewed (7)
- api/worker/v1/generated.pb.go: Generated file
- api/worker/v1/generated.proto: Generated file
- api/worker/v1/generated.protomessage.pb.go: Generated file
- api/worker/v1/zz_generated.deepcopy.go: Generated file
- api/worker/v1/zz_generated.model_name.go: Generated file
- api/worker/v1/zz_generated.register.go: Generated file
- api/worker/zz_generated.openapi.go: Generated file
Suppressed comments (3)
pkg/utils/mathx/division.go:12
- CeilDiv uses (a + b - 1) / b, which can overflow for large unsigned values and produce an incorrect (often much smaller) quotient. Since this helper is used for converting byte counters, it’s safer to implement ceil division via quotient+remainder (no overflow) and constrain the generic to unsigned integers (the current contract doesn’t define behavior for negatives anyway).
func CeilDiv[I typex.Integer](a, b I) I {
if b == 0 {
return 0
}
return (a + b - 1) / b
pkg/worker/extensionapis/worker/instance.metrics.go:508
- If the metrics.k8s.io response has a zero/absent timestamp, currentPodUsage will accept the CPU/memory values and stamp them with meta.Now(), which removes the ability to reject previous pod incarnations and can leak stale data across pod recreation. Treat a missing timestamp as an error (or as “unserved”) so the caller never serves metrics that can’t be scoped to the current pod lifetime.
ts := &podMetrics.Timestamp
if ts.IsZero() {
ts = nil
}
return &cpu, &memory, ts, nil
pkg/worker/extensionapis/worker/instance.metrics.go:372
- When a device-manager snapshot is dropped as stale, the worker currently returns no accelerator section without logging why. This contradicts the documented contract that “the reason is logged” for missing accelerator data, and makes GPU absence hard to diagnose in production.
maxAge = time.Duration(snapshot.PeriodSeconds) * time.Second * 3
}
if time.Since(snapshot.Timestamp) > maxAge {
return nil
}
What type of PR is this?
/kind enhancement
/kind api-change
/area worker
/area devicemanager
/area testing
What this PR does / why we need it:
Adds a read-only
instances/<name>/metricssubresource to the aggregated API (worker.gpustack.ai/v1) so an Instance's current CPU/memory/disk/GPU utilization is visible without entering the instance and without Prometheus or metrics-server:metrics.k8s.iois a CPU/memory-only fallback when the kubelet read fails (entries measured before the pod existed are rejected).datax.Snapshot) served atGET /monitor/snapshot; the subresource merges only the cards recorded in the pod's allocation annotation (an Instance never sees an accelerator it did not allocate).app.kubernetes.io/part-ofUID label + kubelet pod UID match, so a recreated instance never reads the previous one); callers needgetoninstances/metricsspecifically.Which issue(s) this PR fixes:
Fixes #
Special notes for your reviewer:
specs/2026-08-07-instance-utilization-metrics.md(Shipped, includes the design record and the dropped history-based first iteration). Reference doc:docs/reference/instance-metrics.md.-raceclean), plus new e2ecase-37— all checks PASS on a k3s test cluster (current sample fields, figures track the backing pod under load, unprivileged caller denied).Does this PR introduce a user-facing change?