Skip to main content
RunBook Academy

Docker & ContainersXX · Health & Failure DetectionHealthchecks

Health checks — what they can and cannot do

Intermediate⏱ ~26 mindocker

What you'll learn

  • Describe the healthcheck state machine, including what `--start-period` actually does
  • Compute worst-case detection time from `interval`, `timeout` and `retries`
  • Recognise a false green — a check that passes while the service is unusable
  • Explain why an unhealthy container keeps running and keeps taking traffic
  • Read `State.Health.Log` to find out why a check failed

Prerequisites

None — start here.

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.

A Docker healthcheck is a command the daemon runs inside your container on a timer. If it exits 0 the container is healthy; if it exits non-zero enough times in a row, the container is unhealthy.

That is the entire mechanism. Everything people expect to follow from it — a restart, a load balancer update, a page — is something else’s job, and on a standalone Docker host most of those jobs are unfilled. This lesson is about the gap between what the green word in docker ps means and what people read it to mean.

The state machine

A container with a healthcheck is always in exactly one of four states, and the transitions have rules that are worth knowing precisely because two of them are counter-intuitive.

StatusMeaning
noneno healthcheck is defined
startingthe container has started and no check has yet succeeded
healthythe last check exited 0
unhealthyretries consecutive checks have failed, after the start period

The probe’s exit code is interpreted strictly:

Exit codeResult
0healthy
1unhealthy
2reserved — treated as unhealthy
anything elseerror running the probe — treated as a failure

That last row is the one that bites. A probe that cannot be executed at all — the binary is not in the image — exits 127, and the container sits unhealthy forever with an application that is working perfectly.

The five options and their real defaults

Configuration changeDockerfile
HEALTHCHECK --interval=10s \
          --timeout=3s \
          --start-period=60s \
          --start-interval=2s \
          --retries=3 \
CMD curl -fsS --max-time 2 http://localhost:8080/healthz || exit 1
OptionDefaultWhat it bounds
--interval30stime between checks once the container is past the start period
--timeout30show long one check may run before it is killed and counted as a failure
--retries3consecutive failures needed to go unhealthy
--start-period0sgrace window after start during which failures do not count
--start-interval5stime between checks during the start period

The --timeout default of 30s matching the --interval default of 30s is worth pausing on: with both at their defaults, a hanging check occupies the entire interval, so checks run back to back with no gap. Set --timeout to a fraction of --interval, not a value near it.

Detection-time arithmetic

Two numbers matter and people usually only think about one.

Time to detect a failureretries × (interval + worst-case check duration), where the worst case is timeout.

With the defaults — interval 30s, timeout 30s, retries 3 — that is up to 3 minutes of a broken container reporting healthy.

ConfigurationWorst-case detection
defaults (30s / 30s / 3)~180s
--interval=10s --timeout=3s --retries=3~39s
--interval=5s --timeout=2s --retries=2~14s
--interval=2s --timeout=1s --retries=2~6s

Tightening this is not free. The probe runs inside the container, against the same CPU and memory limits as the application, and a check that costs 40 ms every 2 seconds is 2% of a core doing nothing but answering itself. More importantly, a short retries makes the check sensitive to a single slow response — one GC pause longer than timeout and a healthy container is marked unhealthy.

The shape that works: retries of at least 2 so a single blip cannot flip the state, timeout well under interval, and an interval sized against how quickly anything downstream can actually react. Detecting a failure in 6 seconds is pointless if the only consumer is a human reading a dashboard every morning.

The false green

A healthcheck’s value is entirely determined by how much of the request path it exercises. Most of them exercise none of it.

CheckProvesMisses
CMD exit 0nothingeverything
nc -z localhost 8080a socket is listeningthe process that accepted it may be deadlocked
curl -f http://localhost:8080/the HTTP server answersrouting, the database, the queue, the disk
curl -f http://localhost:8080/healthz returning a constant 200the framework is routingevery dependency
/healthz that opens a DB connection and runs SELECT 1the app can reach its databasewrite path, disk full, downstream APIs

The rule: a healthcheck must exercise the dependency path it claims to cover, or it is asserting rather than checking.

An endpoint that returns 200 OK from a handler with no body is not measuring your application; it is measuring your web framework. When the database connection pool is exhausted and every real request is failing, that endpoint keeps returning 200, docker ps keeps saying healthy, and the dashboard is green over a total outage.

Read-only / Saferun the probe by hand before you trust it
CONTAINER=web

# What is the daemon actually configured to run?
docker inspect -f '{{json .Config.Healthcheck}}' "$CONTAINER"

# Run the same command in the same container and read the exit code.
docker exec "$CONTAINER" curl -fsS --max-time 2 http://localhost:8080/healthz
echo "exit=$?"

# Prove it fails when it should: point it at a route that does not exist.
docker exec "$CONTAINER" curl -fsS --max-time 2 http://localhost:8080/definitely-not-a-route
echo "exit=$?"

The second docker exec is the part people skip and the part that matters. A check that has never been observed failing is a check you have not tested — you have confirmed it can return zero, which was never in doubt.

The misconception: Docker does not restart an unhealthy container

This is the single most important sentence in the lesson.

A container marked unhealthy keeps running. The daemon writes the status, emits an event, and does nothing else. It does not stop it, does not restart it, and does not stop it receiving traffic on a published port.

Restart policies and health status are separate mechanisms that do not interact. restart: always restarts a container that exits. An unhealthy container has not exited — that is the definition of being a running container with a failing probe — so the restart policy never fires. Setting restart: unless-stopped in the hope of getting “restart when unhealthy” produces exactly nothing.

