MAATRIX / Блог / Health-чек зелёный, пользователи жалуются: почему проверки живучести обманывают

Health-чек зелёный, пользователи жалуются: почему проверки живучести обманывают

MAATRIX

В панели балансировщика все бэкенды зелёные, в Grafana ни одного алерта, Uptime Kuma бодро рапортует «up» каждую минуту — а в поддержку одно за другим летят сообщения «не открывается», «висит корзина», «оплата не проходит». Это не редкий сбой мониторинга, а системная особенность того, как устроена типичная проверка живучести: она честно отвечает на свой узкий вопрос, но этот вопрос почти никогда не совпадает с вопросом «работает ли сервис для пользователя».

Что на самом деле проверяет типичный /health

Проще всего понять разрыв, если вспомнить, как обычно пишется эндпоинт /health или /ping на старте проекта. Это буквально несколько строк:

@app.get("/health")
def health():
    return {"status": "ok"}, 200

Такой обработчик не трогает базу данных, не ходит в очередь, не проверяет доступность внешнего платёжного шлюза — он просто подтверждает, что процесс жив, поток event loop не завис намертво и веб-сервер принимает соединения. Формально это правда, и для части задач этого достаточно: если единственное, что нужно балансировщику — не слать трафик на процесс, который упал или завис в бесконечном цикле, поверхностная проверка справляется.

Проблема в том, что для пользователя «сервис работает» означает совсем другое: авторизация проходит, товар добавляется в корзину, платёж списывается, письмо с подтверждением уходит. Всё это зависит от цепочки внешних систем — базы данных, очереди сообщений, кеша, стороннего API оплаты, S3-хранилища для файлов. Эндпоинт, который отвечает 200 OK без единого обращения к этим зависимостям, физически не может знать, что база недоступна или платёжный шлюз отдаёт таймауты. Он останется зелёным даже если приложение по факту не может выполнить ни одного полезного запроса.

Похожий случай — когда health check вообще проверяет не тот слой, на котором происходит поломка: контейнер докладывает healthy, потому что процесс внутри жив, а приложение при этом отдаёт 500 на каждый реальный запрос. Разбор именно такого инцидента — с контейнером, который был жив формально, но мёртв по сути — есть в статье про healthcheck, который проверял не то.

Внутренний путь проверки — не путь пользователя

Второй источник расхождения тоньше и коварнее первого. Health check почти всегда идёт по короткому и надёжному маршруту: балансировщик стучится в бэкенд по внутренней сети или даже на localhost, orchestrator проверяет контейнер через docker-сеть, система мониторинга опрашивает сервер из того же дата-центра. Этот путь физически короче и стабильнее того, которым идёт настоящий пользователь.

Реальный запрос человека проходит куда более длинную цепочку: DNS-резолвинг, CDN, публичный интернет, магистральные сети провайдеров, файрвол, и только потом — сам балансировщик и бэкенд. На любом из этих участков может быть проблема, которую внутренняя проверка не способна увидеть:

  • CDN отдаёт закешированную ошибку или неправильно проксирует часть путей после обновления конфигурации;
  • сертификат TLS истёк или неправильно настроена цепочка — браузер пользователя рвёт соединение до того, как запрос вообще доходит до сервера;
  • маршрут от конкретного региона пользователей до дата-центра деградировал или ушёл в объезд — то, что подробно разобрано в статье про то, почему маршрут не совпадает с географией;
  • правило файрвола или WAF внезапно режет легитимный трафик по ложному срабатыванию, а внутренний трафик health check под это правило не попадает, потому что идёт из доверенной подсети.

Health check, который стучится напрямую на порт приложения в обход всей этой цепочки, технически проверяет не тот сервис, которым пользуется клиент — он проверяет только его последнее звено. Зелёный статус в такой конфигурации говорит «бэкенд отвечает по внутренней сети», а не «сайт открывается у пользователя», и это два разных утверждения, которые расходятся ровно тогда, когда расхождение болезненнее всего.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Health check не видит медленную смерть

Третья причина — про то, что бинарный ответ (жив/не жив) ничего не говорит о времени ответа. Классический health check интересует только факт: пришёл 200 — значит жив, не пришёл или пришёл 5xx — значит мёртв. Но для пользователя сервис, который отвечает за 30 секунд, ничем не лучше сервиса, который не отвечает вообще — браузер, скорее всего, покажет таймаут раньше, чем дождётся ответа, а человек уйдёт со страницы намного раньше браузера.

