Kubernetes SLO Alerting with Prometheus Burn Rates
Define a real SLO on Kubernetes, then build multi-window burn rate alerts in Prometheus and Alertmanager that page you before the budget is gone.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project defines a 99% availability SLO for a Kubernetes service and enforces it with multi-window burn rate alerts in Prometheus, routed through Alertmanager to separate Slack channels for pages and tickets. You deploy a sample API that exposes real request metrics, write recording rules for the error ratio over eight time windows, create four burn-rate alerts, build an SLO dashboard in Grafana, and then trigger the alerts with generated errors. You finish with an SLO document, an error budget policy, and a dashboard you can show in a portfolio.
This is the reliability engineering project. It assumes you can already run Prometheus, Grafana, and Alertmanager on Kubernetes, which is exactly what Kubernetes Monitoring with Prometheus and Loki teaches. That project is about collecting metrics and logs. This one is about deciding how reliable a service must be and alerting on how fast you are losing that reliability.
What you build:
- A sample API scraped through a
ServiceMonitor - An SLO: 99% of requests succeed over 28 days, which gives a 1% error budget
- Recording rules for the error ratio over 5m, 30m, 1h, 2h, 6h, 1d, 3d, and 28d
- Four burn-rate alerts, two that page and two that open tickets
- An SLO dashboard and a one-page error budget policy
request metrics] --> PROM[Prometheus] PROM --> REC[Recording rules
error ratios] REC --> ALERT[Burn-rate alerts] REC --> GRAF[Grafana
SLO dashboard] ALERT --> AM[Alertmanager] AM -->|critical| PAGE[Slack: page] AM -->|warning| TICKET[Slack: ticket] classDef src fill:#475569,color:#fff classDef core fill:#F97316,color:#fff classDef rule fill:#8B5CF6,color:#fff classDef out fill:#10B981,color:#fff class APP src class PROM core class REC,ALERT rule class GRAF,AM,PAGE,TICKET out
The problem this solves
A fixed threshold such as "alert when the error rate passes 1 percent" is a poor alert. It pages for brief spikes that cost almost nothing, and it stays silent about a steady 0.9 percent error rate that quietly burns through the budget. Burn rate alerting asks a better question: how fast are we spending the error budget?
An SLO of 99 percent over 28 days allows 1 percent of requests to fail, which is about 403 minutes of full downtime. A burn rate of 1 means you spend the budget exactly over the 28 days. A burn rate of 14.4 means you spend about 2 percent of the whole budget in a single hour and would exhaust it in roughly two days. That deserves a page. A burn rate of 1 sustained for three days deserves a ticket, not a 3am wake-up.
TipWhich SLO you pick is a product decision, not a technical one. Start at 99 percent or 99.5 percent and tighten it only when you can show the service is reliably better.
Milestone 1: Prepare the Cluster and Monitoring Stack
This milestone gives you a cluster with kube-prometheus-stack running. If you finished the monitoring project, reuse that cluster and skip to Milestone 2. Otherwise, run the condensed setup below.
kind create cluster --name slo-project helm repo add prometheus-community https://prometheus-community.github.io/helm-chartshelm repo updatehelm search repo prometheus-community/kube-prometheus-stackexport PROM_VERSION=<version from the search above> kubectl create namespace monitoringCreate prometheus-values.yaml. Retention is 30 days because your SLO window is 28 days, and Prometheus cannot compute a 28-day ratio from 15 days of data.
grafana: adminPassword: "change-me" prometheus: prometheusSpec: retention: 30d serviceMonitorSelectorNilUsesHelmValues: false ruleSelectorNilUsesHelmValues: false kubeEtcd: enabled: falsekubeControllerManager: enabled: falsekubeScheduler: enabled: falsehelm install kube-prometheus-stack prometheus-community/kube-prometheus-stack \ --namespace monitoring \ --values prometheus-values.yaml \ --version $PROM_VERSION \ --wait kubectl get pods -n monitoringCommon MistakeKeeping the default 15-day retention with a 28-day SLO. The long-window queries silently return a ratio computed from only the data that exists, so your budget numbers look better than reality.
Milestone 2: Deploy an App That Exposes Request Metrics
This milestone deploys the service you will measure. An SLO needs a request counter with a status code, so the app must expose one. A plain nginx container does not, so this uses a small example app that publishes http_requests_total{code, method}.
Its / path returns 200 and its /err path returns an error, which gives you a controllable source of failures. A ServiceMonitor tells Prometheus to scrape it.
kubectl apply -f - <<EOFapiVersion: apps/v1kind: Deploymentmetadata: name: orders-api namespace: defaultspec: replicas: 3 selector: matchLabels: app: orders-api template: metadata: labels: app: orders-api spec: containers: - name: orders-api image: quay.io/brancz/prometheus-example-app:v0.5.0 ports: - name: http containerPort: 8080 resources: requests: cpu: 50m memory: 64Mi limits: cpu: 200m memory: 128Mi---apiVersion: v1kind: Servicemetadata: name: orders-api namespace: default labels: app: orders-apispec: selector: app: orders-api ports: - name: http port: 8080---apiVersion: monitoring.coreos.com/v1kind: ServiceMonitormetadata: name: orders-api namespace: default labels: release: kube-prometheus-stackspec: selector: matchLabels: app: orders-api endpoints: - port: http path: /metrics interval: 15sEOFCheck which status code /err returns, because the SLI in the next milestone depends on it:
kubectl port-forward svc/orders-api 8080:8080 &curl -s -o /dev/null -w "%{http_code}\n" localhost:8080/curl -s -o /dev/null -w "%{http_code}\n" localhost:8080/errIn recent versions /err returns 404 rather than 500. That is why this project defines a failed request as any response outside the 2xx range, instead of only 5xx. Confirm the target shows as UP at http://localhost:9090/targets (port-forward svc/kube-prometheus-stack-prometheus 9090:9090 in the monitoring namespace).
Common MistakeAdding
prometheus.io/scrapeannotations and expecting scraping. Those annotations only work with a Prometheus config that reads them. With the Prometheus Operator, you create aServiceMonitor.
Milestone 3: Define the SLI and SLO
This milestone turns a vague goal into a measurable promise. A Service Level Indicator (SLI) is the measurement, and a Service Level Objective (SLO) is the target for it. Writing them down first stops you from alerting on whatever metric happens to be available.
| Item | Definition |
|---|---|
| SLI | Fraction of requests to orders-api that return a 2xx status |
| SLO | 99% of requests succeed over a rolling 28 days |
| Error budget | 1% of requests, about 403 minutes of full outage per 28 days |
| Bad request | Any response where code is not 2xx |
Test the SLI in the Prometheus UI before you build rules on it:
sum(rate(http_requests_total{job="orders-api",code!~"2.."}[5m]))/sum(rate(http_requests_total{job="orders-api"}[5m]))The sum matters. Without it you get one ratio per pod, and a pod with no errors hides a pod with many. The query returns nothing when there is no traffic, which is correct: a service nobody calls has no measurable error ratio.
Common MistakeSetting an SLO of 99.999 percent. That allows about 26 seconds of failure per month, and one routine deployment can use most of it. A budget that small gets ignored.
Milestone 4: Write Recording Rules for Every Window
This milestone pre-computes the error ratio over each time window. Burn-rate alerts compare a long window against a short one, so you need the same ratio at several lengths. Recording rules calculate each one on a schedule, so alert evaluation reads a stored value instead of scanning raw samples every time.
Create slo-recording-rules.yaml:
apiVersion: monitoring.coreos.com/v1kind: PrometheusRulemetadata: name: orders-api-slo-recording namespace: monitoring labels: release: kube-prometheus-stackspec: groups: - name: orders-api-slo-recording interval: 30s rules: - record: orders_api:error_ratio:rate5m expr: | sum(rate(http_requests_total{job="orders-api",code!~"2.."}[5m])) / sum(rate(http_requests_total{job="orders-api"}[5m])) - record: orders_api:error_ratio:rate30m expr: | sum(rate(http_requests_total{job="orders-api",code!~"2.."}[30m])) / sum(rate(http_requests_total{job="orders-api"}[30m])) - record: orders_api:error_ratio:rate1h expr: | sum(rate(http_requests_total{job="orders-api",code!~"2.."}[1h])) / sum(rate(http_requests_total{job="orders-api"}[1h])) - record: orders_api:error_ratio:rate2h expr: | sum(rate(http_requests_total{job="orders-api",code!~"2.."}[2h])) / sum(rate(http_requests_total{job="orders-api"}[2h])) - record: orders_api:error_ratio:rate6h expr: | sum(rate(http_requests_total{job="orders-api",code!~"2.."}[6h])) / sum(rate(http_requests_total{job="orders-api"}[6h])) - record: orders_api:error_ratio:rate1d expr: | sum(rate(http_requests_total{job="orders-api",code!~"2.."}[1d])) / sum(rate(http_requests_total{job="orders-api"}[1d])) - record: orders_api:error_ratio:rate3d expr: | sum(rate(http_requests_total{job="orders-api",code!~"2.."}[3d])) / sum(rate(http_requests_total{job="orders-api"}[3d])) - record: orders_api:error_ratio:rate28d expr: | sum(rate(http_requests_total{job="orders-api",code!~"2.."}[28d])) / sum(rate(http_requests_total{job="orders-api"}[28d]))kubectl apply -f slo-recording-rules.yamlTipA real team would generate these rules from an SLO definition with a tool such as Sloth or Pyrra instead of writing eight copies by hand. Write them by hand once so you understand what those tools produce.
Milestone 5: Create Multi-Window Burn Rate Alerts
This milestone creates the alerts. Each one fires only when a long window and a short window both exceed the same burn rate. The long window proves the problem is significant, and the short window proves it is still happening, so the alert also stops quickly once the problem is fixed.
The threshold for a burn rate is the burn rate multiplied by the budget (0.01):
| Severity | Burn rate | Long window | Short window | Budget used | Budget gone in |
|---|---|---|---|---|---|
| critical (page) | 14.4 | 1h | 5m | about 2% in 1 hour | about 2 days |
| critical (page) | 6 | 6h | 30m | about 5% in 6 hours | about 5 days |
| warning (ticket) | 3 | 1d | 2h | about 10% in 1 day | about 9 days |
| warning (ticket) | 1 | 3d | 6h | about 10% in 3 days | 28 days |
Create slo-alert-rules.yaml:
apiVersion: monitoring.coreos.com/v1kind: PrometheusRulemetadata: name: orders-api-slo-alerts namespace: monitoring labels: release: kube-prometheus-stackspec: groups: - name: orders-api-slo-alerts rules: - alert: OrdersAPIBurnRateCritical1h expr: | orders_api:error_ratio:rate1h > (14.4 * 0.01) and orders_api:error_ratio:rate5m > (14.4 * 0.01) for: 2m labels: severity: critical slo: orders-api-availability annotations: summary: "orders-api is burning error budget at 14.4x" description: "Error ratio is {{ $value | humanizePercentage }} over 1h. About 2% of the 28-day budget is used per hour at this rate." - alert: OrdersAPIBurnRateCritical6h expr: | orders_api:error_ratio:rate6h > (6 * 0.01) and orders_api:error_ratio:rate30m > (6 * 0.01) for: 5m labels: severity: critical slo: orders-api-availability annotations: summary: "orders-api is burning error budget at 6x" description: "Error ratio is {{ $value | humanizePercentage }} over 6h. The budget is gone in about 5 days at this rate." - alert: OrdersAPIBurnRateWarning1d expr: | orders_api:error_ratio:rate1d > (3 * 0.01) and orders_api:error_ratio:rate2h > (3 * 0.01) for: 15m labels: severity: warning slo: orders-api-availability annotations: summary: "orders-api is burning error budget at 3x" description: "Error ratio is {{ $value | humanizePercentage }} over 1d. Open a ticket and investigate in working hours." - alert: OrdersAPIBurnRateWarning3d expr: | orders_api:error_ratio:rate3d > (1 * 0.01) and orders_api:error_ratio:rate6h > (1 * 0.01) for: 30m labels: severity: warning slo: orders-api-availability annotations: summary: "orders-api is burning error budget at 1x" description: "Error ratio is {{ $value | humanizePercentage }} over 3d. Left alone, the 28-day budget runs out exactly at the end of the window."kubectl apply -f slo-alert-rules.yamlCause-based alerts such as high memory or crash looping are not in this set on purpose. Page a person for symptoms users feel. Keep cause-based signals on dashboards, or as tickets, to help diagnose.
Common MistakeUsing one window per alert. A single short window pages for every blip. A single long window keeps paging for an hour after you fixed the problem. The pair is what makes the alert both sensitive and quick to clear.
Milestone 6: Route Pages and Tickets to Slack
This milestone sends critical alerts to one channel and warnings to another. If both land in one channel, people learn to ignore it, because a 2am outage and a slow leak look the same.
Create two Slack incoming webhooks (or one webhook per channel) and store them as a Secret so they never reach Git:
kubectl create secret generic slack-webhooks -n monitoring \ --from-literal=page='https://hooks.slack.com/services/REPLACE_PAGE' \ --from-literal=ticket='https://hooks.slack.com/services/REPLACE_TICKET'Create alertmanager-values.yaml. It uses api_url_file, which reads the webhook from the mounted Secret:
alertmanager: alertmanagerSpec: secrets: - slack-webhooks config: route: receiver: slack-ticket group_by: ["alertname", "slo"] group_wait: 30s group_interval: 5m repeat_interval: 4h routes: - receiver: "null" matchers: - alertname = "Watchdog" - receiver: slack-page matchers: - severity = "critical" receivers: - name: "null" - name: slack-page slack_configs: - api_url_file: /etc/alertmanager/secrets/slack-webhooks/page channel: "#alerts-page" send_resolved: true - name: slack-ticket slack_configs: - api_url_file: /etc/alertmanager/secrets/slack-webhooks/ticket channel: "#alerts-ticket" send_resolved: truehelm upgrade kube-prometheus-stack prometheus-community/kube-prometheus-stack \ --namespace monitoring \ --values prometheus-values.yaml \ --values alertmanager-values.yaml \ --version $PROM_VERSIONThe default message already shows the alert name, labels, and annotations, so this config avoids custom Go templates. Custom templates inside Helm values need extra escaping, because Helm tries to render them first.
SecurityTreat webhook URLs like passwords. Anyone holding one can post to your channel.
Milestone 7: Build the SLO Dashboard
This milestone makes the error budget visible to people who do not read PromQL. In Grafana, create a new dashboard with these panels, each backed by your recording rules:
| Panel | Query | What it tells you |
|---|---|---|
| Error ratio now | orders_api:error_ratio:rate5m * 100 |
Current failure percentage |
| Burn rate (1h) | orders_api:error_ratio:rate1h / 0.01 |
1 is on budget, above 14.4 pages |
| Error budget remaining | (1 - orders_api:error_ratio:rate28d / 0.01) * 100 |
Percent of the 28-day budget left |
| Requests per second | sum(rate(http_requests_total{job="orders-api"}[5m])) |
Traffic, so you can tell quiet from broken |
Save it as Orders API SLO Overview, then export the JSON from Dashboard Settings and commit it to Git. Grafana keeps dashboards in its own storage, so a recreated pod loses any dashboard that only lives in the UI.
Milestone 8: Write the Error Budget Policy
This milestone records what the team does as the budget runs down. An SLO that changes no decisions is decoration. Create a one-page SLO.md in your repository with the SLO from Milestone 3 and a policy such as this:
| Budget remaining | Action |
|---|---|
| More than 50% | Ship features normally |
| 25% to 50% | Review recent incidents; require a rollback plan on risky changes |
| Under 25% | Slow releases; prioritise reliability work |
| Exhausted | Freeze feature releases until the budget recovers |
Adjust the numbers for your own service, and state who decides when the policy is invoked. This document is the deliverable that interviewers ask about most.
Validation & Testing
First confirm the pieces exist:
kubectl get prometheusrule -n monitoring# Expected: orders-api-slo-recording and orders-api-slo-alerts # In Prometheus (http://localhost:9090), run:# orders_api:error_ratio:rate5m# Expected: a value once the app has trafficThen break the service on purpose. This traffic generator sends one good and one bad request per loop, so about half the requests fail:
kubectl run loadgen --image=busybox --restart=Never -- /bin/sh -c \ 'while true; do wget -q -O /dev/null http://orders-api.default.svc:8080/ wget -q -O /dev/null http://orders-api.default.svc:8080/err sleep 0.2 done'- In Prometheus,
orders_api:error_ratio:rate5mshould settle near 0.5. - In a fresh lab there is little history, so the 1h ratio also reads high quickly. Open Alerts in Prometheus and watch
OrdersAPIBurnRateCritical1hmove fromInactivetoPending, thenFiringafter its two-minuteforperiod. - Confirm the message reaches your page channel, and that warning alerts go to the ticket channel.
- On the dashboard, confirm the burn rate panel shows a value far above 14.4 and the budget remaining panel drops.
Now fix the problem and watch the alerts resolve:
kubectl delete pod loadgenRecord the time from the first bad request to the page, and the time from the fix to the resolved message. If the gap is longer than you want, shorten the for durations and note the trade-off.
Clean up when you finish:
kubectl delete pod loadgen --ignore-not-foundkubectl delete deployment,service,servicemonitor orders-api -n defaulthelm uninstall kube-prometheus-stack -n monitoringkind delete cluster --name slo-projectCommon MistakeTesting with a service that has no traffic. A ratio of zero requests divided by zero returns nothing, so the alert never fires. Pair your SLO alerts with a separate check for a service that has stopped receiving traffic at all.
As an extension, add a latency SLO using the app's request duration histogram, for example "95% of requests complete in under 300 ms", and give it its own burn-rate alerts.