What does consume health status:

ConsumerEffectAvailable in standalone Docker
Compose depends_on: condition: service_healthydelays dependent startup onlyyes
Docker Swarmreplaces the taskonly in Swarm mode
Traefik loadbalancer.healthcheckstops routing — its own check, not Docker’syes, but it is Traefik’s probe
docker events / docker ps --filter health=unhealthytells youyes, if something is watching
Kubernetes liveness proberestarts the containernot Docker

Note the Traefik row carefully: a proxy that “respects health” is running its own probe against the application, not reading Docker’s State.Health.Status. Two independent health checks with different intervals and different endpoints is the normal state of affairs, and they can disagree.

Configuration changethe supervisor Docker does not ship
#!/usr/bin/env bash
set -euo pipefail

# Only ever restart containers that opt in with a label, so this can
# never act on a database that happens to fail one probe.
docker events --filter 'event=health_status' --format '{{.Actor.Attributes.name}} {{.Status}}' | while read -r name status; do
  [ "$status" = 'health_status: unhealthy' ] || continue
  optin=$(docker inspect -f '{{index .Config.Labels "autoheal"}}' "$name" 2>/dev/null || true)
  [ "$optin" = 'true' ] || continue
  logger -t autoheal "restarting unhealthy container $name"
  docker restart "$name"
done

Reading the health log

The status word is the least useful part of the output. The log is where the answer is.

Read-only / Safewhy it is unhealthy
$ docker inspect --format '{{json .State.Health}}' web | python3 -m json.tool
{
  "Status": "unhealthy",
  "FailingStreak": 7,
  "Log": [
      {
          "Start": "2026-08-12T02:14:31.118Z",
          "End": "2026-08-12T02:14:33.121Z",
          "ExitCode": -1,
          "Output": "Health check exceeded timeout (2s)"
      },
      {
          "Start": "2026-08-12T02:14:41.130Z",
          "End": "2026-08-12T02:14:41.288Z",
          "ExitCode": 22,
          "Output": "curl: (22) The requested URL returned error: 503"
      }
  ]
}

Illustrative output

Read the exit codes as a diagnosis:

Exit code in the logMeans
-1 with “exceeded timeout”the probe was killed at --timeout; the application is hanging, not erroring
7 (curl)connection refused — nothing is listening on that port inside the container
22 (curl)the server answered with 4xx or 5xx — the application is up and rejecting
28 (curl)curl’s own --max-time fired before Docker’s timeout
127the probe binary is not in the image

FailingStreak is the counter that --retries is compared against. Watching it climb and reset is how you distinguish a genuinely broken service from one that fails one probe in ten — the second is a --retries or --timeout problem, not an application problem.

Read-only / Safefleet-wide health
# Unhealthy right now
docker ps --filter health=unhealthy --format 'table {{.Names}}\t{{.Status}}'

# Containers with NO healthcheck - usually a bigger finding than an
# unhealthy one, because nothing is watching them at all.
docker ps --filter health=none --format 'table {{.Names}}\t{{.Image}}'

# Watch transitions live. Flapping shows as repeated pairs.
docker events --filter 'event=health_status' --format '{{.Time}} {{.Actor.Attributes.name}} {{.Status}}'

The health=none query is the one to run on a host you have just inherited. An unhealthy container is at least being measured; a container with no healthcheck is invisible to every mechanism in this part of the course, including depends_on: service_healthy, which silently degrades to “started” when the dependency has no check.

Two levels, and which one Docker gets

Most well-built applications expose two endpoints:

  • /health/live — is the process responsive? Cheap, no dependencies. Answers “should this be restarted?”
  • /health/ready — can it serve a real request right now? Checks the dependencies it needs. Answers “should this get traffic?”

Kubernetes consumes both, as liveness and readiness probes, and does different things with them. Docker has exactly one healthcheck and no built-in consumer, which forces a choice.

Point Docker’s healthcheck at readiness. The reasoning: the only things that consume it on a standalone host are depends_on: service_healthy and whatever you write, and both of those are asking “is it usable yet”, which is readiness. Liveness has no consumer because Docker will not restart on unhealthy anyway.

Verification that can fail

Read-only / Safeprove the check detects the failure it claims to
set -euo pipefail
APP=web
DEP=postgres

# Baseline: it should be healthy.
docker inspect -f '{{.State.Health.Status}}' "$APP"

# Break the dependency the check claims to cover.
docker stop "$DEP"

# Wait out (retries x interval) plus a margin, then look again.
sleep 45
docker inspect -f '{{.State.Health.Status}}' "$APP"

# Put it back and confirm recovery. One success is enough to go healthy.
docker start "$DEP"
sleep 20
docker inspect -f '{{.State.Health.Status}}' "$APP"

If the middle reading is still healthy, your healthcheck does not check what you think it checks — and you have found that out on a Tuesday afternoon rather than during an incident. That sequence is the only meaningful test of a healthcheck, and it takes ninety seconds.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A container has `restart: unless-stopped` and its healthcheck has been failing for ten minutes. What has Docker done about it?

  2. Q2. What does `--start-period=60s` actually change?

  3. Q3. Which of these are false greens — checks that can pass while the service is unusable? Select all that apply.

  4. Q4. With the default `--interval`, `--timeout` and `--retries`, a broken container can report healthy for around three minutes.

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

Where next

Restart policies are the mechanism people reach for when they discover Docker will not restart an unhealthy container. The next lesson covers what they actually do, including the backoff, and why they are not that mechanism.