Skip to main content
RunBook Academy

Docker & ContainersXVIII Β· MonitoringDaemon signals

What the daemon can tell you β€” stats, events, healthchecks, /metrics

Intermediate⏱ ~22 min

What you'll learn

  • Read docker stats correctly, including the two columns that mislead
  • Use docker events as an audit stream and know what it does not retain
  • Expose the daemon metrics endpoint and know what it does not contain
  • Separate signals you can alert on from signals you can only look at

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

Not yet marked complete on this device.

Before you deploy an exporter, know what the daemon already emits. There are exactly four channels, and the useful distinction between them is not what they contain β€” it is whether you can build an alert on them.

SignalShapeRetained?Alertable?
docker statslive streamnono
docker eventsevent streamin memory, until daemon restartonly via a collector
healthcheck stateper-container statuslast 5 probesyes, via events or an exporter
daemon /metricsPrometheus scrapeby your TSDByes

Three of the four are things you look at during an incident. One of them is monitoring.

docker stats

docker stats streams live resource usage, refreshing about once a second. --no-stream takes a single sample and exits, which is the form you want in a script.

Read-only / Safeone sample
$ docker stats --no-stream \
--format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.PIDs}}'
NAME         CPU %     MEM USAGE / LIMIT   MEM %     PIDS
otel         0.02%     140MiB / 512MiB     27.34%    12
grafana      0.89%     194.6MiB / 512MiB   38.01%    17
prometheus   1.56%     98.98MiB / 1GiB     9.67%     12
loki         0.36%     114.3MiB / 512MiB   22.33%    17
postgres     0.00%     36.63MiB / 1.5GiB   2.38%     6
redis        0.41%     17.51MiB / 512MiB   3.42%     6

Two columns mislead often enough to be worth memorising.

CPU % is not a share of the host. It is scaled by the number of CPUs, so 100% means one fully consumed CPU and a container using four cores reads 400%. On a 12-CPU host, 1200% is the ceiling, not 100%. An alert written as β€œCPU % above 90” fires constantly on a busy two-core workload and never fires on a saturated one-core workload.

MEM USAGE / LIMIT shows the host’s total memory as the limit when the container has no memory limit set. MEM % is then that container’s usage as a fraction of the whole host, which is not a number anyone wants. If your MEM LIMIT column shows the same large figure for every container, nothing has a limit β€” that is the finding, and it matters more than any of the numbers beside it.

docker events

The daemon publishes an event for every lifecycle transition it performs: containers created, started, killed, dying, OOM-killed; images pulled; volumes mounted; networks connected; health status changing.

Read-only / Safe30 minutes of events
$ docker events --since 30m --until 0m \
--format '{{.Time}} {{.Type}} {{.Action}} {{.Actor.Attributes.name}}'
1786462730 container exec_create: /bin/sh -c wget --quiet --spider http://127.0.0.1:3000/api/health || exit 1 grafana
1786462730 container exec_start: /bin/sh -c wget --quiet --spider http://127.0.0.1:3000/api/health || exit 1 grafana
1786462730 container exec_die grafana
1786462731 container exec_create: /bin/sh -c wget --quiet --spider http://127.0.0.1:8222/healthz || exit 1 nats
1786462731 container exec_start: /bin/sh -c wget --quiet --spider http://127.0.0.1:8222/healthz || exit 1 nats
1786462731 container exec_die nats

--since and --until accept timestamps or durations, and --until with a value makes the command exit instead of streaming β€” which is what you want in a script. Filter aggressively:

Read-only / Safethe events that matter
docker events --since 24h --until 0m \
--filter 'event=die' \
--filter 'event=oom' \
--filter 'event=health_status' \
--format '{{.Time}} {{.Actor.Attributes.name}} {{.Action}} exit={{.Actor.Attributes.exitCode}}'

Healthcheck state

A container’s health is exposed on .State.Health, and Docker keeps the last five probe results:

Read-only / Safehealth state
$ docker inspect loki \
--format '{{.State.Health.Status}} streak={{.State.Health.FailingStreak}} probes={{len .State.Health.Log}}'
healthy streak=0 probes=5

Two properties matter for monitoring design:

  • health_status events fire on transition, not on every probe. A container that has been healthy for four days emits no health_status events at all. Absence of the event means β€œno change”, not β€œno data”.
  • The probe log is five deep. A container that flapped unhealthy β†’ healthy β†’ unhealthy β†’ healthy overnight shows a clean status and a FailingStreak of 0 in the morning. The flapping is only recoverable from the event stream, if something captured it.

The daemon metrics endpoint

This is the only one of the four that is a real monitoring interface. Enable it in /etc/docker/daemon.json:

{
  "metrics-addr": "127.0.0.1:9323"
}

Restart the daemon, then scrape it:

curl -s http://127.0.0.1:9323/metrics | grep -E '^engine_daemon' | head
# prometheus.yml
scrape_configs:
  - job_name: docker
    static_configs:
      - targets: ["127.0.0.1:9323"]

Bind it to 127.0.0.1 unless you have a specific reason not to. It is an unauthenticated endpoint that describes your infrastructure.

Sanity check

Knowledge check Β· 4 questions

  1. Q1. docker stats shows a container at 380% CPU on a 12-CPU host. What does that mean?

  2. Q2. Which signal will tell you, at 09:00, that a container was OOM-killed at 02:10 last night?

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

  4. Q4. A container that shows health status healthy with FailingStreak 0 has not been unhealthy at any point in the last 24 hours.

Passing score: 75%. Answers are checked in this browser.