Одно кривое правило — и алертинг лёг всем тенантам: анатомия отказа vmalert в мультитенантной платформе

от автора

Я строю мультитенантную платформу мониторинга на стеке VictoriaMetrics + Grafana + vmalert: у каждого клиента — изолированный tenant в хранилище и собственный набор правил алертинга, которые он редактирует через личный кабинет. Расскажу про два сцепленных инцидента, которые изменили моё понимание изоляции тенантов, — с конкретными числами, конфигами и кодом фиксов. Спойлер: изолировать данные — этого мало.

Часть 1. График, который никто не заказывал

Тестовый стенд, 2 vCPU. Загрузка CPU по дням за неделю:

пн 23% · вт 24% · ср 20% · чт 25% · пт 60% · сб 82% · вс 87%

Излом в один день, прод на том же стеке — стабильные 3%. Значит, дело не в версии и не в клиентской нагрузке.

top показал, что ядро делят vmalert и vmselect — то есть кто‑то непрерывно гоняет запросы к хранилищу. Первым делом смотрю на самонаблюдение vmalert — он экспортирует метрики о себе:

promql

# сколько групп и как долго вычисляютсяcount(vmalert_iteration_duration_seconds_count)# успешен ли последний reload конфигурацииvmalert_config_last_reload_successful

Групп оказалось 199. Живых тенантов на стенде — два. Дальше всё банально:

bash

$ ls /etc/vmalert/rules.d/ | wc -l198$ ls /etc/vmalert/rules.d/ | head -3tenant-2f6c9a1e.ymltenant-31bb02c7.ymltenant-3d81f44a.yml

198 файлов правил — 1 889 правил суммарно. 196 из них — сироты от e2e‑тестов: тест создавал тенанта, платформа генерировала ему файл правил, при удалении тенанта файл оставался. Каждая группа — с interval: 30s, и vmalert честно вычислял их все:

  • ~42 выполнения правил в секунду;

  • ~41 запрос в секунду в vmselect;

  • почти целое ядро из двух — на алерты аккаунтов, которых не существует.

Почему течь жила месяц незаметно? В коде удаления тенанта чистка внешних ресурсов выглядела так:

python

try:    delete_rules_file(tenant_id)except Exception:    pass  # «удаление не должно падать из-за файла»

Файл не удалился — и узнать об этом было неоткуда: ни лога, ни метрики. Каждый проглоченный эксепшен — чек, который система выпишет позже, с процентами. Мой стоил 87% CPU.

После чистки сирот: 42 выполнения/с → 0.8, запросы к хранилищу 41/с → 0.9, load average 7.6 → 1.7.

Часть 2. А что, если правило кривое?

Разбираясь с каталогом, я задал следующий вопрос: что произойдёт, если тенант сохранит синтаксически невалидное правило? В мультитенантной платформе это не «если», а «когда»: правила пишут клиенты.

Поведение vmalert при hot reload задокументировано, и оно консервативно‑разумное для одного владельца конфигурации: если хоть один файл из -rule не парсится, весь reload отвергается, vmalert продолжает работать на прежней конфигурации и выставляет:

vmalert_config_last_reload_successful 0

Для single‑tenant это правильный дизайн: лучше старый валидный конфиг целиком, чем частично применённый новый. Для multi‑tenant тот же дизайн означает: опечатка клиента А замораживает изменения правил клиента Б — и клиента В, и всех остальных. Reload у vmalert один на каталог, а не на файл.

Три обстоятельства превращают неприятность в мину:

1. Ошибка персистится. Кривой текст правила лежит в настройках тенанта в Postgres, и фоновая синхронизация при каждом проходе честно перегенерирует кривой файл на диск. Само не рассосётся.

2. Каталог отравлен. Файл после неудачного reload остаётся на диске. С этой секунды правки порогов любого тенанта не применяются — тихо: клиент жмёт «сохранить», получает «ок» от API, а vmalert живёт на старой конфигурации.

3. Рестарт превращается в аварию. Hot reload на невалидном каталоге vmalert переживает. А вот старт — нет: процесс с невалидным файлом в -rule завершается с ошибкой. Следующий деплой, OOM‑kill или обновление ноды — и под уходит в CrashLoopBackOff. Алертинг платформы мёртв целиком, у всех тенантов сразу.

Отдельная ловушка — шаблоны аннотаций. PromQL‑выражение проверяется и даёт понятную ошибку рано, а вот Go‑шаблон в annotations взрывается позже. Достаточно одного такого правила:

yaml

groups:  - name: tenant-a    interval: 30s    rules:      - alert: DiskFull        expr: disk_used_percent > 90        annotations:          summary: '{{ $value | humanizeX }}'   # нет такой функции

— и reload каталога отвергнут для всех.

Самое обидное: данные при этом были изолированы образцово — per‑tenant аккаунты хранилища, изоляция запросов на уровне параметров группы (extra_filters с меткой тенанта), автотест кросс‑тенантной изоляции на каждый деплой. А конфигурация — нет. Изоляция данных без изоляции конфигурации — изоляция наполовину.

Фиксы

1. Транзакционная подмена файла с проверкой по метрике

Идея: файл на диске — это транзакция. Записали новую версию → дёрнули reload → проверили результат по метрике vmalert → при отказе откатили файл и перечитались ещё раз. Клиент получает честную ошибку, каталог всегда валиден:

python

