golang

Как мы построили распределённый мониторинг аптайма

  • суббота, 20 июня 2026 г. в 00:00:07
https://habr.com/ru/articles/1049518/

В прошлый раз я писал про рекурсивную задачу мониторинга: кто мониторит монитор? Если 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 — это и есть «аккаунт». Никакой регистрации, почты, пароля. Ваши мониторы и алерты подписаны этим ключом и расходятся по сети. Закрыли вкладку, открыли на другом устройстве с тем же ключом — увидели свои сервисы. Аккаунт — это просто пара ключей, которая живёт у вас, а не в чьей-то базе.

Как результат проверки путешествует по сети

Давайте проследим за одним пингом от рождения до дашборда — на этом пути спрятана вся суть.

  1. Probe запускает проверку, скажем, HTTP-запрос к вашему сайту. Получает результат: код 200, задержка 143 мс.

  2. Probe упаковывает это в структуру и подписывает своим приватным ключом. Подпись неотделима от данных.

  3. Probe отправляет подписанный результат на свой домашний хаб по обычному HTTPS.

  4. Хаб проверяет подпись, применяет политику доверия, сохраняет результат — и пересылает его дальше, соседним хабам по mesh.

  5. Соседний хаб получает результат через 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 приветствуются.