Я строю мультитенантную платформу мониторинга на стеке 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 -l 198 $ ls /etc/vmalert/rules.d/ | head -3 tenant-2f6c9a1e.yml tenant-31bb02c7.yml tenant-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, pathlib VMALERT = "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 False async 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 re ALLOWED = 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 (по замечанию модератора)
Диагностика с командами: top, ls|wc, PromQL к метрикам самонаблюдения vmalert.
Точные механики из документации vmalert:
/-/reload,-reloadAuthKey,vmalert_config_last_reload_successful,-dryRun, различие hot reload vs старт (CrashLoopBackOff).Пример YAML‑правила с кривым Go‑шаблоном, который валит reload.
Полный код транзакционной подмены с проверкой по метрике (httpx).
Код белого списка подстановок (regex) и сверки сирот.
Код e2e‑теста с двумя инвариантами.
Упомянуты vmalert‑tool/dryRun как штатные способы валидации.