Привет, Хабр!
В моём uptime-мониторинге Valpero сейчас семь production-мониторов и десять probe-регионов. Когда я только начал, false-positive алёрты приходили часто — типичная история с single-region проверкой. Поставил quorum-логику. Тут оказалось, что вариантов quorum’а несколько, и они дают разное поведение в пограничных случаях.
Ниже расскажу про два главных подхода — K-of-N (как в Pingdom, BetterStack) и all-must-agree (как у меня) — с реальным кодом, который у меня сейчас в проде.
В конце разберу edge-кейсы которые ломают каждый из подходов, и почему я остановился на all-must-agree с consecutive-failure threshold.
Два подхода к quorum
K-of-N: incident открывается когда K из N регионов сообщают down. Например, 3 из 10. Это позволяет терпеть до K-1 одновременных fail’ов как «шум».
All-must-agree: incident открывается только когда все регионы видят down. Если хоть один регион видит up — статус становится partial (часть мира видит проблему, часть нет), но алёрт не идёт.
На первый взгляд K-of-N выглядит сильнее — он чувствительнее, ловит partial outages быстрее. На практике у этого подхода есть нюанс: что считать «N»? Если у вас сайт на CDN с PoP’ами в разных регионах, и одна PoP падает — 3-4 ваших probe-узла, которые ближе к этой PoP, увидят сайт down. Остальные 6-7 — up (они попали на другую PoP). K=3 алёрт сработает, хотя реально сайт работает для большинства мира.
All-must-agree даёт меньше false-positive’ов, но позже ловит реальные partial outages. Это компромисс в пользу precision вместо recall.
Что у меня в коде
Покажу как это сейчас работает в Valpero. Код упрощён, но логика та же.
Хранилище — Redis (per-region status + counter):
from redis import Redis from app.config import get_settings
_TTL_SECONDS = 86400 # 24h
def _redis() -> Redis: return Redis.from_url(get_settings().REDIS_URL, decode_responses=True)
def _key(site_id: int, region: str) -> str: return f"probe:region_status:{site_id}:{region}"
def _fail_key(site_id: int, region: str) -> str: return f"probe:region_fails:{site_id}:{region}"
Когда приходит результат probe-проверки, обновляем per-region state:
def update_region_status( site_id: int, region: str, is_up: bool, threshold: int = 1, has_open_incident: bool = False, ) -> tuple[str, int]: r = _redis() status_key = _key(site_id, region) fkey = _fail_key(site_id, region)
if is_up: # UP всегда подтверждается мгновенно, счётчик fail-ов сбрасывается r.delete(fkey) r.set(status_key, "up", ex=_TTL_SECONDS) return "up", 0 # DOWN — требуем threshold consecutive failures fails = int(r.incr(fkey) or 1) r.expire(fkey, 3600) if has_open_incident or fails >= max(threshold, 1): r.set(status_key, "down", ex=_TTL_SECONDS) return "down", fails # Down но не подтверждено — оставляем предыдущее состояние prev = r.get(status_key) kept = prev if prev in ("up", "down") else "up" r.set(status_key, kept, ex=_TTL_SECONDS) return kept, fails
Ключевая логика: single fail не флипает регион в down. Нужно threshold подряд (по умолчанию 1, но в проде у меня 2). Это давит шум от транзиентных сетевых проблем.
Quorum-verdict — простой:
def get_quorum_verdict( site_id: int, all_regions: list[str], current_region: str, current_is_up: bool, ) -> tuple[str, list[str]]: """Returns one of: 'up', 'partial', 'down' + список регионов в down.""" if not all_regions: return ("up" if current_is_up else "down", [])
statuses: dict[str, str] = {} r = _redis() pipe = r.pipeline() for region in all_regions: pipe.get(_key(site_id, region)) results = pipe.execute() for region, val in zip(all_regions, results): statuses[region] = val if val in ("up", "down") else "up" # Текущий результат всегда побеждает (Redis может ещё не подтянуть) statuses[current_region] = "up" if current_is_up else "down" down_regions = [r for r, s in statuses.items() if s == "down"] if not down_regions: return ("up", []) if len(down_regions) == len(all_regions): return ("down", down_regions) return ("partial", down_regions)
Алёрт уходит только на down. partial записывается в БД для последующего анализа, но никого не будит.
Почему consecutive-failure threshold
Самое частое что я ловил без threshold — single TCP-timeout на пограничном RTT. Probe в Tokyo делает HTTP-запрос на сайт в Германии. Round-trip 240 ms, но один пакет пропал на пути — handshake задерживается, timeout 10 секунд срабатывает, probe пишет «fail». Следующая проверка через 30 секунд — всё OK.
Если threshold = 1, single timeout достаточно чтобы флипать регион в down. Если threshold = 2, нужно два подряд — что в практике у меня случается на порядок реже (потому что transient network issues обычно длятся <30 секунд).
У Valpero сейчас в проде threshold = 2. На семи мониторах за последнюю неделю — ноль false-positive incident’ов, при этом два реальных kratkodobých падения (одного из тестовых сайтов на пару минут) пойманы корректно.
Edge-кейсы
Single-probe transient failure — описал выше. Threshold = 2 ловит.
AS-correlation — когда несколько probe в одной autonomous system падают одновременно из-за общего upstream. У меня 6 probe на одной AS (об этом писал в предыдущей статье). All-must-agree их защищает: даже если 6 узлов в одной AS все видят сайт down, оставшиеся 4 на других AS видят up — significant verdict будет partial, алёрт не уйдёт. Это правильное поведение, потому что real cause — не сайт, а моя сеть.
CDN edge failure — целевой сайт на Cloudflare, один PoP в Tokyo упал. Probe в Tokyo видит сайт down, остальные 9 probe попадают на другие PoP и видят сайт up. All-must-agree сообщит partial. Это правильно: пользователи в Tokyo испытывают проблему, но это не «весь сайт лежит», и операторам алёрт ночью не нужен.
Regional internet outage — например, AWS US-East-1 уходит вниз. Один probe в Нью-Джерси видит сайт down (если он на AWS), остальные на других провайдерах. All-must-agree: partial, алёрт не уйдёт. Это уже компромисс — реальные пользователи на US-East-1 не могут попасть на сайт. Здесь K-of-N был бы чувствительнее.
Простой K-of-N на тех же данных дал бы false-positive на AS-correlation и CDN edge cases. У него меньше precision, но больше recall. Для моих 7 мониторов precision важнее.
Когда переключиться на K-of-N
Я бы переключился если:
Появятся клиенты с СLA на uptime обещанием для конкретных регионов (например, «99.9% доступности из EU»). Тогда нужно ловить partial outages в EU как настоящий incident.
Сеть вырастет до 30+ regions с гарантированно разными AS. Тогда K=5 из 30 это уже не шум — это серьёзная индикация что что-то реально не так.
Пока 7 мониторов и 10 regions с 3 разными AS — all-must-agree даёт больше пользы.
Что я понял
Quorum это не один алгоритм, а семейство. K-of-N лучше для крупных сетей с надёжной AS-разнесённостью. All-must-agree лучше для маленьких сетей где false-positive дороже чем поздний catch real outage. Consecutive-failure threshold (минимум 2) обязателен в обоих случаях — single fail-on-fail-result почти всегда транзиент.
Если запиливаешь свой мониторинг — начни с all-must-agree + threshold=2. Это самый точный сигнал и наименьшее число ложных побудок. Когда нужна чувствительность — переключайся на K-of-N с K=floor(N/2).
Ссылки
Сайт: valpero.com — мой uptime-мониторинг с описанным выше quorum-алгоритмом
Клиентский open-source: будет в репо организации https://github.com/valpero
Granulex
«Все регионы согласны» – звучит как принцип командных встреч, и отказывает по той же причине: один несогласный – и всё замерло. Кстати, network partition – это именно тот момент, когда all-must-agree звонит в 3 ночи по поводу сервиса, который прекрасно себя чувствует, просто один регион не смог дозвониться до мониторинга.