Docker & ContainersXVIII · MonitoringPrometheus stack
Prometheus + node_exporter + cAdvisor + Grafana
What you'll learn
- Deploy Prometheus, node_exporter, cAdvisor and Grafana on a Docker host
- Explain which exporter owns which layer, and where they overlap
- Expose the Docker daemon metrics endpoint and reach it from a container
- Write a scrape config whose every line you can justify
- Write alert rules against verified metric names
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-12
Prometheus scrapes, Grafana draws, and three exporters produce the numbers. That much fits on a slide. What does not fit on a slide is which exporter owns which layer, where two of them report the same thing differently, and the two networking details that make a correctly assembled stack scrape nothing at all on the first attempt.
Who publishes what
flowchart LR
Kernel[Kernel: /proc, /sys] --> NodeExp[node_exporter :9100]
Cgroups[cgroup v2 tree] --> CAdv[cAdvisor :8080]
Daemon[dockerd] --> DMetrics[metrics-addr :9323]
NodeExp --> Prom[Prometheus]
CAdv --> Prom
DMetrics --> Prom
AppExp[Application /metrics] --> Prom
Prom --> Grafana
Prom --> AlertMgr[Alertmanager]
| Component | Reads | Answers | Does not answer |
|---|---|---|---|
| node_exporter | /proc, /sys | Is the host healthy | Anything per-container |
| cAdvisor | the cgroup tree | Is this container healthy | Whether the app inside works |
| Docker daemon metrics | dockerd internals | Is the engine healthy | Per-container resource usage |
| Application exporter | the app | Are users affected | Why |
The four are not interchangeable and the overlaps are small but real:
node_exporter’s node_memory_MemAvailable_bytes and the sum of every
container’s working set describe the same machine from different ends, and
they will not add up, because the host runs things that are not containers
and because page cache belongs to nobody in particular.
node_exporter
NODE_EXPORTER_VERSION=v1.9.1
docker run -d \
--name node-exporter \
--restart unless-stopped \
--network host \
--pid host \
--read-only \
--cap-drop ALL \
--security-opt no-new-privileges=true \
-v '/:/host:ro,rslave' \
"quay.io/prometheus/node-exporter:${NODE_EXPORTER_VERSION}" \
--path.rootfs=/hostThree flags carry the whole design:
-v '/:/host:ro,rslave'mounts the host root read-only.rslavepropagates mounts made on the host after the container started into the container, so a filesystem mounted next Tuesday still appears innode_filesystem_*. Without it, the exporter reports on the set of filesystems that existed at container start and silently stops noticing new ones.--path.rootfs=/hosttells the exporter to prefix host paths with that mount point. Omit it and you get metrics about the container’s own tiny filesystem, which look plausible and are wrong.--network hostand--pid hostlet network and process metrics describe the host. In a network namespace of its own,node_network_*reports on avethpair.
The collectors you get by default include cpu, meminfo, filesystem,
diskstats, netdev, pressure, loadavg, stat and vmstat — around
fifty of them. The vmstat collector is the one worth knowing about here:
by default it exports only fields matching ^(oom_kill|pgpg|pswp|pg.*fault).*,
which means node_vmstat_oom_kill is available out of the box. That
counter is host-wide OOM kills, and it catches the case where the host ran
out of memory and the kernel picked a victim, which is a different failure
from a container exceeding its own cgroup limit.
cAdvisor
CADVISOR_VERSION=v0.55.1
docker run -d \
--name cadvisor \
--restart unless-stopped \
--publish 127.0.0.1:8080:8080 \
--volume /:/rootfs:ro \
--volume /var/run:/var/run:ro \
--volume /sys:/sys:ro \
--volume /var/lib/docker/:/var/lib/docker:ro \
--volume /dev/disk/:/dev/disk:ro \
--device /dev/kmsg \
--privileged \
"ghcr.io/google/cadvisor:${CADVISOR_VERSION}"The metric names you will actually use, all of them verified against cAdvisor’s published metric list:
| Metric | Type | Use |
|---|---|---|
container_cpu_usage_seconds_total | counter | rate() gives cores consumed |
container_cpu_cfs_throttled_periods_total | counter | numerator of the throttle ratio |
container_cpu_cfs_periods_total | counter | denominator of the throttle ratio |
container_spec_cpu_quota, container_spec_cpu_period | gauge | quota divided by period equals the limit in cores |
container_memory_working_set_bytes | gauge | memory that is not reclaimable cache — alert on this |
container_memory_usage_bytes | gauge | includes page cache — do not alert on this |
container_spec_memory_limit_bytes | gauge | the cgroup limit |
container_oom_events_total | counter | OOM kills inside the cgroup, including children |
container_fs_usage_bytes, container_fs_limit_bytes | gauge | writable-layer and filesystem usage |
container_network_receive_bytes_total, container_network_transmit_bytes_total | counter | per-interface throughput |
container_last_seen | gauge | timestamp of last observation — the basis for absence alerts |
container_start_time_seconds | gauge | detects a restart without needing RestartCount |
The Docker daemon’s own metrics
The daemon publishes Prometheus metrics when you tell it where to listen.
The key is metrics-addr in /etc/docker/daemon.json, and the conventional
port is 9323.
# 1. Add the key, keeping any existing content in the file
sudo python3 - <<'PY'
import json, pathlib
p = pathlib.Path('/etc/docker/daemon.json')
cfg = json.loads(p.read_text()) if p.exists() and p.read_text().strip() else {}
cfg['metrics-addr'] = '127.0.0.1:9323'
p.write_text(json.dumps(cfg, indent=2) + '\n')
PY
# 2. Reload. SIGHUP is enough for metrics-addr; it does not stop containers.
sudo systemctl reload docker
# 3. Verify the endpoint answers and carries the expected namespace
curl -fsS http://127.0.0.1:9323/metrics | grep -c '^engine_daemon_'A non-zero count from step 3 is the evidence. A zero means the daemon
reloaded without the key — most often because the JSON was malformed and
dockerd kept its previous configuration.
The scrape config, annotated
global:
scrape_interval: 15s # container lifetimes are short; 60s misses them
scrape_timeout: 10s # must be < scrape_interval, and cAdvisor is slow
evaluation_interval: 15s
external_labels:
host: docker-prod-01 # survives federation and remote_write
scrape_configs:
# ---- host layer -------------------------------------------------------
- job_name: node
static_configs:
- targets: ['127.0.0.1:9100']
labels:
layer: host
# ---- container layer --------------------------------------------------
- job_name: cadvisor
scrape_interval: 30s # cAdvisor walks the whole cgroup tree; 15s hurts
scrape_timeout: 25s
static_configs:
- targets: ['127.0.0.1:8080']
labels:
layer: container
metric_relabel_configs:
# Drop every cgroup that is not a container: systemd slices, the root
# cgroup, and machine.slice all arrive with an empty name label.
- source_labels: [name]
regex: ''
action: drop
# Drop the per-interface network series for the loopback interface.
- source_labels: [interface]
regex: 'lo'
action: drop
# ---- engine layer -----------------------------------------------------
- job_name: docker
static_configs:
- targets: ['127.0.0.1:9323']
labels:
layer: engine
# ---- application layer ------------------------------------------------
- job_name: apps
metrics_path: /metrics
static_configs:
- targets: ['api:8000', 'worker:8000']
labels:
layer: appThe lines that are not defaults, and why:
scrape_interval: 15sglobally. Prometheus defaults to 1 minute. A container that crash-loops with a five-second restart backoff is born and dies entirely between two 60-second scrapes, so its CPU and memory series never exist and the incident is invisible in the container layer. Fifteen seconds is the smallest interval most people can afford in storage.scrape_timeout: 25son cAdvisor, with a 30-second interval. cAdvisor’s response time scales with the number of cgroups on the host, not the number of containers, and on a host with many systemd units it can take several seconds. The default 10-second timeout produces intermittent scrape failures that look exactly like a flapping network. The timeout must always be less than the interval; Prometheus refuses to start otherwise.external_labels. The one label that identifies which host an alert came from once metrics leave this Prometheus. Adding it later means every historical series lacks it.labels: {layer: ...}. Cheap, and it lets a single alert rule or dashboard variable select a whole layer.metric_relabel_configsrather thanrelabel_configs. Relabelling runs against targets before the scrape; metric relabelling runs against samples after it. Dropping cAdvisor’s non-container series is a per-sample decision, so it belongs in the second one. Putting it in the first drops the whole target and you get nothing.
Alert rules against verified metrics
groups:
- name: docker-containers
rules:
- alert: ContainerRestartLoop
expr: changes(container_start_time_seconds{name!=""}[15m]) >= 3
for: 0m
labels: { severity: ticket }
annotations:
summary: 'Container restarted 3+ times in 15 minutes'
- alert: ContainerOOMKilled
expr: increase(container_oom_events_total{name!=""}[10m]) > 0
for: 0m
labels: { severity: ticket }
annotations:
summary: 'OOM kill inside container cgroup'
- alert: ContainerMemoryNearLimit
expr: |
container_memory_working_set_bytes{name!=""}
/ container_spec_memory_limit_bytes{name!=""} > 0.9
and container_spec_memory_limit_bytes{name!=""} > 0
for: 10m
labels: { severity: ticket }
annotations:
summary: 'Working set above 90 percent of the cgroup limit'
- alert: ContainerCPUThrottled
expr: |
rate(container_cpu_cfs_throttled_periods_total{name!=""}[5m])
/ rate(container_cpu_cfs_periods_total{name!=""}[5m]) > 0.25
for: 10m
labels: { severity: ticket }
annotations:
summary: 'Over a quarter of scheduling periods throttled'
- name: docker-engine
rules:
- alert: DockerDaemonDown
expr: up{job="docker"} == 0
for: 2m
labels: { severity: page }
annotations:
summary: 'Docker daemon metrics endpoint unreachable'
- alert: DockerMetricsMissing
expr: absent(engine_daemon_container_states_containers)
for: 15m
labels: { severity: ticket }
annotations:
summary: 'Engine metric name changed or endpoint reconfigured'The last rule is the one people leave out. Docker’s engine metric names are
explicitly not stable, and a rule whose metric no longer exists does not
error — it evaluates to an empty vector and never fires again. absent()
turns silent blindness into a ticket.
The and container_spec_memory_limit_bytes > 0 clause in the memory rule is
the guard against the false positive from the previous lesson: containers
with no limit report a value derived from host memory, and without the guard
they would dominate the alert.
Grafana
Add Prometheus as a data source pointing at the Prometheus container, then import the community dashboards as a starting point:
- Node Exporter Full, dashboard ID 1860 — the reference host dashboard, and genuinely good.
- cAdvisor / Docker, dashboard ID 893 — dated, but a reasonable skeleton for container panels.
Import them, then delete two thirds of the panels. An imported dashboard is a catalogue of everything an exporter can produce, which is the opposite of what you want during an incident. The dashboard that earns its place has four rows in the order of the previous lesson’s failure map: user impact, container health, engine health, host health — so that scrolling down is the same motion as narrowing the cause.
Verifying the stack, in a way that can fail
$ curl -fsS http://127.0.0.1:9090/api/v1/targets \
| python3 -c 'import json,sys
for t in json.load(sys.stdin)["data"]["activeTargets"]:
print(t["labels"]["job"], t["health"], t.get("lastError", ""))'node up
cadvisor up
docker down connection refused
apps upIllustrative output
That output is the real first-run experience: three targets up and the
daemon down, because metrics-addr is bound to a loopback the Prometheus
container cannot see. “Open Grafana and see if there are graphs” would not
have told you which target failed or why.
Knowledge check
Knowledge check · 5 questions
Q1. Prometheus runs as a container. The daemon has `"metrics-addr": "127.0.0.1:9323"` and the docker target is down with connection refused. What is happening?
Q2. Which metric should a container memory alert be built on?
Q3. Which are true of the Docker daemon metrics endpoint? Select all that apply.
Q4. Why does the cAdvisor job in the example config use `metric_relabel_configs` rather than `relabel_configs` to drop non-container series?
Q5. Neither cAdvisor nor the Docker daemon metrics endpoint publishes a counter for failed image pulls.
Passing score: 75%. Answers are checked in this browser.