AI EngineeringZero to ProductionHome·About·What’s new·Contact
Kubernetes Orchestration · Part 8

Observability & SLOs

You cannot operate what you cannot see. This lesson turns a cluster from a black box into an instrumented system: the four signals (metrics, logs, traces, events), Prometheus for metrics + PromQL, Grafana for dashboards, Loki for logs, OpenTelemetry for distributed traces, and kube-state-metrics for object state. Then the part that matters to your users: defining SLIs/SLOs, spending an error budget, and wiring Alertmanager so you page on symptoms, not noise.

⏱️ ~110 min📈 Observability🎯 Advanced→Production
🌱 Honesty up front: this needs a cluster + moving partsEvery kubectl/Helm/PromQL/YAML sample here needs a cluster (a local minikube/kind for the Prometheus stack, or EKS) — terminal output is illustrative. The Prometheus Operator, kube-prometheus-stack, Loki, and OpenTelemetry Collector move fast: verify CRD names, field paths and chart values against their current docs. The SLO math (error budget, burn rate) is real arithmetic and is runnable offline in plain Python — that part does not need a cluster.

Learning objectives

  • Name the four signals (metrics, logs, traces, events) and what each answers.
  • Deploy a Prometheus + Grafana stack (conceptually) and scrape a service with a ServiceMonitor.
  • Write PromQL for a rate, an error ratio, and a p95 latency from a histogram.
  • Explain kube-state-metrics vs node/cAdvisor metrics — object state vs resource usage.
  • Aggregate logs with Loki and correlate a trace across services with OpenTelemetry.
  • Define an SLI/SLO, compute an error budget and burn rate, and alert on symptoms via Alertmanager.

1 · The four signals — what each one answers

Picture the 3 a.m. page. Your phone buzzes: "checkout is slow." You SSH into a box and… now what? If all you have is a login shell and a hunch, you're a detective who arrived after the crime with no camera footage, no witnesses, and no fingerprints. You'll spend an hour guessing. The engineers who fix it in five minutes aren't smarter — they instrumented the system beforehand so the evidence was already being recorded. That is the entire promise of observability: turn a black box you poke at into a system that tells you what it's doing.

Here's the mental model that makes the rest of this lesson click. Think of your service as a patient in a hospital. Metrics are the vital-signs monitor — heart rate, blood pressure, updated every few seconds, cheap to keep running, and the thing that sets off the alarm. Logs are the detailed chart notes: "14:02 — patient reported chest pain" — a rich, per-event record you read after the alarm to learn exactly what happened. Traces are the surgeon following the problem through the body, timing each step to find where the delay actually is. And events are the hospital's own admin log — "moved to room 4, scheduled for surgery" — Kubernetes narrating what it decided to do (schedule, evict, OOM-kill). Four instruments, four questions, one patient.

The common beginner mistake is to reach for the wrong instrument. People try to build a dashboard out of logs (expensive and slow — like taking someone's pulse by reading their diary), or they try to debug a single failing request from metrics alone (impossible — a heart-rate average can't tell you what one patient said). Metrics answer "is something wrong, and roughly how bad?" — they're aggregated, so they can't point at the one request that broke. Logs and traces answer "what and where exactly?" for a specific request, but they're too voluminous and costly to alarm on continuously. The skill isn't collecting more of everything; it's knowing which signal answers which question, and — the part most setups miss — wiring them so you can jump between them. A metric spikes → you pivot to the trace for a slow request → the trace hands you a shared ID → you land on the exact log lines. That hand-off is what separates a pile of dashboards from real observability.

Observability is not one thing. Four kinds of telemetry answer four different questions, and a mature setup collects all four and correlates them. Metrics tell you that something is wrong; traces and logs tell you where and why.

Metrics is it healthy? Logs what happened? Traces where's the time? Events what did k8s do?
🗺️ How to read this diagram

Read the four boxes left-to-right, following the blue arrows — each box is one telemetry signal and the small text under its name is the question it answers.

  • The first box, Metrics, answers "is it healthy?" — cheap aggregated numbers; the next box, Logs (green), answers "what happened?" for a specific request.
  • The blue arrows chain the four boxes in order — Metrics → Logs → Traces → Events — the sequence you pivot through when debugging: notice a problem, then get more specific about what and where.
  • The last two boxes are Traces ("where's the time?", purple) and Events ("what did k8s do?", amber) — traces locate the slow span across services, events say what Kubernetes itself did (schedule, evict, OOM).

In short: Four signals, four questions — Metrics/Logs/Traces/Events each answer a different piece, and a mature setup collects and correlates all four.

SignalAnswersCardinality / costTool here
MetricsIs it healthy? How much? How fast?Cheap, aggregated, low-cardinalityPrometheus
LogsWhat exactly happened on this request?Expensive, high-volume textLoki
TracesWhere did the latency go across services?Sampled spans, medium costOpenTelemetry
EventsWhat did Kubernetes itself do (schedule, evict, OOM)?Low volume, short TTLkubectl events / kube-state-metrics
Metrics for alerting, traces/logs for debuggingA common trap is to alert on logs (expensive, noisy) or to try to debug from metrics alone (no per-request detail). The healthy split: alert on metrics (cheap, low-cardinality symptoms), then drill into traces and logs for the specific failing request. High-cardinality labels (user IDs, request IDs) belong in logs/traces, never in Prometheus metric labels — they explode series count and can OOM Prometheus.

