Я в одиночку делаю и эксплуатирую платформу мониторинга: метрики, логи и трейсы для чужой инфраструктуры, мультитенантно, на VictoriaMetrics cluster / VictoriaLogs / VictoriaTraces, FastAPI, nginx и Postgres. Прод — один сервер: Debian 12, 4 CPU, 8 ГБ, четырнадцать контейнеров.

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

Это статья не про то, «как правильно проводить НТ» — таких статей достаточно. Она про то, какие именно виды нагрузки ломают систему приёма телеметрии, почему привычный сценарий «загоним 1000 rps через k6 и посмотрим на перцентили» проходит мимо всех трёх, и как выглядит воспроизводимый прогон, который эти отказы ловит.

Все цифры ниже — с живого сервера, не синтетика.

Дисклеймер: платформа называется Voltir, я её автор. Продуктовой рекламы дальше не будет — только грабли и числа; всё описанное воспроизводится на любом стеке из vmagent/Vector + VictoriaMetrics + nginx.

Почему у observability‑платформы другая нагрузка

Веб‑приложение нагружают люди. Человек — вежливый клиент: он ждёт ответа, злится, обновляет страницу и уходит. Пиковая нагрузка ограничена сверху числом людей.

Платформу приёма телеметрии нагружают агенты. Агент — невежливый клиент. Он не уходит. Он не устаёт. Если ему не ответили — он повторит, и повторит с тем же телом, и будет повторять сутками. Его пиковая нагрузка ограничена сверху только вашим кодом ответа.

Отсюда три оси нагрузки, по которым платформу нужно ломать отдельно:

  1. Поток записи — сколько сэмплов и строк лога в секунду вы принимаете. Единственная ось, которую обычно и тестируют.

  2. Кардинальность — сколько уникальных временных рядов у вас в базе. Растёт не от трафика, а от лейблов клиента. Именно она определяет память хранилища.

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

Дальше — по каждой, с числами и с тем, что у меня сломалось.

Ось первая: память под cgroup‑лимитом, и почему средний расход ничего не значит

Начну с самого скучного отказа, потому что он самый частый.

VictoriaMetrics (и VictoriaLogs, и VictoriaTraces — там та же механика) не имеет фиксированного потолка памяти. Флаги -memory.allowedPercent / -memory.allowedBytes управляют только размером внутренних кэшей и буферов, а не всем потреблением процесса. В документации это сказано прямо: лимиты не учитывают дополнительную память, которая может понадобиться на обработку входящих запросов.

Дальше происходит вот что. Компонент честно занимает кэшами свою долю от лимита — и на этом фоне любой всплеск (тяжёлый запрос из Grafana, скачок кардинальности, ретрай‑шторм) выталкивает RSS за границу cgroup. Ядро убивает процесс. Средний расход при этом выглядел прекрасно.

У меня это выглядело так. Дефолты в compose были посчитаны когда‑то под тестовый сервер с 2 ГБ и уехали на прод с 8 ГБ:

MEM_VICTORIALOGS    160m
MEM_VMSTORAGE       224m
MEM_VMSELECT        160m
MEM_VICTORIATRACES   96m

Разбирая совсем другой инцидент, я посмотрел на RestartCount контейнеров и увидел:

victorialogs   4702 рестарта
vmstorage-1    ~570
vmstorage-2    ~570

Четыре тысячи семьсот. В dmesg — memcg‑OOM каждые несколько минут, anon-rss 227 МБ против лимита 224m. Что важно: метрики в это время до хранилища вообще не доходили, их отбивало на авторизации (об этом ниже) — то есть это не следствие нагрузки, это базовое состояние системы. Хранилище перезапускалось каждые несколько минут месяцами, и снаружи это не проявлялось никак: клиент видит разрывы в графиках на 5–10 секунд, а разрывы легко списать на сеть.

Что сделал:

MEM_VICTORIALOGS    160m -> 384m
MEM_VMSTORAGE       224m -> 384m
MEM_VMSELECT        160m -> 256m
MEM_VICTORIATRACES   96m -> 192m

И тут вторая, менее очевидная часть. После подъёма лимита vmstorage сел на 84% нового потолка — потому что кэши заполняют заданную долю от лимита, сколько ни дай. Запас до OOM — 60 МБ, то есть один тяжёлый запрос. Поэтому долю кэшей пришлось снизить с 60% до 50%: кэшам 192 МБ из 384, потребление стало ~50%, запас удвоился. На моём объёме данных разница в попадании в кэш незаметна.

