Как устроен health check и почему живой сервер объявляют мёртвым
Балансировщик не видит, жив ли ваш сервер. Он видит только ответ на один конкретный запрос, отправленный по расписанию, — и на основе этого ответа принимает решение, пускать на бэкенд трафик или нет. Если это решение настроено небрежно, вы получите ситуацию, которая на первый взгляд выглядит абсурдно: приложение отвечает пользователям нормально, логи чистые, CPU не в потолке — а балансировщик упорно считает узел мёртвым и исключает его из пула. Разберёмся, как устроена эта проверка изнутри и почему она умеет ошибаться в обе стороны.
Содержание
- Что балансировщик на самом деле проверяет
- Почему один провал не значит смерть
- Таймаут проверки — это не таймаут пользователя
- Тяжёлый эндпоинт вместо лёгкого — вторая типичная ошибка
- Как нормальная нагрузка выглядит для проверки как смерть сервера
- Как настроить health check так, чтобы он не подрывал сам себя
Что балансировщик на самом деле проверяет
У health check нет доступа к внутреннему состоянию вашего приложения. Он умеет одно: с заданным интервалом отправлять пробный запрос на конкретный эндпоинт и смотреть на результат — код ответа, время до первого байта, иногда содержимое тела. Всё остальное — за кадром.
В nginx (в open-source версии — только пассивная проверка, активная доступна в nginx plus) типичная конфигурация выглядит так:
upstream backend {
server 10.0.0.11:8080 max_fails=3 fail_timeout=30s;
server 10.0.0.12:8080 max_fails=3 fail_timeout=30s;
}
Здесь нет отдельного эндпоинта для проверки — nginx OSS отслеживает реальные запросы пользователей и считает подряд идущие неудачи. Если вам нужна именно активная проверка с отдельным URL по расписанию, независимая от живого трафика, — это HAProxy, Traefik, Consul, cloud-балансировщики (ALB, GCP LB) или nginx plus.
В HAProxy проверка настраивается явно:
backend app_servers
option httpchk GET /healthz
http-check expect status 200
default-server inter 2s fall 3 rise 2
server app1 10.0.0.11:8080 check
server app2 10.0.0.12:8080 check
Смысл параметров:
inter 2s— интервал между пробами.fall 3— сколько подряд неудачных проб нужно, чтобы объявить сервер мёртвым.rise 2— сколько подряд успешных проб нужно, чтобы вернуть его в пул после падения.option httpchk GET /healthz— какой именно маршрут дёргается.
Ключевая деталь: /healthz — это отдельный маршрут, который вы сами описываете в приложении. Балансировщику всё равно, что происходит внутри — он верит коду ответа. И вот здесь начинаются практические проблемы, потому что то, что делает этот маршрут, целиком на совести того, кто его писал.
Почему один провал не значит смерть
Наивная реализация health check выглядела бы так: не ответил на один запрос — исключить из пула. На практике так никто не делает, и причина простая — сеть и планировщик ОС генерируют случайные задержки постоянно. Пакет может потеряться и переотправиться по TCP, процесс может на 200 мс уйти в своп при скачке нагрузки, GC в рантайме может дать паузу — ни одна из этих причин не означает, что сервер сломан.
Если решение принимать по одному неудачному ответу, вы получите то, что называется flapping — сервер то выпадает из пула, то возвращается, каждые несколько секунд, без единой реальной причины. Именно поэтому во всех серьёзных реализациях решение принимается по нескольким подряд идущим неудачам: fall 3 у HAProxy, unhealthy_threshold у AWS ALB, --retries у Docker HEALTHCHECK.
Docker-пример:
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/healthz || exit 1
Контейнер получит статус unhealthy только после трёх подряд неудачных попыток — то есть не раньше чем через 30 секунд после начала проблемы (три интервала по 10 с). Это осознанный компромисс: чем больше порог, тем устойчивее система к случайным сбоям, но тем дольше живой трафик будет литься на действительно упавший узел. Обратная сторона того же компромисса — то, ради чего вообще существует эта статья: если проверка ошибается систематически, а не случайно, повторение её несколько раз подряд не спасает, а только откладывает момент, когда балансировщик всё равно решит, что сервер мёртв.
Отдельно стоит развести liveness (жив ли процесс вообще) и readiness (готов ли он сейчас принимать трафик). В Kubernetes это два разных пробника с разными последствиями: упавший liveness перезапускает под, упавший readiness просто убирает его из Service. Балансировщик перед бэкендом обычно ближе к readiness — его интересует не "жив ли процесс", а "может ли он сейчас обработать запрос за разумное время".
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверТаймаут проверки — это не таймаут пользователя
Вот здесь чаще всего и рождается конкретная проблема из заголовка. У проверки есть собственный таймаут — сколько балансировщик готов ждать ответ на пробный запрос, прежде чем засчитать провал:
default-server inter 2s fall 3 rise 2 timeout check 1s
Этот таймаут выбирается отдельно от таймаута, который вы даёте реальным пользовательским запросам (proxy_read_timeout в nginx, timeout server в HAProxy). И вот тут прячется несоответствие: если таймаут проверки строже, чем реальное время ответа сервера под нормальной рабочей нагрузкой, вы получаете ложные срабатывания на полностью исправном узле.
Представьте: сервер под обычной дневной нагрузкой отвечает на большинство запросов быстро, но какая-то часть — вполне ожидаемо — обрабатывается дольше, потому что бэкенд занят реальными пользовательскими запросами, а не потому что он сломан. Если timeout check выставлен так, будто сервер всегда простаивает и должен ответить мгновенно, то именно в моменты пиковой (но нормальной!) нагрузки проверка начинает проваливаться — не потому что сервис деградировал, а потому что health check меряет его по нереалистичной линейке. Дальше отрабатывает порог fall, и балансировщик убирает из пула узел, который прекрасно обслуживал бы трафик, если бы его не проверяли отдельным строгим правилом.
Ирония в том, что убирание узла из пула в момент высокой нагрузки не решает проблему, а усугубляет её: у оставшихся узлов нагрузка вырастает, их ответ тоже замедляется — и балансировщик своими руками разгоняет то, от чего должен был защищать.
Тяжёлый эндпоинт вместо лёгкого — вторая типичная ошибка
Вторая частая причина ложной смерти живого сервера — не таймаут сам по себе, а то, какой маршрут проверяется. Health check должен отвечать на вопрос "может ли процесс сейчас принять и обработать запрос", а не "насколько быстро работает самая тяжёлая часть системы". Если в качестве эндпоинта проверки используется, скажем, страница дашборда, которая делает несколько запросов к базе и внешний вызов, а не отдельный лёгкий /healthz, вы фактически измеряете производительность самого дорогого пути в приложении вместо готовности процесса отвечать в принципе.
Разница на практике огромная. Простой обработчик:
@app.get("/healthz")
def healthz():
return {"status": "ok"}, 200
отвечает за время, определяемое почти исключительно тем, дошёл ли запрос до процесса и способен ли он вообще ответить — это ближе к честной проверке "жив ли сервис". А маршрут, который на пути к ответу трогает базу, кэш, внешний API, наследует все их задержки и сбои. Под нагрузкой пул соединений к базе может быть занят реальными запросами, и проверочный запрос встаёт в ту же очередь — сервер при этом полностью работоспособен для 99% пользовательских запросов, но проверка, случайно попавшая в момент занятости пула, покажет провал.
Если хотите проверять доступность БД — делайте это явно, отдельным лёгким запросом вроде SELECT 1 с собственным коротким таймаутом, а не полагаясь на то, что тяжёлый бизнес-маршрут где-то внутри тоже трогает базу. Слепая проверка "первого попавшегося" маршрута обычно означает, что эндпоинт достался health check случайно, потому что был под рукой, а не потому что его кто-то выбрал осознанно.
Похожая история разбирается в статье о том, как контейнер объявляли живым при неверной проверке — там ошибка была в обратную сторону: проверка была слишком мягкой и пропускала реально сломанный процесс. Здесь же речь о зеркальной ситуации — проверка слишком строгая и топит здоровый процесс.
Как нормальная нагрузка выглядит для проверки как смерть сервера
Соберём предыдущие два пункта в одну картину, потому что по отдельности они звучат как две разные мелкие недоработки конфига, а вместе — это системная причина, из-за которой health check регулярно "убивает" рабочие серверы именно в самый неподходящий момент, то есть под нагрузкой.
Механика такая:
- Трафик растёт (пиковые часы, распродажа, вирусный пост — не важно).
- У части запросов время ответа увеличивается — это ожидаемо и само по себе не поломка: очередь на обработку становится длиннее, пул соединений к базе занят чаще.
- Пробный запрос health check встаёт в ту же очередь, что и пользовательские запросы (если проверяемый маршрут делит с ними ресурсы — пул соединений, воркеры, CPU).
- Таймаут проверки, рассчитанный на "тихое" время, срабатывает раньше, чем сервер успевает ответить.
- После
fallподряд идущих провалов балансировщик убирает узел из пула. - Оставшиеся узлы получают ещё больше трафика, их время ответа растёт ещё сильнее, они тоже начинают проваливать ту же проверку.
Итог: чем сильнее нагрузка приближается к пределу возможностей кластера, тем агрессивнее health check выкашивает узлы — ровно в тот момент, когда каждый работающий сервер на счету. Формально при этом "сервер не отвечал вовремя" — правда. Но по сути это не отказ сервера, а отказ проверки: она была настроена так, будто нормальная нагрузка не существует.
Разница между активной и пассивной проверкой здесь тоже играет роль. Пассивная схема (как в nginx OSS через max_fails/fail_timeout) считает провалом реальные пользовательские запросы — честнее отражает живой трафик, но реагирует на деградацию медленнее. Активная схема с отдельным эндпоинтом (HAProxy, Kubernetes readiness probe) реагирует быстрее и предсказуемее по времени, но именно поэтому чувствительнее к тому, насколько честно выбран проверяемый маршрут и таймаут — она не видит реальной нагрузки, только свою узкую пробу.
Смежная тема — что вообще происходит с трафиком, который балансировщик уже успел отправить на узел до того, как решил считать его мёртвым, разобрана в материале о том, как трафик продолжал идти на уже упавшую ноду: там задержка была в обратной последовательности событий, но причина та же — рассинхронизация между реальным состоянием сервера и тем, что о нём знает балансировщик.
Как настроить health check так, чтобы он не подрывал сам себя
Практические выводы из всего написанного выше сводятся к нескольким конкретным решениям, каждое из которых стоит проговорить отдельно.
Отдельный лёгкий эндпоинт. Заведите /healthz (или /ping, /status — название не важно), который не ходит в базу, не дёргает внешние сервисы и не пересекается по ресурсам с бизнес-логикой. Он должен отвечать за счёт того, что процесс вообще жив и способен принять соединение — это самый дешёвый и самый честный сигнал.
@app.get("/healthz")
def healthz():
return {"status": "ok"}, 200
Отдельный эндпоинт для зависимостей, если он вам нужен. Если хотите проверять и базу тоже — заведите второй маршрут, например /readyz, с явным SELECT 1 и собственным коротким таймаутом, и решите осознанно, должен ли он влиять на решение балансировщика или это отдельная метрика для мониторинга.
Таймаут с запасом относительно реальной задержки под нагрузкой. Если не знаете, каким бывает время ответа в пиковые (но нормальные) часы — выясните по логам или метрикам приложения, а не подбирайте таймаут на глаз в тихое время. Ориентируйтесь на хвост распределения (p95/p99), а не на среднее: именно туда попадает пробный запрос в момент нагрузки.
Порог неудач, соразмерный терпимости к простою. Слишком маленький fall (1-2) — flapping на случайных задержках. Слишком большой — реальный отказ обнаруживается медленно. Универсального числа нет — это баланс между устойчивостью к случайным сбоям и скоростью обнаружения настоящего отказа.
Интервал проверки без перегиба в обе стороны. Слишком редкая проверка — долгое обнаружение отказа. Слишком частая — лишняя нагрузка на бэкенд от самих проверок, особенно если за одним балансировщиком независимо проверяют узлы несколько зон облачного LB.
Проверка на отдельном порту или воркере, если стек это позволяет. Некоторые приложения выносят health-эндпоинт в отдельный лёгкий процесс, чтобы он физически не мог встать в очередь с основными запросами, — радикальное, но надёжное решение проблемы "проверка делит ресурсы с трафиком".
Сводная таблица параметров разных инструментов (значения — из документации проектов, не измеренные бенчмарки; под вашу нагрузку могут понадобиться другие цифры):
| Инструмент | Интервал | Таймаут | Порог "мёртв" | Порог "жив" |
|---|---|---|---|---|
| HAProxy | inter | timeout check | fall | rise |
| Docker | --interval | --timeout | --retries | возврат в healthy после первого успеха |
| Kubernetes readinessProbe | periodSeconds | timeoutSeconds | failureThreshold | successThreshold |
| AWS ALB | HealthCheckIntervalSeconds | HealthCheckTimeoutSeconds | UnhealthyThresholdCount | HealthyThresholdCount |
Логика везде одна и та же под разными именами, что упрощает перенос настроенных значений между инструментами при миграции.
Если у вас балансировка на nginx перед бэкендом: 504-й код на реальных запросах пользователей часто путают с проблемой health check, хотя причина обычно в таймаутах между nginx и бэкендом на живом трафике — это разобрано в материале про 504 Gateway Timeout. Если настраиваете балансировку с нуля, общий обзор параметров — в статье про настройку балансировки нагрузки на VPS.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Как понять, что причина именно в health check, а не в реальном отказе сервера?
Сравните момент исключения узла из пула с логами и метриками приложения на этом узле. Если приложение продолжало нормально отвечать напрямую (в обход LB, запросом на IP:порт узла), а балансировщик уже считал его мёртвым, — проблема в настройке проверки.
Можно ли отключить health check, чтобы не было ложных срабатываний?
Не стоит — тогда трафик пойдёт и на реально упавший узел, что хуже. Цель не убрать проверку, а сделать её честной относительно поведения системы под нагрузкой.
Почему узел то появляется, то пропадает каждые несколько секунд?
Классический flapping: порог неудач (fall/rise) слишком маленький, проверка реагирует на единичные случайные задержки. Увеличьте порог и проверьте, не слишком ли строгий таймаут.
Нужен ли отдельный health-эндпоинт, если приложение и так быстро отвечает на любой маршрут?
Да — "быстро отвечает сейчас" не гарантирует, что так будет всегда: как только проверка и бизнес-логика начнут делить ресурсы (пул к базе, воркеры), появится зависимость, которую сложно диагностировать постфактум.
Если под нагрузкой узел реально перестаёт справляться — это тоже "ложное" срабатывание?
Нет, это честная работа проверки. Разница в том, что "разумное время" ответа должно определяться реальными характеристиками системы под нагрузкой, а не произвольным числом.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →