Мониторинг, который не переживёт собственного падения
- вторник, 4 августа 2026 г. в 00:00:09
Всем привет! Хотел бы поговорить о мониторинге наших web, да и не только web, приложений, в первую очередь, о проблемах, с которыми мы сталкиваемся. В этой статье я буду использовать язык python и популярные сервисы, чтобы содержимое было максимально понятно большинству.
К примеру, мы написали простой проект, ставить Prometheus с Grafana ради одной машины скорее избыточно, поэтому мы используем что‑то типо этого:
curl -sf https://example.com/ >/dev/null || \ curl -s "https://api.telegram.org/bot$TOKEN/sendMessage" \ -d chat_id=$CHAT_ID -d text="Сайт лежит"
Вроде всё под контролем, сайт упадет — узнаю, всё протестировал. Сплю спокойно. Спустя пару месяцев узнаю, что сайт лежит, но не от TG‑бота, а от клиента. За 4 часа ни одного сообщения в телеграме.
Самое очевидное — проверять можно по‑разному, и большинство способов проверяют не то, что вам кажется.
ICMP ping. Отвечает ядро. Приложение может быть мертво уже сутки, диск забит под завязку, Postgres не поднимается — хост всё равно пингуется. Плюс ICMP часто режут файрволы, и вы получаете ложное падение на ровном месте.
TCP‑коннект. Уже лучше: порт слушается, значит процесс жив. Но за открытым портом прекрасно живёт 502 Bad Gateway — reverse‑proxy(Caddy/Nginx) стоит, а бэкенда за ним нет.
HTTP‑код. Вот тут начинается что‑то осмысленное. Но и здесь ловушка: 200 OK отдаёт что угодно, включая страницу‑заглушку, которую ваш реверс‑прокси показывает вместо упавшего приложения. Формально всё хорошо. Фактически пользователь видит «извините, технические работы».
Тело ответа. Минимально честный вариант. Проверяем не только код, но и что внутри.
Deep healthcheck. Отдельный эндпоинт, который сам ходит в свои зависимости и рассказывает, как дела.
Вот последний вариант и стоит обсудить подробнее, потому что делают его чаще всего неправильно.
Как выглядит нормальный /healthz:
{ "status": "degraded", "version": "a3f9c21", "uptime_s": 84213, "checks": { "postgres": { "status": "ok", "latency_ms": 3 }, "redis": { "status": "ok", "latency_ms": 1 }, "celery": { "status": "degraded", "detail": "queue depth 5210" }, "s3": { "status": "down", "detail": "timeout after 2s" } } }
Состояние degraded — это то, ради чего вообще имеет смысл городить deep healthcheck. Сервис работает, отдаёт страницы, пользователь ничего не замечает — но очередь Celery растёт третий час, и через сорок минут всё встанет колом.
Дальше — коды ответа, и вот здесь легко запутаться.
200 на ok и на degraded. 503 только на down.
Причина простая: в этот же эндпоинт может ходить не только ваш мониторинг. В него ходит HEALTHCHECK докера, в него ходит балансировщик, в него может ходить оркестратор. И если вы отдадите 503 из‑за подросшей очереди Celery — вас выкинут из ротации, контейнер уйдёт в рестарт, и вместо мелкой деградации вы получите настоящее падение. Собственными руками.
Нюансы читает мониторинг из тела. Код ответа — это грубый бинарный сигнал для автоматики, которая умеет только «в ротацию / из ротации».
Эндпоинт ходит в БД, в Redis, в S3. Мониторинг дёргает его раз в десять секунд. Пока мониторинг один — терпимо. Появился второй, потом докеровский healthcheck, потом балансировщик и вы получаете постоянный поток запросов в базу, который живёт исключительно ради того, чтобы сообщать, что база жива.
Поэтому считаем раз в 5–10 секунд, отдаём из памяти:
_cache = {"ts": 0.0, "payload": None} _lock = threading.Lock() TTL = 8 def health_payload(): now = time.monotonic() with _lock: if cache["payload"] and now - cache["ts"] < TTL: return _cache["payload"] payload = collect_checks() with _lock: _cache.update(ts=now, payload=payload) return payload
/healthz сервиса A не должен дёргать /healthz сервиса B. Проверяйте только свои прямые зависимости — свою базу, свой кэш, своё файловое хранилище.
Иначе одно падение подсвечивает красным вообще ВСЁ. Упал сервис аутентификации — и у вас двадцать сервисов в статусе down, потому что каждый честно сходил к соседу и получил ошибку. Искать первопричину в этой гирлянде вы будете долго.
Допустим, проверка теперь честная, идем дальше.
Один таймаут — это не падение. Это моргнула сеть, провайдер переключил маршрут, сборщик мусора притормозил процесс на две секунды. Если алертить на каждый провал, вы получите сорок сообщений за ночь, из которых тридцать девять ни о чём.
Правило простое: N провалов подряд — падение, M успехов подряд — восстановление. Обычно N=3, M=2. Между порогами живёт счётчик, а не сообщение.
И отсюда важное следствие: алерт рождается на переходе состояния, а не на состоянии. Сервис лежит шесть часов — это одно сообщение о падении и одно о восстановлении. Не 999 сообщений раз в десять секунд.
Упал хост. На нём двенадцать контейнеров. Вы получаете тринадцать сообщений: хост и каждый сервис отдельно.
А нужно одно: «хост prod-1 недоступен, затронуто 12 сервисов». Для этого мониторинг должен знать про зависимости — что эти двенадцать целей живут на том хосте, и если родитель мёртв, про детей сообщать бессмысленно.
Каждая выкатка — это несколько секунд недоступности и возможный алерт.
Важно! Я имею в виду перезапуск с выключением нашего проекта, не в счет простой перезапуск с билдом новых контейнеров и замена старым.
Лечится окнами обслуживания: либо расписание в конфиге, либо ручное «заглуши на пятнадцать минут», либо самый правильный вариант — ваш деплой сам стучится в мониторинг перед выкаткой и говорит «я начал».
Это самая обидная штука, и её почти никто не ловит.
Контейнер падает, докер его поднимает, через сорок секунд он падает снова. Приложение при этом успевает подняться и секунд двадцать отвечать 200.
Мониторинг с интервалом в тридцать секунд попадает в живое окно примерно в половине случаев. Порог «три провала подряд» не срабатывает никогда: между провалами всегда влезает успешная проверка. С точки зрения мониторинга у вас всё хорошо, просто иногда моргает.
Сервис при этом фактически нерабочий: половина запросов пользователей уходит в никуда.
Ловится это не HTTP‑проверкой, а счётчиком рестартов: больше трёх перезапусков контейнера за десять минут — алерт, независимо от того, что показывает healthcheck.
Ещё один момент, который сильно экономит нервы. Алерт «CPU 90%» бесполезен — может, у вас там видео кодируется и так и надо. Алерт «5% запросов отдают 5xx» — вот это про пользователя.
Гугловские Four Golden Signals ровно про это: latency, traffic, errors, saturation. Алертить надо на то, что чувствует пользователь, а не на то, что видно в top.
И вот теперь главное — то, ради чего писалась вся статья.
Вернёмся к моим четырём часам простоя. Я перебрал версии.
Может, упала сеть до Telegram — тогда curl к API телеги не прошёл, и сообщение просто не ушло. Может, кончилось место на диске, и крон не смог ни залогировать, ни толком отработать. Может, сервис ушёл в crashloop, и проверка попадала в живые окна. Может, упал сам сервер целиком вместе с кроном, который должен был про это сообщить.
Все четыре версии выглядят снаружи абсолютно одинаково. Из телеграма не приходит ничего.
И вот в этом вся суть проблемы:
Отсутствие алерта неотличимо от исправной работы. Вы физически не можете отличить «всё хорошо» от «мониторинг мёртв», потому что в обоих случаях входящих сообщений ноль.
Пока мониторинг живёт на той же машине, которую он мониторит, он бесполезен ровно в том сценарии, ради которого его ставили. Падение сервера — это единственное событие, о котором он гарантированно не сможет рассказать.
Причём вынести мониторинг на отдельную VPS — только половина решения. Да, теперь падение прода вы увидите. Но у вас появилась новая машина, чьё падение снова выглядит как тишина. Вы просто передвинули проблему.
Ладно, ставим наблюдателей на оба сервера, пусть следят друг за другом.
Тут вылезает вещь, которую полезно проговорить явно: при двух узлах задача определения «кто упал» не решается в принципе.
A перестал видеть B. Что произошло? Умер B? Умер канал между ними? Отрезали от сети самого A? Информации, чтобы это различить, у A нет и быть не может — он видит только «нет ответа».
Формально правильный ответ: минимум три наблюдателя в разных сетях. Если A и C не видят B, а друг друга видят — упал B. Если только A не видит никого — отрезали A. Это работает, но ради двух VPS поднимать распределённую систему — перебор.
Кворум у нас на самом деле уже есть. Третий участник — сам Telegram. Он независим от обоих серверов, он не в вашей сети, и он не падает вместе с вашим провайдером.
Ставим наблюдателя на оба сервера. Каждый следит за своими сервисами плюс за наблюдателем соседа. И смотрим, что приходит:
Пришёл алерт только от A. B действительно мёртв. Он молчит не потому, что у него всё хорошо, а потому что не может говорить. Односторонняя тишина — это и есть диагноз.
Пришли два противоречивых алерта, A пишет «B упал», B пишет «A упал». Оба живы, оба имеют выход в интернет — иначе сообщения бы не дошли. Развалилась сеть между ними. Это не отказ сервиса, это сетевая проблема, и чинить надо не сервера.
Не пришло ничего, а сайт лежит. Умер канал до Telegram или лёг весь ДЦ целиком.
Три разных ситуации — три разные картинки во входящих. Молчание перестало быть неоднозначным.
Но чтобы это работало, нужна одна деталь, о которой легко забыть: в каждом сообщении должно быть имя инстанса, который его прислал.
🔴 API недоступен Инстанс: srv-prod-1 Причина: HTTP 502 (3 провала подряд)
Без этой строчки вся конструкция рассыпается. Два одинаковых сообщения «сервер упал» без указания отправителя — это просто два одинаковых сообщения. С указанием — это готовый диагноз. Отправитель алерта сам по себе является диагностическим сигналом, и его надо не потерять.
Ещё одна вещь, о которой стоит предупредить: не назначайте одну и ту же цель обоим наблюдателям. Если оба следят за публичным сайтом, при падении вы получите дубль. Каждый следит за своим хозяйством плюс за соседом — и всё.
Раз наблюдатель теперь стоит на самом сервере, ему не нужен отдельный агент для железа. Он читает свой диск, свой load average, свои рестарты контейнеров локально. А сосед видит эту информацию через его healthcheck‑эндпоинт.
Одна сущность вместо двух. И список того, что стоит проверять по железу, оказывается коротким:
Что | Порог | Почему |
|---|---|---|
Диск | > 85%, или «кончится за 24ч» | убийца номер один |
Наблюдатель соседа | нет ответа | тот самый взаимный контроль |
Рестарты контейнера | > 3 за 10 минут | crashloop, healthcheck его не видит |
OOM kill | любой факт | контейнеры умирают молча |
Срок TLS | < 14 дней | Caddy обычно продлевает сам. |
Load average | > 4 на ядро | косвенный, но иногда спасает |
Отдельно про диск, потому что это самая недооценённая проверка.
Алерт «диск заполнен на 87%» бесполезен, если он такой уже полгода. Вы на него посмотрите и закроете. Полезен другой алерт: «при текущем темпе роста диск кончится примерно через 9 часов».
Считается элементарно — линейная регрессия по точкам за последние сутки, десять строк кода, никакого numpy. А разница огромная: первое сообщение вы проигнорируете, на второе встанете и пойдёте чистить логи.
Всё описанное я собрал в маленький сервис. Python, две зависимости, конфиг одним YAML‑файлом, запуск через docker compose up -d.
instance: srv-prod-1 telegram: token: ${TG_BOT_TOKEN} chat_id: ${TG_CHAT_ID} targets: - name: API type: http url: https://api.example.com/api/healthz/ headers: Authorization: Bearer ${HEALTH_TOKEN} expect: status: 200 json: status: { in: [ok, degraded] } - name: Наблюдатель на srv-prod-2 type: http url: https://srv2.example.com:9111/healthz - name: Диск / type: disk path: / forecast: true
Проверяйте то, что чувствует пользователь.
Алерт это событие перехода, а не состояние.
Молчание должно быть однозначным.
Ссылка на репозиторий: github.com/slime4ik/watcher/