2 · Prometheus: scrape, store, query

Most monitoring tools you've met push their data somewhere — your app calls a "send this metric" API and hopes it lands. Prometheus flips that around, and the flip is the single most important thing to understand about it. Instead of every app shouting its numbers into a central collector, Prometheus walks up to each app and reads the numbers off it, on its own schedule. Imagine a meter reader who strolls down the street once a minute and jots down the number showing on each house's electricity meter. The houses don't phone anyone; they just keep a running total on a dial, and the reader records it. That is pull-based scraping, and it's why Prometheus scales calmly: the central server controls the pace, a dead app simply shows up as "target down" instead of silently stopping its pushes, and there's no queue to overflow when things get busy.

The "dial" each app exposes is a plain HTTP endpoint at /metrics that prints current values as text — you can literally curl it and read it. The counterpart to grasp: because Prometheus only records a snapshot each scrape, most of the numbers you expose are counters that only ever go up (total requests since start, total errors since start). A raw counter is nearly useless on its own — "4,912,338 requests total" tells you nothing about now. The magic happens in the query language, PromQL, where rate() looks at two snapshots and computes the slope between them: "requests are climbing by 42 per second." Nearly every useful query is a rate() over a counter, and forgetting the rate() — graphing a bare counter and wondering why it's a straight line marching to the moon — is the rookie mistake everyone makes once.

Where Kubernetes comes in. On a cluster, pods come and go constantly, so hand-editing a static list of scrape targets is hopeless — the list is stale the moment you save it. The Prometheus Operator (bundled in the kube-prometheus-stack Helm chart) solves this by letting you declare intent as a Kubernetes object instead of config: a ServiceMonitor says "scrape any Service with these labels, on this port, at this path," and the Operator keeps Prometheus's target list in sync as pods churn. You describe the rule; the Operator does the bookkeeping. Prometheus itself is, at heart, a pull-based time-series database — it periodically scrapes an HTTP /metrics endpoint on each target, stores the samples, and answers PromQL queries. In Kubernetes the Prometheus Operator lets you declare scrape targets as CRDs — a ServiceMonitor — instead of editing raw config.

Prometheus walks up and reads each app's dial — on its own schedule Prometheus pull-based TSDB app pod · /metrics counters that only go up app pod · /metrics counters that only go up app pod · /metrics counters that only go up scrape every 15s ServiceMonitor (CRD) "scrape Services with these labels" Operator syncs targets Prometheus pulls; it is never pushed to. The server scrapes each pod's /metrics dial on its own clock, and a ServiceMonitor declares which pods to scrape so the target list stays correct as pods churn.
🗺️ How to read this diagram

Follow the blue arrows outward from Prometheus on the left to the app pods on the right — the direction of the arrow is the whole point.

  • The left blue box is the Prometheus server; the right blue boxes are your app pods, each exposing a /metrics HTTP endpoint whose numbers are counters that only ever increase.
  • The blue arrows point from Prometheus to the pods: Prometheus reaches out and reads each dial on a fixed interval (here 15s). Nothing is pushed into Prometheus — a dead pod simply shows up as "target down".
  • The amber box is the ServiceMonitor CRD; the dashed arrow shows the Operator using it to keep Prometheus's list of pods to scrape in sync as pods come and go.

In short: The arrows run from the server to the apps — pull, not push — and the ServiceMonitor is how you declare the target list instead of hand-editing it.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
bash · install the kube-prometheus-stack (needs a cluster: minikube/kind local, or EKS)
install.sh# Verify chart name/version and values against the current kube-prometheus-stack docs.
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
helm install kps prometheus-community/kube-prometheus-stack \
  --namespace monitoring --create-namespace
# This bundles Prometheus, Alertmanager, Grafana, node-exporter and kube-state-metrics.

Now point Prometheus at your app. Your app must expose /metrics (most languages have a Prometheus client). A ServiceMonitor selects the Service by label and tells Prometheus which port/path to scrape:

yaml · a ServiceMonitor scraping your app's /metrics (needs a cluster; verify CRD fields vs docs)
servicemonitor.yamlapiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: llm-api
  namespace: monitoring
  labels:
    release: kps          # must match the Prometheus serviceMonitorSelector
spec:
  selector:
    matchLabels:
      app: llm-api        # selects the Service to scrape
  namespaceSelector:
    matchNames: [default]
  endpoints:
    - port: http          # the named Service port
      path: /metrics
      interval: 15s
The label link is the #1 gotchaA ServiceMonitor is only picked up if its labels match the Prometheus serviceMonitorSelector (often release: <helm-release>), and its selector must match your Service's labels, and the port must be the Service's named port. If a target isn't scraped, check Status → Targets in the Prometheus UI first. Verify these field names against the current Operator docs.

PromQL basics. Counters only go up, so you almost always wrap them in rate() over a window. Three queries you will write constantly:

promql · request rate, error ratio, and p95 latency (illustrative — needs live metrics)
queries.promql# 1) Requests per second, per route, averaged over 5m:
sum by (route) (rate(http_requests_total[5m]))

# 2) Error ratio (5xx as a fraction of all requests) — an SLI:
sum(rate(http_requests_total{status=~"5.."}[5m]))
  /
