Я строю мультитенантную платформу мониторинга на стеке 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 (по замечанию модератора)

  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 как штатные способы валидации.

Комментарии (0)