Деградация производительности — самый частый сценарий инцидентов на практике, и именно его типичный health check пропускает почти всегда. Пул соединений к базе исчерпан, и запросы выстраиваются в очередь по несколько секунд каждый; диск, на котором лежат логи, заполнен под завязку, и каждая операция записи подвисает; сборщик мусора в рантайме встаёт на длинные паузы под нагрузкой. Health-эндпоинт при этом может отвечать 200 — просто не за 5 миллисекунд, а за 8 секунд, что для проверки, у которой обычно нет строгого таймаута или он выставлен слишком щедро, всё ещё считается успехом.

Простое, но полезное усиление — использовать при проверке жёсткий таймаут и явно замерять время ответа, а не только код:

curl --max-time 2 -o /dev/null -s -w "code=%{http_code} time=%{time_total}\n" \
  https://example.com/health

Если у вас уже стоит мониторинг критичных путей, а не только «жив ли сайт», разбор похожего инцидента — когда мониторинг годами смотрел на главную страницу и оставался зелёным, пока страница оплаты отвечала ошибками почти час — есть в статье про мониторинг, который проверял главную, а лежала оплата.

Частота проверок: слишком редко, чтобы поймать сбой

Четвёртая причина — банальная арифметика. Если балансировщик или система мониторинга опрашивает эндпоинт раз в 30 или 60 секунд, а сбой длится 10-15 секунд (кратковременный рестарт процесса, спайк нагрузки, временная недоступность зависимости), есть заметный шанс, что ни одна проверка не попадёт в это окно. Мониторинг честно покажет ровный зелёный график, потому что технически ни разу не поймал сервис в момент падения — а пользователи, которые обращались к сервису именно в эти секунды, получили ошибку.

Ситуация усугубляется, если для перевода бэкенда в «неисправные» нужно несколько подряд неудачных проверок (нормальная защита от ложных срабатываний на единичный сетевой глитч, но она же увеличивает время реакции). Конфигурация с интервалом 10 секунд и порогом в 3 неудачи подряд — это до 30 секунд, за которые балансировщик продолжит слать трафик на уже нездоровый узел:

# пример конфигурации проверки в HAProxy
backend app_backend
    option httpchk GET /health
    http-check expect status 200
    server app1 10.0.0.11:8080 check inter 10s fall 3 rise 2

Здесь inter 10s — интервал между проверками, fall 3 — число неудач подряд для пометки «down», rise 2 — число успехов подряд для возврата в пул. Уменьшение интервала снижает окно незамеченного сбоя, но увеличивает нагрузку от проверок и риск ложных срабатываний — универсального значения нет, баланс подбирается под конкретный сервис.

Как сделать health check глубоким, не обрушив кластер

Логичный первый импульс — заставить /health реально проверять зависимости: сходить в базу, дёрнуть очередь, постучаться во внешний API. Направление верное, но с ловушкой: если при недоступности любой зависимости эндпоинт отвечает 5xx, а balancer или orchestrator реагирует на это исключением узла из пула или перезапуском контейнера — кратковременная просадка одной внешней зависимости мгновенно превращается в каскадное падение всего кластера. Все узлы одновременно решат, что они нездоровы, и вместо частичной деградации вы получите полный простой.

Практический компромисс — разделять зависимости по критичности и не превращать любую из них в моментальный приговор:

  • зависимости, без которых сервис буквально не может ответить пользователю осмысленно (основная база данных), действительно должны влиять на статус — но с коротким таймаутом на саму проверку и без немедленного жёсткого отказа при единичном сбое;
  • второстепенные зависимости (необязательный кеш, сервис рекомендаций, аналитика) не должны валить health check вообще — деградация в духе «сайт работает медленнее без кеша» лучше, чем «сайт полностью недоступен»;
  • сам health-эндпоинт должен возвращать не только бинарный статус, но и детали — какая именно зависимость недоступна, это резко ускоряет диагностику при инциденте.

Пример эндпоинта, который различает критичные и некритичные зависимости и не роняет статус целиком из-за второстепенного сбоя:

@app.get("/health")
def health():
    checks = {
        "database": check_db(timeout=1.5),      # критично
        "cache": check_redis(timeout=0.5),       # некритично
        "queue": check_queue(timeout=1.0),        # критично
    }
    critical_ok = checks["database"] and checks["queue"]
    status_code = 200 if critical_ok else 503
    return {"checks": checks, "critical_ok": critical_ok}, status_code

Отдельная важная деталь — сама проверка зависимости не должна создавать нагрузку на неё в стиле полноценного запроса и не должна виснуть без таймаута: проверка с таймаутом в 5 секунд на балансировщике, который опрашивает раз в 10 секунд, сама может стать причиной каскада.

Liveness и readiness: две проверки вместо одной

Одна из главных причин, по которой «просто углубить /health» не решает проблему целиком — попытка одним эндпоинтом ответить сразу на два разных вопроса: «жив ли процесс вообще» и «готов ли он прямо сейчас принимать трафик». Смешивание их в одну проверку — источник значительной части описанных выше каскадов.