sum(rate(http_requests_total[5m]))

# 3) p95 latency from a histogram (buckets recorded by the client lib):
histogram_quantile(0.95,
  sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
route         value
/chat         42.7
/embed        11.3
# error ratio -> 0.004  (0.4%)
# p95 -> 0.82  (820 ms)

3 · kube-state-metrics, node metrics & Grafana

Here's a failure that fools people for hours. Your app is down — users are getting errors — but every graph you open is green. CPU is calm, memory is fine, the nodes are barely breathing. So what's wrong? The trap is that you're only watching how hard the machines are working, and completely missing what Kubernetes thinks the state of the world is. Your Deployment wants 5 replicas; only 1 is actually ready; the other 4 are stuck Pending because no node has room. The surviving pod is under-loaded (hence low CPU) yet drowning in traffic it can't serve. No amount of staring at CPU graphs reveals this, because CPU is the wrong instrument for the question.

That's why there are two completely different families of metric, and confusing them is one of the most common gaps in a monitoring setup. Think of a delivery company. node-exporter and cAdvisor are the fuel gauges and engine-temperature sensors on each truck — resource usage: how much CPU, memory, and disk the nodes and containers are actually burning. kube-state-metrics is the dispatch office whiteboard — object state read straight from the Kubernetes API server: how many trucks you ordered out (desired replicas) versus how many are actually on the road (ready replicas), which ones are stuck in the depot (Pending), and which keep breaking down and returning (restart counts / CrashLoopBackOff). One family tells you the engines are hot; the other tells you the gap between what you asked for and what you got — and that gap is where most outages live.

You genuinely need both, and the reason is that Kubernetes is a declarative system: you tell it your desired state and it continuously tries to make reality match. When reality can't match — image won't pull, no room to schedule, liveness probe failing — the trucks-ordered-vs-on-road gap is your earliest and clearest signal, long before it shows up as user pain. Once you've collected all these numbers, though, a raw pile of time series helps no one at 3 a.m. That's the job of Grafana: it turns PromQL into dashboards you can actually read. The temptation — and the mistake — is to build a wall of 200 panels that looks impressive and tells you nothing. A useful dashboard is focused: it leads with the handful of signals that map to user pain and relegates everything else to drill-down. The next sections give you two naming conventions (RED and USE) so you build the focused kind on purpose.

two different questions → two different metric families node-exporter & cAdvisor resource usage"how hard are the engines working?" CPU % memory disk network kube-state-metrics object state from the API server"asked vs got" desired: 5 ready: 1 4 Pending · CrashLoopBackOff Green engines can be red on the whiteboard. Node/cAdvisor metrics (left) can all look calm while kube-state-metrics (right) shows the outage: 5 replicas desired, only 1 ready — the gap is where the failure lives.
🗺️ How to read this diagram

Read it as two panels side by side — the left panel is one family of metric, the right panel is the other, and the contrast is the lesson.

  • The left box (node-exporter & cAdvisor) holds resource-usage gauges — CPU, memory, disk, network. These tell you how hard the machines are working, and in this failure they can all look fine (green).
  • The right box (kube-state-metrics) holds object state read from the Kubernetes API server: how many replicas you desired (5) versus how many are actually ready (1), plus pods stuck Pending or CrashLooping (red).
  • The payoff is the red gap on the right: desired 5 vs ready 1 is the outage, and no amount of staring at the green resource gauges on the left would reveal it.

In short: Resource usage (left) and object state (right) are different families — most outages hide in the desired-vs-ready gap that only kube-state-metrics shows.

QuestionWhich sourceExample metric
Is a node out of memory?node-exporternode_memory_MemAvailable_bytes
Is a container using its whole limit?cAdvisorcontainer_memory_working_set_bytes
Are all Deployment replicas ready?kube-state-metricskube_deployment_status_replicas_ready
Is a Pod stuck Pending / CrashLooping?kube-state-metricskube_pod_status_phase, kube_pod_container_status_restarts_total

Grafana turns PromQL into dashboards. The stack ships useful defaults; the skill is building a focused service dashboard around your SLIs (rate, errors, latency — the 'RED' method) rather than 200 panels no one reads. Provision dashboards as code (a ConfigMap the Grafana sidecar picks up) so they live in Git.

RED for services, USE for resourcesTwo mnemonics keep dashboards honest. RED (Rate, Errors, Duration) for request-driven services — the three panels that map directly to user pain. USE (Utilization, Saturation, Errors) for resources like nodes and queues. Start every service dashboard with RED at the top; everything else is drill-down.

4 · Logs with Loki, traces with OpenTelemetry

The old way of storing logs bankrupts you slowly. Traditional log systems index every word of every line so you can search on anything — which sounds great until you're paying to build a full-text search engine over terabytes of "GET /health 200" that nobody will ever search for. Teams routinely spend more on their log stack than on the app it watches. Loki is the deliberate cheapskate's answer, and its one clever idea is worth internalizing: don't index the log text at all — index only a few labels (app, namespace, pod), the same way Prometheus labels a metric. Picture a filing cabinet. Full-text logging photocopies and cross-references every sentence in every document; Loki just writes a label on each folder — "llm-api / prod" — and drops the papers in unsorted. To find something you first grab the right folder by its label (instant, indexed), then skim the pages inside (a quick scan). You give up "search everything everywhere" and get a log store cheap enough to actually keep. You query it with LogQL, which is deliberately shaped to look like PromQL, and because Loki reuses the same labels as your metrics, you can click from a spiking error graph straight to the matching logs — no separate mental map.

full-text logging: index every word huge index over ALL log text GET /health 200 … GET /health 200 … 💸 pay to index text nobody searches Loki: index only labels index: {app, namespace, pod} folder "llm-api / prod" · raw lines folder "llm-api / prod" · raw lines grab folder by label, then scan pages Loki is the deliberate cheapskate. Full-text stores index every word (left, expensive); Loki indexes only the labels on each folder (right) and scans the lines inside — cheap enough to actually keep.
🗺️ How to read this diagram

Compare the two panels: the left is the traditional full-text approach, the right is Loki, and the difference is what gets indexed.

  • The left red bar is a full-text index built over every word of every log line — powerful but expensive, because you pay to index mountains of text like GET /health 200 that no one ever searches.
  • The right green bar is Loki's index: just a few labels per stream (app, namespace, pod) — the same labels your metrics use. The raw log lines below sit unindexed in labelled "folders".
  • The payoff is on the right: to find something you grab the right folder by its label (instant), then scan the pages inside — you trade "search everything" for a log store cheap enough to keep, and reuse metric labels so you can pivot from a graph to its logs.

In short: Full-text indexes all the text (costly); Loki indexes only labels and scans the rest — same labels as metrics, so metrics-to-logs pivots are free.

But logs and metrics share a blind spot in a world of many small services: neither can tell you where a slow request spent its time. Say a chat request takes 4 seconds. Was it the retriever? The reranker? The LLM call? The database? Metrics say "p95 is 4s" (unhelpfully aggregate); logs say "request started… request finished" in each service separately, with no thread connecting them. This is what distributed tracing fixes, and the analogy is a parcel's tracking history. Every request gets a trace ID — one tracking number stamped on it at the front door and carried through every service it touches. Each service records a span: a timestamped "I received it here, I handed it off there," with attributes attached. Assemble the spans by their shared trace ID and you get a waterfall — a Gantt chart of the request's life — where the longest bar is your culprit, obvious at a glance.

OpenTelemetry (OTel) is the piece that makes this portable rather than a vendor lock-in trap. The mistake teams made for years was instrumenting their code against one tracing vendor's SDK, then rewriting everything when they switched. OTel is the vendor-neutral standard: you instrument once with the OTel SDK, ship spans to the OTel Collector, and the Collector fans them out to whatever backend you choose (Tempo, Jaeger) — swap the backend, keep your code. For a RAG or agent app this is genuinely revelatory: you finally see retrieve → rerank → LLM-call as separate spans and discover, concretely, that the LLM call is 90% of your latency and the retrieval you spent a week optimizing was never the problem. Now the plain-language recap. Loki is 'Prometheus for logs': it indexes only labels (app, namespace, pod) — not the full log text — which makes it cheap to run; you query with LogQL, which looks like PromQL, and an agent (Promtail / Grafana Alloy) ships pod stdout to Loki using the same labels as your metrics. Distributed tracing assigns each request a trace ID; each service records spans that nest into a waterfall; OpenTelemetry is the vendor-neutral way to emit them and export via the Collector to any backend.

logql · find errors for one app in the last window (illustrative — needs Loki)
logs.logql# Label filter (indexed, fast) then a line filter (scanned):
{app="llm-api", namespace="default"} |= "error" | json | status >= 500

# Rate of error lines per pod (LogQL metric query):
sum by (pod) (rate({app="llm-api"} |= "error" [5m]))

Distributed tracing answers the question metrics can't: for this slow request, which service or call ate the time? A request is assigned a trace ID; each service records spans (start/end + attributes) that nest into a waterfall. OpenTelemetry (OTel) is the vendor-neutral standard — instrument once with the OTel SDK, export via the OTel Collector to any backend (Tempo, Jaeger). For a RAG/agent app this is gold: you see retrieve → rerank → LLM-call as separate spans and learn the LLM call is 90% of latency.

App + OTel SDK emits spans OTel Collector batch/route Tempo / Jaeger store traces Grafana waterfall view
🗺️ How to read this diagram

Follow the blue arrows left-to-right — a span made by your app travels through four stages until you can see it as a waterfall.

  • The first box is your App + OTel SDK, which emits spans — you instrument the code once with the vendor-neutral OpenTelemetry SDK.
  • The blue arrows carry the spans onward to the OTel Collector (purple), which batches and routes them, then on to a trace store — Tempo / Jaeger (green).
  • The last box, Grafana (amber), is the payoff: it assembles the stored spans by their shared trace ID into a waterfall view where the longest bar is the culprit.

In short: Instrument once with the OTel SDK, ship spans through the Collector to Tempo/Jaeger, and read them as a waterfall in Grafana — swap the backend, keep your code.

Correlate by shared IDs, sample by costThe payoff of the three signals is correlation: a metric alert links to the trace ID, the trace links to the exact logs. Make that possible by propagating trace_id into log lines and using consistent labels. Because storing every span is expensive, use tail-based sampling (keep all traces that erred or were slow, sample the rest) — configure it in the Collector. Verify OTel Collector processor names against current docs; they change.

5 · SLIs, SLOs, error budgets & burn rate

"How reliable should our service be?" has a wrong answer that everyone gives: "100%." It sounds responsible and it's a trap. Chasing 100% is infinitely expensive — every extra nine of reliability costs multiples more than the last — and it's also pointless, because the user's home wifi, their phone, and the public internet between you and them already fail more often than your service ever will. Aiming for perfect uptime means you can never ship a change, because every deploy carries risk. The uncomfortable truth SRE culture is built on: the right reliability target is not 100%, and choosing it is a business decision, not a virtue.

The vocabulary here is precise, so anchor each term to the previous one — they stack. An SLI (Service Level Indicator) is just a measurement: a ratio of good events to total, like "fraction of requests served under 500 ms without a 5xx." It's a thermometer reading, no judgment attached. An SLO (Service Level Objective) adds the judgment — a target for that SLI over a window: "99.9% of requests, measured over 28 days." (An SLA, which you'll hear next to these, is the same target with money attached — a contractual refund if you miss it; the SLO is the internal goal you set tighter than the SLA so you never actually breach the contract.) Then comes the idea that changes how teams behave: subtract the SLO from 100% and you get the error budget — 1 − SLO. At 99.9%, that budget is 0.1% of requests you are allowed to fail before you've broken your promise.