Правило, которое я из этого вынес: под cgroup‑лимитом вы тюните не потребление, а запас. Метрика, за которой надо следить, — не «сколько занято», а «сколько остаётся до лимита в момент пика». И RestartCount контейнеров — это метрика первого класса, а не диагностическая мелочь: она молча копит месяцами то, чего не видно в дашбордах.

Ровно тот же дефект на тестовом сервере выглядел иначе: vminsert с mem_limit 96m получил 18 memcg‑OOM за сутки после подключения полного стенда — наружу это выходило как 502 на приёме и плавающие падения e2e.

Ось вторая: кардинальность, а не rps

Самое частое заблуждение при тестировании TSDB — что нагрузка измеряется в запросах в секунду. Нет: сто хостов, шлющих раз в 15 секунд, дают тривиальный rps и могут положить хранилище по памяти, потому что важно число активных серий (ряд считается активным, если получил хотя бы один сэмпл за последний час), а не число HTTP‑запросов.

Обычный Linux‑хост с node_exporter — это порядка 900–1000 серий (цифра зависит от версии и набора коллекторов, у разных вендоров встречаются оценки от ~500 до 1000 — считайте на своей инсталляции, а не по статьям).

А теперь замер с моего демо‑кластера Kubernetes. Один узел, тринадцать подов:

control-plane (apiserver/etcd/workqueue)   39 576 серий
node_exporter (все хосты аккаунта)          4 247
cAdvisor/kubelet, container_*               3 135
kube-state-metrics                          1 103
kubelet_*                                     742
---------------------------------------------------
активных серий у аккаунта                  ~49 000

Один узел кластера весит как полсотни обычных серверов. И 80% этого веса — гистограммы control‑plane:

apiserver_request_duration_seconds_bucket       10 370
etcd_request_duration_seconds_bucket             8 375
apiserver_request_sli_duration_seconds_bucket    6 428

Ключевое: эта часть не растёт от числа узлов. Она растёт от активности API‑сервера, то есть на боевом кластере клиента будет не десять тысяч рядов, а сотни тысяч — с одного клиента. От числа узлов растёт другая часть: ~900 серий node_exporter плюс 2–3 тысячи cAdvisor/kubelet на узел, то есть 3–4 тысячи на каждый добавленный узел. Кластер на 10 узлов — примерно +35 тысяч серий, на 50 узлов — +175 тысяч.

Лечится отсевом на стороне сбора — metric_relabel_configs с action: drop по суффиксу _bucket для джобов kubelet и pods‑annotated:

yaml

metric_relabel_configs:
  # Гистограммы control-plane: 80% кардинальности кластера.
  # Дропаем только _bucket — _sum и _count остаются,
  # средние задержки и rate() по ним считаются, теряются лишь квантили.
  - source_labels: [__name__]
    regex: '(apiserver|etcd|workqueue)_.*_bucket'
    action: drop

На демо это убрало 39 тысяч серий из 49 тысяч. Отдельным блоком с комментарием — чтобы снять правило одной строкой, когда квантили действительно понадобятся.

Как это тестировать. Синтетику для этой оси лучше не писать руками: у VictoriaMetrics есть официальный prometheus‑benchmark — генерирует write‑нагрузку реальными метриками node_exporter через remote write, умеет несколько бэкендов сразу. Для «злого» варианта — генератор высокой кардинальности avalanche. Прогон, который стоит того: зафиксировать rps и линейно поднимать число уникальных серий, наблюдая за памятью vmstorage и RestartCount. Точка, в которой хранилище начинает перезапускаться, — это и есть ваша реальная ёмкость, и она измеряется в сериях, а не в запросах.

Отдельно про churn: если в лейблы протекает что‑то уникальное на запуск (pod, uuid, queryid, таймстемп), старые ряды постоянно заменяются новыми. Активных серий при этом немного, а памяти уходит как на порядок большее число — ловится тем же avalanche с включённой сменой лейблов.

Ось третья: тот самый отказ, который я не тестировал

Теперь главное. Самый дорогой инцидент за всё время эксплуатации не имел отношения к производительности вообще.

Что произошло

