RE:NODE

Docker healthcheck: interval, start period и retries

Автор Команда RE:NODE
16 мин чтения


Docker запускает первую проверку здоровья не сразу, а через целый interval после старта контейнера. С настройками по умолчанию это 30 секунд в состоянии health: starting. С --interval=1h это час, и всё, что ждёт service_healthy, ждёт этот час вместе с ним. Решение не в том, чтобы уменьшить interval на всё время жизни контейнера. Задайте start_period, достаточный для загрузки приложения, а на Docker Engine 25.0 и новее добавьте короткий start_interval: тогда проверка опрашивает контейнер часто, пока приложение стартует, и редко, когда оно уже работает. Вторая частая ошибка: контейнер со статусом unhealthy продолжает работать. Обычный Docker его не перезапускает, и Compose тоже.

В этой статье тайминги разобраны подробно: каждая из пяти опций, как они складываются в "сколько до healthy" и "сколько до unhealthy", синтаксис Dockerfile и Compose рядом, как написать проверку для образа без curl и как читать результат. О том, что должен проверять сам endpoint и почему liveness и readiness - разные вопросы, читайте в статье graceful shutdown и настоящие health checks. О том, как health check вписывается в полный стек с прокси и базой данных, - в статье Docker Compose для небольших стеков.

Пять опций времени и их значения по умолчанию#

Health check - это команда, которую Docker по таймеру запускает внутри контейнера. Код выхода 0 означает healthy, 1 - unhealthy, а 2 зарезервирован, и использовать его не нужно. На практике Docker считает сбоем любой ненулевой код, но документация просит 1, поэтому в привычной идиоме проверка заканчивается на || exit 1.

ОпцияПо умолчаниюЧто задаёт
--interval30sВремя между проверками в работающем контейнере
--timeout30sСколько может длиться одна проверка, прежде чем её сочтут неудачной
--retries3Сколько неудач подряд нужно, чтобы пометить контейнер unhealthy
--start-period0sЛьготное окно после старта, в котором неудачи не учитываются
--start-interval5sВремя между проверками во время start period (Engine 25.0+)

Несколько деталей, которых в таблице нет:

  • Interval отсчитывается от конца предыдущей проверки, а не от её начала. Проверка, которая занимает 4 секунды при interval в 30 секунд, запускается примерно каждые 34 секунды.
  • Timeout - жёсткий предел для одного запуска. Если команда ещё работает, когда он истёк, этот запуск считается неудачным. Значение по умолчанию в 30 секунд слишком щедро для локального HTTP-запроса. Если /healthz не ответил за 3-5 секунд, что-то не так, а долгое ожидание лишь откладывает диагноз.
  • Retries считает неудачи подряд. Одна успешная проверка сбрасывает счётчик. Проверка, которая падает через раз, никогда не дойдёт до unhealthy, и это веский довод сделать проверку детерминированной.
  • Start period не откладывает первую проверку. Проверки в это время выполняются, просто их неудачи не засчитываются. Успех во время start period завершает его досрочно: контейнер становится healthy, и все последующие неудачи уже считаются.
  • Start interval действует только внутри start period. При start_period: 0s, то есть по умолчанию, start_interval ничего не делает.

Почему первая проверка ждёт целый interval#

Именно это поведение стоит за большинством вопросов вида "мой контейнер завис в starting". Docker планирует первую проверку через один interval после старта контейнера. Он не запускает её в нулевой момент, чтобы посмотреть, не поднялось ли приложение уже. Пока первая проверка не прошла, docker ps показывает (health: starting).

С 30 секундами по умолчанию это безобидно. Безобидно быть перестаёт, когда кто-то рассуждает так: "проверка дешёвая, но часто она мне не нужна, поставлю interval: 5m". Теперь каждый свежий контейнер пять минут сидит в starting. Каждый сервис с depends_on: condition: service_healthy ждёт пять минут, прежде чем Compose вообще его создаст. А docker compose up --wait блокируется на пять минут при деплое, который был готов за две секунды. При interval: 1h контейнер находится в starting целый час.

До Engine 25.0 чистого выхода не было. Длинный interval означал медленный первый вердикт, короткий - опрос каждые несколько секунд без конца. Люди выбирали компромисс в 10-30 секунд и мирились с обеими издержками.

