Короткий ответ

unhealthy значит: процесс жив, а HEALTHCHECK/healthcheck.test возвращает ненулевой код. На host.example запустите ту же команду через docker compose exec, почините URL/порт/утилиту/start_period. Не ставьте disable: true и не удаляйте healthcheck «чтобы стек поднялся»: тогда depends_on: condition: service_healthy врёт зелёным. Docker Engine сам не убивает unhealthy в чистом Compose (в Swarm/k8s — да); у вас сломается оркестрация зависимостей и мониторинг.

Симптомы и как отличить

  • docker compose psUp (unhealthy);
  • web не стартует: ждёт db healthy;
  • health: starting минутами (длинный start_period или вечный fail);
  • контейнер Restarting — это не health, это exit code.
psСмысл
Up (healthy)test exit 0
Up (unhealthy)test ≠ 0
Upнет HEALTHCHECK
RestartingPID 1 падает

Возможные причины

  1. wget/curl нет в distroless/alpine.
  2. Порт 8080 в YAML, test стучится в 80.
  3. HTTPS self-signed, curl без -k (лучше http://127.0.0.1 внутренний).
  4. start_period меньше прогрева JVM/миграций.
  5. Health бьёт в http://db:5432 из db-контейнера вместо pg_isready.
  6. CMD vs CMD-SHELL и переменные не раскрылись.
  7. Редко: IPv6 localhost::1, слушает только v4.

Диагностика

cd /opt/app
docker compose ps
docker inspect app-web-1 --format '{{json .State.Health}}' | python3 -m json.tool
docker inspect app-web-1 --format '{{json .Config.Healthcheck}}' | python3 -m json.tool

Последние Log в .State.Health — stdout/stderr теста, не приложения.

Повторите test вручную:

docker compose exec web sh -c 'wget -qO- http://127.0.0.1:8080/health || curl -sfS http://127.0.0.1:8080/health; echo exit:$?'

Если exec ок, а health нет — смотрите оболочку (CMD json без shell), рабочий каталог, USER без права на бинарь wget.

docker compose config | grep -n -A20 'healthcheck'

Решение

Сценарий A. Нет клиента HTTP в образе

Плохо: test: ["CMD", "curl", "-f", "http://localhost/health"] в scratch.

Варианты: поставить curl в runtime-образе; использовать CMD-SHELL с wget busybox; для Go — сам бинарь -healthcheck; для postgres:

healthcheck:
  test: ["CMD-SHELL", "pg_isready -U app -d app"]
  interval: 5s
  timeout: 3s
  retries: 20
  start_period: 20s

Не exit 0 заглушка.

Сценарий B. Неверный URL/порт

Выровняйте с EXPOSE и command. Внутри сети это 127.0.0.1:8080, не публичный DNS и не имя соседнего сервиса, если проверяете себя.

test: ["CMD-SHELL", "curl -sfS http://127.0.0.1:8080/health || exit 1"]

Сценарий C. Медленный старт

start_period: 60s
retries: 10
interval: 10s

starting в этот период не считает unhealthy. Не раздувайте до 30 минут вместо depends_on.

Сценарий D. Хотели отключить

Не healthcheck: { disable: true }. Если тест врал — чините. Временно в окне можно поднять сервис без condition, не оставляйте в git.

Сценарий E. Health бьёт в соседа и падает из-за DNS

Проверка своего процесса. Связность webdb — отдельный readiness в приложении, плюс healthy у db. Иначе циклические ложные unhealthy: DNS, сети.

После правки YAML:

docker compose up -d --force-recreate web
sleep 15
docker inspect app-web-1 --format '{{.State.Health.Status}}'

start_period не отключает проверки, а не считает неудачи в этот интервал как unhealthy. Если JVM греется 90 с, а period 10 с и retries 3, статус станет unhealthy ещё на старте и depends_on не отпустит web. Увеличьте period по факту, не disable. Лог Health.Log покажет connection refused — это прогрев, не «Docker сломал health».

Distroless/scratch: нет shell, нет curl. JSON CMD должен указывать реальный бинарь, например сам сервис с флагом --health. Добавление apt install curl в финальный stage ради health раздувает образ и поверхность. Лучше крошечный static wget или встроенный endpoint.

Не проверяйте внешний https://registry.example из health web: получите unhealthy при сбое DNS, хотя процесс жив. Liveness — сам процесс и локальный порт. Readiness — зависимости; в Compose это healthy у db плюс логика приложения, не один test на всё.

interval: 1s на тяжёлом SQL-health убьёт БД. 5–10 с достаточно. timeout меньше времени ответа даст ложные единицы. Сверьте с curl -w time_total. После правки нужен recreate: healthcheck вшит в конфиг контейнера при create, не live-reload.

Как проверить, что проблема устранена

docker compose ps
docker inspect app-web-1 --format '{{.State.Health.Status}} {{.State.Health.FailingStreak}}'
docker compose exec web sh -c 'curl -sfS http://127.0.0.1:8080/health'

healthy, FailingStreak 0. Зависимый сервис стартовал по service_healthy. Статус стабилен 2–3 минуты, не мигает.

Если не помогло

  • Health null — YAML не применился, нет recreate / другой -f.
  • exec ок, inspect Log Permission denied — test от другого USER чем exec (вы exec под override).
  • Distroless: добавьте статический health бинарь, не apt в проде.
  • HTTP 401 на /health — либо публичный путь без auth, либо -H Authorization в секрете не в YAML plaintext; лучше отдельный /healthz без секретов.

Код 401 на /health из-за глобального basic-auth — вынесите /healthz без пароля на loopback, не disable healthcheck и не кладите пароль в YAML test.

Профилактика

  • /healthz без зависимостей от далёких API (liveness) vs /ready (readiness) — не смешивать.
  • Обязательный healthcheck у БД перед depends_on.
  • CI: compose ps healthy timeout 90s.
  • Не disable в шаблонах.
  • Логи test в inspect при алёрте unhealthy.

depends_on с service_healthy без рабочего test у db зависнет web в created. Смотрите compose ps обоих. Если test pg_isready требует пользователя, которого нет на холодном томе, первый старт будет unhealthy до инициализации — увеличьте start_period у db, не отключайте проверку у web.

FAQ

disable: true быстрее чем чинить curl?

Быстрее и слепее. Зависимые сервисы стартанут на неготовый Postgres.

CMD vs CMD-SHELL

JSON CMD без shell не знает $VARS и ||. Для pg_isready удобен CMD-SHELL. Для одного бинаря — CMD.

Swarm перезапускает unhealthy, Compose нет

Да, разная семантика. На Compose unhealthy — сигнал вам и depends_on.

Можно ли test: ["CMD", "true"]?

Это отключение под видом галочки. Нет.

Healthcheck нагружает CPU

Увеличьте interval, облегчите endpoint. Не disable.