Утром я увидел у хостера входящий трафик, которого там быть не должно. Разбор по access‑логам nginx занял час и дал цепочку из четырёх звеньев, ни одно из которых по отдельности не выглядит дефектом:

  1. У одного тенанта истекла подписка. Это тестовый тенант, его агент (vmagent + Vector) стоит на моём же тестовом сервере и пишет на прод.

  2. Незадолго до этого выкатилась блокировка приёма по подписке: эндпоинт validate-token стал отвечать 402 Payment Required для истёкших. Код честный, семантичный, ревью прошёл.

  3. Этот эндпоинт вызывается из nginx через auth_request. И тут выясняется, что auth_request пробрасывает клиенту только 401 и 403. Документация формулирует это прямым текстом: «Any other response code returned by the subrequest is considered an error» — то есть 402 превращается в 500.

  4. Для vmagent и Vector 5xx означает «сервер моргнул, повторяй». Оба ушли в бесконечный ретрай полными телами метрик и логов.

В логах прода это было видно дословно: auth request unexpected status: 402, следом 500 клиенту.

Цифры за окно 15:00–00:00:

всего запросов          61 417
из них 500               6 232   (все с одного адреса)
       502               1 823
       402                 996   (heartbeat — он идёт мимо auth_request
                                  и потому показывает честный код)
отдано наружу           < 10 МБ  — весь объём входящий

Круглосуточно, около 700 ответов 500 в час, трое суток до момента, когда я заметил. Побочный урон — те самые OOM victorialogs: 96 МБ кэшей не держат ретрай‑шторм, и в моменты её рестартов задержки логов получали все остальные тенанты. Один истёкший клиент деградировал сервис всем.

Почему это именно нагрузочный дефект

Потому что нагрузку здесь создал мой собственный код ответа. Ни один сценарий k6 этого бы не нашёл: нагрузка тут не в rps, а в контуре обратной связи «код ответа приложения → поведение nginx → политика ретраев клиента». Все три звена по отдельности корректны.

Фикс

На всех трёх путях приёма (метрики, логи, трейсы) отказ по подписке теперь 403. Heartbeat идёт мимо auth_request и по‑прежнему отвечает 402 с человекочитаемой причиной — это единственное место, где агент может узнать, что вообще случилось, и его трогать нельзя.

Результат через десять минут после выкатки: ответов 500 — ноль, строк auth request unexpected status — ноль. Vector, как и ожидалось, батчи отбросил; vmagent продолжает ретраить, но мелкими телами — 15 запросов за 4 минуты вместо непрерывного потока. Общий поток на приём упал примерно в десять раз: было ~1470 запросов за 10 минут, стало ~150.

Матрица, которую стоит держать в голове

Разница в поведении клиентов на коды — это то, что превращает ваш код ответа в оружие против себя:

Клиент

4xx

5xx

vmagent

409 — дроп блока; 400/415 — попытка репаковки, затем дроп; остальные 4xx (403, 429) — ретрай с экспоненциальным backoff, бесконечно, с уважением Retry-After

ретрай бесконечно, буфер на диске в -remoteWrite.tmpDataPath, при переполнении -remoteWrite.maxDiskUsagePerURL дропаются самые старые данные

Vector (HTTP sink)

ретраит только 408 и 429; прочие 4xx не ретраит — при отсутствии disk‑buffer батч теряется

ретраит всё ≥500, кроме 501

То есть 403 у меня работает как «дорогой сигнал»: Vector (основной объём — логи) сбрасывает батчи сразу, vmagent продолжает стучаться, но дёшево. А 500 — как приглашение долбить бесконечно полными телами.

Здесь же — про 429. Для отсечения слишком болтливых агентов у меня зона limit_req на 1200 запросов в минуту; за сутки нормальной работы — ни одного срабатывания, то есть лимит поставлен выше рабочего профиля и служит именно предохранителем. Важная деталь: limit_req_status по умолчанию 503, и это ровно та ошибка, которую агент воспримет как «сервер сломался» и продолжит ретраить полными телами. Переключается одной строкой:

nginx

# ключ зоны — токен агента; переменная своя, из map по заголовку авторизации
map $http_authorization $agent_token { default $http_authorization; }

limit_req_zone $agent_token zone=agents:10m rate=1200r/m;
limit_req_status 429;   # по умолчанию 503 — агент прочитает его как «сервер моргнул»

