06 · Health Checks¶
docker ps showing Up only means the main process hasn't exited — it
says nothing about whether the app inside is actually able to serve
traffic. A health check runs a command periodically inside the
container and reports healthy/unhealthy/starting, giving tooling a
real readiness signal.
HEALTHCHECK in a Dockerfile¶
FROM nginx:1.25
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost/ || exit 1
| Option | Meaning |
|---|---|
--interval |
Time between checks (default 30s) |
--timeout |
How long a single check may run before it's considered failed |
--start-period |
Grace period after container start during which failures don't count toward --retries (for slow-starting apps) |
--retries |
Consecutive failures required before marking unhealthy |
The check command must exit 0 for healthy, 1 for unhealthy — exactly
like any shell command's exit code convention. curl -f (fail on HTTP
error status) is a common check for HTTP services; for services without
curl installed, a small script using the runtime's own HTTP client
(e.g. Python's urllib) avoids adding a dependency just for this.
Observing health status¶
docker ps
# STATUS column shows "Up 2 minutes (healthy)" or "(unhealthy)"
docker inspect -f '{{.State.Health.Status}}' myapp
docker inspect -f '{{json .State.Health.Log}}' myapp | python3 -m json.tool
.State.Health.Log keeps the last several check results (output,
duration, exit code) — the first place to look when a container is stuck
unhealthy and it isn't obvious why.
Health checks in Compose¶
services:
api:
build: .
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 3s
start_period: 5s
retries: 3
worker:
build: ./worker
depends_on:
api:
condition: service_healthy
depends_on with condition: service_healthy is what turns "start
order" into "actual readiness" — worker won't start until api's
health check reports healthy, not merely running. Without a health check,
depends_on only guarantees api's container process has begun
starting, which for anything with its own startup time (loading models,
running migrations, warming a cache) is not the same as being ready to
serve requests.
Worked example: a database-dependent service¶
services:
db:
image: postgres:16
environment:
- POSTGRES_PASSWORD=devpassword
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
web:
build: .
depends_on:
db:
condition: service_healthy
pg_isready is Postgres's own bundled readiness-probe binary — preferring
a service's own health-check tool (over guessing with a raw TCP connect)
avoids false "healthy" reports from a database that has accepted a TCP
connection but hasn't finished recovery/initialization yet.
How It Actually Works¶
The health check runs as its own short-lived process inside the
container's namespaces, on a timer owned by the daemon. The Docker
daemon maintains a per-container timer; on each tick it uses the same
exec-into-container mechanism as docker exec (joining the container's
namespaces via setns()) to run the check command, capturing its exit
code, stdout+stderr (truncated), and duration into
.State.Health.Log. This is why a healthcheck sees exactly what
docker exec would see — the container's own filesystem, network stack,
and installed binaries — and why a HEALTHCHECK command that hangs
matters: it doesn't get killed automatically at --timeout's natural
end without daemon involvement, but rather the daemon marks that
individual check attempt as failed once --timeout elapses and moves on,
which is why a check command should itself be one that returns quickly
rather than one that can block indefinitely.
Why --start-period exists as a genuinely separate concept from
--interval. Without it, a slow-starting service (JVM warm-up, model
loading) would rack up --retries consecutive failures before it's ever
had a fair chance to become ready, tripping unhealthy even though
nothing is actually wrong. The daemon tracks two separate counters:
elapsed wall-clock time since container start (compared against
--start-period) and a consecutive-failure count (compared against
--retries) — failures during the start period still run and still get
logged, but they don't increment the counter that can flip the container
to unhealthy. Once --start-period has elapsed, ordinary
--interval/--retries accounting takes over for the rest of the
container's life, including any future failures after a period of being
healthy.
Exercise¶
Add a HEALTHCHECK to a Dockerfile for a simple HTTP service (any
language) that checks GET / every 5 seconds with a 2-second timeout and
3 retries. Deliberately make the app sleep for 8 seconds before binding
its port on startup, and use --start-period correctly so the container
doesn't flip to unhealthy during that startup window. Verify with
docker inspect -f '{{json .State.Health}}'.