Skip to content

AI Lab Observability

kube-prometheus-stack, trimmed to fit three 6 GB nodes: no Alertmanager, 24-hour retention, Grafana on a NodePort because agentgateway already owns :80 on those nodes.

The dashboard JSON lives in git and provisions from a ConfigMap, so Grafana deliberately has no persistence — a restart costs nothing and the UI is never the only copy.

TargetMonitorNotes
Model serversPodMonitorNot a ServiceMonitor — the simulators have no Service. The InferencePool selects their pods directly, which is the point of the inference extension
Endpoint pickerServiceMonitorAuthenticated — see below
ollama(none)It exposes no Prometheus endpoint at all. It appears only through cAdvisor’s container CPU and memory

The PodMonitor copies each pod’s llm-d.ai/role label onto every series via podTargetLabels. Without that, prefill and decode are two anonymous pods and none of the by-role panels are possible.


An endpoint that answered 200 with nothing

Section titled “An endpoint that answered 200 with nothing”

The endpoint picker guards /metrics behind --metrics-endpoint-auth, which defaults to on. An unauthenticated scrape gets an empty 200 body, not a 401 — which reads exactly like a component that exports nothing, and was filed as an open item for a day.

The fix was not to turn authentication off. Prometheus got a ServiceAccount, a ClusterRole granting get on the non-resource URL /metrics, and a bearer token:

rules:
- nonResourceURLs: ["/metrics"]
verbs: ["get"]

Then the token was rejected too — and the reason is worth knowing, because it inverts where you look. Controller-runtime does not validate bearer tokens itself: it calls TokenReview and then SubjectAccessReview against the API server, using the component’s own ServiceAccount. The chart enables metrics auth without granting the picker those rights:

tokenreviews.authentication.k8s.io is forbidden: User
"system:serviceaccount:llm-d:sim-pool-epp" cannot create resource "tokenreviews"

So every authenticated request failed identically, whoever made it and whatever token they held. The credential was never the problem — the checker was. Binding the built-in system:auth-delegator ClusterRole to the picker’s ServiceAccount fixed it.

Two smaller ones from the same afternoon: the operator resolves scrape credentials in the ServiceMonitor’s own namespace, not Prometheus’s; and a ServiceMonitor whose selector or port name is wrong exists, validates, and scrapes nothing — the same silent failure shape as the auth problem it sat next to.


Fourteen panels, written against the metric inventory read off Prometheus rather than from the project’s catalogue — which lists far more than any one configuration populates.

RowAnswers
HeadlineIs traffic flowing, and is caching working?
RoutingWhat is the picker deciding, and what does deciding cost?
CacheWhere are the cached prefixes, and which pod is filling up?
ThroughputIs prefill work landing on prefill and decode work on decode?
LatencyWhat does a caller feel, and which plugin is expensive?
Real vs simulatedA real model burning CPU beside simulators that do not

The find worth having was llm_d_epp_disagg_decision_total — the picker counts its own prefill/decode decisions, which turns the split we proved by hand into a time series.

Every vllm:* query works unchanged against real vLLM, because the simulator emits vLLM’s metric names. That compatibility is what makes a GPU-free lab worth building.


A dashboard that only says the system is alive is wallpaper. Each panel here exists because it changes what you would do:

What you seeWhat it meansWhat to do
Hit ratio collapses when you add replicasMore pods, same prompts, each cache colderScale in, or raise the cache-affinity weight. More replicas is not free for cache-bound workloads
One pod near full KV cache, a sibling idleCache affinity is outvoting load — a routing faultRaise kv-cache-utilization-scorer. Adding pods will not fix a distribution fault
waiting above zero on all pods of a roleThat phase is genuinely saturatedScale that role only — the whole point of disaggregating
TTFT rising with the split rateThe KV transfer costs more than the prefill it avoidsRaise nonCachedTokens. Disaggregation is not always right
Queue depth up, CPU flatThe pods are not working, they are waitingSuspect the hypervisor before the application
ready_endpoints drops to zeroFailOpen keeps requests flowing, round-robin and cache-blindUrgent. The system looks fine and every routing decision has stopped happening

That last row is the one to internalise: FailOpen converts an outage into a silent quality regression. Only that panel and the hit ratio reveal it.

The full mapping — every panel, every threshold, and the cross-panel diagnoses that actually discriminate — is in reading-the-dashboard.md.


  • Answer quality. Nothing here knows whether the model said anything useful.
  • Whether the cache hits were worth having. A hit on a prefix nobody reuses is a coincidence, not a design.
  • Cost. No dollars, no GPU hours — the column that decides whether any of this pays for itself on real hardware.