Reframing failure as a budget you spend rather than a sin you avoid is the whole point, and the mistake is treating the budget as leftover slack rather than a tool. Think of it like a monthly data cap. If you've barely used any, you can stream freely — ship risky features fast, run bold experiments, because you have reliability to spare. If you've nearly blown through it, you throttle yourself — freeze risky changes, spend the sprint on hardening — because one more incident breaks the promise to users. That's what makes the error budget a data-driven brake on shipping: it turns "should we deploy on Friday?" from a gut argument into arithmetic. And because a budget can be spent slowly or catastrophically fast, the last term you need is burn rate — how quickly you're draining the budget compared to the "even" pace that would use it up exactly at the end of the window. A burn rate of 1× means you'll finish the budget right on schedule; 14.4× means a two-day budget is gone in a couple of hours, which is the difference between "file a ticket" and "wake someone up." Now the crisp definitions to keep. An SLI is a measured ratio of good events to total; an SLO is a target for that SLI over a window; the gap between 100% and the SLO is your error budget (at 99.9%, you may 'spend' 0.1% of requests failing); this reframes reliability from 'never fail' to 'fail within budget' and gives you a data-driven brake on shipping.

100% of requests = the SLO target + the error budget SLO: 99.9% succeed (over 28 days) budget 0.1% SLI = good / total (the measured ratio) = 1 − SLO burn rate = how fast you spend that 0.1% budget 1× burn budget lasts the full 28 days 14.4× burn budget gone in ~2 days → page Reliability is a budget, not a virtue. The green bar is the 99.9% you promise; the red sliver is the 0.1% you're allowed to fail. Burn rate is the speed you drain it — 1× lasts the window, 14.4× empties it in two days.
🗺️ How to read this diagram

