Somewhere between “the container is running” and “the container is healthy” there’s a whole category of problems that only docker stats catches — a memory leak that’s slowly climbing toward the limit, a container pegging CPU because of a runaway loop, or a network-chatty process eating bandwidth nobody accounted for. I check docker stats reflexively any time something feels sluggish, before I even open logs. This guide covers the command itself, how to make it scriptable, and what’s actually happening under the hood via cgroups — because understanding that is what lets you interpret the numbers correctly instead of guessing.
Basic Usage
docker stats
This launches a live, auto-refreshing table for all running containers:
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
a1b2c3d4e5f6 web 2.34% 45.2MiB / 512MiB 8.83% 1.2kB / 850B 0B / 4.1kB 4
f6e5d4c3b2a1 worker 78.91% 390MiB / 512MiB 76.17% 3.4kB / 1.2kB 12MB / 0B 9
Press Ctrl+C to exit — this doesn’t stop any containers, only detaches the view.
Scoping to Specific Containers
docker stats web worker
Or by pattern using shell expansion with docker ps:
docker stats $(docker ps --filter "name=web" --format "{{.Names}}")
Getting a One-Time Snapshot (Not Live-Streaming)
By default docker stats streams continuously, which is awkward in scripts. Use --no-stream for a single sample:
docker stats --no-stream
Expected output: the same table format, printed once, then the command exits immediately — ideal for cron jobs, health-check scripts, or logging snapshots at intervals.
Machine-Readable Output With --format
For scripting or feeding into a monitoring pipeline, use Go template formatting:
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}"
Expected output:
NAME CPU % MEM USAGE / LIMIT MEM %
web 2.34% 45.2MiB / 512MiB 8.83%
worker 78.91% 390MiB / 512MiB 76.17%
For JSON (much easier to parse programmatically):
docker stats --no-stream --format '{{json .}}'
Expected output — one JSON object per container:
{"BlockIO":"0B / 4.1kB","CPUPerc":"2.34%","Container":"a1b2c3d4e5f6","ID":"a1b2c3d4e5f6","MemPerc":"8.83%","MemUsage":"45.2MiB / 512MiB","Name":"web","NetIO":"1.2kB / 850B","PIDs":"4"}
Pipe through jq to alert on thresholds:
docker stats --no-stream --format '{{json .}}' | jq -r 'select(.MemPerc | rtrimstr("%") | tonumber > 75) | "\(.Name) is at \(.MemPerc) memory usage"'
Expected output when a container crosses 75% memory:
worker is at 76.17% memory usage
Scripted Polling for Monitoring Pipelines
A simple script that logs stats every 10 seconds to a CSV for later analysis:
#!/usr/bin/env bash
# poll-stats.sh
OUTFILE="container-stats.csv"
echo "timestamp,name,cpu_perc,mem_usage,mem_perc" > "$OUTFILE"
while true; do
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
docker stats --no-stream --format '{{.Name}},{{.CPUPerc}},{{.MemUsage}},{{.MemPerc}}' \
| while IFS=, read -r name cpu mem_usage mem_perc; do
echo "${ts},${name},${cpu},${mem_usage},${mem_perc}" >> "$OUTFILE"
done
sleep 10
done
chmod +x poll-stats.sh
nohup ./poll-stats.sh &
This is a lightweight starting point — for anything beyond ad-hoc analysis, feed the same data into Prometheus instead (see below), which handles retention, querying, and alerting far better than a flat CSV file.
Inspecting a Single Container’s Raw cgroup Stats via the API
docker stats is actually a formatted view over the Docker Engine API’s /containers/{id}/stats endpoint, which returns raw cgroup accounting data. You can query it directly:
curl --unix-socket /var/run/docker.sock \
"http:/containers/web/stats?stream=false" | jq .
Expected output (trimmed): a large JSON object including cpu_stats, precpu_stats, memory_stats, networks, and blkio_stats — this is the raw data docker stats computes its percentages from. Useful when you need finer-grained fields than the CLI table exposes, like per-CPU usage or detailed memory breakdown (cache, rss, swap).
{
"memory_stats": {
"usage": 47431680,
"limit": 536870912,
"stats": {
"cache": 2048000,
"rss": 45383680
}
}
}
How Docker Calculates These Numbers (cgroups Under the Hood)
Docker containers get their resource accounting from Linux control groups (cgroups) — the same kernel mechanism that enforces resource limits. Every container is assigned a cgroup, and the kernel tracks CPU time, memory usage, and I/O directly against it. Docker doesn’t measure anything itself; it reads these kernel-maintained counters and does the math.
CPU % is calculated as:
cpu_delta = current_cpu_usage - previous_cpu_usage
system_delta = current_system_cpu_usage - previous_system_cpu_usage
CPU % = (cpu_delta / system_delta) * number_of_cpus * 100
This is why CPU percentages can exceed 100% for multi-threaded containers on multi-core hosts — a container using 2 full cores shows as ~200%.
MEM USAGE is the cgroup’s tracked memory, which by default includes page cache on cgroup v1 — meaning a container reading a lot of files from disk can show inflated memory usage that isn’t actually “at risk” memory the OOM killer would reclaim under pressure. On cgroup v2 (default on modern Linux distros and Docker 20.10+), this accounting is more accurate and generally excludes reclaimable cache from the pressure calculation, though docker stats display still includes cache in the raw usage number.
Check which cgroup version your host uses:
docker info --format '{{.CgroupVersion}}'
Expected output:
2
Setting Resource Limits (What “LIMIT” in the Output Means)
The LIMIT column reflects whatever you constrained the container to at run time — if unset, it defaults to the host’s total memory. Set explicit limits:
docker run -d --name limited-app \
--memory=256m \
--memory-swap=256m \
--cpus="0.5" \
nginx:alpine
docker stats limited-app --no-stream
Expected output:
NAME CPU % MEM USAGE / LIMIT MEM %
limited-app 0.05% 3.2MiB / 256MiB 1.25%
--cpus="0.5" caps the container to half a CPU core — under sustained load, docker stats CPU% for this container will plateau near 50% even though more capacity exists on the host, because the cgroup CPU quota is throttling it.
Docker Compose Resource Limits and Stats
# docker-compose.yml
services:
worker:
image: myapp/worker:latest
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
memory: 256M
Note: deploy.resources is honored by docker compose up for local development in recent Compose versions, and always honored in Swarm mode. Check applied limits:
docker inspect worker --format '{{.HostConfig.Memory}}'
Feeding Stats Into Prometheus for Real Monitoring
For anything beyond ad-hoc checks, run cAdvisor, which exposes per-container resource metrics in Prometheus format — this is the standard approach in production rather than polling docker stats yourself:
docker run -d \
--name cadvisor \
--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 \
--publish=8080:8080 \
--privileged \
--device=/dev/kmsg \
gcr.io/cadvisor/cadvisor:v0.49.1
Verify metrics are exposed:
curl -s http://localhost:8080/metrics | grep container_memory_usage_bytes | head -3
Expected output:
container_memory_usage_bytes{container_label_...,name="web"} 4.7431680e+07
Point a Prometheus scrape config at it:
scrape_configs:
- job_name: 'cadvisor'
static_configs:
- targets: ['localhost:8080']
From there, Grafana dashboards and alerting rules (e.g., container_memory_usage_bytes / container_spec_memory_limit_bytes > 0.9) give you the sustained, historical view that a live docker stats terminal session can’t.
Security Considerations
- cAdvisor requires broad host mounts and
--privilegedto read cgroup/proc data — restrict it to trusted internal networks and don’t expose port 8080 publicly without authentication in front of it (e.g., a reverse proxy with auth). - Access to
docker statsrequires Docker socket access, same root-equivalent caveat as every other command in this series — don’t grantdockergroup membership casually on shared/multi-tenant hosts. - Resource limits (
--memory,--cpus) are a security boundary as much as a performance one — an unconstrained container can starve co-located workloads (noisy-neighbor problem) or exhaust host memory, triggering the OOM killer against unrelated processes.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
docker stats shows -- for all fields | Container just started, first sample not yet available | Wait one refresh cycle (~1s) |
| MEM % seems too high for what the app should use | cgroup v1 including page cache in usage | Check docker info cgroup version; consider upgrading host to cgroup v2 |
| CPU % stuck near a fixed ceiling | --cpus limit throttling the container | Check docker inspect --format '{{.HostConfig.NanoCpus}}' |
docker stats hangs / very slow to update | Large number of containers on the host | Scope to specific containers instead of the full fleet |
| cAdvisor container fails to start | Missing --privileged or required host mounts | Re-check the exact volume/device flags in the run command above |
Real-World Deployment Notes
docker stats is the tool I reach for first because it needs zero setup — no exporter, no scrape config, just a running Docker daemon. That makes it ideal for the “something feels off, let me check right now” moment. But I’ve learned not to trust a single snapshot too much: a container can look fine at the instant you check and still have been thrashing memory five minutes earlier. That gap is exactly why cAdvisor-plus-Prometheus earns its place once a service matters enough to have an on-call rotation — it turns “what does this look like right now” into “what did this look like during the incident window,” which is almost always the more useful question during a postmortem.
Summary
docker stats gives you a real-time window into what a container is actually doing on the host — CPU, memory, network, and block I/O — sourced directly from the kernel’s cgroup accounting rather than anything Docker computes independently. It’s the right tool for live debugging and quick sanity checks; for anything you need historical trends or alerting on, graduate to cAdvisor plus Prometheus and Grafana, which read the same underlying cgroup data but retain and query it properly over time.