Docker Monitoring and Logging
This page covers what the Docker engine itself gives you: container logs and the drivers that carry them, live resource statistics, the event stream, health check state, and the metrics endpoint that lets Prometheus scrape the daemon.
It deliberately stops short of building a monitoring stack. Prometheus, Grafana, Alertmanager, and the exporters have their own guides — see Infrastructure Monitoring for the stack and Grafana — Installation for a complete Compose deployment including cAdvisor and Node Exporter.
Table of Contents
- Container Logs
- Logging Drivers
- Log Rotation
- Resource Statistics
- The Event Stream
- Health Checks
- Debugging a Container
- Metrics for Prometheus
- Best Practices
Container Logs
Docker captures whatever a container writes to stdout and stderr. Anything an application
writes to a file inside the container is invisible to docker logs — which is why
containerized software should log to stdout rather than to /var/log.
docker logs mycontainer
# Follow
docker logs -f mycontainer
# Last 100 lines, then follow
docker logs -f --tail 100 mycontainer
# With timestamps
docker logs -t mycontainer
# Time-bounded
docker logs --since 10m mycontainer
docker logs --since 2026-08-01T09:00:00 --until 2026-08-01T10:00:00 mycontainer
# Separate the streams — stderr only
docker logs mycontainer 2>&1 1>/dev/null
For a Compose project:
docker compose logs -f
docker compose logs -f --tail 100 web
docker compose logs --since 15m web db
Where Logs Live
With the default json-file driver, each container's log is a file on the host:
docker inspect -f '{{.LogPath}}' mycontainer
# /var/lib/docker/containers/<id>/<id>-json.log
# Size of every container log, largest first
sudo du -sh /var/lib/docker/containers/*/*-json.log | sort -rh | head
Warning
json-file has no size limit by default. A container logging steadily will fill the
disk, and no docker system prune command removes container logs. This is one of the most
common causes of a full Docker host — see Log Rotation.
Logging Drivers
Confirm what your daemon supports:
docker info --format '{{.Plugins.Log}}'
# [awslogs fluentd gcplogs gelf journald json-file local splunk syslog]
docker info --format '{{.LoggingDriver}}'
| Driver | docker logs works |
Notes |
|---|---|---|
json-file |
Yes | Default. Cap the size. |
local |
Yes | More efficient, compresses, and rotates by default. Not human-readable on disk. |
journald |
Yes | Into the systemd journal — unified with host logging, queryable via journalctl. |
syslog |
No | Ships to a syslog collector. |
gelf |
No | Graylog Extended Log Format — Graylog, Logstash. |
fluentd |
No | Fluentd / Fluent Bit. |
awslogs, gcplogs, splunk |
No | Hosted log services. |
none |
No | Disables logging for a container. |
Important
With any driver that ships logs elsewhere, docker logs returns an error rather than
output — the daemon never stored them locally. If you want both local inspection and
centralized shipping, use journald or local and collect from the host, rather than
pointing containers directly at a remote collector.
Setting a Driver
Per container:
docker run -d --log-driver local --log-opt max-size=10m myapp
docker run -d --log-driver none noisy-batch-job
docker run -d --log-driver journald --log-opt tag="{{.Name}}" myapp
In Compose:
services:
web:
image: nginx:alpine
logging:
driver: local
options:
max-size: "10m"
max-file: "3"
app:
build: .
logging:
driver: journald
options:
tag: "{{.Name}}"
Host-wide defaults belong in daemon.json — see
Logging Configuration.
Blocking vs Non-Blocking Delivery
A remote driver that cannot reach its collector will, by default, block the container's
writes — the application stalls on stdout. This turns a logging outage into an
application outage.
logging:
driver: fluentd
options:
fluentd-address: "logs.internal:24224"
mode: non-blocking
max-buffer-size: 4m
mode: non-blocking buffers in memory and drops messages once the buffer fills, which is
almost always the right trade for a production service. Set it on every remote driver.
Log Rotation
json-file
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3",
"compress": "true"
}
}
That caps each container at roughly 30 MB.
Note
Log settings are fixed when a container is created. Changing daemon.json affects only
containers created afterwards — existing ones keep their original limits until recreated
(docker compose up -d --force-recreate). This is the usual reason a rotation change
"didn't work."
The local Driver
local rotates by default and is more efficient than json-file. It is the better choice
for anything that does not need external tooling to read the raw files:
{
"log-driver": "local",
"log-opts": { "max-size": "10m", "max-file": "5" }
}
Emergency Truncation
When a log has already filled the disk, truncate in place rather than deleting the file —
the daemon holds an open descriptor, so rm frees nothing until the container stops:
sudo truncate -s 0 "$(docker inspect -f '{{.LogPath}}' mycontainer)"
Then fix the rotation settings so it does not recur.
Resource Statistics
# Live, all running containers
docker stats
# One-shot, script-friendly
docker stats --no-stream
# Specific containers
docker stats web db
# Custom columns
docker stats --no-stream \
--format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}\t{{.BlockIO}}"
# JSON for further processing
docker stats --no-stream --format json | jq -r '"\(.Name) \(.CPUPerc)"'
Available fields: .Container, .Name, .ID, .CPUPerc, .MemUsage, .MemPerc,
.NetIO, .BlockIO, .PIDs.
Note
MemUsage is the cgroup's accounting, which includes page cache attributable to the
container. It will not match what the application reports as its own heap, and a container
sitting near its memory limit is not necessarily in trouble — cache is reclaimable. Judge
pressure by whether the kernel is actually killing the process, not by MemPerc alone.
docker stats is a live view with no history. For trends, scrape cAdvisor into Prometheus —
see Metrics for Prometheus.
Processes Inside a Container
docker top mycontainer
docker top mycontainer -eo pid,ppid,user,%cpu,%mem,cmd
Disk Consumption
docker system df
docker system df -v
Covered in full under Disk Usage and Pruning.
The Event Stream
The daemon emits an event for everything it does. This is the most direct way to answer "what happened to that container at 3 a.m."
# Live
docker events
# Historical window
docker events --since 1h --until 0s
# Container lifecycle only, formatted
docker events --since 24h --until 0s \
--filter type=container \
--format '{{.Time}} {{.Action}} {{.Actor.Attributes.name}}'
1785595807 exec_start: /bin/sh -c exit 0 t-health
1785595807 exec_die t-health
1785595809 kill t-health
1785595809 die t-health
1785595809 destroy t-health
Useful filters:
# Why did it restart? Look for oom, die, and the exit code
docker events --since 24h --until 0s --filter event=oom
docker events --since 24h --until 0s --filter event=die \
--format '{{.Actor.Attributes.name}} exit={{.Actor.Attributes.exitCode}}'
# Health transitions
docker events --filter event=health_status
# A single container
docker events --filter container=myapp
# Image pulls and pushes
docker events --filter type=image
Tip
docker events without --since shows only events from the moment you run it. Historical
events are held in memory and lost on daemon restart — if you need durable audit history,
pipe the stream to a file from a long-running service.
Health Checks
A health check is defined in the image (see Metadata and Health Checks) or at run time. This section covers observing the result.
docker run -d --name web \
--health-cmd 'curl -fsS http://localhost/ || exit 1' \
--health-interval 30s \
--health-timeout 3s \
--health-retries 3 \
--health-start-period 40s \
nginx
Reading Health State
# Current status: starting | healthy | unhealthy
docker inspect -f '{{.State.Health.Status}}' web
# The last few probe results, with output and exit codes
docker inspect -f '{{json .State.Health.Log}}' web | jq
# Filter containers by health
docker ps --filter health=unhealthy
docker ps --filter health=healthy --format '{{.Names}}'
docker ps also shows health in the STATUS column — Up 2 minutes (healthy).
Important
Docker does not restart an unhealthy container. unhealthy is a reported state, not an
action — a restart policy reacts to the process exiting, not to a failing probe. If you
want automatic recovery you need an orchestrator: Swarm reschedules unhealthy
tasks, and Kubernetes restarts on a failed liveness probe.
Compose Dependency Conditions
Health checks are what make depends_on meaningful — without a condition, Compose only waits
for the container to start, not to become usable:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
app:
build: .
depends_on:
db:
condition: service_healthy
Watching for Failures
# Alert-worthy: anything currently unhealthy
docker ps --filter health=unhealthy --format '{{.Names}}'
# Live transitions
docker events --filter event=health_status \
--format '{{.Actor.Attributes.name}} {{.Status}}'
Debugging a Container
Why It Stopped
docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' mycontainer
docker inspect -f '{{.State.Error}}' mycontainer
| Exit code | Meaning |
|---|---|
0 |
Clean exit |
1 / 2 |
Application error |
125 |
The daemon itself failed — bad docker run flags |
126 |
Command found but not executable |
127 |
Command not found — usually a wrong path in ENTRYPOINT/CMD |
137 |
SIGKILL (128+9) — OOM kill, or a docker stop that timed out |
143 |
SIGTERM (128+15) — a normal docker stop |
A 137 with OOMKilled=true is the kernel killing the process for exceeding its memory
limit. A 137 with OOMKilled=false usually means the container ignored SIGTERM and was
killed after the stop grace period — see
ENTRYPOINT vs CMD for why shell-form entrypoints cause this.
Inspecting a Running Container
docker exec -it mycontainer sh
docker exec mycontainer ps aux
docker exec mycontainer cat /proc/1/status
# Full configuration and state
docker inspect mycontainer | jq
# Filesystem changes since the image
docker diff mycontainer
For minimal images without a shell, attach a toolbox to the container's namespaces rather than rebuilding it:
docker run --rm -it --pid container:mycontainer --network container:mycontainer \
nicolaka/netshoot bash
The same technique for networking specifically is covered in Testing From Inside a Container.
Metrics for Prometheus
Daemon Metrics
The daemon can expose its own Prometheus metrics — engine-level counters covering builds, container actions, and daemon health:
{
"metrics-addr": "127.0.0.1:9323"
}
sudo systemctl restart docker
curl -s http://127.0.0.1:9323/metrics | head
Caution
Bind to 127.0.0.1, not 0.0.0.0. The endpoint is unauthenticated and reveals detail
about what the host is running. Scrape it from a collector on the same host, or over an
internal interface protected by firewall rules.
# prometheus.yml
scrape_configs:
- job_name: docker-engine
static_configs:
- targets: ['127.0.0.1:9323']
Container Metrics
The daemon endpoint does not provide per-container CPU, memory, and I/O. Those come from cAdvisor, with host-level metrics from Node Exporter. Both are documented, with working configuration, in Prometheus — Exporters; a complete Compose stack wiring them to Prometheus and Grafana is in Grafana — Installation.
Alert rules for container conditions belong with the rest of the rule set — see Prometheus — Alerting, and Alertmanager for routing. Two container-specific notes when writing them:
up == 0means the scrape target is down, which is not the same as a container being down. For container liveness from cAdvisor, key offcontainer_last_seengoing stale.- Aggregate CPU rates before comparing.
rate(container_cpu_usage_seconds_total[5m])yields one series per CPU per container; withoutsum by (name)a threshold fires on individual series rather than on the container's total.
Centralized Logging
Shipping container logs to Loki, the ELK stack, or a hosted service is a logging driver choice on the Docker side. The collectors themselves are covered under Log Management.
Best Practices
- Log to stdout/stderr. Files inside a container are invisible to every Docker logging mechanism.
- Cap log size on day one —
json-fileis unbounded by default, or uselocal, which rotates. - Set
mode: non-blockingon any remote driver so a collector outage cannot stall the application. - Emit structured JSON from applications where the log volume justifies parsing.
- Add a
HEALTHCHECKto anything long-running, and remember Docker alone will not act on it. - Never log secrets — container logs are readable by anyone with daemon access and are usually shipped somewhere less protected.
- Recreate containers after changing log settings; the configuration is fixed at creation.
- Keep the metrics endpoint on localhost.
Related Topics
- Daemon Configuration — host-wide log driver defaults and the metrics endpoint
- Docker Storage — disk usage, and why logs are not covered by pruning
- Building Images — authoring
HEALTHCHECKand correct signal handling - Docker Compose — per-service logging and health configuration
- Docker Swarm — health-driven rescheduling and rolling updates
- Infrastructure Monitoring — the Prometheus and Grafana stack
- Prometheus — Exporters — cAdvisor and Node Exporter