Read the top bar left-to-right as all 100% of your requests, then look down to the two burn-rate boxes.

  • The long green bar is the SLO — the 99.9% of requests you promise to serve well (the SLI is the measured good/total ratio underneath it).
  • The small red bar on the right is the error budget: 1 − SLO = 0.1% of requests you are allowed to fail before you break the promise.
  • The two bottom boxes are burn rate — how fast you spend that budget. Amber 1× drains it evenly across the 28-day window; red 14.4× drains it in ~2 days, which is fast enough to wake someone.

In short: Green = the promise (SLO), red sliver = what you may fail (error budget), and burn rate is how fast that sliver empties — 14.4× is a page.

TermDefinitionExample
SLIA measured quality ratio (good / total)% requests < 500 ms and non-5xx
SLOTarget for the SLI over a window99.9% over 28 days
Error budgetAllowed failure = 1 − SLO0.1% of requests may fail
Burn rateHow fast you're spending the budget vs 'even' pace14.4× = budget gone in 2 days

The error budget and burn rate are just arithmetic — here it is as offline-runnable Python (no cluster needed), the same math Prometheus alert rules encode:

python · error budget + multi-window burn-rate alerting (offline-runnable)
error_budget.pySLO = 0.999                      # 99.9% success objective
WINDOW_DAYS = 28
budget = 1 - SLO                 # 0.001 -> 0.1% of requests may fail

def burn_rate(bad, total):
    """How many times faster than the 'even' pace we are spending budget."""
    if total == 0:
        return 0.0
    error_ratio = bad / total
    return error_ratio / budget  # 1.0 = exactly on pace to exhaust budget over the window

def budget_remaining(bad_28d, total_28d):
    spent = (bad_28d / total_28d) / budget if total_28d else 0.0
    return max(0.0, 1 - spent)   # fraction of the error budget still available

