Каскад из одной аварии: как схлопнуть шторм алертов в одну карточку инцидента
- среда, 2 сентября 2026 г. в 00:00:16
Дисклеймер: я делаю self-hosted observability-платформу Gotcha, и дальше - разбор того, как в ней устроена группировка инцидентов. Механика описана так, чтобы её можно было повторить в любой своей системе алертинга: конкретный код тут вторичен, первичны грабли.
Падает один узел - шлюз, гипервизор, единственный узел БД. Дальше начинается то, что дежурный видит как ковровую бомбардировку:
перестают слать телеметрию хосты, которые сидят за этим узлом, - открываются инциденты “хост молчит”;
гаснет uptime-монитор, который дёргал сервис снаружи;
срабатывают метрические пороги на тех же хостах: данных нет, значит, порог “нет метрик” пробит;
горит SLO-монитор, у которого бюджет ошибок съело за минуту;
и всё это разъезжается по каналам уведомлений и лестницам эскалации, каждая из которых через N минут расширит круг получателей.
Событие одно. Уведомлений - десятки. В три часа ночи человек, разбуженный такой пачкой, тратит первые пятнадцать минут не на починку, а на реконструкцию того, что вообще упало первым.
Задача звучит просто: собрать каскад в один инцидент с составом. Практика оказалась заметно интереснее формулировки, и вот те места, где ломается наивная реализация.
Глобальный rate limit. “Не больше пяти уведомлений в минуту”. Гасит шторм и вместе с ним - независимую аварию, которая случилась в ту же минуту в другом конце инфраструктуры. Дежурный узнаёт о ней через час.
Дедупликация по тексту. Работает, пока сообщения похожи. Инциденты из шести разных источников не похожи вообще: “хост молчит”, “монитор не отвечает”, “метрика выше порога”, “бюджет SLO исчерпан” - у них нет общего ключа, кроме того, что причина у них одна. А причина в тексте не написана.
Задержка. “Подождём пять минут, вдруг само”. Не решает задачу, а сдвигает шторм на пять минут. Плюс делает хуже там, где авария настоящая и одиночная.
Рабочая идея одна, и она не новая: у узлов инфраструктуры есть зависимости, и если упал родитель, то падение детей - следствие, а не отдельная новость. Интересное начинается, когда эту идею пытаешься довести до состояния, в котором она не врёт.
Наивная формулировка: “у инцидента есть родитель в графе зависимостей, родитель упал - значит, подавляем”. Она даёт неправильную группировку, как только цепочка длиннее одного звена.
Пусть есть цепочка шлюз -> гипервизор -> виртуалка. Падает шлюз. Виртуалка замолчала. Её прямой родитель - гипервизор, и он тоже молчит, но корень каскада не он. Если группировать по прямому родителю, получится две группы вместо одной, и дежурный снова гадает.
Правильный предикат - не “родитель упал”, а “кто верхний упавший предок”. Обход идёт вверх только по упавшим родителям и останавливается там, где упавших родителей больше нет:
func downRootFromSnapshot(snap *snapshot, start node) (node, bool) { var roots []node visited := map[node]bool{start: true} stack := append([]node{}, downParents(snap, start)...) for len(stack) > 0 { p := stack[len(stack)-1] stack = stack[:len(stack)-1] if visited[p] { continue } visited[p] = true pp := downParents(snap, p) if len(pp) == 0 { roots = append(roots, p) // p - down-корень continue } stack = append(stack, pp...) } if len(roots) == 0 { if nodeIsDown(snap, start) { return start, true // сам узел упал, упавших предков нет - корень он } return node{}, false } ... }
Три детали, которые в этом куске выглядят как перестраховка, а на деле каждая закрывает реальный сценарий.
visited и итеративный обход вместо рекурсии. Граф зависимостей рисует пользователь. Рано или поздно он нарисует цикл: A зависит от B, B зависит от A. Рекурсивный обход в этот момент кладёт планировщик алертов - ровно тогда, когда алерты нужнее всего.
Сбор всех корней, а не первого попавшегося. Ромб в графе - обычное дело: узел висит на двух аплинках, оба упали. Первый найденный корень зависит от порядка обхода, порядок обхода зависит от порядка строк из базы, и группа начинает “прыгать” между двумя корнями на соседних тиках. Поэтому корни собираются все, а затем выбирается один детерминированно - сортировкой по виду узла и id. Правило выбора неважно, важно, что оно стабильное.
Отдельная ветка “корень - сам узел”. Если упавших предков нет, но сам узел лежит, корнем становится он. Иначе одиночная авария не образует группы вообще и проваливается мимо всей механики.
Каскад не обязан ехать сверху вниз. Детектор, который опрашивает хосты, может заметить молчание виртуалки раньше, чем монитор снаружи заметит, что не отвечает шлюз. Тогда в момент открытия инцидента виртуалки корня ещё нет: обход честно отвечает “упавших предков не вижу”, инцидент уходит в самостоятельное плавание и шлёт своё уведомление.
Через полминуты открывается инцидент шлюза - и оказывается, что группа должна была существовать полминуты назад.
Лечится ретро-перебором: при открытии любого потенциального корня система проходит по уже открытым инцидентам и втягивает в группу тех, чей down-корень совпал с только что открывшимся узлом.
func (g *Grouper) OnRootOpened(ctx context.Context, rootSource string, rootIncidentID int64, rootNodeKind string, rootNodeID, projectID int64) error { // Снимок депсуппрессора может не знать о только что открытом корне // (кеш 5 с) - сбрасываем, иначе перебор молча пропустит всех членов. g.Roots.Invalidate() cands, err := g.openCandidates(ctx, projectID) ... }
Комментарий про кеш - не украшение. Снимок графа с состоянием “упал / не упал” кешируется на несколько секунд, иначе каждый тик планировщика идёт в базу за всем графом. И ретро-перебор, запущенный сразу после открытия корня, с огромной вероятностью попадает в окно, где кеш ещё не знает про этот корень. Перебор при этом не падает и не ругается - он просто ничего не находит и молча завершается успехом. Отладка такого поведения на живой системе стоит дорого, потому что воспроизводится оно только под нагрузкой.
Когда группа собрана, участники молчат. Оказалось, что “молчат” - это два независимых механизма, и в интерфейсе их обязательно надо разделять.
Молчание из-за группы. Участник не отправляет своё уведомление об открытии, потому что вместо него уже проинформировал корень: в уведомлении корня стоит строка “Зависимых узлов: N”. Решение принимается один раз, в момент присоединения к группе.
Подавление эскалации по зависимости. Совсем другая вещь: инцидент открылся, уведомление про него, возможно, уже ушло, но лестница эскалации не поднимается дальше, потому что родитель узла лежит. Решение принимается планировщиком эскалаций, на каждом тике, и в другой момент времени.
Оба флага могут быть выставлены одновременно - и каждый про свою причину. В карточке группы видно, кто молчит потому, что информирует корень, а кто подавлен недоступным родителем. Если склеить их в один флаг “подавлен”, дежурный теряет возможность понять, придёт ли ему что-нибудь по этому инциденту в принципе.
Главный риск любой системы подавления алертов - подавить настоящую аварию. Поэтому во всех неоднозначных местах решение принимается в пользу шума.
Немой корень. Корень может сам оказаться молчаливым: он попал в окно обслуживания, или он сам подавлен зависимостью, или он ещё в грейс-периоде. Тогда участник присоединяется к группе “только для состава” - он попадёт в карточку, но своё уведомление отправит сам. Иначе получилось бы худшее из возможного: группа собралась, все молчат, потому что информировать должен корень, а корень не информирует никого.
Гонка на закрытии. Корень может закрыться между тем, как его увидел снимок графа, и тем, как система пошла создавать группу. В этом случае группа не создаётся вовсе.
Ленивое создание группы. Проверка “этот инцидент вообще может стать участником” делается до создания группы, а не после. Порядок здесь не косметический: инцидент, уже привязанный к другой открытой группе, повторно не перепривязывается - и если группу нового корня успели создать раньше проверки, она остаётся пустой и навсегда висит в интерфейсе карточкой без состава.
Уборщик. Раз в минуту отдельный процесс закрывает осиротевшие группы - те, у которых корень закрылся, а сама группа осталась открытой. Он работает всегда, независимо от того, включён ли ретеншен и настроен ли он вообще: чистка старых записей - опциональная оптимизация, а закрытие осиротевшей группы - вопрос корректности.
Соблазн - завести incident_group_members со связью многие-ко-многим. На практике участник принадлежит ровно одной группе, а источников инцидентов несколько, и каждый живёт в своей таблице. Поэтому членство хранится колонкой:
CREATE TABLE incident_groups ( id BIGSERIAL PRIMARY KEY, project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, root_source TEXT NOT NULL CHECK (root_source IN ('host','uptime')), root_incident_id BIGINT NOT NULL, root_node_kind TEXT NOT NULL CHECK (root_node_kind IN ('host','monitor')), root_node_id BIGINT NOT NULL, started_at TIMESTAMPTZ NOT NULL DEFAULT now(), resolved_at TIMESTAMPTZ, UNIQUE (root_source, root_incident_id) ); ALTER TABLE host_incidents ADD COLUMN group_id BIGINT; ALTER TABLE incidents ADD COLUMN group_id BIGINT; ALTER TABLE metric_incidents ADD COLUMN group_id BIGINT; ALTER TABLE slo_incidents ADD COLUMN group_id BIGINT; CREATE INDEX host_incidents_group_idx ON host_incidents(group_id) WHERE group_id IS NOT NULL;
Что тут стоит отметить.
UNIQUE (root_source, root_incident_id) - гарантия того, что у одного корневого инцидента не может появиться двух групп при гонке двух параллельных детекторов.
Частичные индексы WHERE group_id IS NOT NULL - потому что подавляющее большинство инцидентов ни в какой группе не состоит, и индексировать миллион NULL-ов смысла нет.
Отдельный полный индекс по project_id рядом с частичным индексом открытых групп - неочевидная, но обязательная штука. Частичный индекс с предикатом по другой колонке не работает как покрывающий для внешнего ключа, и каскадное удаление проекта уходит в последовательный скан. У нас на этом уже был прецедент парой миграций раньше, поэтому в схеме стоит сторож в тестах, который проверяет, что у каждого FK есть покрывающий индекс.
Миграция целиком аддитивная: новая таблица плюс nullable-колонки. Откат не теряет данных инцидентов.
Есть неприятная вилка. Детектор заметил, что молчит ребёнок, а родитель ещё считается живым - его собственный детектор просто не успел отработать. Если отправить уведомление немедленно, подавление опоздает: шум уже ушёл.
Планировщик эскалаций держит нулевую ступень, пока у инцидента есть живой родитель, - в пределах настраиваемого грейса. За это время родитель либо падает следом, и тогда подавление успевает сработать до первого уведомления, либо остаётся живым, и тогда после грейса нулевая ступень всё равно уходит. Ожидание ограничено сверху и не может превратиться в вечное молчание.
Лента инцидентов на одной странице:
открытые группы с раскрываемым составом и пометкой у каждого участника, почему именно он молчит;
инциденты вне групп - по всем источникам сразу;
то, что закрылось за последние сутки.
Отдельный случай - инцидент, переживший свою группу. Корень починили, группа закрылась, а участник всё ещё открыт: например, хост вернулся, а метрический порог на нём так и не пришёл в норму. Такой инцидент появляется среди внегрупповых с бейджем “был в группе” и ссылкой на неё, чтобы не выглядел возникшим из ниоткуда.
Если будете делать похожее у себя:
Группируйте по верхнему упавшему предку, а не по прямому родителю. Обход - только по упавшим.
Считайте, что порядок событий вам не подчиняется. Ретро-перебор при открытии корня обязателен, и не забудьте сбросить кеш снимка перед ним.
Разделяйте “молчит, потому что за него сказал корень” и “не эскалируется, потому что родитель лежит”. Это разные механизмы в разные моменты времени.
В каждом спорном месте выбирайте шум. Немой корень, гонка на закрытии, непонятное состояние - пусть участник уведомит сам.
Заведите уборщика осиротевших групп и включайте его всегда, а не только вместе с ретеншеном.
Ромбы и циклы в графе зависимостей нарисует пользователь, а не тест. Детерминированный выбор корня и защита от циклов нужны с первого дня.
Код проекта открыт под Apache-2.0: https://github.com/OtezVikentiy/gotcha - группировка живёт в internal/incidentgroup, обход графа в internal/depsuppress.
Буду рад разбору в комментариях, особенно если вы решали ту же задачу иначе.