golang

Каскад из одной аварии: как схлопнуть шторм алертов в одну карточку инцидента

  • среда, 2 сентября 2026 г. в 00:00:16
https://habr.com/ru/articles/1074588/

Дисклеймер: я делаю 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”. Решение принимается один раз, в момент присоединения к группе.

Подавление эскалации по зависимости. Совсем другая вещь: инцидент открылся, уведомление про него, возможно, уже ушло, но лестница эскалации не поднимается дальше, потому что родитель узла лежит. Решение принимается планировщиком эскалаций, на каждом тике, и в другой момент времени.

Оба флага могут быть выставлены одновременно - и каждый про свою причину. В карточке группы видно, кто молчит потому, что информирует корень, а кто подавлен недоступным родителем. Если склеить их в один флаг “подавлен”, дежурный теряет возможность понять, придёт ли ему что-нибудь по этому инциденту в принципе.

Fail-noisy: лишнее уведомление дешевле тишины

Главный риск любой системы подавления алертов - подавить настоящую аварию. Поэтому во всех неоднозначных местах решение принимается в пользу шума.

Немой корень. Корень может сам оказаться молчаливым: он попал в окно обслуживания, или он сам подавлен зависимостью, или он ещё в грейс-периоде. Тогда участник присоединяется к группе “только для состава” - он попадёт в карточку, но своё уведомление отправит сам. Иначе получилось бы худшее из возможного: группа собралась, все молчат, потому что информировать должен корень, а корень не информирует никого.

Гонка на закрытии. Корень может закрыться между тем, как его увидел снимок графа, и тем, как система пошла создавать группу. В этом случае группа не создаётся вовсе.

Ленивое создание группы. Проверка “этот инцидент вообще может стать участником” делается до создания группы, а не после. Порядок здесь не косметический: инцидент, уже привязанный к другой открытой группе, повторно не перепривязывается - и если группу нового корня успели создать раньше проверки, она остаётся пустой и навсегда висит в интерфейсе карточкой без состава.

Уборщик. Раз в минуту отдельный процесс закрывает осиротевшие группы - те, у которых корень закрылся, а сама группа осталась открытой. Он работает всегда, независимо от того, включён ли ретеншен и настроен ли он вообще: чистка старых записей - опциональная оптимизация, а закрытие осиротевшей группы - вопрос корректности.

Схема данных: без таблицы участников

Соблазн - завести 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-колонки. Откат не теряет данных инцидентов.

Грейс-период: дать родителю шанс упасть

Есть неприятная вилка. Детектор заметил, что молчит ребёнок, а родитель ещё считается живым - его собственный детектор просто не успел отработать. Если отправить уведомление немедленно, подавление опоздает: шум уже ушёл.

Планировщик эскалаций держит нулевую ступень, пока у инцидента есть живой родитель, - в пределах настраиваемого грейса. За это время родитель либо падает следом, и тогда подавление успевает сработать до первого уведомления, либо остаётся живым, и тогда после грейса нулевая ступень всё равно уходит. Ожидание ограничено сверху и не может превратиться в вечное молчание.

Что в итоге видит дежурный

Лента инцидентов на одной странице:

  • открытые группы с раскрываемым составом и пометкой у каждого участника, почему именно он молчит;

  • инциденты вне групп - по всем источникам сразу;

  • то, что закрылось за последние сутки.

Отдельный случай - инцидент, переживший свою группу. Корень починили, группа закрылась, а участник всё ещё открыт: например, хост вернулся, а метрический порог на нём так и не пришёл в норму. Такой инцидент появляется среди внегрупповых с бейджем “был в группе” и ссылкой на неё, чтобы не выглядел возникшим из ниоткуда.

Выводы

Если будете делать похожее у себя:

  1. Группируйте по верхнему упавшему предку, а не по прямому родителю. Обход - только по упавшим.

  2. Считайте, что порядок событий вам не подчиняется. Ретро-перебор при открытии корня обязателен, и не забудьте сбросить кеш снимка перед ним.

  3. Разделяйте “молчит, потому что за него сказал корень” и “не эскалируется, потому что родитель лежит”. Это разные механизмы в разные моменты времени.

  4. В каждом спорном месте выбирайте шум. Немой корень, гонка на закрытии, непонятное состояние - пусть участник уведомит сам.

  5. Заведите уборщика осиротевших групп и включайте его всегда, а не только вместе с ретеншеном.

  6. Ромбы и циклы в графе зависимостей нарисует пользователь, а не тест. Детерминированный выбор корня и защита от циклов нужны с первого дня.

Код проекта открыт под Apache-2.0: https://github.com/OtezVikentiy/gotcha - группировка живёт в internal/incidentgroup, обход графа в internal/depsuppress.

Буду рад разбору в комментариях, особенно если вы решали ту же задачу иначе.