# A fast-burn page: 2% errors over the last 5m is 20x the budget pace.
print(round(burn_rate(bad=20, total=1000), 1))          # -> 20.0  (page now)
print(round(budget_remaining(bad_28d=1200, total_28d=2_000_000) * 100, 1))  # -> 40.0% left
20.0
40.0
Multi-window, multi-burn-rate alertingAlerting on 'error ratio > 0' is noise; alerting only on the 28-day SLO is too slow. The Google SRE pattern is multi-window multi-burn-rate: page on a fast burn (e.g. 14.4× over 1h and 5m) which would exhaust the budget in ~2 days, and open a ticket on a slow burn (e.g. 3× over 6h). This pages you for real, fast damage and files calmer work for slow leaks. Verify exact multipliers against the SRE workbook.

6 · Alerting that pages on symptoms, not causes

There is a specific way that good monitoring destroys a team, and it's worth naming out loud. You wire up alerts on everything — CPU, memory, disk, restarts, queue depth — because more visibility feels safer. Then one bad deploy trips sixty of them at once, the pager goes off twelve times in an hour, and by month three your on-call engineers have muted the channel out of pure survival. The night the real outage arrives, the page that matters is buried under forty that don't, and nobody's looking. This is alert fatigue, and it kills more incident responses than any technical failure. It's the boy who cried wolf, encoded in YAML — and the cure is not more alerts or better alerts, it's far fewer, far more meaningful ones.

The governing principle is a single distinction: alert on symptoms, not causes. A symptom is something a user feels — requests failing, pages loading slowly, the SLO error budget burning down. A cause is a mechanical detail underneath — a node at 80% CPU, a pod that restarted, a disk 70% full. The reason to page on symptoms is subtle but decisive: causes are simultaneously too noisy and too quiet. Too noisy, because a node can happily run at 85% CPU for weeks while every user request succeeds — paging on that wakes someone for a non-problem. Too quiet, because your service can be completely broken (a dependency timing out at trivial CPU) while every resource gauge stays green. Symptoms track the thing you actually promised users, so they fire when it matters and stay silent when it doesn't. Causes still belong on dashboards — you consult them after a symptom page, to find the "why" — they just don't earn the right to wake a human.

Even paging only on symptoms, one incident can still produce a flood, which is where the two tools of this section come in. Prometheus records the alert conditions as rules — the "if this, then alert" logic — but it deliberately doesn't decide who gets told or how. That's the job of Alertmanager, the switchboard sitting between raw firing alerts and human beings. It does four things worth knowing by name: grouping (thirty pods failing the same way become one notification, not thirty), routing (a severity=page alert goes to PagerDuty and the on-call phone; a severity=ticket alert goes to a work queue), silencing (mute expected noise during a planned maintenance window), and inhibiting — the cleverest one — where a firing root-cause alert suppresses the hundred downstream alerts it obviously caused ("the whole region is down" mutes "service A can't reach service B"). The philosophy that keeps a rotation sane, stated plainly: alert on the symptoms your users feel — SLO burn, high error ratio, high latency — and never on every cause; one node at 80% CPU is not a page.

many raw alerts in → one actionable page out SLO burn (symptom) pod restarted (cause) node 80% CPU (cause) Prometheus rules fire Alertmanager group · inhibit silence · route 📟 PAGE on-call 🎫 ticket / dashboard severity=page causes → not a page inhibition: the firing page suppresses the downstream cause-tickets for the same namespace Alertmanager is the switchboard. Prometheus only fires the rules; Alertmanager groups and inhibits them so a real symptom becomes one page while causes drop to a ticket or dashboard — no page storm.
🗺️ How to read this diagram

Follow it left-to-right: raw alerts on the left flow through Alertmanager in the middle and come out on the right as either a page or a ticket.

  • The left column is every alert Prometheus fires — one true symptom (SLO burn, red) mixed in with many causes (a pod restart, a hot node, amber). Prometheus records the rules but decides nothing about who gets told.
  • The middle box is Alertmanager, doing its four jobs: group duplicates, inhibit downstream noise, silence planned windows, and route by severity.
  • The right side is the payoff: only severity=page (the symptom) reaches the on-call phone; the causes drop to a ticket or dashboard. The dashed note shows inhibition — the firing page suppresses the cause-tickets it obviously triggered.

In short: Prometheus fires everything; Alertmanager filters it down so one incident produces one page on a user-felt symptom — causes stay off the pager.

yaml · a burn-rate PrometheusRule that pages on symptoms (needs a cluster; verify CRD vs docs)
prometheusrule.yamlapiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: llm-api-slo
  labels: { release: kps }
spec:
  groups:
    - name: slo-burn
      rules:
        - alert: FastErrorBudgetBurn
          # 5xx ratio > 14.4x the 0.1% budget, confirmed over 1h and 5m windows.
          expr: |
            (sum(rate(http_requests_total{status=~"5.."}[1h])) / sum(rate(http_requests_total[1h])) > 14.4*0.001)
            and
            (sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) > 14.4*0.001)
          for: 2m
          labels: { severity: page }
          annotations:
            summary: "Fast error-budget burn on llm-api"
yaml · Alertmanager routing: page on severity=page, ticket otherwise (verify vs docs)
alertmanager.yamlroute:
  receiver: default-ticket
  group_by: [alertname, namespace]
  routes:
    - matchers: [ 'severity = page' ]
      receiver: pagerduty-oncall
inhibit_rules:
  - source_matchers: [ 'severity = page' ]
    target_matchers: [ 'severity = ticket' ]
    equal: [namespace]        # if we're already paging, don't also file tickets for the same ns
