The inference-cache operator ships a default Prometheus alert bundle under
config/observability/. Each alert below catches
a degenerate metric pattern that has surfaced in production at least once: the
metric was on /metrics from day one, but nothing watched the pattern, so the
failure ran silently for days. The alerts make those patterns loud.
The alert annotations are deliberately short (one sentence + runbook_url).
This file is the long form: causes, triage steps, example PromQL.
There are two distribution shapes, same rule set, drift-gated by
make verify-prometheus:
-
prometheus-operator / kube-prometheus installs: apply the bundle:
kubectl apply -k config/observability
Ships FOUR CRs together —
kubectl apply -kapplies all four:- A
ServiceMonitorthat tells Prometheus to scrapeinference-cache-server:8080/metrics. Without this, kube-prometheus installs will load the rules but never collect the server-sideinferencecache_*series the rules read —prometheus.io/scrapeannotations are commonly ignored in favor of explicitServiceMonitor/PodMonitorCRs. - A
PodMonitorthat tells Prometheus to scrape the controller pod's:8080/metrics. Required for the controller-side alerts (ServerProbeFailreadsinferencecache_backend_probe_result_total, which the CacheBackend reconciler emits; the existinginferencecache_backend_probe_result_totalis controller-emitted). Without this, those rules load but never have a series to evaluate. - A second
PodMonitorthat discovers successfully injected PodLocal LMCache native sidecars across workload namespaces and scrapes their namedlmcache-httpport. - The
PrometheusRulecarrying the alerts.
All four CRs are pinned to namespace
inference-cache-system. The example selector labels each CR carries are:PrometheusRule→prometheus: k8s,role: alert-rules(matched byPrometheus.spec.ruleSelector).ServiceMonitor→prometheus: k8s(matched byPrometheus.spec.serviceMonitorSelector).- both
PodMonitorresources →prometheus: k8s(matched byPrometheus.spec.podMonitorSelector).
All four target the upstream kube-prometheus stack, whose default
Prometheusis namedk8s. Theprometheus-community/kube-prometheus-stackHelm chart uses a DIFFERENT convention — its selector matchesrelease: <helm-release-name>(noprometheus:label). Custom Prometheus CRs use whatever theirruleSelector/serviceMonitorSelector/podMonitorSelectorspecifies. If your install uses a different label set, edit each CR's labels to match — the YAML comments next to each label spell out the exactkubectl get prometheus -A -o jsonpath=...introspection command.Heads-up — Prometheus may scope rule discovery by namespace. Most prometheus-operator installs run a
PrometheusCR with both aruleSelector(matchesPrometheusRule.metadata.labels) AND aruleNamespaceSelector(matches the namespaces the rules live in). The default kube-prometheus install allows all namespaces, but a hardened install may restrict to e.g.monitoring. Ifkubectl get prometheusrule -Ashows the CR landed but Prometheus never loads it, check yourPrometheus.spec.ruleNamespaceSelectorand either widen it, override the namespace inconfig/observability/kustomization.yaml, or move the CR by hand. - A
-
Vanilla Prometheus / Helm: mount
alerting-rules.yamlinto Prometheus via therule_files:config block, a ConfigMap, or the Helmprometheus.serverFilesvalue (depending on your install). You ALSO needscrape_configs:entries for all applicable targets — theinference-cache-serverpod (server-side series: index, lookup, auth) AND theinference-cache-controller-managerpod (controller-side per-stage probe-result counter) and each injected PodLocal LMCache sidecar (:8080/metrics). Server-only scrape leaves the controller-side alerts (ServerProbeFailtoday) loaded but inert — they readinferencecache_backend_probe_result_totalwhich is controller-emitted. To keep the alerts' per-install scoping working, both scrapes must inject anamespacelabel. Two valid shapes:- Recommended — Kubernetes service discovery
(
kubernetes_sd_configs: podorendpoints) withrelabel_configs:that copies__meta_kubernetes_namespaceto anamespacelabel. Select onapp.kubernetes.io/name: inference-cacheand split server vs. controller byapp.kubernetes.io/component. Works in single-install AND shared-Prometheus setups. - Single-install only — a static DNS scrape of
inference-cache-server.inference-cache-system.svc.cluster.local:8080plus a pod-IP scrape of the controller manager pods (no Service fronts the controller's:8080, so a DNS-style static target isn't an option there — pod IPs change on restart, so static pod-IP scrapes are operator-of-last-resort). Simpler but produces NOnamespacelabel, so the alerts collapse into one unlabeled group. Acceptable when one Prometheus only ever scrapes one inference-cache install; do not use it for shared Prometheus deployments.
ServiceMonitor (server) + PodMonitors (controller and LMCache MP) in the operator bundle are the prometheus-operator equivalent of shape (1); both shapes (1) and (2) require you to wire all applicable scrape entries explicitly when you are not on prometheus-operator.
- Recommended — Kubernetes service discovery
(
Both files contain the same six active alerts (five Stage 1 alerts plus
the controller-side ServerProbeFail) plus commented-out placeholders for
two more that depend on metrics not yet exposed (see Deferred
alerts below).
One alert depends on a separate scrape this bundle does NOT ship.
LMCacheT2NoHitsreadsvllm:external_prefix_cache_*, which vLLM exposes on its own/metrics. The included scrape configs do not collect vLLM's own metrics. To makeLMCacheT2NoHitslight up, your install must also scrape engine pods — typically a separatePodMonitorfor your vLLM Deployment, or aServiceMonitoron a headless / per-pod Service (Endpoints discovery), orkubernetes_sd_configs: podfor vanilla Prometheus.The scrape MUST preserve both
namespaceandpodlabels — the alert groupssum by (namespace, pod)and its summary substitutes{{ $labels.pod }}. A scrape against a load-balanced ClusterIP Service (single target, singleinstancelabel) would aggregate all replicas under one series with nopodlabel and render the summary with an empty pod. PodMonitor + standard prometheus-operator relabel rules give you both labels for free; ServiceMonitor against a headless Service does too (one Endpoints entry per Pod). Static targets do NOT.And the PodMonitor MUST scope to cache-bound engine pods only. The alert reads
vllm:external_prefix_cache_*from EVERY scraped vLLM pod — there is no controller-injected label on engine pods that Prometheus can filter on, because the operator stamps aninferencecache.io/injected-byannotation (not a label), and annotations don't show up on scraped series. So scope at scrape time: the PodMonitor'sselector.matchLabelsshould match the same labels yourCacheBackend.spec.engineSelector.matchLabelsuses. Otherwise an unrelated vLLM workload in the cluster (no cache-plane wiring, no LMCache offload) will tripLMCacheT2NoHitson inference-cache's behalf. A minimal scoped PodMonitor looks like:apiVersion: monitoring.coreos.com/v1 kind: PodMonitor metadata: name: vllm-cache-bound namespace: <your-engine-namespace> spec: selector: matchLabels: app: my-engine # MUST match CacheBackend.spec.engineSelector.matchLabels podMetricsEndpoints: - port: http # the port-name your vLLM container exposes /metrics on path: /metrics interval: 30sThe other five alerts work as-is once this bundle is applied — they only read
inferencecache_*series, which the shipped ServiceMonitor (server-side:IndexEmpty,LookupRouteDegenerate,LookupRouteHighTimeout,IndexEvictionsSpike) and PodMonitor (controller-side:ServerProbeFail) cover between them.The alerts rely on a
namespacelabel per install. Both the shippedServiceMonitorforinference-cache-serverand any prometheus-operatorPodMonitoryou add for vLLM automatically injectnamespacevia the operator's standard relabel rules (sourced from the__meta_kubernetes_namespaceKubernetes SD label). Vanilla Prometheus with hand-writtenscrape_configs:does NOT inject this by default — you must add arelabel_configs:entry that copies__meta_kubernetes_namespaceto anamespacelabel. Without it, per-install isolation collapses into one unlabeled group and one install's outage can mask another's.
- Severity:
critical - For: 2 minutes
- Source metrics:
inferencecache_server_up,inferencecache_index_entries{model},inferencecache_lookup_route_calls_total
The cache policy server reports inferencecache_server_up=1 but the
index holds zero prefix entries across every model while a gateway is
actively making LookupRoute calls. That means ReportCacheState is
not receiving (or not recording) any KV events from engines. The cache
plane is effectively a no-op until this clears: every LookupRoute
returns NO_HINT; the gateway falls back to its default routing.
The traffic gate (
sum(rate(inferencecache_lookup_route_calls_total[10m])) > 0) distinguishes a genuine pipeline outage from a healthy idle install — e.g. an operator applied the bundle but has not deployed any engines yet, or no gateway is sending traffic. Those installs SHOULD have an empty index; alerting critical there would page every fresh opt-in deployment.
A cold-start dwell (server just started, first engine still booting) is
normal and dampened by the 2-minute for: window. Sustained firing means
the subscriber → server pipeline is genuinely broken.
kvevent-subscribersidecar not injected: the operator's--kvevent-subscriber-imageflag is empty on the controller, or the pod carries theinferencecache.io/skip-inject: "true"annotation, so the mutating webhook does not attach the sidecar.- Engine prefix-cache disabled or KV events publisher off: the
engine needs to be configured to emit KV events. For vLLM that means
both
--enable-prefix-cachingand a--kv-events-configblock. If prefix caching is on but events are not configured, the engine internally has cache state but never publishes it. - Subscriber → server gRPC dial failing: NetworkPolicy too tight, TLS misconfig (TLS opted in at the server but plaintext at the subscriber), DNS not resolving the Service, or the subscriber is silently exiting on a bad flag.
# 1. Confirm the server reports up.
kubectl -n inference-cache-system port-forward svc/inference-cache-server 8080:8080 &
curl -s localhost:8080/metrics | grep -E 'inferencecache_server_up|inferencecache_index_entries'
# 2. Confirm the subscriber sidecar is attached and running on an engine pod.
kubectl get pod -l <engine-selector-label> -o jsonpath='{.items[0].spec.containers[*].name}'
kubectl logs <engine-pod> -c kvevent-subscriber --tail=50
# 3. Confirm the engine's KV-event publisher is ON. Prefix-cache metrics
# being present is NOT proof of this — vLLM emits prefix_cache_*
# independently of the KV-events publisher. The signal we actually
# want is the publisher's ZMQ socket / config:
kubectl exec <engine-pod> -- ps -ef | grep -E 'kv-events-config|vllm'
kubectl exec <engine-pod> -- cat /etc/vllm/kv-events.yaml 2>/dev/null || \
kubectl get pod <engine-pod> -o jsonpath='{.spec.containers[?(@.name=="engine")].args}' \
| tr ',' '\n' | grep -i kv-events
# 4. Confirm the subscriber sidecar is healthy. The subscriber logs
# `subscribed to engine KV events ...` once on startup and is then
# silent on the success path; failures (ZMQ recv error, gRPC stream
# open/send/close error) are logged at WARN. So the working signal
# is "startup log line present, no recent WARN":
kubectl logs <engine-pod> -c kvevent-subscriber --tail=200 \
| grep -E 'subscribed to engine KV events|level=WARN|level=ERROR'
# If you see only the "subscribed" line and no WARN/ERROR, the
# subscriber half is healthy. To prove forwarding is actually
# landing at the server, check that the server's index has at least
# one entry attributed to this pod's replica:
kubectl -n inference-cache-system port-forward svc/inference-cache-server 8081:8081 &
TOKEN=$(kubectl -n inference-cache-system create token \
inference-cache-controller-manager --audience=inferencecache.io/controller)
curl -s localhost:8081/snapshot -H "Authorization: Bearer $TOKEN" \
| jq '.replicas[] | select(.replicaId | startswith("<engine-pod>"))'
# 5. Confirm the controller is wiring the sidecar image.
kubectl -n inference-cache-system get deploy/inference-cache-controller-manager \
-o jsonpath='{.spec.template.spec.containers[?(@.name=="manager")].args}'Triage queries:
# Per-model index population
inferencecache_index_entries
# Server liveness across all instances
inferencecache_server_up
- Severity:
warning - For: 5 minutes
- Source metrics:
vllm:external_prefix_cache_queries{pod}(or_total-suffixed variant),vllm:external_prefix_cache_hits{pod}(or_total-suffixed variant). Emitted by vLLM, not by this operator — upstream's metrics page lists the unsuffixed names but the Python prometheus_client convention appends_totalto counters at exposition time, so deployments see one or the other depending on vLLM build/client. The alert accepts both.
The engine is hitting the external (offload) prefix cache tier at more
than 1000 tokens per second of queries but is getting zero hit
tokens. This is a textbook silent-failure signal: the offload tier looks
"wired" to Kubernetes (the sidecar is up, the CacheBackend is Ready),
but no offloaded prefix is being recalled. Every offload put is wasted
work; every get returns empty; the engine refills T2 forever without
ever benefiting from it.
vLLM's
external_prefix_cache_{queries,hits}_totalcount tokens, not requests. A single 1500-token shared prefix counts as 1500 queries when it's checked against the offload tier. The 1000 tokens/sec floor catches a single moderately-prefixed request per second; idle replicas (no prefix-caching traffic at all) stay below it. Tune the threshold if your workload's prefix size differs substantially.
The 5-minute for: window requires sustained zero-hit behavior — a
transient miss-streak (e.g. after a backend restart with empty T2)
does not trip it.
- Client/server version skew in the offload subsystem (most common
in practice). An old offload-client library baked into the engine
image is talking to a newer offload-server image, or vice versa. The
TCP wire handshake succeeds (so the engine thinks it is connected)
but
put/getopcodes diverge:putsucceeds at the client and returns silently,getreturns empty at the server. The cache reports "stored N tokens" in the engine logs and nothing ever comes back. - Externally owned backend down or unreachable for a CacheBackend with
spec.remoteStorage.ownership: Externaland a wrong endpoint configured. - Authentication mismatch between the offload client and server (when the offload backend supports auth — most offload backends today do not).
vLLM emits vllm:external_prefix_cache_{queries,hits} (or, under the
Python prometheus_client exposition convention, the _total-suffixed
variants) on its own /metrics endpoint (typically :8000/metrics),
not via this operator. The upstream metric names are documented at
docs.vllm.ai/.../usage/metrics/;
the series have been present since vLLM 0.18 (the first release tagged
in the upstream v0.18 docs page). Our alert and triage queries accept
both the unsuffixed and _total forms via {__name__=~"...(_total)?"}.
This operator has no in-process scrape of those upstream metrics — its
own scraper (internal/subscriber/metrics_scraper.go) only reads the T1
vllm:prefix_cache_{hits,queries} plus vllm:*_cache_usage_perc. That
means the alert binds directly to vLLM's exposition, and an upstream
rename, deprecation, or version skew can silently make the alert inert
while promtool test rules (which uses synthetic series) still passes.
Operator responsibility: before enabling the alert in production, confirm at least one engine pod publishes the series:
# Matches BOTH the Python-prometheus-client convention
# (vllm:external_prefix_cache_queries_total) AND the unsuffixed form
# (vllm:external_prefix_cache_queries) — the alert uses
# `{__name__=~"...(_total)?"}` so it accepts whichever form your vLLM
# build emits.
kubectl exec <engine-pod> -- curl -s localhost:8000/metrics \
| grep -E '^vllm:external_prefix_cache_(queries|hits)(_total)?'A pod running an older vLLM (no offload support) will return no lines;
the alert won't fire there either (the unless guard handles absent
hits-series, but the queries gate of >1000 tokens/sec assumes the
metric IS exposed). If your install runs vLLM <0.18 for some pods,
exclude them via a label in the alert expression or upgrade. If a
future vLLM release renames the metric, update the alert (and this
runbook) accordingly.
# 1. Confirm the symptom on the engine pod (accepts both the
# unsuffixed and `_total` forms; see "Verify the metric is
# exposed" above for the upstream-versions distinction).
kubectl exec <engine-pod> -- curl -s localhost:8000/metrics \
| grep -E '^vllm:external_prefix_cache_(queries|hits)(_total)?'
# 2. Compare the offload-client version (in the engine image) against
# the offload-server pod image tag. Skew is the root cause in most
# incidents.
kubectl exec <engine-pod> -- pip show <offload-client-pkg> | grep Version
kubectl get pod -l <offload-server-selector> -o jsonpath='{.items[0].spec.containers[0].image}'
# 3. Inspect the offload server log for protocol errors.
kubectl logs <offload-server-pod> --tail=200 | grep -iE 'invalid|version|protocol|scheme'Triage queries (use the {__name__=~"...(_total)?"} form so the query
matches whichever exposition shape your vLLM uses):
# External cache hit rate per pod (should be > 0 on a working offload)
sum by (namespace, pod) (rate({__name__=~"vllm:external_prefix_cache_hits(_total)?"}[10m]))
/
sum by (namespace, pod) (rate({__name__=~"vllm:external_prefix_cache_queries(_total)?"}[10m]))
# Stores vs. hits over the last hour
sum by (namespace, pod) (increase({__name__=~"vllm:external_prefix_cache_queries(_total)?"}[1h]))
sum by (namespace, pod) (increase({__name__=~"vllm:external_prefix_cache_hits(_total)?"}[1h]))
The
vllm:prefix is how vLLM exposes its metrics. If your install uses ametricRelabelingrule to strip the colon (some Helm charts do), adjust the alert expressions accordingly.
- Severity:
warning - For: 5 minutes
- Source metric:
inferencecache_lookup_route_calls_total{model, reason_code}
For at least one model, more than 90% of LookupRoute calls over the
past 10 minutes returned reason_code="NO_HINT". The cache plane is
surfacing no replica hints; the gateway is falling back to its default
routing (round-robin, least-loaded, …) and the prefix-cache hit benefit
is not being realized.
The 0.1 q/s rate gate on total calls avoids tripping on an idle model that happens to have published one NO_HINT and nothing else.
- Tenant ID mismatch: the gateway is calling
LookupRoutewith atenantthat does not match the tenant key used atReportCacheStateingest. The index has entries, but they are filed under a different key. Look for sustained NO_HINT alongsideinferencecache_index_entries > 0. - Engine not emitting KV events (overlaps with
IndexEmptybut at sub-cluster scale — only some models broken). hash_schememismatch: the gateway's request carries an empty or unrecognizedhash_scheme. An emptyhash_schemeis dropped on lookup as a forward-compat safeguard (reason-codes.md).CachePolicy.minimumPrefixTokensset above the real prompt-prefix length: the chain walk filter rejects every candidate before the ranker sees it.CachePolicy.lookupTimeoutMstoo tight also presents as degenerate routing — seeLookupRouteHighTimeoutfirst to disambiguate.
# 1. Port-forward the public + controller-facing HTTP listeners.
# :8080 carries /metrics, /healthz, /readyz. :8081 carries the
# controller-only /snapshot + /policy + /probe endpoints (bearer-auth gated).
kubectl -n inference-cache-system port-forward svc/inference-cache-server 8080:8080 &
kubectl -n inference-cache-system port-forward svc/inference-cache-server 8081:8081 &
# 2. Confirm the reason_code distribution per model.
curl -s localhost:8080/metrics | grep 'inferencecache_lookup_route_calls_total'
# 3. Spot-check what the gateway sends. The fastest way is gRPC client
# debug logging in the gateway; failing that, take a tcpdump on the
# server pod and decode a few LookupRoute frames.
# 4. Confirm the index has entries for the model and tenant.
# The snapshot endpoint is gated by a SA bearer with the
# `inferencecache.io/controller` audience (see internal/server/auth/audience.go).
# From a controller pod:
# TOKEN=$(cat /var/run/secrets/inferencecache.io/controller-token/token)
# Or generate a one-off via `kubectl create token` against the
# controller ServiceAccount with `--audience=inferencecache.io/controller`.
curl -s localhost:8081/snapshot -H "Authorization: Bearer $TOKEN" | jq '.tenants[]'Triage queries:
# Per-model NO_HINT ratio over the last hour
sum by (namespace, model) (rate(inferencecache_lookup_route_calls_total{reason_code="NO_HINT"}[1h]))
/
sum by (namespace, model) (rate(inferencecache_lookup_route_calls_total[1h]))
# Distribution across reason codes per model
sum by (namespace, model, reason_code) (rate(inferencecache_lookup_route_calls_total[1h]))
- Severity:
warning - For: 5 minutes
- Source metric:
inferencecache_lookup_route_calls_total{model, reason_code="TIMEOUT"}
For at least one model, more than 5% of LookupRoute calls hit the
lookup-timeout budget over the past 10 minutes. The fail-open path
returned an empty hint; the gateway routed those requests by its own
default policy. Every TIMEOUT is a missed cache-hit opportunity.
- Server overload: the lookup ranking is in-memory and should be
sub-millisecond. A surprising p95/p99 tail usually means the working
set has grown past what the index's global
MaxEntriescap was sized for. Checkinferencecache_lookup_route_latency_seconds. CachePolicy.spec.lookupTimeoutMstoo tight: a 5 ms timeout on a gateway-side 50 ms deadline is asymmetric. Either raise the timeout or reduce index pressure.- gRPC backpressure at the server: too many concurrent streaming
subscribers, file-descriptor pressure, or a slow connection holding
the ranker mutex. Inspect
go_goroutinesfor the server pod.
# 1. Confirm the per-model TIMEOUT ratio.
kubectl -n inference-cache-system port-forward svc/inference-cache-server 8080:8080 &
curl -s localhost:8080/metrics | grep 'inferencecache_lookup_route_calls_total'
# 2. Inspect the lookup latency tail.
curl -s localhost:8080/metrics | grep 'inferencecache_lookup_route_latency_seconds'
# 3. Inspect server resource consumption.
kubectl top pod -n inference-cache-system -l app.kubernetes.io/name=inference-cache,app.kubernetes.io/component=serverTriage queries:
# Lookup-latency p99 per model
histogram_quantile(0.99,
sum by (namespace, model, le) (rate(inferencecache_lookup_route_latency_seconds_bucket[5m]))
)
# Server pod CPU + memory pressure
sum by (namespace, pod) (rate(process_cpu_seconds_total[1m]))
sum by (namespace, pod) (process_resident_memory_bytes)
- Severity:
info - For: 10 minutes
- Source metric:
inferencecache_index_evictions_total{algorithm, reason="cap"}
The cache index is evicting more than 10 entries per second under the
reason="cap" policy. That means the working set has outgrown the
index's global MaxEntries cap — the cluster-wide upper bound on
total (replica × prefix) entries the server's in-memory index will hold
(default 1_000_000, configured via the WithMaxEntries option on the
server's index constructor; not currently exposed as a CRD or CLI flag).
Recent prefix entries are being dropped on top of recording new ones —
the cache is doing useful work but is under sustained capacity pressure.
This is informational, not an outage signal. It is a tuning lever:
- Raise the global
MaxEntriescap to fit the observed working set. Today this requires a code-side server-binary build change; expose it as a CLI flag if you need to retune in place. - Accept the reduced hit rate at the current cap.
- Shorten the index TTL (server's
WithTTLoption, default 30m) so old prefixes age out before the cap kicks in. - Tighten per-tenant budgets via
CacheTenant.spec.quota.maxIndexEntriesso a runaway tenant cannot starve the global cap. Theinferencecache_tenant_evictions_total{tenant_id}counter attributes pressure per tenant.
The 10/sec threshold is conservative for steady-state operation; tune
it locally to your install's expected baseline by editing the alert's
expr.
reason="ttl" evictions are excluded — those are healthy lifecycle.
Triage queries:
# Cap vs. TTL eviction rate
sum by (namespace, algorithm, reason) (rate(inferencecache_index_evictions_total[10m]))
# Current index population, per (namespace, model). Sum across models in a
# namespace gives the total against the cap.
sum by (namespace, model) (inferencecache_index_entries)
sum by (namespace) (inferencecache_index_entries)
# Per-tenant eviction pressure (CacheTenant quota — distinct from the
# global cap above)
sum by (namespace, tenant_id) (rate(inferencecache_tenant_evictions_total[10m]))
- Severity:
critical - For: 5 minutes
- Source metric:
inferencecache_backend_probe_result_total{backend, stage, result}— emitted by the controller binary, not the server. Requires the controller-sidePodMonitorshipped in this same observability overlay; without it the alert loads but never has a series to evaluate.
The CacheBackend controller drives a synthetic round-trip against each
managed backend on a 30-second cadence — an ingest → routing → t2
self-test — and records the per-stage outcome (result="ok", "failed",
"skipped") in this counter. A sustained result="failed" rate means
the cache-plane internal pipeline is broken in a way the basic
Service-endpoint probe and Ready gate cannot catch:
stage label |
What failed means |
|---|---|
ingest |
The probe wrote a synthetic prefix entry through the server's in-process index.Ingest path and the entry did not land. This pins the index ingest path; it does NOT exercise the gRPC ReportCacheState handler nor the kvevent-subscriber sidecar (subscriber wire bugs are invisible to Stage A by design — see the design doc and internal/server/probe.go lead-in). A failure here means the index itself is dropping writes — a regression in internal/index keying, scheme handling, or eviction. |
routing |
The probe wrote the entry, the index recorded it, but LookupRoute returned NO_HINT for the probe's hash. Likely an index-key-scheme mismatch (the probe's hashScheme is derived from spec.runtime; an empty scheme fails open and produces NO_HINT on lookup) or a lookup-filter regression in internal/server. |
t2 |
(When a T2Prober is wired into the server.) The tier-2 put/get cycle against the configured external backend (LMCache today) failed. No T2Prober is wired in this revision, so this stage reports skipped on every install — an alert here only fires once a follow-up registers a real T2Prober. |
The alert uses increase(...{result="failed"}[5m]) >= 2 for: 5m — a
single transient flake (one failed probe that recovers on the next 30s
tick) does not page. The alert requires at least two failed
increments within a 5-minute window, sustained for another 5 minutes
before firing. This is calibrated to the controller's 30s probe cadence:
~10 probes per 5m window, so the >= 2 threshold is a real signal
(≥20% failure rate), not a baseline.
A 200 response whose body has empty or unrecognized stage values is also
recorded as result="failed" (the alerting contract MUST match what
ProbeResult.AllPassed considers non-passing) — a malformed {} body
or a future stage outcome the controller doesn't yet recognize will
page just like a real per-stage failure rather than silently coercing
to skipped. The raw wire string is preserved in the
FunctionalProbeOK condition message for diagnosis.
By stage label:
ingest— the in-process index ingest path is dropping writes. Check the server'sinferencecache_index_entriesgauge to see if the index is accumulating entries at all; check server logs forinternal/indexerrors; verifyinferencecache_server_up == 1. (A subscriber → server wire bug is not what causes this stage to fail — subscriber bugs show up as a missing-state pattern on real workload, not on this probe. If you suspect the subscriber, scope your investigation to thekvevent-subscriberpod logs + gRPC:9090reachability instead.)routing— the index recorded the probe entry but lookup can't find it. The probe'shashSchemeis derived from the backend'sspec.runtime; verify it is not being silently dropped on ingest (an empty scheme fails open and producesNO_HINTon lookup). Check the server-side lookup-filter logs forreason_code=NO_HINTon calls that should match.t2— not applicable today; noT2Proberis wired into the server. If you're seeingt2=failedon a live install, a follow-up has registered a realT2Proberand its connection to the configured LMCache server (or its tracking ofexternal_prefix_cache_*engine metrics) is broken.
-
Identify which backend(s) and which stage are failing:
sum by (backend, stage) ( increase(inferencecache_backend_probe_result_total{result="failed"}[5m]) ) -
Compare to the success rate for the same backend — if
okis non-zero, the probe is at least running, so the issue is stage-specific, not "controller can't reach server at all":sum by (backend, stage, result) ( increase(inferencecache_backend_probe_result_total[5m]) ) -
Inspect the
CacheBackend.status.conditions[?type=="FunctionalProbeOK"]on the affected backend — thereasonfield (ProbeIngestFailed/ProbeRoutingFailed/ProbeT2Failed) and themessagepayload mirror what the probe handler reported. The condition is the operator-visible signal; the metric is the alerting signal. -
If the controller is also reporting
Ready=Falseon the backend with the same reason, the gate is doing its job — the backend'sReady=Trueposture has been downgraded for as long as the probe keeps failing. Routing-aware clients see a degraded backend; the alert is the operator's signal to investigate. -
To temporarily suppress the alert during a known-bad investigation without modifying the rule, annotate the affected backend(s) with
inferencecache.io/skip-functional-probe: "true"— the controller short-circuits before calling/probe(reasonProbeBypassedon the condition), so no new per-stage metric increments fire while bypassed (neitherfailednorskipped). The existingresult="failed"increments age out of the 5-minuteincrease()window naturally, and the alert clears once the window drains. The bypass writesFunctionalProbeOK=True/ProbeBypassed, so Ready is no longer downgraded either — operators should treat the bypassed state as an explicit "I am ignoring the gate," not as "the backend is healthy." Remove the annotation when you're done; a bypassed backend with a real regression still ships broken cache state.
Triage queries:
# Per-backend, per-stage failure rate over the alert window
sum by (backend, stage) (
increase(inferencecache_backend_probe_result_total{result="failed"}[5m])
)
# Same backend, all results — sanity-check that the probe is firing at all
sum by (backend, stage, result) (
increase(inferencecache_backend_probe_result_total[5m])
)
Two more alerts are scoped to ship as part of the same observability
bundle, but they depend on metrics not yet exposed on /metrics. The
placeholder rules sit in alerting-rules.yaml
and the PrometheusRule CR
as comments; uncomment them in the same change that ships the corresponding
metric.
| Alert | Blocked on | What it would catch |
|---|---|---|
VersionSkewDetected |
inferencecache_backend_version_skew gauge — exposed by a follow-up that detects engine-vs-cache-server version skew |
The LMCacheT2NoHits failure class, but BEFORE it manifests as zero hits — caught proactively by the operator detecting the skew at admit time. |
KvEventsStaleness |
inferencecache_replica_last_event_at gauge — exposed by the Ready-on-first-event follow-up |
A replica that was emitting KV events stopped (engine crash, OOMKill, NetworkPolicy regression, subscriber dead). Distinct from IndexEmpty (replica never published) — this catches post-warmup silence. |
The five Stage 1 alerts are not independent — they map onto a small set of recurring failure modes:
| Failure mode | Alerts that fire | Where to look first |
|---|---|---|
| Subscriber sidecar not injected | IndexEmpty (critical) |
controller flags + webhook config |
| Engine prefix-cache off | IndexEmpty (critical) |
engine flags --enable-prefix-caching + --kv-events-config |
| Offload tier version skew | LMCacheT2NoHits (warning) + maybe LookupRouteDegenerate if T2 is the only cache path |
offload client/server image tags |
Tenant or hash_scheme mismatch |
LookupRouteDegenerate (warning) alongside inferencecache_index_entries > 0 |
gateway-side request shape |
| Server overload | LookupRouteHighTimeout (warning) + maybe LookupRouteDegenerate |
inferencecache_lookup_route_latency_seconds p99, process_resident_memory_bytes, server's global MaxEntries |
| Working set outgrew config | IndexEvictionsSpike (info) |
server's global MaxEntries vs. observed working-set size; CacheTenant.spec.quota.maxIndexEntries per tenant |
If two alerts fire together, work the more-severe one first; the lower one usually clears once the root cause does.
- Prometheus metrics inventory — the
inferencecache_*surface (what THIS operator emits). The bundle also reads vLLM-emittedvllm:external_prefix_cache_*for theLMCacheT2NoHitsalert; those metrics are documented atdocs.vllm.ai/.../usage/metrics/. - Reason-code vocabulary — meaning of
each
reason_codelabel oninferencecache_lookup_route_calls_total - Operator install — where the alert bundle is surfaced in the install README