Оговорюсь: «429 лучше 5xx» — это мой вывод из наблюдаемого поведения агентов, а не рекомендация из документации nginx. Документация просто даёт возможность выбрать код.

Что из этого стало регрессом в CI

Самое ценное, что можно сделать после такого разбора, — превратить его в проверку, которая упадёт при повторении.

У меня перед выкаткой на прод стоит барьер: пять групп e2e, 22 набора, около 50 минут на тестовом сервере, прод катается только вручную и только после зелёного барьера. В набор про блокировки добавлено:

  • истёкший тенант на /insert, /logs/jsonline и трейсах получает 403; любой 5xx роняет тест с явным текстом «агент будет ретраить вечно»;

  • после продления подписки приём возвращается — без этой проверки набор был бы зелёным и при полностью мёртвом приёме;

  • victorialogs жив, RestartCount не растёт.

Формулировка правила, которую я держу как инвариант: любой отказ на путях приёма обязан быть 401, 403 или 429. Всё остальное клиентский агент увидит как 500.

Чек‑лист: что прогнать на своей платформе

Если вы принимаете телеметрию — вот прогоны, которые у меня нашли реальные дефекты, в порядке отношения «находки/усилия»:

  1. Посмотрите RestartCount всех контейнеров прямо сейчас. Не под нагрузкой, просто сейчас. Это тридцать секунд и самый вероятный источник неприятного открытия.

  2. Замерьте запас до cgroup‑лимита в пике, а не средний расход. Если компонент сидит на 84% — он уже одноразовый.

  3. Прогон по кардинальности при фиксированном rps. Ищется ёмкость в сериях. Инструменты — prometheus‑benchmark, avalanche.

  4. Матрица кодов отказа. На каждый способ отказать клиенту (нет подписки, превышен лимит, невалидный токен, тенант заблокирован) — проверить, что реально видит агент после прохода через nginx. Не то, что возвращает приложение.

  5. Прогон «злой клиент»: агент, которому отвечают ошибкой, оставленный на час. Смотреть на трафик и на соседних тенантов. Деградация соседей — это отдельный класс дефектов, который тест одного тенанта не находит.

  6. Soak на сутки вместо получасового прогона. Утечки, рост churn и переполнение дисковой очереди на получасовом тесте не видны.

  7. Открытая модель нагрузки. Если генератор ждёт ответа перед следующей итерацией (closed model), то при деградации системы он сам снижает rps — и не измерит именно те медленные ответы, ради которых тест затевался. Это coordinated omission, и он систематически занижает хвостовые перцентили ровно в тот момент, когда они важны. В k6 это constant-arrival-rate / ramping-arrival-rate.

  8. Тест на чтение, а не только на запись. Тяжёлый запрос из Grafana по широкому диапазону — легальный способ выбить хранилище за лимит памяти, потому что запросы не учитываются в -memory.allowedPercent.

Чего я не сделал

Честно про границы: у меня до сих пор нет регулярного синтетического прогона на отдельном стенде — все цифры выше добыты из эксплуатации и из разборов инцидентов. Prometheus‑benchmark развёрнут разово, не в CI. Тёплого резерва хранилища нет, отказ узла я тестировал только вручную. Это в плане, и когда дойдут руки — будет вторая часть, уже с графиками из‑под генератора.

Но главный вывод у меня сложился уже сейчас, и он не про инструменты. Классическое нагрузочное тестирование проверяет систему в состоянии «всё хорошо, только много». А ломается она в состоянии «что‑то одно отказало, и все клиенты одновременно решили, что надо повторить». Второе состояние нужно тестировать отдельно и специально — потому что нагрузку в нём создаёт не пользователь, а ваш собственный код ответа.


Если у вас в проде стоят vmagent, Vector или fluent‑bit — потратьте пять минут и проверьте, какой код они получают на каждом вашем способе отказать. У меня это стоило трёх суток самообстрела.

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


  1. ITsuperiorRF
    30.08.2026 13:33

    Спасибо за статью. Интересно было почитать


  1. vvolio
    30.08.2026 13:33

    Интересная статья, большое спасибо. Единственное, что немного огорчило:

    У одного тенанта истекла подписка. Это тестовый тенант, его агент (vmagent + Vector) стоит на моём же тестовом сервере и пишет на прод.

    Все же стоит отделять мух от котлет, я считаю. Не везде приветствуется пересечение сред эксплуатации даже на тривиальном уровне