receivers:
  - name: default-ticket
  - name: pagerduty-oncall
    # pagerduty_configs / webhook_configs go here — verify fields vs current docs
Every page must be actionableThe fastest way to burn out an on-call rotation is alerts that fire but need no action ('CPU high', 'a pod restarted'). If a page can't be tied to a human action, it should be a dashboard or a ticket, not a page. Tie pages to SLO burn so you're woken only when users are actually being hurt fast — and every page links to a runbook (K12). Verify Alertmanager routing/inhibit syntax against current docs.

✓ Checkpoint — you can move on when you can…

  • Name the four signals and which one you alert on vs debug with.
  • Write a ServiceMonitor and explain the label link to Prometheus and to the Service.
  • Write PromQL for a rate, an error ratio, and a p95 from a histogram.
  • Distinguish kube-state-metrics (object state) from node/cAdvisor (resource usage).
  • Define an SLI/SLO, compute an error budget and burn rate, and say why you page on symptoms.
  • Explain multi-window multi-burn-rate alerting and Alertmanager grouping/inhibition.
✓ Knowledge check

A teammate proposes an alert: node_cpu utilisation > 80% for 5m → page on-call. Why is this a poor page, and what should page instead?

Show answer
High node CPU is a cause, not a symptom — a node can sit at 85% CPU while every user request is fast and successful, so this pages you when nothing is wrong (alert fatigue) and stays silent when a downstream dependency is failing at low CPU. Page on what users feel: an SLO error-budget burn (e.g. 5xx ratio or p95 latency exceeding the burn-rate threshold). High CPU belongs on a dashboard or at most a low-severity ticket for capacity planning. Rule of thumb: page on symptoms (RED/SLO), investigate causes (USE/CPU/memory).
✓ Knowledge check

Your Prometheus keeps OOMing after a deploy. Someone added a request_id label to http_requests_total. What's the connection, and what's the fix?

Show answer
Cardinality explosion. Every unique label combination is a separate time series; a request_id is unbounded (a new value per request), so the series count grows without limit and Prometheus's memory blows up. Prometheus labels must be low-cardinality (route, method, status — bounded sets). The fix: remove request_id from the metric; if you need per-request detail, put it in logs (Loki) or traces (OTel), which are built for high cardinality, and correlate by trace_id.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Expose and scrape a serviceBeginner

Context: Nothing is observable until something scrapes it.

Your task: Describe how to get an app's custom metrics into Prometheus in a kube-prometheus-stack cluster.

Requirements:

  • App exposes a Prometheus /metrics endpoint
  • A ServiceMonitor selects the Service and names the port/path
  • Note the label that links the ServiceMonitor to Prometheus
  • Label it as needing a cluster

💡 Hint: The ServiceMonitor's own labels must match Prometheus's serviceMonitorSelector.

Show solution

The app imports a Prometheus client and serves /metrics on a named Service port (e.g. http). Then a ServiceMonitor wires it in:

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: llm-api
  labels: { release: kps }   # matches Prometheus serviceMonitorSelector
spec:
  selector: { matchLabels: { app: llm-api } }   # matches the Service
  endpoints: [ { port: http, path: /metrics, interval: 15s } ]

Three links must all line up: ServiceMonitor label → Prometheus selector; ServiceMonitor selector → Service labels; port → the Service's named port. If it isn't scraped, check Prometheus → Status → Targets. Needs a cluster; verify CRD fields vs current docs.

Exercise 2 · Write the three RED queriesIntermediate

Context: The RED method (Rate, Errors, Duration) is the backbone of any service dashboard.

Your task: Write PromQL for request rate per route, the 5xx error ratio, and p95 latency, and say which is your SLI.

Requirements:

  • A rate() over a counter for throughput
  • An error ratio as a fraction of total
  • A p95 from a histogram via histogram_quantile
  • Identify which one(s) you'd base an SLO on

💡 Hint: Counters always need rate(); percentiles come from *_bucket series.

Show solution
# Rate per route
sum by (route) (rate(http_requests_total[5m]))

# Error ratio (an SLI)
sum(rate(http_requests_total{status=~"5.."}[5m]))
 /
sum(rate(http_requests_total[5m]))

# p95 latency (an SLI)
histogram_quantile(0.95,
  sum by (le) (rate(http_request_duration_seconds_bucket[5m])))

SLIs: the error ratio and p95 latency are the user-facing quality signals you'd set SLOs on (e.g. availability 99.9%, latency p95 < 500 ms). Raw rate is throughput context, not a quality target. Illustrative — needs live metrics.

Exercise 3 · Correlate a slow request across metrics, traces, logsAdvanced

Context: A metric tells you latency spiked; only traces+logs tell you why.

Your task: Design how you'd go from a p95-latency alert to the exact failing code path using all three signals.

Requirements:

  • Start from a metric/SLO alert
  • Use a trace to find where time went across services
  • Jump to the matching logs by a shared ID
  • Say what makes this correlation possible

💡 Hint: Propagate trace_id into log lines and keep labels consistent.

Show solution

Flow: (1) A p95-latency SLO alert fires (metric). (2) Open the service's traces and filter to slow traces in that window — the waterfall shows one span (say the LLM call, or a DB query) dominating. (3) That span carries a trace_id; in Grafana, pivot to Loki filtered by that trace_id to read the exact log lines for that request.

