Observability Runbook¶
Day-2 operator reference for the observability stack: Prometheus +
Alertmanager (kube-prometheus-stack), Grafana, Loki, Tempo, Vector,
the OpenTelemetry Collector, and kube-state-metrics. Covers scrape
authoring, alert routing/silencing, log and trace lookup, and the
common failure shapes for each. Manifests live under
kubernetes/apps/observability/.
For general pod/Flux/Ceph/Longhorn triage see debugging.md.
For the MCP fleet's own metrics see mcp_observability.md.
For named recurring failure modes with a documented root cause see
failure_mode_runbooks.md.
Stack map¶
| Component | Role | Manifests |
|---|---|---|
kube-prometheus-stack |
Prometheus, Alertmanager, the Prometheus Operator CRDs | kubernetes/apps/observability/kube-prometheus-stack/ |
| Grafana | Dashboards, datasource-backed queries | kubernetes/apps/observability/grafana/ |
| Loki | Log storage (Monolithic mode, Garage S3 backend) | kubernetes/apps/observability/loki/ |
| Tempo | Trace storage (single-binary, ceph-block local backend) |
kubernetes/apps/observability/tempo/ |
| Vector (agent + aggregator) | Log shipping: node → aggregator → Loki | kubernetes/apps/observability/vector/ |
| OpenTelemetry Collector | OTLP trace ingestion → Tempo | kubernetes/apps/observability/opentelemetry-collector/ |
kube-state-metrics |
Cluster object-state metrics (pods, deployments, PVCs, …) | kubernetes/apps/observability/kube-state-metrics/ |
silence-operator |
GitOps-managed Alertmanager silences | kubernetes/apps/observability/silence-operator/ |
blackbox-exporter |
ICMP / TCP / HTTP / custom-module probes | kubernetes/apps/observability/exporters/blackbox-exporter/ |
Retention and durability, for reference when triaging "why is this data gone":
| Store | Retention | Backend |
|---|---|---|
| Prometheus | 14d or 100GB, whichever hits first | local ceph-block PVC |
| Loki | 30d (retention_period: 30d) |
Garage S3 (loki-chunks-v1 bucket) |
| Tempo | 14d (336h) |
local ceph-block PVC |
Prometheus retention shrinking or Loki retention shrinking are both propose-only changes per the observability prime directive — they remove the historical baseline an in-flight investigation needs. Don't drop either without a documented reason and a rollback path.
Scrape config authoring¶
Four CRDs cover scrape target discovery; pick based on what's being scraped, not habit:
| CRD | Use for |
|---|---|
ServiceMonitor |
A Service backed by pods exposing /metrics |
PodMonitor |
Pods with no fronting Service (or you need pod-level labels) |
Probe |
Blackbox-style checks — ICMP reachability, HTTP 2xx, TCP connect, or a custom blackbox module hitting an app endpoint |
ScrapeConfig |
Static or file-based targets outside the pod/service model (e.g. bare-metal exporters) |
The HelmRelease sets podMonitorSelectorNilUsesHelmValues: false,
probeSelectorNilUsesHelmValues: false, scrapeConfigSelectorNilUsesHelmValues: false,
and serviceMonitorSelectorNilUsesHelmValues: false — Prometheus picks
up every CR of these kinds cluster-wide, no label-matching dance
required. A new CR anywhere in the tree is scraped automatically once
Flux reconciles it; you don't need to touch the kube-prometheus-stack
HelmRelease itself to add a target.
ServiceMonitor example¶
---
# yaml-language-server: $schema=https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/monitoring.coreos.com/servicemonitor_v1.json
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: <app>
labels:
app.kubernetes.io/name: <app>
spec:
selector:
matchLabels:
app.kubernetes.io/name: <app>
endpoints:
- port: http
path: /metrics
interval: 30s
scrapeTimeout: 10s
selector matches the Service's labels, not the pod's — a
ServiceMonitor with a selector that matches nothing produces zero
targets with no error anywhere. That's the single most common cause of
"I added a ServiceMonitor and Grafana still shows no data."
Probe example (blackbox)¶
---
# yaml-language-server: $schema=https://k8s-schemas.home-operations.com/monitoring.coreos.com/probe_v1.json
apiVersion: monitoring.coreos.com/v1
kind: Probe
metadata:
name: <target-group>
spec:
interval: 2m
module: icmp # or http_2xx, tcp_connect, or a custom module (see blackbox-exporter helmrelease.yaml)
prober:
url: blackbox-exporter.observability.svc.cluster.local:9115
targets:
staticConfig:
static:
- <hostname-or-url>
Pair interval with any downstream for: clause deliberately — a
15m-for: alert gains nothing from a 30s probe interval, and a too-slow
interval delays detection past what the alert's for: assumes. See
kubernetes/apps/observability/exporters/blackbox-exporter/app/probes.yaml
for the live pairing conventions (interval vs scrapeTimeout vs the
alert's for:), including the "devices" / "devices-sleepy" split for
targets with legitimate radio/power-save downtime.
ScrapeConfig example (static, non-k8s target)¶
---
# yaml-language-server: $schema=https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/monitoring.coreos.com/scrapeconfig_v1alpha1.json
apiVersion: monitoring.coreos.com/v1alpha1
kind: ScrapeConfig
metadata:
name: <target>
spec:
staticConfigs:
- targets: ["<host>:<port>"]
labels:
job: <target>
metricsPath: /metrics
See kubernetes/apps/observability/kube-prometheus-stack/addons/scrapeconfigs/
for live examples (bare-metal node-exporter on hosts outside the
kubelet's reach, an SMTP relay, Home Assistant).
Why isn't my target being scraped?¶
# List every discovered target and its health
kubectl -n observability port-forward svc/kube-prometheus-stack-prometheus 9090:9090
# then browse http://localhost:9090/targets, or:
prom_execute_query 'up{job="<job>"}'
Triage order:
- Does the ServiceMonitor/PodMonitor selector actually match the Service/pod labels?
kubectl -n <ns> get servicemonitor <name> -o jsonpath='{.spec.selector.matchLabels}'
kubectl -n <ns> get svc <svc> --show-labels
A mismatch here means the target never appears in Prometheus at
all — not "down," just absent. prom_execute_query 'up{job=~".*<app>.*"}'
returning nothing (not 0, nothing) is the signature.
-
Is the CR's namespace covered by
namespaceSelector? Most ServiceMonitors in this repo omitnamespaceSelector(defaults to same-namespace), which is correct for co-located metrics. -
Does the target respond on the declared port/path?
If this 404s or connects but returns HTML, the path in the
ServiceMonitor is wrong or the app doesn't expose metrics on that
port.
-
CNP egress from Prometheus, or ingress on the target? Prometheus scrapes cross a
CiliumNetworkPolicyboundary for every namespace it reaches into. If the target namespace has a default-deny ingress policy, it needs an explicit allow for the Prometheus pod — seekubernetes/apps/observability/kube-prometheus-stack/app/cnp-allow.yamlfor the egress side and any target app's owncnp-allow.yamlfor the matching ingress. A missing ingress-side allow is a silent drop —up == 0, not a Cilium-visible policy-deny in the target's own metrics (the packet never reaches the app). -
up{job="..."} == 0with a real connection failure (target discovered, scrape fails). Check the target pod isRunning+Readyand its own logs for a crash or panic under load.
Alert routing¶
Routing is entirely by alertname, not by severity — see
kubernetes/apps/observability/kube-prometheus-stack/app/alertmanagerconfig.yaml.
Two alertnames route to a null receiver (Watchdog, InfoInhibitor,
plus KubePodPendingBrief — deliberately Grafana-only, see the comment
in that file for why an inhibitRule can't do this instead); every
other alert — critical, warning, or info — falls through to the single
pushover receiver and notifies. There is no severity-based
suppression today: if you want an alert to page, no label change is
needed; if you want one silenced, it needs an explicit null-receiver
route by alertname.
The one severity-aware behavior is an inhibitRule: a firing
critical suppresses a warning sharing the same alertname +
namespace. This is the flood/mute tradeoff already made in this
config — it exists to stop a critical+warning pair on the same
condition from double-paging, not to hide unrelated warnings.
# Current Alertmanager routing state
kubectl -n observability port-forward svc/kube-prometheus-stack-alertmanager 9093:9093
# then browse http://localhost:9093, or query via Prometheus:
prom_execute_query 'ALERTS{alertstate="firing"}'
# Which receiver would a given alert route to (dry-run, no state change)
# — use the Alertmanager UI's routing tree view, not a live send.
Adding a new alerting rule¶
Minimum shape for a new alert:
---
# yaml-language-server: $schema=https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/monitoring.coreos.com/prometheusrule_v1.json
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: <app>-rules
spec:
groups:
- name: <app>.rules
rules:
- alert: <AppSomethingWrong>
expr: <promql> > <threshold>
for: 5m
labels:
severity: warning # or critical
annotations:
summary: <one-line, includes {{ $labels.* }} for context>
description: <what it means, what to check first>
Rules for the for: clause:
- Never ship
for: 0m(or omitfor:) on a metric that can transient — pod restart, brief network blip, a scrape miss. That's the textbook flap generator: it fires and clears within the scrape interval and trains the recipient to swipe the Pushover notification away without reading it. - 5m is the floor for anything keyed on pod/target state
(
up == 0,kube_pod_status_phase). This repo's own rules land between 2m (things that resolve fast on their own, kept aswarning) and 30m (steal, CPU load — things that need to be sustained to matter). Seekubernetes/apps/observability/kube-prometheus-stack/addons/alerts/node-exporter.yamlfor the reference set. - Target-down alerts use
absent(up{job=~"..."} == 1)orup{job="..."} == 0,for: 5m–15mdepending on how tolerant the target is of restarts. Seekubernetes/apps/observability/exporters/omada-exporter/app/prometheusrule.yamlandkubernetes/apps/observability/exporters/snmp-exporter/app/apc-ups/prometheusrule.yamlfor two live examples, including the comment threads on why a target-specific rule was added on top of the generic upstreamTargetDownalert (faster, named detection vs. a 22-hour-unnoticed generic page). - Route new critical alerts to
pushoverby doing nothing — that's the default receiver. Only touchalertmanagerconfig.yamlif you need to suppress an alertname, and that's a propose-only change (see below).
Silences vs. suppression routes — pick the narrower tool¶
Two ways to make an alert stop paging, with very different blast radius:
| Need | Tool | Scope | Reversal |
|---|---|---|---|
| "This alert is expected noise for a specific target, permanently" (e.g. one known-bursty NVMe device, one specific OSD) | A Silence CR via silence-operator |
One alertname + specific label matchers | Delete the Silence CR |
| "I'm doing maintenance for the next N hours" | A Silence CR with a defined end, OR (if the operator isn't present) amtool silence add directly against Alertmanager |
Time-bounded, scoped to the maintenance | Silence expires on its own |
| "This whole class of alert should never page" | Route change in alertmanagerconfig.yaml |
Every instance of that alertname, forever, cluster-wide | Requires restoring the AlertmanagerConfig — propose-only |
silence-operator reconciles Silence CRs (observability.giantswarm.io/v1alpha2)
into Alertmanager every hour (interval: 1h on its HelmRelease) — a
CR you commit doesn't take effect instantly; force it if you need it
live now:
Example — narrow, permanent silence for one specific noisy label
combination (see kubernetes/apps/observability/silence-operator/silences/ceph.yaml
for the live version):
---
# yaml-language-server: $schema=https://k8s-schemas.home-operations.com/observability.giantswarm.io/silence_v1alpha2.json
apiVersion: observability.giantswarm.io/v1alpha2
kind: Silence
metadata:
name: <descriptive-name>
spec:
matchers:
- name: alertname
value: <AlertName>
- name: <label>
value: "<specific-value>"
A Silence CR with no time bound is a standing exception, not a
maintenance window. The silence-operator silences in this repo
(kubernetes/apps/observability/silence-operator/silences/) are all
of the first kind above — permanent, narrow, one label-set — not
maintenance windows. For an actual maintenance window, set a real
start/end and remove the CR (or let it lapse) when the window closes;
don't leave a "while we figure it out" silence in the tree with no
expiry — that's exactly the kind of change this repo's observability
persona treats as propose-only if it's broader or longer than the
actual work.
"Is this alert flapping, or did I bury a real one?"¶
Symptoms and how to tell them apart:
- Flapping: the same alertname fires and resolves repeatedly within a short window, each cycle shorter than a human reasonably reacts to. Confirm with:
A high change-count with a short mean time-firing is flap. The fix is
almost always a longer for: clause or a threshold that better
separates signal from noise — not suppression. Suppressing a flapping
alert without fixing the underlying for:/threshold just delays the
same problem to the next person who forgets it's silenced.
- Buried: an alert is not flapping — it fired once, stayed firing,
and nobody saw it, because a null route, an overly broad
inhibitRule, or a stale silence swallowed it. Confirm with:
cross-referenced against what actually reached Pushover. If
ALERTS shows firing but Pushover has nothing, walk the routing tree
(alertmanagerconfig.yaml routes, then any active Silence CRs) —
don't assume it's a delivery failure until routing is ruled out.
kubectl get silences.observability.giantswarm.io -A
# or against Alertmanager directly:
kubectl -n observability port-forward svc/kube-prometheus-stack-alertmanager 9093:9093
# browse http://localhost:9093/#/silences
The prime directive for this stack cuts one way on this dichotomy:
never trade a buried-alert risk for a flap-reduction win without
naming the tradeoff first. A for: clause bump that's too aggressive
(say, 30m on something that matters within 5m) trades flap-reduction
for muting a real incident — check what the alert is actually
protecting against before lengthening its for:.
Loki (logs)¶
Log path: node-level vector agents (DaemonSet) tail container logs
→ ship to the vector aggregator (vector-aggregator-app Service,
:6000) → aggregator writes to Loki over the loki HTTP sink
(:3100). See kubernetes/apps/observability/vector/agent/resources/vector.yaml
and .../vector/aggregator/resources/vector.yaml for the full
source/transform/sink pipeline, including the one active
transform (a filter that drops a known-benign readiness-probe log line
at the aggregator rather than weakening the probe itself — a good
template for handling other single-line log noise).
Loki indexed labels (from the aggregator's loki_kubernetes sink):
app, namespace, node. Everything else lives in the log line body
— query it with LogQL's line filters or | json / | logfmt parsing,
not as a label (Loki's cardinality model punishes high-cardinality
labels hard).
# Recent logs for an app, tailed
{app="<app>"} |= ""
# Errors only, last hour
{namespace="<ns>", app="<app>"} |~ "(?i)error|panic|fatal"
# Rate of log lines per app, for spotting a noisy or silent app
sum by (app) (rate({namespace="<ns>"}[5m]))
Query via the Grafana Explore view (Loki datasource) or logcli
against http://loki.observability.svc.cluster.local:3100 from inside
the cluster.
Loki ingestion stalled¶
kubectl -n observability get pods -l app.kubernetes.io/name=loki
kubectl -n observability logs -l app.kubernetes.io/name=loki --tail=200
Loki ships its own alerting rules (kubernetes/apps/observability/loki/app/prometheus-rule.yaml):
LokiRequestErrors (>10% 5xx over 5m, for: 15m), LokiRequestPanics
(any panic, no for: — a panic is never transient-noise), and
LokiRequestLatency (p99 >1s, for: 15m, excludes tail routes). If
none of those are firing but Grafana shows no log data:
- Check the aggregator is actually receiving from agents — the agent
sink is a
vector-native TCP sink tovector-aggregator-app:6000, not HTTP; aCiliumNetworkPolicygap here fails silently the same way a Prometheus scrape gap does. - Check
configmap.reloader.stakater.com/reloadannotations reloaded after any bucket-credential rotation — Loki'spodAnnotationsdepend on theloki-chunks-bucket-v1ConfigMap/Secret hash to trigger a restart on credential change. - Confirm the Garage bucket is reachable — Loki's storage backend is S3 (Garage), not local disk; a Garage outage stalls both ingestion and any query touching non-cached chunks. See the storage operator's Garage runbook coverage for that side.
Tempo (traces)¶
Single-binary Tempo, local ceph-block storage (deliberately not
Garage — avoids Garage's own traces depending on Garage, a circular
observability dependency). Two ingestion paths, both OTLP:
- Apps → OpenTelemetry Collector (
otlp/tempoexporter,tempo.observability.svc.cluster.local:4317) — the standard path, getsk8s_attributesenrichment (namespace, pod name, etc.) via the collector'skubernetesAttributespreset. - Apps → Tempo directly (
tempoService:4317/:4318) — a skip-collector shortcut for early debugging of a single OTel-instrumented app; loses the k8s-attribute enrichment.
# Collector health (traces-only pipeline: otlp -> memory_limiter -> k8s_attributes -> batch -> otlp/tempo)
kubectl -n observability logs -l app.kubernetes.io/name=opentelemetry-collector --tail=100
# Tempo health
kubectl -n observability get pods -l app.kubernetes.io/name=tempo
kubectl -n observability logs -l app.kubernetes.io/name=tempo --tail=200
Query traces via Grafana Explore (Tempo datasource) — search by
k8s.namespace.name / k8s.pod.name attribute, or by trace ID if
propagated from a log line. metricsGenerator is off (no
trace-derived Prometheus metrics yet) — if a dashboard needs
span-derived RED metrics, that's a deliberate follow-up, not something
already available.
Trace ingestion stalled¶
- Confirm the app is actually emitting OTLP — most silent-trace issues
are the app never calling
otlp/tempo(wrong endpoint, missing SDK config) rather than a broken pipeline. - Check the collector's
memory_limiterisn't dropping —check_interval: 1s,limit_percentage: 75,spike_limit_percentage: 25against 256Mi request / 1Gi limit; amemorylimiterprocessorrefusal shows up in collector logs asdata refused due to high memory usage. - Confirm the CNP allows the app's egress to the collector's OTLP
ports (
4317gRPC /4318HTTP) — same silent-drop failure mode as Prometheus scrapes and Loki shipping.
kube-state-metrics¶
Feeds kube_pod_status_phase, kube_pod_container_status_*,
kube_persistentvolumeclaim_*, and similar object-state metrics that
back most of the custom PrometheusRules in this repo (pod-pending,
PV/PVC, OOMKilled — see
kubernetes/apps/observability/kube-prometheus-stack/app/prometheusrule-pod-pending.yaml
and sibling prometheusrule-*.yaml files). If a rule referencing
kube_* metrics goes permanently inactive (not firing, not resolving
— just gone), check kube-state-metrics is Running+Ready before
suspecting the rule itself:
kubectl -n observability get pods -l app.kubernetes.io/name=kube-state-metrics
prom_execute_query 'up{job=~".*kube-state-metrics.*"}'
Common failure triage — quick index¶
| Symptom | First check |
|---|---|
| Target not scraped | ServiceMonitor/PodMonitor selector vs. Service/pod labels — see "Why isn't my target being scraped?" above |
| No data in a Grafana panel, target IS scraped | Datasource UID matches the panel's datasource field; check the panel's PromQL/LogQL directly in Explore before suspecting the panel |
| Alert not firing when it should | for: clause elapsed? Check ALERTS_FOR_STATE; then check the expr actually matches current label values (prom_execute_query the raw expr) |
| Alert firing too much (flapping) | See "Is this alert flapping, or did I bury a real one?" above |
| A real alert seems to have been missed | Check ALERTS{alertstate="firing"} against Pushover history, then walk the routing tree + active Silence CRs — don't assume delivery failure |
| No logs for an app in Loki | Agent → aggregator → Loki pipeline, in that order (see Loki section) |
| No traces for an app in Tempo | App emitting OTLP at all? Then collector memory_limiter, then CNP egress (see Tempo section) |
| Grafana dashboard renders blank/broken after a ConfigMap edit | Confirm the dashboard JSON is valid and the grafana_folder annotation matches an existing provider path — a downloaded dashboard with no matching dashboardProviders entry crashes the init container, not just the panel |
What this is NOT¶
- Not a substitute for
debugging.md's general pod/Flux/Ceph/Longhorn/etcd triage commands — this page only covers the observability stack itself. - Not a substitute for
mcp_observability.md, which owns the MCP fleet's own per-tool metrics and alerts. - Not a design doc — see
.agents/instructions/schema.correction.mdfor the full CRD-to-schema mapping used across this repo, and the observability persona's decision framework (flood vs. mute, routing correctness, successor/predecessor, maintenance vs. permanent silence) for how alerting changes should be evaluated before they're made.