Как мы построили распределённый мониторинг аптайма
- суббота, 20 июня 2026 г. в 00:00:07
В прошлый раз я писал про рекурсивную задачу мониторинга: кто мониторит монитор? Если Prometheus упал — вы не видите ничего, и самое коварное тут в том, что отвалившийся мониторинг внешне неотличим от идеальной стабильности. Та статья заканчивалась честно и немного грустно: чистого решения нет, есть только слои подстраховки и остаточный риск, с которым приходится жить.
Эта статья — про то, что было дальше. Я попробовал зайти с другой стороны: не «как защитить монитор от падения», а «как сделать так, чтобы падать было нечему». Получился LibrePing — децентрализованный peer-to-peer мониторинг аптайма, где нет центрального сервера, который мог бы упасть и утащить за собой всё.
Сразу оговорюсь: это не серебряная пуля и не «убийца» Pingdom с UptimeRobot. Это попытка решить вполне конкретную архитектурную боль. Дальше расскажу, как оно устроено, без занудства про каждую структуру данных, но с достаточным количеством деталей, чтобы было понятно, где тут инженерия, а где маркетинг (его тут нет).
Если хочется сначала просто потыкать — вот живой публичный хаб, без регистрации: nl.lp.mw.gg (другие — в вики проекта).
У классического мониторинга две родовые проблемы.
Первая — это та самая рекурсия из прошлой статьи. Любая централизованная система мониторинга сама является инфраструктурой, а значит, тоже падает. Вы навешиваете на неё meta-heartbeat, dead man’s switch, кросс-мониторинг — и в какой-то момент ловите себя на том, что строите мониторинг для мониторинга мониторинга.
Вторая — точка обзора. Когда вы мониторите сервис из одного места (даже из «облака»), вы видите доступность из этого одного места. А «сайт лежит» и «сайт не открывается из вашего ДЦ в Франкфурте, но прекрасно открывается из Сингапура» — это очень разные инциденты, и отличить их из одной точки невозможно. Коммерческие сервисы решают это, держа сеть собственных точек присутствия. Это дорого, поэтому за это берут деньги.
Мне хотелось вещь, у которой обе проблемы решаются одной и той же архитектурой. Идея простая до неприличия: что если точки мониторинга — это и есть сама система? Не один сервер с пачкой воркеров, а сеть равноправных узлов, которые общаются напрямую друг с другом. Узел упал — сеть этого даже не заметила. Точек обзора ровно столько, сколько людей подняли узлы.
Это, по сути, прямой ответ на финал прошлой статьи. Там лучшим вариантом был «кросс-мониторинг: независимые системы следят друг за другом». LibrePing доводит эту мысль до логического предела — вся система и есть кросс-мониторинг, по построению.
В сети LibrePing живут узлы двух ролей.
Probe (зонд) — лёгкий агент. Он запускает проверки из своей точки: HTTP, TCP, DNS, TLS, ICMP, traceroute. Каждый результат он подписывает своим ключом и отправляет на хаб. Probe не требует белого IP, не хранит ничего долговременно и спокойно живёт на NAS дома или на копеечном VPS. И — важная деталь, к которой я вернусь ниже — он не привязан к одному-единственному хабу намертво.
Hub (хаб) — публично доступный узел. Он принимает результаты от зондов, проверяет подписи, хранит данные, отдаёт дашборд и — главное — разносит результаты другим хабам по mesh-сети на libp2p (это сетевой стек, на котором стоит IPFS). Центрального сервера нет: хабы федерируются напрямую между собой, как почтовые серверы или узлы Mastodon.
А ещё есть третья «роль», которая на самом деле вообще не сервер. Обычному пользователю поднимать ничего не надо: веб-дашборд прямо в браузере генерирует ключ Ed25519 — это и есть «аккаунт». Никакой регистрации, почты, пароля. Ваши мониторы и алерты подписаны этим ключом и расходятся по сети. Закрыли вкладку, открыли на другом устройстве с тем же ключом — увидели свои сервисы. Аккаунт — это просто пара ключей, которая живёт у вас, а не в чьей-то базе.
Давайте проследим за одним пингом от рождения до дашборда — на этом пути спрятана вся суть.
Probe запускает проверку, скажем, HTTP-запрос к вашему сайту. Получает результат: код 200, задержка 143 мс.
Probe упаковывает это в структуру и подписывает своим приватным ключом. Подпись неотделима от данных.
Probe отправляет подписанный результат на свой домашний хаб по обычному HTTPS.
Хаб проверяет подпись, применяет политику доверия, сохраняет результат — и пересылает его дальше, соседним хабам по mesh.
Соседний хаб получает результат через gossip и делает ровно ту же проверку независимо. Он не верит хабу-ретранслятору ни на грамм. Он верит только исходной подписи зонда — а публичный ключ зонда едет внутри самого результата.
Вот этот пятый пункт — ключевой. В сети нет «доверенных» узлов. Хаб, который переслал вам данные, мог быть скомпрометирован, мог быть откровенно вредоносным — неважно. Если подпись зонда не сходится, результат летит в мусор. Доверие привязано к ключу автора данных, а не к каналу доставки. Это и называется self-certifying identity: ID узла — это хэш его публичного ключа, подделать ID, не имея ключа, нельзя.
Gossip — штука best-effort, какие-то сообщения теряются. Поэтому свежеподключившийся хаб ещё и дозапрашивает у соседей то, что пропустил, через отдельный поток синхронизации — и снова перепроверяет каждую подпись. Сеть в итоге сходится к одному и тому же набору данных у всех участников, как git после нескольких pull’ов.
Тут внимательный читатель ловит меня за руку. Вся статья про «нет единой точки отказа», а зонд-то отправляет результаты на один хаб. Этот хаб лёг — и что, зонд ослеп вместе с ним? Для системы, которая хвастается децентрализацией, это была бы досадная дыра ровно в том месте, где её обиднее всего иметь.
Поэтому зонд не привязан к одному хабу. В конфиге у него список «затравочных» хабов, но главное — он сам узнаёт о других хабах из сети. Каждый хаб публикует директорию известных ему хабов (и в которой URL’ы уже проверены на достижимость). Зонд её просто читает у того хаба, с которым сейчас работает, и складывает остальные хабы в карман как запасные. То есть достаточно указать зонду один живой хаб — а дальше он узнает про всю остальную сеть сам.
Дальше работает простое и предсказуемое поведение. Пока текущий хаб жив — зонд работает с ним. Хаб перестал отвечать — зонд автоматически перекатывается на следующий живой: регистрируется там, забирает у него назначение проверок, шлёт результаты ему. А когда домашний хаб оживает — зонд возвращается к нему на ближайшем хартбите (предпочтение в списке закреплено, так что зонд гравитирует к своему настроенному хабу). Упавший хаб ненадолго уходит в «остывание», чтобы зонд не долбился в труп на каждой попытке.
И что приятно — тут красиво сыграла исходная архитектура. Зонду всё равно, на какой хаб приземлится его результат: регистрация зонда не привязана к хабу (любой хаб примет валидно подписанного зонда и сам раздаст ему проверки), а результат всё равно разойдётся по всей сети через gossip. Так что когда домашний хаб вернётся, он получит всё, что зонд за время простоя слал чужим хабам — через ту же синхронизацию, что и любой опоздавший участник. Результат, который не удалось доставить текущему хабу, не выбрасывается, а тут же повторяется на следующем живом. Потеря возможна только если легла вообще вся сеть разом — но тогда у вас проблемы поинтереснее мониторинга.
Проверка (check) контентно-адресуема. Её ID выводится из самого содержимого: тип, цель, параметры прогоняются через хэш. Это значит, что если сто человек добавят мониторинг https://example.com, то это будет одна проверка, которую сеть выполняет один раз — а не сто одинаковых проверок, дубасящих чужой сервер сотней запросов.
А то, что связывает лично вас с этой проверкой — это подписка (subscription): подписанная вашим ключом запись «слежу вот за этим». «Мои сервисы» на дашборде — это просто список ваших подписок.
Из этого красиво следует управление нагрузкой: проверка назначается на зонды, только пока у неё есть хотя бы одна живая подписка. Никому больше не интересен сервис — подписки истекают (у них есть срок жизни, дашборд их продлевает, пока вкладка открыта), проверка перестаёт назначаться, мощности высвобождаются. Capacity follows demand, без ручного управления.
Раз нет координатора, возникает два честных вопроса: как решается, какой зонд какую проверку выполняет, и как не заставлять каждый хаб хранить вообще всё.
Назначение проверок. Каждый хаб самостоятельно, жадным алгоритмом, раскладывает глобальный каталог проверок по своим зарегистрированным зондам. Он целится в нужную избыточность (несколько зондов на проверку, чтобы был кворум), не превышает заявленную зондом пропускную способность и назначает зонду только те типы проверок, которые тот умеет. Каждый хаб решает это локально и независимо — а глобальная избыточность возникает сама собой из того, что над общим каталогом работает много хабов. Никто никого не координирует, а в сумме всё покрыто.
Хранение. Поначалу каждый хаб хранил весь поток результатов всей сети. Это нормально, пока сеть маленькая, но не масштабируется. Поэтому управляющие данные (каталог, подписки, алерты, директория хабов — это всё мелочь) реплицируются полностью, а вот результаты шардируются. Каждая проверка попадает в шард, каждый шард хранят top-K хабов, выбранных взвешенным по ёмкости rendezvous-хэшированием — это такой способ детерминированно и без координатора договориться, кто за что отвечает (тот же приём, что и в распределённых кэшах). Каждый хаб вычисляет это сам из общей директории.
Важная деталь про безопасность по умолчанию: пока в сети мало хранящих хабов, все по-прежнему хранят всё. Расползание данных по шардам включается, только когда хабов становится достаточно. То есть соло-узел или маленький меш ведут себя ровно как раньше — никаких сюрпризов на старте.
Если на дашборде запросили проверку, данные по которой этот хаб не хранит, он сходит за ними к держателям шарда по HTTP — и, как вы уже догадались, перепроверит подписи, потому что ретранслятору веры нет и тут.
Алерты в централизованной системе — это просто: есть сервер, он знает все правила, он шлёт уведомления. В децентрализованной так нельзя, и тут было больше всего интересной возни.
Правила алертов расходятся по всей сети, как и всё остальное. Но если правило знают все хабы — кто шлёт уведомление? Если каждый — вы получите N одинаковых писем «сайт лёг». Если механизм наивный — при сбое ответственного хаба уведомление не уйдёт вообще.
Решено так. Для каждого правила ответственность за отправку несёт ровно один хаб — тот, кого выбирает rendezvous-хэширование среди живых получателей. Он один оценивает условие и шлёт уведомление. Падение факта downtime подтверждается корроборацией: несколько независимых зондов должны видеть, что цель недоступна, и никто не должен видеть, что она доступна — это защита от ложных срабатываний из-за одной кривой точки обзора.
Если ответственный хаб замолчал — его «протухание» отслеживается по таймауту, и в течение нескольких минут ответственность подхватывает другой хаб-получатель. Доставка — at-least-once без потерь: статус «доставлено» продвигается только после успешной отправки, неудавшийся webhook не считается доставкой, и на следующем тике будет повтор. Хабы обмениваются подписанным состоянием доставки (last-writer-wins), так что хаб, подхвативший эстафету, знает, что уже отправлено, и не дублирует. Честно скажу: истинный exactly-once против внешних эндпоинтов недостижим в принципе (подтверждение об отправке может само потеряться), так что дубль теоретически возможен в коротком окне передачи ответственности. Я предпочитаю написать это прямо, а не обещать невозможное.
Теперь самое приятное — приватность. Куда именно слать алерт (ваш ntfy-топик, ваш webhook) — это чувствительные данные, а правила-то расходятся по всей сети, к операторам чужих хабов. Поэтому адрес назначения запечатан: браузер шифрует его (анонимный X25519-box) на ключи только тех top-K хабов, что отвечают за это правило. Оператор любого другого хаба видит в правиле лишь запечатанные байты — ни топика, ни URL. Отвечающий хаб распечатывает свой конверт и шлёт. Формат шифрования закреплён тестовым вектором JS→Go, чтобы браузер и сервер гарантированно понимали друг друга.
Каналы — это ntfy, Discord, Slack и «сырой» webhook. Все они устроены одинаково: обычный исходящий HTTPS-запрос на адрес, который указали вы. Поэтому оператору хаба не нужно вообще ничего настраивать — ни SMTP, ни ключей, ничего. (Раньше был ещё email через SMTP хаба, но он полагался на то, что операторы поднимут почтовые серверы, — выпилил в пользу универсальных HTTP-каналов.)
Если выкинуть один тезис из этой статьи в память, пусть будет такой: в открытой сети нельзя доверять никому, поэтому доверять надо подписям, а не узлам.
Подписано почти всё, что ездит по сети: результаты проверок, записи каталога, анонсы хабов, подписки, правила алертов, состояние доставки, даже регистрация зонда. Каждый узел проверяет подпись на входе сам и независимо. Это даёт устойчивость к враждебным участникам без всякого центрального удостоверяющего центра: пакость, которую вы вбросите в сеть, либо корректно подписана вами (и тогда вы за неё отвечаете), либо не пройдёт проверку и будет отброшена.
Отдельная тонкость — данные, которые подписывает браузер. JSON нельзя надёжно воспровести байт-в-байт между языками (порядок ключей, пробелы, экранирование), поэтому подписки и правила алертов подписываются не как JSON, а как явная каноническая строка. JS-кодировщик в браузере и Go-кодировщик на хабе обязаны выдавать одинаковые байты — это закреплено кросс-языковым тестовым вектором, который обе стороны должны воспроизвести из фиксированного зерна. Тронул кодировщик — изволь обновить обе стороны и перегенерировать вектор, иначе данные, созданные в браузере, перестанут верифицироваться. Звучит занудно, но это ровно тот клей, на котором держится доверие между браузером и сетью.
И сразу про честные ограничения, потому что обещать звёзды легко. Открытый меш уязвим к Sybil-атаке (наклепать тысячу фейковых зондов) — смягчение тут не в привратнике на входе, а в корроборации между независимыми зондами. А локация узла — самозаявленная: по умолчанию определяется по публичному IP, руками переопределяется. IP-геолокация — это удобство, а не доказательство. Proof-of-location честно вынесен в «отложено»: подтвердить криптографически, что зонд физически находится там, где говорит, — нерешённая задача, и я не делаю вид, что решил её.
Если отвлечься от внутренностей, картинка для человека такая.
Поднимать сервер не обязательно. Заходите на дашборд, браузер генерирует вам ключ, добавляете монитор — и получаете проверки из всех точек, где люди подняли зонды, с географической картой: откуда сервис доступен, а откуда нет. Это та самая мультилокационность, за которую коммерческие сервисы берут деньги, — здесь она побочный эффект архитектуры.
Хотите дать сети точку обзора из своего города — поднимаете probe-узел одной командой, указав чужой хаб как домашний. Хотите быть полноправным участником федерации, хранить и разносить данные — поднимаете хаб. Один контейнер, и вы часть сети.
А возвращаясь к вопросу из заголовка прошлой статьи — кто мониторит монитор? В этой архитектуре вопрос как-то растворяется. Монитор — это не сервер, который надо сторожить, а сеть, которая сторожит сама себя. Узел упал — соседи этого не заметили. Это не отменяет остаточный риск (он есть всегда, об этом была вся та статья), но смещает его из разряда «единая точка отказа, которую мы обвешиваем подпорками» в разряд «свойство, размазанное по всем участникам».
Для тех, кому интересны гайки:
Ядро — Go, три модуля в одном go.work workspace: общий pkg (идентификация, протокол), probe, hub.
Mesh — libp2p: gossipsub для рассылки, DHT для обнаружения пиров. Версии жёстко запинены — в новых релизах есть неприятная неоднозначность со сплитом модуля core.
Хранилище — TimescaleDB. Результаты хранятся ярусами: свежие сырые (сжатые), потом почасовые и посуточные агрегаты, у каждого яруса своя политика хранения. Потому что каждый хаб тащит поток всей сети — детальность гасится с возрастом данных, чтобы диск не разрастался. Без базы хаб тоже работает (in-memory), удобно для локальных проб.
Криптография — Ed25519 для подписей, X25519 для запечатывания адресов алертов. В браузере — нативный WebCrypto, в Go — стандартная библиотека, между ними закреплённые тестовые векторы.
Дашборд — React + Vite + TypeScript, карта на MapLibre GL. Вся криптография аккаунта живёт в браузере.
SSRF-guard — отдельный общий пакет: проверки целей, доставка вебхуков и верификация директории ходят наружу только через SSRF-safe клиент, который перепроверяет резолвнутый IP в момент соединения. Мониторинг, которым можно простукивать внутреннюю сеть, — так себе мониторинг.
Прошлая статья была про смирение: идеального решения проблемы «кто мониторит монитор» нет, есть слои и остаточный риск. Эта — про попытку зайти с архитектурной стороны и убрать саму единую точку, которую надо было мониторить. Остаточный риск никуда не делся — он просто стал другим, размазанным и куда более дружелюбным.
Получилось ли «лучше, чем у всех»? Нет, и не было такой цели. Получилось «иначе», с понятными свойствами и честно описанными ограничениями. Мне кажется, в инфраструктурных штуках это и есть правильная планка.
Если зацепило — буду рад вопросам и критике в комментариях. Особенно по части того, где в этой модели доверия я не доглядел: ломать чужие схемы доверия Хабр умеет как никто.
Код, документация и трекер — на GitHub: github.com/mwgg/LibrePing. Лицензия AGPL-3.0; PR и issue приветствуются.