Идея, ставшая стандартной в мире контейнерной оркестрации (в том числе в Kubernetes, где она формализована как отдельные типы проб) — разделить эти вопросы на два независимых сигнала:

  • liveness («жив ли процесс») отвечает на вопрос, не завис ли сам процесс намертво — не занят ли event loop бесконечным циклом, отвечает ли рантайм в принципе. Провал liveness — сигнал «нужно перезапустить процесс», и он не должен зависеть от внешних систем: если положить в liveness проверку базы данных, недоступная база начнёт вызывать бессмысленные перезапуски здорового процесса, который тут ни при чём;
  • readiness («готов ли принимать трафик прямо сейчас») отвечает на вопрос, стоит ли балансировщику слать сюда запросы в данный момент. Именно сюда логично класть проверку критичных зависимостей — провал readiness должен временно исключать узел из пула, но не перезапускать его: как только зависимость восстановится, узел сам вернётся в строй без лишнего рестарта.

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

Синтетический мониторинг и алерты на реальные метрики

Даже идеально настроенный внутренний health check с разделением liveness/readiness не закрывает второй источник расхождения — путь до пользователя через CDN, DNS и публичный интернет. Проверить его можно только снаружи, тем же путём, которым идёт настоящий трафик. Это и есть смысл синтетического мониторинга: внешний узел (в идеале — из региона основной аудитории) периодически выполняет не «дёрнуть /health», а реальный сценарий — открыть главную, пройти логин, добавить товар в корзину, дойти до формы оплаты. Подробнее об этом — в статье про синтетические проверки сайта.

Второй обязательный слой — алерты не на статус health check, а на агрегированные метрики реального трафика: долю ошибок (error rate) и перцентили задержки (обычно p95/p99, а не среднее — среднее маскирует именно те медленные запросы, которые чувствует заметная часть аудитории). Пример правила для Prometheus/Alertmanager, реагирующего на фактическое поведение запросов, а не на health check:

groups:
  - name: user-facing
    rules:
      - alert: HighErrorRate
        expr: |
          sum(rate(http_requests_total{status=~"5.."}[5m]))
          / sum(rate(http_requests_total[5m])) > 0.02
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Доля ошибок 5xx выше 2% за последние 5 минут"

      - alert: HighLatencyP95
        expr: |
          histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le)) > 2
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "p95 задержки выше 2 секунд"

Такие алерты ловят то, что health check в принципе не видит: постепенную деградацию, ошибки на конкретных маршрутах, рост задержки без полного падения. Они не заменяют health check — балансировщику всё ещё нужен быстрый сигнал «не слать сюда трафик», — а дополняют его метриками, честно отражающими опыт пользователя.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Значит ли это, что от health check вообще мало пользы?

Нет, польза есть и она конкретная — быстро убрать из пула узел, который завис или упал полностью. Проблема не в самом health check, а в том, что его часто считают достаточным индикатором здоровья всего сервиса, хотя он отвечает только на узкий вопрос «жив ли процесс».

Стоит ли проверять внешний API оплаты прямо в health check?

Как критичную зависимость с коротким таймаутом и без немедленного каскадного отказа — да, разумно. Но если внешний сервис сам иногда медленный или нестабильный не по вашей вине, лучше не давать его сбою мгновенно валить весь узел — точнее выявлять деградацию через отдельные алерты на error rate именно по операциям с этим API.

Нужен ли синтетический мониторинг, если уже есть Prometheus и алерты по метрикам?

Это разные слои и они не взаимозаменяемы. Метрики сервиса показывают, что происходит внутри инфраструктуры, которую вы контролируете; синтетические проверки — что видит пользователь снаружи, включая CDN, DNS и маршрутизацию, до которых метрики сервера не дотягиваются.

Как часто должен опрашивать health check балансировщик?

Ориентировочно: чем короче интервал, тем быстрее реакция на сбой, но тем выше нагрузка от самих проверок и риск ложных срабатываний на единичные сетевые задержки. Конкретное значение зависит от чувствительности сервиса к простою и от того, насколько дорого стоит один ложный вывод узла из пула — универсального числа здесь нет, стоит подбирать эмпирически под свою нагрузку.

Что делать, если health check зелёный, а жалобы уже идут?

Сначала проверить путь до пользователя вручную — с внешней точки, а не с сервера: curl с таймаутом на публичный домен, а не на localhost. Дальше смотреть на перцентили задержки и error rate за последние минуты, а не на статус health check — расхождение между «зелёным» индикатором и жалобами почти всегда означает, что проверка смотрит не туда, куда смотрит пользователь.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →