Docker Compose: как правильно настроить healthcheck
Разберите, как Docker Compose оценивает healthcheck, почему depends_on не ждёт готовности и как составить проверки readiness для Postgres и приложения.
Что на самом деле делает healthcheck в Docker Compose
Healthcheck в Docker Compose — это одна команда, которую Docker периодически запускает внутри контейнера. Docker не читает журналы, не отслеживает порт и не проверяет список процессов. Он запускает команду, получает код завершения и сохраняет для контейнера одно состояние: starting, healthy или unhealthy. Код завершения 0 означает, что контейнер работоспособен. Любой другой код означает, что контейнер неработоспособен. Код завершения 2 зарезервирован Docker, поэтому не возвращайте его намеренно.
Так работает весь механизм. Почти любая проблема с healthcheck сводится к одному: написанная команда отвечает на другой вопрос, а не на тот, который вы хотели задать. Предполагается, что вы уже знаете как написать compose-файл на VPS, и теперь разбираетесь с ситуацией, когда стек запускается в неправильном порядке.
services:
api:
image: ghcr.io/example/api:1.4.0
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30sПараметр test имеет два полезных варианта. Список, начинающийся с CMD, запускает команду напрямую, без shell. Поэтому конвейеры, && и подстановка переменных не работают. Список, начинающийся с CMD-SHELL, передаёт остальные элементы одной строкой в /bin/sh -c внутри контейнера. Этот вариант нужен, если проверка использует синтаксис shell. Простая строка обрабатывается как CMD-SHELL. Список ровно из ["NONE"] удаляет healthcheck, заданный в Dockerfile образа.
Проверка выполняется внутри контейнера, поэтому каждый указанный в ней бинарный файл должен существовать в этом образе. Сначала проверьте это. Иначе slim-образ без curl создаст контейнер, который постоянно считается неработоспособным, а причина этого никогда не появится в журнале приложения. Проверьте команду вручную:
docker compose exec api curl --versionОтсутствующий бинарный файл возвращает OCI runtime exec failed: exec: "curl": executable file not found in $PATH: unknown. В образах на базе Alpine обычно используется BusyBox wget, поэтому проверка будет такой:
["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"].
Как сочетаются interval, retries и start_period
Время проверок определяют пять параметров. Их значения по умолчанию задаёт Docker Engine, а не Compose.
interval: интервал между двумя проверками после завершения start period контейнера. По умолчанию 30s.timeout: максимальная длительность одного запуска проверки. Если проверка длится дольше, Docker завершает её и считает этот запуск неудачным. По умолчанию 30s.retries: количество последовательных неудачных проверок, необходимое для перехода состояния вunhealthy. По умолчанию 3.start_period: период ожидания после запуска контейнера. По умолчанию 0s.start_interval: частота запуска проверки в течение start period. По умолчанию 5s; требуется Docker Engine версии 25.0 или новее.
Главное правило: в течение start period неудачная проверка не учитывается в retries, а контейнер остаётся в состоянии starting. После первой успешной проверки контейнер переходит в состояние healthy, и start period немедленно завершается, даже если большая часть этого времени ещё не использована. Если start period заканчивается, пока проверка всё ещё завершается неудачно, начинается обычный отсчёт, и для пометки контейнера как unhealthy ему требуются retries последовательных неудачных проверок.
Таким образом, максимальное время от запуска контейнера до состояния unhealthy равно start_period плюс retries, умноженное на interval, плюс timeout. Для значений из файла выше это 30 плюс 5, умноженное на 13, то есть 95 секунд. Запишите это число до настройки тайм-аута развёртывания: если процесс развёртывания прекращается через 60 секунд, контейнер никогда не успеет перейти в конечное состояние.
Распространённая ошибка — увеличивать retries, чтобы учесть медленный запуск. Сначала это помогает, но затем постоянно создаёт проблемы: сервису, которому потребовалось 8 повторных попыток для запуска, в рабочей среде теперь разрешены 8 последовательных неудачных проверок, прежде чем система обнаружит проблему. Вместо этого используйте start_period, поскольку он применяется только до первой успешной проверки.
Почему один depends_on ничего не гарантирует
Краткая форма depends_on чаще всего вызывает путаницу.
api:
depends_on:
- dbЭто означает только одно: сначала запустите контейнер db, затем контейнер api. Compose ожидает создания и запуска контейнера. Он не ожидает завершения первой инициализации PostgreSQL и не ожидает, пока порт 5432 начнёт принимать подключения. Приложение запускается примерно через секунду, подключается к порту, на котором ещё никто не прослушивает соединения, и завершается. В журнале отображается Connection refused или FATAL: the database system is starting up, если сервер уже запущен, но ещё выполняет восстановление.
Именно этого обычно хотят добиться с помощью полной формы:
api:
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfullycondition имеет три значения. service_started соответствует краткой форме. service_healthy задерживает запуск зависимого сервиса, пока зависимость не сообщит о состоянии готовности. Это имеет смысл только в том случае, если для зависимости определён healthcheck — в compose-файле или в образе. service_completed_successfully ожидает завершения одноразового контейнера, например контейнера миграции базы данных, со статусом 0.
Рядом с condition находятся ещё два поля. restart: true указывает Compose перезапустить этот сервис после обновления сервиса-зависимости. required: false изменяет отсутствующую зависимость с ошибки на предупреждение.
Теперь ограничение, которое часто становится неожиданным. Эти условия проверяются при запуске стека. Они определяют порядок запуска, но не являются правилом контроля состояния. Если база данных перезапустится в 3 часа ночи, условие service_healthy не будет проверено повторно, и приложение не будет перезапущено для его повторного выполнения. Код приложения всё равно должен самостоятельно восстанавливать подключения. docker compose up --no-deps api намеренно обходит весь этот механизм. То же самое происходит при прямом запуске контейнера с помощью docker start.
Напишите проверку готовности, а не существования процесса
Проверка вроде pgrep nginx подтверждает, что запись о процессе существует в таблице процессов. Она ничего не говорит о том, может ли сервис ответить на запрос. Веб-приложение может удерживать открытым сокет в состоянии прослушивания даже после отказа пула подключений к базе данных, а проверка процесса будет оставаться успешной на протяжении всего сбоя.
Поручите контейнеру выполнить ту работу, для которой он предназначен:
- Для HTTP-сервиса запросите реальную конечную точку.
curl -fsSзавершается с ненулевым кодом при любом статусе 400 или выше из-за-f, поэтому ответ 500 от неисправного приложения считается неуспешной проверкой. - Для PostgreSQL используйте
pg_isready. Команда завершается с кодом 0, если сервер принимает подключения, с кодом 1, если он отклоняет подключения, с кодом 2, если он вообще не отвечает, и с кодом 3, если переданные параметры некорректны. - Для Redis используйте
redis-cli ping. Команда выводитPONGи завершается с кодом 0. - Для MariaDB официальный образ содержит скрипт
healthcheck.sh, аhealthcheck.sh --connect --innodb_initialized— это форма, которую документируют его сопровождающие.
В pg_isready есть одна особенность, о которой следует знать. При самом первом запуске с пустым каталогом данных официальный образ postgres выполняет инициализацию во временном сервере, который принимает подключения только через Unix-сокет. pg_isready без аргумента host использует этот сокет, поэтому команда может сообщить, что подключения принимаются, хотя TCP-порт 5432 всё ещё закрыт для приложения. Явно укажите TCP в проверке, и проблема исчезнет, поскольку временный сервер не отвечает через него.
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30sДвойные знаки доллара указаны намеренно. Compose самостоятельно раскрывает $VAR при чтении файла и тем самым встраивает в проверку значение из окружения хост-системы. $$ экранирует его до одинарного $, поэтому оболочка внутри контейнера раскрывает его с учётом собственного окружения контейнера.
Стек PostgreSQL и приложения, который запускается в правильном порядке
services:
db:
image: postgres:17.5
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
restart: unless-stopped
api:
image: ghcr.io/example/api:1.4.0
environment:
DATABASE_URL: postgres://appuser:${DB_PASSWORD}@db:5432/appdb
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30s
ports:
- "127.0.0.1:8080:8080"
restart: unless-stopped
volumes:
pgdata:Запустите стек и отслеживайте изменение состояний:
docker compose up -d
docker compose psВ столбце STATUS состояние работоспособности указано в скобках. У здоровой пары в обеих строках отображается Up 41 seconds (healthy). Пока база данных ещё инициализируется, в db отображается Up 4 seconds (health: starting), а api отсутствует в списке, поскольку Compose ещё не создал этот контейнер.
Чтобы понять, почему проверка завершилась успешно или с ошибкой, просмотрите журнал состояния:
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q db)"Docker сохраняет несколько последних результатов. Для каждого результата указываются время начала, время окончания, ExitCode и Output команды. Сохранённый вывод обрезается, поэтому проверка, которая выводит большое тело страницы, создаёт бесполезную запись в журнале. Проверки должны выводить минимум данных.
Что делает Docker, когда контейнер становится нездоровым
Ничего. Именно этот ответ чаще всего удивляет пользователей.
Docker Engine на одном хосте не перезапускает нездоровый контейнер. Политика restart: unless-stopped реагирует на завершение основного процесса, а нездоровый контейнер не завершил работу. Он может оставаться в состоянии unhealthy неделю, пока Compose не предпринимает никаких действий. Режим Swarm заменяет нездоровые задачи, но обычный стек Compose на одном сервере этого не делает.
Остаются два рабочих варианта. Завершать процесс, когда он определяет, что работает некорректно, чтобы политика перезапуска могла сработать. Либо отслеживать состояние извне и отправлять оповещения. Если направить монитор Uptime Kuma на ту же конечную точку, которую вызывает healthcheck, неисправная зависимость будет отображаться в обоих местах, и вы узнаете о проблеме от монитора, а не от пользователя. Если трафик поступает к приложению через обратный прокси Traefik, помните, что собственное представление прокси о состоянии backend отделено от состояния healthcheck в Docker, поэтому одно не заменяет другое.
Отладка проверки, которая никогда не переходит в состояние healthy
Выполните точную команду самостоятельно в том же контейнере и проверьте код завершения:
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"exit=0 здесь, пока контейнер по-прежнему сообщает о состоянии unhealthy, означает, что ваш compose test отличается от только что введённой команды. Обычно это происходит, когда вместо синтаксиса оболочки используется CMD.
В большинстве остальных случаев причина одна из двух. Первая — неправильный порт. Проверка работоспособности выполняется внутри контейнера, поэтому она должна использовать порт контейнера, а не опубликованный порт хоста. В ports: - "8080:3000" приложение прослушивает порт 3000. Проверка через http://localhost:8080 будет постоянно завершаться с ошибкой, хотя сайт нормально открывается в браузере. Вторая причина — неправильный хост. Внутри проверки localhost обозначает тот же контейнер. Это правильно при проверке самого контейнера, но неправильно при проверке соседнего контейнера. Для этого нужно использовать имя сервиса, например db.
Есть ещё один случай, который требует отдельного рассмотрения: проверка работоспособности завершается успешно, а пользователи получают ошибки. Это происходит, когда endpoint возвращает статический код 200, не обращаясь к реальным зависимостям. Endpoint готовности, который не выполняет запрос к базе данных, не может определить, что база данных недоступна. Добавьте в него один простой реальный запрос.
FAQ
Почему приложение по-прежнему не может подключиться, если в depends_on указано, что база данных работоспособна?
Потому что condition: service_healthy проверяется один раз при запуске стека. После этого он ничего не контролирует. Если контейнер базы данных позднее перезапустится, Compose не перезапустит приложение, чтобы повторно выполнить это условие. Поэтому в коде приложения должна быть собственная логика повторного подключения и повторных попыток. Это условие также не действует при запуске отдельного контейнера с помощью docker start или docker compose up --no-deps.
Нужна ли healthcheck, если в образе уже определена проверка?
Обычно нет. Переопределение часто ухудшает ситуацию, поскольку сопровождающий образа знает, что означает готовность именно для этого программного обеспечения. Добавляйте собственную проверку только в том случае, если проверка в образе не подходит для вашей конфигурации, например проверяет порт, который вы изменили. Чтобы отключить healthcheck из образа, задайте для сервиса test: ["NONE"] или disable: true.
Должна ли healthcheck использовать curl или wget?
Используйте утилиту, которая уже есть в образе, и проверьте это с помощью docker compose exec <service> curl --version, прежде чем полагаться на нее. Во многих образах на базе Debian нет ни одной из них. В образах на базе Alpine есть wget из BusyBox. Не добавляйте пакет в образ только ради запуска healthcheck, если программное обеспечение поставляется со своим клиентом, например pg_isready или redis-cli.
Перезапускается ли неисправный контейнер автоматически?
Docker Engine на одном хосте этого не делает. Политики перезапуска реагируют на завершение процесса, а не на состояние healthcheck. Поэтому неисправный контейнер продолжает работать и остается неисправным, пока другое средство не выполнит действие. Либо завершайте процесс при обнаружении сбоя, либо запускайте внешний мониторинг, который отправляет оповещения об этом состоянии.
Какой должна быть длительность start_period?
Она должна быть достаточной для самого медленного штатного первого запуска, измеренного вами, с дополнительным запасом. Измерьте это время с помощью docker compose up на пустом томе, поскольку первый запуск базы данных значительно медленнее всех последующих. Слишком длинный период запуска лишь откладывает первый вердикт unhealthy. Слишком большое количество повторных попыток ослабляет проверку на протяжении всего срока работы контейнера, что является более серьезной проблемой.