--start-interval разделяет эти две задачи. Во время start period Docker проверяет контейнер каждые start_interval. После первого успеха или по окончании start period он переключается на обычный interval. Контейнер можно проверять каждые 2 секунды, пока он загружается, и каждые 5 минут после этого:

Dockerfile
HEALTHCHECK --interval=5m --timeout=3s --retries=3 \  --start-period=60s --start-interval=2s \  CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1

Если приложение отвечает через 7 секунд, четвёртая или пятая проверка проходит, и контейнер становится healthy примерно через 8 секунд после старта, а не через 5 минут. Если приложение так и не поднимается, start period заканчивается через 60 секунд, и с этого момента неудачи считаются с интервалом в 5 минут.

Сколько до healthy и сколько до unhealthy#

Для любой проверки стоит посчитать два числа: как быстро исправный контейнер объявляется healthy и как медленно мёртвый объявляется unhealthy.

Время до healthy - это примерно время загрузки, округлённое до следующей проверки. Без start interval первая проверка происходит через interval, так что контейнер никогда не станет healthy раньше. Со start interval это время загрузки плюс не более одного start_interval.

Время до unhealthy для приложения, которое зависло после того, как было healthy, составляет примерно retries проверок:

code
worst case ~ retries x (interval + timeout)defaults:    3 x (30s + 30s) = about 3 minutestuned:       3 x (10s + 3s)  = about 40 seconds

Timeout тут важен, потому что зависшее приложение не отклоняет соединение. Оно принимает его и никогда не отвечает, так что каждая проверка работает до полного timeout, прежде чем упасть. Это вторая причина уменьшить timeout по умолчанию: с настройками по умолчанию зависший процесс может отдавать ошибки три минуты, прежде чем Docker признает, что что-то не так.

Для приложения, которое вообще никогда не становится healthy, добавьте start period. Неудачи внутри него не считаются, поэтому первая засчитанная неудача приходится на момент после start_period, а unhealthy наступает ещё через retries проверок.

Несколько отправных точек, которые нужно подстроить под своё приложение:

НагрузкаInterval / timeoutStart period
Небольшое веб-приложение или API30s / 3s30s, start interval 2s
PostgreSQL или MySQL10s / 5sот 30s до 60s
Приложение на JVM или .NET30s / 5sот 60s до 120s
База данных, восстанавливающая дамп при первом запуске10s / 5sНесколько минут

Оставляйте retries равным 3, если нет причин для другого значения. Одна неудача - шум. Три подряд - закономерность.

Синтаксис Dockerfile и синтаксис Compose#

Одна и та же проверка может жить в трёх местах, и в каждом она записывается по-своему.

В Dockerfile это инструкция с флагами, и она становится частью метаданных образа:

Dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --start-interval=2s \  --retries=3 CMD ["curl", "-fsS", "http://127.0.0.1:8080/healthz"]

В Dockerfile может быть только один HEALTHCHECK. Если их несколько, побеждает последний. Health check из базового образа наследуется, а HEALTHCHECK NONE его отключает.

В Compose это ключ healthcheck: у сервиса, с подчёркиваниями там, где у флагов дефисы:

compose.yaml
services:  app:    image: example/app:1.4.2    healthcheck:      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/healthz"]      interval: 30s      timeout: 3s      retries: 3      start_period: 30s      start_interval: 2s

Длительности записываются как 1m30s, 10s или 500ms. healthcheck в Compose переопределяет проверку, заложенную в образ, поле за полем, так что можно оставить команду из образа и поменять только тайминги. Чтобы полностью убрать проверку образа, используйте disable: true (или test: ["NONE"]). start_interval в файле Compose требует Docker Compose 2.20.2 или новее и, как и флаг в Dockerfile, Engine 25.0 или новее, чтобы вступить в силу.

В `docker run` те же опции задаются флагами: --health-cmd, --health-interval, --health-timeout, --health-retries, --health-start-period, --health-start-interval, а --no-healthcheck отключает унаследованную проверку.

Какое место выбрать? Помещайте проверку в Dockerfile, когда она принадлежит приложению: образ знает свой порт и путь проверки, и каждый деплой получает разумное значение по умолчанию. Помещайте её в Compose, когда она зависит от развёртывания: другой порт, зависимость, которую проверка должна дождаться, или чужой образ, который вы не можете пересобрать.

