Docker & ContainersXXXVIII Β· CapstoneCapstone
Capstone stage 4 β wiring observability to an SLO
What you'll learn
- Scrape every component of the capstone stack, including the daemon itself
- Correlate metrics, logs and traces through a shared service label
- Derive alerting rules from an SLO error budget instead of static thresholds
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11
Stage 3 left the stack serving traffic. Stage 4 makes it answer questions: is it working, for whom, how badly is it failing, and where.
The trap in this stage is building a beautiful observability stack that nobody consults. The defence is to work backwards from a single number β the SLO β and add only the telemetry that either measures it or explains a violation of it.
The SLO first
Service: api.example.com
SLI: proportion of HTTP requests that return a non-5xx
status within 300 ms, measured at the edge
SLO: 99.5% over a rolling 28 days
Error budget: 0.5% of 28 days of requests
at 400 rps = ~4.8 million allowed bad requests
or, in time terms, 3 h 22 m of total outage
Measured at the edge, not in the application: a request the API never received still failed for the user. That choice decides where the metric comes from, which decides what stage 4 scrapes.
What gets scraped
# prometheus.yml
global:
scrape_interval: 15s
external_labels:
env: production
host: capstone-1
scrape_configs:
- job_name: caddy
static_configs:
- targets: ['caddy:2019']
- job_name: api
static_configs:
- targets: ['api:8080']
metrics_path: /metrics
- job_name: web
static_configs:
- targets: ['web:8080']
metrics_path: /metrics
- job_name: worker
static_configs:
- targets: ['worker:8080']
metrics_path: /metrics
- job_name: postgres
static_configs:
- targets: ['postgres-exporter:9187']
- job_name: redis
static_configs:
- targets: ['redis-exporter:9121']
- job_name: node
static_configs:
- targets: ['node-exporter:9100']
- job_name: cadvisor
static_configs:
- targets: ['cadvisor:8080']
- job_name: docker
static_configs:
- targets: ['172.17.0.1:9323']
The last job is the one usually missing. Stage 1 set
"metrics-addr": "127.0.0.1:9323" in daemon.json, which exposes the
daemonβs own metrics β build counts, container state transitions, API
request latency. Prometheus is in a container, so it reaches the host
through the bridge gateway rather than through 127.0.0.1.
curl -fsS http://localhost:9090/api/v1/targets \
| jq -r '.data.activeTargets[] | "\(.labels.job)\t\(.health)\t\(.lastError)"'
curl -fsS http://localhost:9090/api/v1/targets \
| jq -e '[.data.activeTargets[] | select(.health != "up")] | length == 0' \
&& echo 'OK: all targets up'caddy up
api up
web up
worker up
postgres up
redis up
node up
cadvisor up
docker up
OK: all targets upIllustrative output
One label ties the three pillars together
Metrics, logs and traces are only useful together if they agree on the
name of a thing. The capstone uses service everywhere, and stage 1βs
log-opts.labels setting is what makes it work on the log side.
# each service in compose.yml
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
labels: "com.docker.compose.service"
# promtail / alloy scrape config β relabels the Compose service into `service`
scrape_configs:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 15s
relabel_configs:
- source_labels: ['__meta_docker_container_label_com_docker_compose_service']
target_label: 'service'
- source_labels: ['__meta_docker_container_name']
regex: '/(.*)'
target_label: 'container'
TRACE=$(curl -fsS -D - -o /dev/null https://api.example.com/v1/orders \
| awk '/^traceparent:/ {split($2,a,"-"); print a[2]}')
echo "trace: $TRACE"
curl -fsS "http://localhost:3200/api/traces/$TRACE" | jq -r '.batches | length'
curl -fsS --get http://localhost:3100/loki/api/v1/query_range \
--data-urlencode "query={service=\"api\"} |= \"$TRACE\"" \
| jq -r '.data.result | length'trace: 4bf92f3577b34da6a3ce929d0e0e4736
1
3Illustrative output
If that returns a trace and no logs, the log pipeline is not injecting the trace ID and the correlation is decorative. Fix it now β during an incident is the wrong time to discover that the βclick through to logsβ link does nothing.
Alerting from the budget
Static thresholds produce alerts that are either too noisy or too late. Burn-rate alerting asks a different question: at the current error rate, how fast is the budget being consumed?
# rules.yml
groups:
- name: api-slo
rules:
- record: job:api_request:error_ratio_5m
expr: |
sum(rate(caddy_http_requests_total{host="api.example.com",code=~"5.."}[5m]))
/
sum(rate(caddy_http_requests_total{host="api.example.com"}[5m]))
- record: job:api_request:error_ratio_1h
expr: |
sum(rate(caddy_http_requests_total{host="api.example.com",code=~"5.."}[1h]))
/
sum(rate(caddy_http_requests_total{host="api.example.com"}[1h]))
- alert: ApiErrorBudgetFastBurn
expr: |
job:api_request:error_ratio_5m > (14.4 * 0.005)
and
job:api_request:error_ratio_1h > (14.4 * 0.005)
for: 2m
labels:
severity: page
annotations:
summary: "API burning error budget 14.4x β full budget gone in ~2 days"
- alert: ApiErrorBudgetSlowBurn
expr: |
job:api_request:error_ratio_1h > (6 * 0.005)
for: 15m
labels:
severity: ticket
annotations:
summary: "API burning error budget 6x β budget gone in ~5 days"
The two magic numbers are not magic:
budget window = 28 days
fast burn multiple = 14.4 -> exhausts the 28-day budget in 2 days
slow burn multiple = 6 -> exhausts it in about 5 days
error ratio trigger = multiple x (1 - SLO) = 14.4 x 0.005 = 7.2%
The fast rule requires both the 5-minute and the 1-hour windows to be burning. The short window makes it react quickly; the long window stops a 90-second blip from paging anyone. That pairing is the whole technique.
promtool check rules rules.yml
curl -fsS http://localhost:9090/api/v1/rules \
| jq -r '.data.groups[].rules[] | select(.type=="alerting") | "\(.name)\t\(.state)"'
curl -fsS --get http://localhost:9090/api/v1/query \
--data-urlencode 'query=1 - (job:api_request:error_ratio_1h / 0.005)' \
| jq -r '.data.result[0].value[1]'Checking rules.yml
SUCCESS: 4 rules found
ApiErrorBudgetFastBurn inactive
ApiErrorBudgetSlowBurn inactive
0.9993Illustrative output
The stage 4 gate
An alert that has never fired is an untested alert.
# Force a burn and confirm the fast rule fires, then confirm it clears.
docker compose stop api
sleep 180
curl -fsS http://localhost:9090/api/v1/alerts \
| jq -r '.data.alerts[] | "\(.labels.alertname) \(.state)"'
docker compose start api
Expected: ApiErrorBudgetFastBurn firing while api is down, and no
alerts a few minutes after it returns. Run this in a maintenance window
on the capstone, and record the transcript β βwe tested the pagerβ is a
claim that decays and needs a date attached.
- Write the SLO and the error budget down before configuring anything, including where the SLI is measured.
- Add every target, including cadvisor, node-exporter and the Docker daemon through the bridge gateway.
- **Standardise on one
servicelabel** across metrics, logs and traces, and prove correlation with a single trace ID. - Write burn-rate rules with paired short and long windows; validate them with
promtool check rules. - Set Prometheus retention and size the volume from it, and add a dead-man alert for the monitoring stack itself.
- Gate: every target up, one trace ID found in all three pillars, and a recorded test in which the fast-burn alert fired and cleared.
Sanity check
Knowledge check Β· 4 questions
Q1. Prometheus, running in a container, reports the `docker` job down with connection refused against `127.0.0.1:9323`. Why?
Q2. A fast-burn alert pairs a 5-minute and a 1-hour window with the same threshold. What does the long window contribute?
Q3. Why does the capstone scrape both cadvisor and node-exporter? Select all that apply.
Q4. When the Prometheus data volume fills, queries and dashboards fail immediately, making the problem obvious.
Passing score: 75%. Answers are checked in this browser.