Docker · beginner
Docker healthcheck flapping: separate process health from probe timing
Docker healthcheck flapping occurs when a container's HEALTHCHECK directive causes the Docker daemon to toggle the service between healthy and unhealthy states. This guide separates two contracts: the in-process health signal emitted by the application, and the probe timing chosen in the Dockerfile or compose file. Engineers learn to read `docker inspect` health fields, match interval/timeout/retries/start_period to actual process readiness, and prove stability with a sustained observation window.
The symptoms
- •Service alternates between "healthy" and "unhealthy" in `docker ps` over short intervals (seconds to a couple of minutes) with no restart.
- •Upstream orchestrators or load balancers deregister and re-register the container repeatedly, producing connection storms.
- •Application logs show the workload completes successfully each time, yet the daemon still reports a failed probe.
- •Increasing CPU or latency in the container correlates with the unhealthy transitions, suggesting probe timing rather than a logic fault.
- •The container's exit code is 0 on graceful shutdown, but `docker inspect` shows `Status: unhealthy` immediately before exit, blocking ordered drain.
Likely causes
- •Probe interval shorter than the worst-case response time of the health endpoint, so a slow but successful response is counted as a failure.
- •Timeout set below realistic network, GC, warm-up, or cold-cache latency for the process under load.
- •Retries value too low, so a single transient blip (DNS, disk, JIT) cascades into an unhealthy state.
- •`start_period` shorter than genuine warm-up time, so the probe races initialization and reports unhealthy before readiness.
- •Health endpoint shared with readiness or liveness logic, causing the probe to flip on transient dependencies the process can still serve.
- •Healthcheck command itself is flaky: shell parsing, missing tool inside the image, or a port/socket path that differs between runtime and build.
First ten minutes
- 01Capture the current health state and recent transition log with `docker inspect --format '{{json .State.Health}}' <container>` to see `Status`, `FailingStreak`, `Log` entries with timestamps and exit codes.
- 02Reproduce the flip deterministically by sending a single probe against the same command the Dockerfile declares, then comparing its exit code and latency to the values shown in the health log.
- 03Time the in-process health endpoint under representative load (using a load tool or a recorded trace), measuring p50, p95, and p99 latency, to see whether the configured timeout is reachable.
- 04Map the transition timestamps against application logs and host metrics (CPU, GC pauses, disk I/O) to determine whether each "unhealthy" reading coincides with a slow probe or a real failure.
- 05Record the configured `interval`, `timeout`, `retries`, and `start_period` from the Dockerfile or compose file, and compare them against the measured readiness latency and worst-case response time.
Evidence to collect
- •`docker inspect` health block: `Status`, `FailingStreak`, and the `Log` array showing each probe's `Start`, `End`, `ExitCode`, and `Output`.
- •Dockerfile or compose file HEALTHCHECK line with the exact options string and command, including any shell wrappers.
- •Measured response time distribution of the in-process health endpoint under the same load the container normally serves.
- •Application-side readiness signal (for example a `/ready` route, a marker file, or a readiness file written by the entrypoint) and the time it takes to flip from "starting" to "ready".
- •Host or container metrics for CPU, memory pressure, GC, and I/O at the timestamps where the state flipped to unhealthy.
Where to look
- •Dockerfile `HEALTHCHECK` directive, or the `healthcheck` block under the service in docker-compose.yml, where interval, timeout, retries, and start_period are declared.
- •`docker inspect <container>` JSON, specifically `State.Health.Status`, `State.Health.FailingStreak`, and the `State.Health.Log` array that records each probe outcome.
- •The application process boundary: the HTTP or TCP handler that the healthcheck command reaches, including any middleware that may delay or fail the response.
- •Container logs (for example `docker logs`) filtered to the timestamps of each `unhealthy` transition, to see whether the workload actually errored or merely slowed.
- •Daemon-level health log on the host, if the daemon writes probe events to its own log, for an independent record of probe start, end, and exit code.
Diagnostic steps
- 01Read `State.Health.Log` and confirm whether every unhealthy transition has `ExitCode` non-zero, or whether some are timeouts recorded as non-zero even though the process eventually responded.
- 02Compare each probe's `End - Start` duration to the configured `timeout`; values at or near the timeout indicate the probe is being killed, not the application failing.
- 03Run the healthcheck command directly inside the container with `docker exec` and time it under realistic load; if it succeeds but exceeds the configured `timeout`, the contract is unstable rather than the process unhealthy.
- 04Cross-check probe timestamps with application readiness events; if readiness completes after `start_period`, the probe window begins before the process is honestly ready.
- 05Verify the probe command exists in the image (for example, `wget`, `curl`, or `nc`) and that the target port or socket path matches what the process binds during normal operation.
- 06Reproduce a single flap cycle in an isolated test container with the same healthcheck options and load profile, then vary one option at a time to identify which parameter triggers the flip.
Common mistakes
- •Treating every unhealthy reading as a logic bug, when the probe timeout is simply shorter than the slowest legitimate response.
- •Reusing a liveness or readiness endpoint as the Docker HEALTHCHECK target, conflating orchestration-level signals with Docker's per-second probe timing.
- •Setting `retries: 1` so that any single slow probe immediately promotes the container to unhealthy.
- •Forgetting that `start_period` only delays the first probe, not subsequent ones; warm caches after the first request can still time out later probes.
- •Assuming the Docker daemon retries the command inside the container's filesystem context; a missing binary, wrong PATH, or different shell in a slim image produces false negatives that look like flapping.
Safe fixes
- •Set `timeout` to a value comfortably above the measured p99 latency of the health endpoint under load, with documented headroom for GC and cold cache.
- •Set `interval` longer than `timeout`, and ensure `interval * retries` exceeds the worst observed stretch of slow responses so transient blips do not cascade.
- •Introduce or lengthen `start_period` to cover genuine warm-up, and gate readiness on a process-internal signal rather than the first probe response.
- •Use a dedicated, lightweight health endpoint that performs a shallow check (process up, event loop responsive, dependencies reachable) instead of a full dependency sweep used by orchestrator readiness probes.
- •Rebuild the image after any change to the HEALTHCHECK directive so the daemon re-evaluates options; verify with `docker inspect` that the new values are recorded under `Config.Healthcheck`.
Prove the fix
- 01Run the container under representative load for a defined observation window (for example 30 minutes) and confirm `docker ps` shows `healthy` continuously with no transitions.
- 02Confirm `docker inspect --format '{{json .State.Health}}' <container>` shows `FailingStreak` remains 0 and the `Log` array contains only entries with `ExitCode: 0`.
- 03Inject a controlled fault (for example, a brief dependency pause) lasting longer than `timeout * retries` and confirm the container transitions to `unhealthy` exactly once, then back to `healthy` after recovery, with no oscillation.
- 04Verify that a graceful shutdown still records a clean exit and that the last health entry before exit is `healthy`, allowing orchestrators to drain connections in order.
Prevention and next steps
- •Define the healthcheck contract independently from orchestration probes, with documented SLOs for response time and availability of the health endpoint.
- •Keep the healthcheck command minimal and self-contained inside the image, using binaries or built-in shell features that are guaranteed to exist at runtime.
- •Right-size `interval`, `timeout`, `retries`, and `start_period` from measured p99 latency and warm-up data, and revisit them whenever the workload's profile changes.
- •Record healthcheck parameters and their measured latency budgets alongside the Dockerfile so reviewers can spot a probe contract that drifts below real performance.
- •Treat `docker inspect` health fields as a first-class signal in CI, alerting on `FailingStreak > 0` or on non-zero `ExitCode` entries in `State.Health.Log`.
Safe commands and checks
docker inspect --format '{{json .State.Health}}' <container>
docker inspect --format '{{json .Config.Healthcheck}}' <container>
docker ps --format 'table {{.Names}}\t{{.Status}}'
docker logs --since <timestamp> --until <timestamp> <container>
docker exec <container> sh -c 'time <healthcheck-command>'