Skip to main content
RunBook Academy

Docker & ContainersXVIII · MonitoringPrometheus stack

Prometheus + node_exporter + cAdvisor + Grafana

Intermediate⏱ ~30 min

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

Not yet marked complete on this device.

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]
ComponentReadsAnswersDoes not answer
node_exporter/proc, /sysIs the host healthyAnything per-container
cAdvisorthe cgroup treeIs this container healthyWhether the app inside works
Docker daemon metricsdockerd internalsIs the engine healthyPer-container resource usage
Application exporterthe appAre users affectedWhy

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

Configuration changenode_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=/host

Three flags carry the whole design:

  • -v '/:/host:ro,rslave' mounts the host root read-only. rslave propagates mounts made on the host after the container started into the container, so a filesystem mounted next Tuesday still appears in node_filesystem_*. Without it, the exporter reports on the set of filesystems that existed at container start and silently stops noticing new ones.
  • --path.rootfs=/host tells 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 host and --pid host let network and process metrics describe the host. In a network namespace of its own, node_network_* reports on a veth pair.

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

Configuration changecAdvisor
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:

MetricTypeUse
container_cpu_usage_seconds_totalcounterrate() gives cores consumed
container_cpu_cfs_throttled_periods_totalcounternumerator of the throttle ratio
container_cpu_cfs_periods_totalcounterdenominator of the throttle ratio
container_spec_cpu_quota, container_spec_cpu_periodgaugequota divided by period equals the limit in cores
container_memory_working_set_bytesgaugememory that is not reclaimable cache — alert on this
container_memory_usage_bytesgaugeincludes page cache — do not alert on this
container_spec_memory_limit_bytesgaugethe cgroup limit
container_oom_events_totalcounterOOM kills inside the cgroup, including children
container_fs_usage_bytes, container_fs_limit_bytesgaugewritable-layer and filesystem usage
container_network_receive_bytes_total, container_network_transmit_bytes_totalcounterper-interface throughput
container_last_seengaugetimestamp of last observation — the basis for absence alerts
container_start_time_secondsgaugedetects 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.

Service impact possibleenable and verify daemon metrics
# 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

Configuration changeprometheus.yml
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: app

The lines that are not defaults, and why:

  • scrape_interval: 15s globally. 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: 25s on 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_configs rather than relabel_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

Configuration changerules/docker.yml
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

Read-only / Safeevery target, with its last error
$ 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 up

Illustrative 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

  1. 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?

  2. Q2. Which metric should a container memory alert be built on?

  3. Q3. Which are true of the Docker daemon metrics endpoint? Select all that apply.

  4. Q4. Why does the cAdvisor job in the example config use `metric_relabel_configs` rather than `relabel_configs` to drop non-container series?

  5. 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.