Я в одиночку делаю и эксплуатирую платформу мониторинга: метрики, логи и трейсы для чужой инфраструктуры, мультитенантно, на VictoriaMetrics cluster / VictoriaLogs / VictoriaTraces, FastAPI, nginx и Postgres. Прод — один сервер: Debian 12, 4 CPU, 8 ГБ, четырнадцать контейнеров.
За несколько месяцев эксплуатации у меня накопилось десятка полтора отказов под нагрузкой. Ни один из них не был найден классическим нагрузочным тестом. Все — найдены постфактум, по логам, по счётчику рестартов контейнеров и по панели трафика у хостера.
Это статья не про то, «как правильно проводить НТ» — таких статей достаточно. Она про то, какие именно виды нагрузки ломают систему приёма телеметрии, почему привычный сценарий «загоним 1000 rps через k6 и посмотрим на перцентили» проходит мимо всех трёх, и как выглядит воспроизводимый прогон, который эти отказы ловит.
Все цифры ниже — с живого сервера, не синтетика.
Дисклеймер: платформа называется Voltir, я её автор. Продуктовой рекламы дальше не будет — только грабли и числа; всё описанное воспроизводится на любом стеке из vmagent/Vector + VictoriaMetrics + nginx.
Почему у observability‑платформы другая нагрузка
Веб‑приложение нагружают люди. Человек — вежливый клиент: он ждёт ответа, злится, обновляет страницу и уходит. Пиковая нагрузка ограничена сверху числом людей.
Платформу приёма телеметрии нагружают агенты. Агент — невежливый клиент. Он не уходит. Он не устаёт. Если ему не ответили — он повторит, и повторит с тем же телом, и будет повторять сутками. Его пиковая нагрузка ограничена сверху только вашим кодом ответа.
Отсюда три оси нагрузки, по которым платформу нужно ломать отдельно:
Поток записи — сколько сэмплов и строк лога в секунду вы принимаете. Единственная ось, которую обычно и тестируют.
Кардинальность — сколько уникальных временных рядов у вас в базе. Растёт не от трафика, а от лейблов клиента. Именно она определяет память хранилища.
Поведение клиента при отказе — что делает агент, когда вы ответили ему ошибкой. Самая разрушительная ось и единственная, которую нельзя измерить, глядя на графики нагрузки: она проявляется только в момент отказа.
Дальше — по каждой, с числами и с тем, что у меня сломалось.
Ось первая: память под 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 занял час и дал цепочку из четырёх звеньев, ни одно из которых по отдельности не выглядит дефектом:
У одного тенанта истекла подписка. Это тестовый тенант, его агент (vmagent + Vector) стоит на моём же тестовом сервере и пишет на прод.
Незадолго до этого выкатилась блокировка приёма по подписке: эндпоинт
validate-tokenстал отвечать 402 Payment Required для истёкших. Код честный, семантичный, ревью прошёл.Этот эндпоинт вызывается из nginx через
auth_request. И тут выясняется, чтоauth_requestпробрасывает клиенту только 401 и 403. Документация формулирует это прямым текстом: «Any other response code returned by the subrequest is considered an error» — то есть 402 превращается в 500.Для 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, бесконечно, с уважением |
ретрай бесконечно, буфер на диске в |
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.
Чек‑лист: что прогнать на своей платформе
Если вы принимаете телеметрию — вот прогоны, которые у меня нашли реальные дефекты, в порядке отношения «находки/усилия»:
Посмотрите
RestartCountвсех контейнеров прямо сейчас. Не под нагрузкой, просто сейчас. Это тридцать секунд и самый вероятный источник неприятного открытия.Замерьте запас до cgroup‑лимита в пике, а не средний расход. Если компонент сидит на 84% — он уже одноразовый.
Прогон по кардинальности при фиксированном rps. Ищется ёмкость в сериях. Инструменты — prometheus‑benchmark, avalanche.
Матрица кодов отказа. На каждый способ отказать клиенту (нет подписки, превышен лимит, невалидный токен, тенант заблокирован) — проверить, что реально видит агент после прохода через nginx. Не то, что возвращает приложение.
Прогон «злой клиент»: агент, которому отвечают ошибкой, оставленный на час. Смотреть на трафик и на соседних тенантов. Деградация соседей — это отдельный класс дефектов, который тест одного тенанта не находит.
Soak на сутки вместо получасового прогона. Утечки, рост churn и переполнение дисковой очереди на получасовом тесте не видны.
Открытая модель нагрузки. Если генератор ждёт ответа перед следующей итерацией (closed model), то при деградации системы он сам снижает rps — и не измерит именно те медленные ответы, ради которых тест затевался. Это coordinated omission, и он систематически занижает хвостовые перцентили ровно в тот момент, когда они важны. В k6 это
constant-arrival-rate/ramping-arrival-rate.Тест на чтение, а не только на запись. Тяжёлый запрос из Grafana по широкому диапазону — легальный способ выбить хранилище за лимит памяти, потому что запросы не учитываются в
-memory.allowedPercent.
Чего я не сделал
Честно про границы: у меня до сих пор нет регулярного синтетического прогона на отдельном стенде — все цифры выше добыты из эксплуатации и из разборов инцидентов. Prometheus‑benchmark развёрнут разово, не в CI. Тёплого резерва хранилища нет, отказ узла я тестировал только вручную. Это в плане, и когда дойдут руки — будет вторая часть, уже с графиками из‑под генератора.
Но главный вывод у меня сложился уже сейчас, и он не про инструменты. Классическое нагрузочное тестирование проверяет систему в состоянии «всё хорошо, только много». А ломается она в состоянии «что‑то одно отказало, и все клиенты одновременно решили, что надо повторить». Второе состояние нужно тестировать отдельно и специально — потому что нагрузку в нём создаёт не пользователь, а ваш собственный код ответа.
Если у вас в проде стоят vmagent, Vector или fluent‑bit — потратьте пять минут и проверьте, какой код они получают на каждом вашем способе отказать. У меня это стоило трёх суток самообстрела.
Комментарии (2)

vvolio
30.08.2026 13:33Интересная статья, большое спасибо. Единственное, что немного огорчило:
У одного тенанта истекла подписка. Это тестовый тенант, его агент (vmagent + Vector) стоит на моём же тестовом сервере и пишет на прод.
Все же стоит отделять мух от котлет, я считаю. Не везде приветствуется пересечение сред эксплуатации даже на тривиальном уровне
ITsuperiorRF
Спасибо за статью. Интересно было почитать