What makes it work: you must (a) instrument with OpenTelemetry so spans exist, (b) propagate trace_id and log it into every log line, and (c) use consistent labels (app, namespace) across metrics, traces and logs. Without shared IDs the three signals are three silos. Use tail-based sampling so slow/errored traces are always kept. Verify OTel Collector config vs current docs.

Exercise 4 · Define an SLO and its multi-burn-rate alertsExpert

Context: Choosing what to page on is an engineering decision, not a default.

Your task: Define an availability SLO for an LLM API and design the burn-rate alerts, computing the fast-burn threshold.

Requirements:

  • State the SLI, SLO target and window
  • Compute the error budget
  • Design fast-burn (page) and slow-burn (ticket) alerts with windows
  • Explain why single-threshold alerting is wrong

💡 Hint: Fast burn ≈ 14.4× budget over 1h exhausts a 28-day budget in ~2 days.

Show solution

SLI: fraction of requests non-5xx. SLO: 99.9% over 28 days. Error budget = 1 − 0.999 = 0.1% of requests may fail.

Multi-window multi-burn-rate:

# Fast burn -> PAGE: 14.4x budget, confirmed on 1h AND 5m windows (~2 days to exhaust)
err_ratio_1h > 14.4*0.001 and err_ratio_5m > 14.4*0.001

# Slow burn -> TICKET: ~3x budget on 6h AND 30m windows (a slow leak)
err_ratio_6h > 3*0.001 and err_ratio_30m > 3*0.001

Why single-threshold is wrong: a fixed 'errors > X%' either flaps on brief blips (too sensitive) or misses a slow leak that quietly drains the budget over days (too slow). Two windows confirm the burn is real (not a 5-min blip) and two burn rates separate 'wake a human now' from 'file work for tomorrow'. Verify multipliers vs the Google SRE workbook.

Exercise 5 · Cut an alert storm down to actionable pagesProfessional

Context: A team is drowning: one bad deploy fires 60 alerts and pages on-call 12 times.

Your task: Redesign the alerting so a single incident produces one actionable page, using Alertmanager features.

Requirements:

  • Move paging onto SLO symptoms, not causes
  • Use grouping to collapse related alerts
  • Use inhibition so root-cause suppresses downstream
  • Keep non-actionable signals off the pager

💡 Hint: group_by + inhibit_rules + severity routing do most of the work.

Show solution

1 · Page on symptoms. Replace cause-based pages (CPU, single-pod restarts) with a small set of SLO burn-rate pages per service. A node at 80% CPU with healthy SLIs pages no one.

2 · Group. group_by: [alertname, namespace] so 30 pods failing the same way arrive as one notification, not 30.

3 · Inhibit. When the service-level severity=page alert fires, an inhibit rule suppresses the downstream severity=ticket alerts for the same namespace — you get the root cause, not the 60 symptoms.

route: { group_by: [alertname, namespace], routes:
  [ { matchers: ['severity=page'], receiver: pagerduty } ] }
inhibit_rules:
  - source_matchers: ['severity=page']
    target_matchers: ['severity=ticket']
    equal: [namespace]

4 · Everything non-actionable becomes a dashboard panel or a low-severity ticket. Net effect: one incident → one page → one runbook. Verify Alertmanager syntax vs current docs.

Exercise 6 · Design the observability + SLO stack for an LLM platformIndustry scenario

Context: Representative scenario: you own an LLM/RAG platform on EKS serving several product teams, and leadership wants 'real reliability, not vanity dashboards'.

Your task: Produce an end-to-end observability and SLO design: signals collected, stack, SLOs, alerting, and the failure modes each choice defends against.

Requirements:

  • All four signals with the right tool and cost stance
  • SLIs/SLOs per user-facing surface with error budgets
  • Symptom-based, multi-burn-rate alerting wired to on-call
  • Trace/log correlation for fast RCA
  • Name the top failure modes each choice defends against

💡 Hint: Metrics for alerting; traces+logs for RCA; SLOs decide what's worth a human.

Show solution

Signals & stack: kube-prometheus-stack for metrics (Prometheus + Grafana + kube-state-metrics + node-exporter), Loki for logs (label-indexed, cheap), OpenTelemetry → Tempo/Jaeger for traces with tail-based sampling (keep slow/errored). Keep high-cardinality IDs out of metrics; they live in logs/traces.

SLOs: per surface (chat, embeddings, RAG query) define availability + latency SLIs with explicit targets and 28-day windows; publish error budgets and use them as a release brake — if the budget is spent, freeze risky changes and spend the sprint on reliability.

Alerting: multi-window multi-burn-rate pages on SLO burn only; Alertmanager groups and inhibits so one incident = one page; everything else is dashboard/ticket. Every page links a K12 runbook.

RCA: shared trace_id across metrics→traces→logs lets on-call go from 'p95 alert' to the exact failing span and its logs in minutes — critical for RAG, where the LLM call often dominates latency.

Failure modes defended: cardinality explosion (IDs kept out of metrics) → Prometheus stays up; alert fatigue (symptom + grouping + inhibition) → on-call trusts pages; blind debugging (correlation) → fast RCA; shipping-into-the-ground (error budgets) → reliability has teeth. Verify every CRD/chart/Collector detail against current docs — this stack drifts.

© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in