CMD и CMD-SHELL, и ловушка со знаком доллара#

Поле test принимает три формы, и разница важна, как только в команде появляется pipe, переменная или ||.

  • ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/healthz"] - exec-форма. Docker запускает бинарник напрямую, без shell. Никаких pipe, подстановки $VAR и ||. Работает и в образах без shell.
  • ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/healthz || exit 1"] - строка выполняется через shell контейнера по умолчанию, /bin/sh -c в Linux. Pipe, переменные и || работают.
  • test: curl -fsS http://127.0.0.1:8080/healthz || exit 1 - простая строка равносильна CMD-SHELL.

В Dockerfile существует такое же разделение: HEALTHCHECK CMD ["curl", ...] - это exec-форма, а HEALTHCHECK CMD curl ... || exit 1 - shell-форма.

Ловушка - переменные в Compose. Compose сам подставляет $VAR из вашего shell и файла .env, до того как контейнер что-либо увидит. Проверка вида pg_isready -U $POSTGRES_USER получит значение с машины, на которой запущен docker compose, а там оно часто пустое. Чтобы переменную раскрывал shell контейнера, удвойте знак доллара:

compose.yaml
  db:    image: postgres:17    environment:      POSTGRES_USER: app      POSTGRES_PASSWORD_FILE: /run/secrets/db_password    healthcheck:      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_USER}"]      interval: 10s      timeout: 5s      retries: 5      start_period: 30s      start_interval: 2s

$$ доходит до контейнера как одиночный $. Окружение контейнера действительно содержит POSTGRES_USER, потому что health checks выполняются с окружением контейнера, как и docker exec.

Ещё две вещи о том, где выполняется проверка. Она работает внутри контейнера, поэтому localhost - это сам контейнер, а не хост. Для проверки приложения это правильно, но значит, что health check никогда не проверяет опубликованный порт или прокси перед приложением. И предпочитайте 127.0.0.1 вместо localhost: в некоторых образах localhost сначала разрешается в IPv6-адрес ::1, приложение, привязанное к 0.0.0.0 (только IPv4), отклоняет соединение, и проверка падает, хотя с приложением всё в порядке.

Когда в образе нет curl или wget#

В примерах выше предполагается curl, а во многих образах его нет. Slim- и минимальные образы не включают ничего, что не нужно приложению: в node:22-slim и python:3.13-slim нет ни curl, ни wget. В образах на основе Alpine есть wget из BusyBox, но нет curl. В distroless-образах нет и shell, поэтому любая проверка через CMD-SHELL падает ещё до запуска. Проверка, не нашедшая свой бинарник, падает каждый раз, и контейнер становится unhealthy, хотя приложение в полном порядке. Тогда docker inspect показывает в логе проверки curl: not found или ошибку exec.

Есть три способа обойти это.

  1. Установить инструмент. Добавить curl в slim-образ на Debian - это одна строка apt-get install и несколько мегабайт. Часто этого достаточно, но в образ добавляется пакет, расширяющий поверхность атаки, только ради проверки.
  2. Использовать то, что уже есть. В Alpine работает wget из BusyBox: wget -q --spider http://127.0.0.1:8080/healthz || exit 1. Базы данных поставляют собственные проверки: pg_isready в образе Postgres, mysqladmin ping в MySQL.
  3. Использовать уже имеющуюся среду выполнения. В образе Node есть Node, в образе Python есть Python. Оба могут сделать HTTP-запрос одной строкой.
