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-сервиса запрашивайте реальный эндпоинт. Благодаря
-f, командаcurl -fsSзавершается с кодом 22, если сервер отвечает статусом 400 или выше, поэтому ответ 500 от неисправного приложения будет считаться неудачной проверкой. В документации curl флаг-fназван ненадежным, особенно для ответов аутентификации 401 и 407, поэтому направляйте проверку на эндпоинт, не требующий входа в систему. - Для 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 без аргумента хоста использует этот сокет, поэтому он может отвечать «принимаю соединения», в то время как 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 уже создал контейнер api и задерживает его запуск до тех пор, пока db не сообщит о своей готовности. Контейнер отсутствует, так как docker compose ps по умолчанию показывает только запущенные контейнеры. Выполните docker compose ps -a в этот момент, и api появится в состоянии created.
Чтобы понять, почему проверка прошла успешно или завершилась ошибкой, изучите журнал проверок работоспособности:
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. Слишком большое количество повторных попыток ослабляет проверку на протяжении всего срока работы контейнера, что является более серьезной проблемой.