import httpx, pathlibVMALERT = "http://vmalert:8880"async def vmalert_reload_ok(client: httpx.AsyncClient) -> bool:    # /-/reload можно защитить флагом -reloadAuthKey    await client.get(f"{VMALERT}/-/reload")    metrics = (await client.get(f"{VMALERT}/metrics")).text    for line in metrics.splitlines():        if line.startswith("vmalert_config_last_reload_successful"):            return line.rstrip().endswith(" 1")    return Falseasync def apply_tenant_rules(tenant_id: str, new_text: str) -> None:    path = pathlib.Path(f"/etc/vmalert/rules.d/tenant-{tenant_id}.yml")    backup = path.read_bytes() if path.exists() else None    path.write_text(render(tenant_id, new_text))    async with httpx.AsyncClient(timeout=10) as client:        if await vmalert_reload_ok(client):            return        # откат: каталог обязан остаться валидным        if backup is None:            path.unlink(missing_ok=True)        else:            path.write_bytes(backup)        assert await vmalert_reload_ok(client), "rollback must succeed"    raise RuleRejected("правило не прошло валидацию vmalert")

Дополнительный рубеж — прогонять кандидата через vmalert -dryRun -rule=<файл> до записи в боевой каталог: dryRun валидирует синтаксис, не трогая работающий процесс. Мы оставили обе проверки: dryRun ловит явный мусор дёшево, проверка по метрике — истина в последней инстанции.

2. Белый список подстановок вместо чёрного

Клиенту в тексте алерта нужны лейблы, значение и пара humanize‑функций — не вся мощь Go‑шаблонов. Поэтому аннотации пропускаются через белый список:

python

import reALLOWED = re.compile(    r"\{\{\s*("    r"\$value(\s*\|\s*(humanize|humanize1024|humanizeDuration"    r"|humanizePercentage))?"    r"|\$labels\.[A-Za-z_][A-Za-z0-9_]*"    r")\s*\}\}")def sanitize_annotation(text: str) -> str:    out, pos = [], 0    for m in re.finditer(r"\{\{.*?\}\}", text):        out.append(text[pos:m.start()])        out.append(m.group(0) if ALLOWED.fullmatch(m.group(0)) else "")        pos = m.end()    out.append(text[pos:])    return "".join(out)

Всё, что не входит в список, молча вырезается до попадания на диск. Именно шаблоны, а не PromQL, оказались самым коварным способом свалить reload — теперь этот класс закрыт на входе.

3. Сверка сирот на старте и по расписанию

python

async def reconcile_rule_files(db) -> None:    alive = {r[0] for r in await db.fetch("select id from tenants")}    for p in pathlib.Path("/etc/vmalert/rules.d").glob("tenant-*.yml"):        tid = p.stem.removeprefix("tenant-")        if tid not in alive:            log.warning("удаляю файл правил сироты: %s", p.name)            p.unlink()

Та же сверка ловит и обратный случай — тенант есть, файла нет. А в except вокруг чистки ресурсов теперь живёт не pass, а лог + инкремент счётчика, на который настроен алерт: несработавший шаг удаления — это событие, а не шум.

4. Тест, который ломает правило намеренно

Постмортем без регрессионного теста — просто грустная история. Сценарий e2e‑теста:

python

async def test_invalid_rule_does_not_poison_neighbors(env):    a, b = await env.create_tenant(), await env.create_tenant()    await env.set_rules(b, VALID_RULE)    with pytest.raises(RuleRejected):        await env.set_rules(a, "groups: [{name: broken")  # мусор    # 1) правила соседа продолжают вычисляться    assert await env.rule_evaluated_recently(b)    # 2) каталог валиден: рестарт vmalert проходит    await env.restart_vmalert()    assert await env.vmalert_healthy()

Инвариант «сосед жив, рестарт проходит» проверяется на каждый деплой.

Выводы

В мультитенантной системе изолировать нужно три вещи, а не одну: данные, конфигурацию и жизненный цикл её применения. Про вторую и третью забывают, потому что данные — «продукт», а конфигурация — «обвязка». У обвязки при этом общий каталог, общий reload и общий рестарт.

Любой общий reload — общая точка отказа. Если применение конфигурации одного клиента способно затронуть применение конфигурации другого, это не мультитенантность, а общежитие с одним рубильником.

Поведение опенсорс‑инструментов по умолчанию спроектировано для одного владельца конфигурации — и это нормально. Съезжаясь в мультитенантность, перечитывайте документацию глазами злоумышленника с самым безобидным оружием: опечаткой.

И про except: pass: каждый проглоченный эксепшен — чек с процентами. Мой был на 87% CPU.


Всё описанное поймано на тестовом стенде и в аудитах — до клиентов не доехало; ради этого стенды и аудиты и существуют. Буду рад обсудить в комментариях, как вы изолируете конфигурацию в своих мультитенантных системах — тема, где чужой постмортем дешевле своего.


Что добавлено против v1 (по замечанию модератора)

  1. Диагностика с командами: top, ls|wc, PromQL к метрикам самонаблюдения vmalert.

  2. Точные механики из документации vmalert: /-/reload, -reloadAuthKey, vmalert_config_last_reload_successful, -dryRun, различие hot reload vs старт (CrashLoopBackOff).

  3. Пример YAML‑правила с кривым Go‑шаблоном, который валит reload.

  4. Полный код транзакционной подмены с проверкой по метрике (httpx).

  5. Код белого списка подстановок (regex) и сверки сирот.

  6. Код e2e‑теста с двумя инвариантами.

  7. Упомянуты vmalert‑tool/dryRun как штатные способы валидации.

ссылка на оригинал статьи https://habr.com/ru/articles/1076162/