Dockerfile (Node 18 or later)
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --start-interval=2s \  CMD ["node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
Dockerfile (Python)
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --start-interval=2s \  CMD ["python", "-c", "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"]

Версия на Python работает, потому что urlopen выбрасывает исключение при любом статусе 4xx или 5xx и при отклонённом соединении, а необработанное исключение завершает процесс с кодом 1. Обе версии в exec-форме, поэтому работают и в образах без shell. Запуск интерпретатора Node или Python обходится дороже, чем curl: на мгновение занимаются десятки мегабайт. При interval в 30 секунд это неважно, а вот при start interval в 2 секунды на маленькой машине об этом стоит подумать.

Для distroless-образов самый чистый ответ - крошечный бинарник проверки, встроенный в образ, или режим --health в самом вашем приложении, которое делает запрос и завершается. Многие сервисы на Go поступают именно так.

Каким бы инструментом вы ни пользовались, пусть он падает на HTTP-ошибке, а не только на отклонённом соединении. Обычный curl завершается с кодом 0 на ответ 500. Именно -f (--fail) превращает статус ошибки в ненулевой код выхода. Без него проверка лишь доказывает, что кто-то слушает порт.

depends_on и service_healthy#

Статус здоровья важнее всего для порядка запуска в Compose. Зависимость, объявленная с condition: service_healthy, удерживает зависимый сервис, пока она не сообщит healthy:

compose.yaml
  app:    image: example/app:1.4.2    depends_on:      db:        condition: service_healthy        restart: true

Здесь настройка interval окупается. Compose ждёт первой успешной проверки базы данных, поэтому база с interval: 60s и без start interval задерживает приложение минимум на минуту при каждом up, как бы быстро Postgres ни стартовал на самом деле. С start_interval: 2s приложение запускается через пару секунд после готовности базы.

Compose также сдаётся, если зависимость становится unhealthy: up завершается с сообщением о том, что зависимость не смогла запуститься. Обычно start period оказался слишком коротким для первого запуска, например когда база инициализирует новый том или восстанавливает дамп, тогда как последующие перезапуски проходили нормально. Увеличьте start period, а не retries, чтобы льгота действовала только при загрузке.

restart: true (Compose 2.17.0 или новее) перезапускает зависимый сервис, когда сам Compose перезапускает или обновляет зависимость. Это не перезапуск по состоянию здоровья. База данных, которая сама стала unhealthy, ничего не запускает.

docker compose up --wait использует тот же сигнал при деплое: он возвращается, когда все сервисы запущены и healthy, и завершается с ненулевым кодом, если один из них не справился. В скрипте деплоя именно это позволяет остановиться до переключения трафика, а не после. На таком ожидании строится статья деплой без простоя на небольшом сервере.

Чтение лога проверок через docker inspect#

docker ps показывает краткий итог в колонке статуса: Up 2 minutes (healthy), (health: starting) или (unhealthy). Чтобы найти все unhealthy-контейнеры на хосте:

bash
$ docker ps --filter health=unhealthy$ docker inspect --format '{{json .State.Health}}' app | python3 -m json.tool

Объект Health содержит Status, FailingStreak (число неудач подряд на данный момент) и Log - список последних результатов проверок. У каждой записи есть Start, End, ExitCode и Output. Docker хранит только последние пять результатов и только первые 4096 байт вывода каждой проверки. Поэтому endpoint, который при сбое выводит короткую причину (db: connection refused), здесь намного полезнее того, что выдаёт stack trace.

Прочитайте Output, прежде чем что-то менять. Типичные сообщения и их смысл:

  • curl: not found, executable file not found in $PATH - в образе нет инструмента. См. раздел выше.
  • Health check exceeded timeout - команда не уложилась в отведённое время. Либо приложение зависло, либо timeout короче, чем законно медленный ответ.
  • Connection refused - по этому адресу внутри контейнера никто не слушает. Проверьте порт и сравните 127.0.0.1 с localhost.
  • HTTP 503 от вашего собственного endpoint - проверка работает, а приложение сообщает о настоящей проблеме. Чините приложение, а не проверку.

Переходы можно наблюдать и в реальном времени командой docker events --filter event=health_status: она печатает строку каждый раз, когда контейнер переключается между healthy и unhealthy.

Unhealthy ничего не перезапускает#

Политики перезапуска Docker (no, on-failure, always, unless-stopped) реагируют только на завершение главного процесса контейнера и ни на что другое. Контейнер unhealthy, процесс которого ещё работает, остаётся как есть и по-прежнему получает трафик, если получал его раньше. Статус здоровья - это информация. Обычный Docker и Compose сообщают о нём и действуют на его основании только при запуске, через depends_on и --wait.

На него реагируют три вещи:

  • Docker Swarm заменяет задачи сервиса, которые стали unhealthy. Если вы используете Swarm, health check - настоящий триггер перезапуска.
  • Наблюдатель, который вы добавляете сами. Небольшой контейнер с доступом к сокету Docker опрашивает контейнеры в поисках unhealthy и перезапускает найденные. Известный пример - образ willfarrell/autoheal. Монтирование сокета Docker даёт этому контейнеру управление хостом на уровне root, так что это осознанный компромисс.
  • Приложение, завершающееся само. Если приложение способно понять, что оно сломано, выход с ненулевым кодом - самый надёжный триггер перезапуска, потому что его понимает любая политика перезапуска.

Kubernetes - исключение, на котором часто спотыкаются: он полностью игнорирует HEALTHCHECK из Dockerfile и использует собственные пробы liveness, readiness и startup. Роль start period там играет startupProbe.

Будьте осторожны с автоматическими перезапусками. Проверка, которая падает из-за того, что база данных недоступна, будет в цикле перезапускать здоровое приложение - в статье о graceful shutdown это названо усилителем. Всё, что перезапускается по здоровью, должно смотреть только на то, что перезапуск способен исправить. И настройте оповещения на смену статуса здоровья, как описано в статье мониторинг, который что-то вам говорит, вместо того чтобы молча замазывать проблему.

На тарифах RE:NODE для приложений вопрос выглядит немного иначе. Каждый сервер - это контейнер, который запускаем мы, без вашего Docker-демона внутри, поэтому настраивать HEALTHCHECK не нужно. Вместо этого наблюдатель за сбоями каждые две минуты проверяет, не уменьшился ли uptime и не ушёл ли сервер в офлайн, а перезапуски, которые вы запросили сами, не учитываются. Три незапланированных перезапуска за час выводят предупреждение на странице сервера и открывают тикет. Шесть - приостанавливают сервер. Поэтому выход при фатальной ошибке и там остаётся правильным решением. Если вам нужен собственный механизм health в Docker с Compose и service_healthy, понадобится машина, где демон принадлежит вам, и именно для этого существует линейка VDS. Настройка описана в статье Docker на VDS: с чего начать.

FAQ#

Почему мой контейнер завис в "health: starting"?

Первая проверка ещё не прошла. Либо первая проверка вообще происходит только через один полный interval (то есть длинный interval означает долгое ожидание), либо все проверки до сих пор падали внутри start period, где неудачи не учитываются. Выполните docker inspect --format '{{json .State.Health}}' для контейнера. Пустой Log означает, что ни одна проверка ещё не запускалась. Записи с ненулевыми кодами выхода означают, что проверка падает, а Output объясняет почему.

Какой interval у Docker healthcheck считать хорошим?

Для обычного веб-сервиса - 30 секунд с timeout в 3 секунды и 3 retries: зависшее приложение будет замечено менее чем за две минуты. Делайте меньше, только если на результат что-то быстро реагирует, например балансировщик или Swarm. На Engine 25.0 и новее любой interval длиннее минуты сочетайте с start_period и start_interval в несколько секунд, чтобы запуск не ждал длинного interval.

Задерживает ли start_period первую проверку здоровья?

Нет. Во время start period проверки выполняются с обычным interval или с start_interval на Engine 25.0 и новее. Неудачи внутри окна просто не засчитываются в retries. Первый успех завершает start period досрочно.

Перезапускает ли Docker unhealthy-контейнер?

Сам по себе нет. Политики перезапуска срабатывают только когда завершается главный процесс. Превратить unhealthy в перезапуск можно через сервисы Swarm, наблюдатель вроде autoheal или завершение самого приложения. Compose использует статус здоровья только для порядка запуска и для up --wait.

Должна ли проверка здоровья обращаться к базе данных?

Обычно нет, если на проверку что-то реагирует перезапуском. Иначе короткий сбой базы разом пометит все контейнеры приложений как unhealthy. Проверяйте сам процесс, а зависимости сообщайте отдельно через readiness endpoint. Для контейнера с базой данных проверкой, разумеется, должна быть собственная проба базы, например pg_isready или mysqladmin ping.

Можно ли изменить health check без пересборки образа?

Да. Блок healthcheck: в Compose или флаги --health-* в docker run переопределяют значения образа, а disable: true или --no-healthcheck отключают проверку. Инструкция в Dockerfile - лишь значение по умолчанию.