Трендовые github проекты в нашем телеграм канале. Подпишись → Три оси, по которым стоит ломать систему приёма метрик и логов
Классический нагрузочный тест проверяет систему в состоянии «всё работает, просто данных много». Платформа приёма телеметрии — метрики, логи, трейсы — ломается в другом состоянии: один компонент отказал, и все клиенты одновременно решили, что запрос надо повторить. Нагрузку в этот момент создаёт не пользователь, а собственный код ответа сервера. Ниже — три оси, по которым такую систему нужно ломать отдельно, и что на каждой из них отказывает раньше, чем прогон через k6 успевает что-то показать.
Почему у observability другая нагрузка
Веб-приложение нагружают люди. Человек ждёт ответа, злится, обновляет страницу и уходит. Пиковая нагрузка ограничена сверху числом людей.
Платформу приёма телеметрии нагружают агенты: vmagent, Vector, fluent-bit, vector-подобные сборщики. Агент не уходит и не устаёт. Если ему ответили ошибкой — он повторит запрос с тем же телом и будет повторять сутками. Его пиковая нагрузка ограничена сверху только вашим кодом ответа.
Отсюда три независимых оси:
- Поток записи — сколько сэмплов и строк лога в секунду принимает система. Единственная ось, которую обычно и тестируют.
- Кардинальность — сколько уникальных временных рядов лежит в базе. Растёт от лейблов клиента, а не от трафика, и именно она определяет память хранилища.
- Поведение клиента при отказе — что делает агент, когда сервер ответил ошибкой. Ось видна только в момент отказа и на графиках нагрузки не проявляется.
Ось первая: запас памяти под cgroup-лимитом
VictoriaMetrics, VictoriaLogs и VictoriaTraces не имеют фиксированного потолка памяти. Флаги -memory.allowedPercent и -memory.allowedBytes управляют размером внутренних кэшей и буферов, а не всем потреблением процесса. Дополнительная память на обработку входящих запросов в этот лимит не входит.
Дальше механика простая. Компонент занимает кэшами свою долю от лимита, и на этом фоне любой всплеск — тяжёлый запрос из Grafana, скачок кардинальности, шторм ретраев — выталкивает RSS за границу cgroup. Ядро убивает процесс по memcg-OOM. Средний расход памяти при этом выглядит спокойно.
Как это выглядит на практике. Лимиты в compose, посчитанные под тестовый сервер с 2 ГБ, уезжают на прод с 8 ГБ и остаются маленькими:
MEM_VICTORIALOGS 160m
MEM_VMSTORAGE 224m
MEM_VMSELECT 160m
MEM_VICTORIATRACES 96m
Счётчик RestartCount контейнеров при таких лимитах показывает тысячи перезапусков: хранилище рестартует каждые несколько минут месяцами, anon-rss держится у самой границы (227 МБ против лимита 224m). Снаружи это выглядит как разрывы в графиках на 5–10 секунд, которые легко списать на сеть.
После подъёма лимитов возникает вторая, менее очевидная часть. Хранилище садится на 84% нового потолка, потому что кэши заполняют заданную долю независимо от того, сколько памяти выдали. Запас до OOM — один тяжёлый запрос. Поэтому долю кэшей приходится опускать, например с 60% до 50%: кэшам 192 МБ из 384, потребление около половины, запас удваивается.
Правило: под cgroup-лимитом тюнится запас, а не потребление. Метрика, за которой надо следить, — сколько остаётся до лимита в момент пика. RestartCount контейнеров здесь метрика первого класса: она молча копит месяцами то, чего не видно в дашбордах.
Ось вторая: кардинальность вместо rps
Нагрузка на TSDB измеряется не в запросах в секунду. Сто хостов, отправляющих данные раз в 15 секунд, дают тривиальный rps и при этом кладут хранилище по памяти, потому что важно число активных серий — рядов, получивших хотя бы один сэмпл за последний час.
Обычный Linux-хост с node_exporter — это порядка 900–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 и родственные). Эта часть не растёт от числа узлов: она растёт от активности API-сервера, то есть на боевом кластере клиента будет не десять тысяч рядов, а сотни тысяч с одного клиента. От числа узлов растёт другая часть: около 900 серий node_exporter плюс 2–3 тысячи cAdvisor/kubelet на узел.
Лечится отсевом на стороне сбора — metric_relabel_configs с action: drop по суффиксу _bucket:
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, а для высокой кардинальности — генератор avalanche. Полезный прогон: зафиксировать rps и линейно поднимать число уникальных серий, наблюдая за памятью vmstorage и RestartCount. Точка, где хранилище начинает перезапускаться, и есть реальная ёмкость, измеренная в сериях. Отдельно проверьте churn: если в лейблы протекает что-то уникальное на запуск (pod, uuid, queryid, таймстемп), старые ряды постоянно заменяются новыми, активных серий немного, а памяти уходит как на порядок большее число.
Ось третья: отказ, который не входит в план тестирования
Самый дорогой инцидент часто не связан с производительностью вообще. Типичная цепочка из четырёх звеньев, каждое из которых по отдельности корректно:
- У тестового тенанта истекла подписка, его агент продолжает писать на прод.
- Выкатилась блокировка приёма по подписке: эндпоинт проверки токена стал отвечать
402 Payment Requiredдля истёкших. Код семантичный, ревью прошёл. - Этот эндпоинт вызывается из nginx через
auth_request, аauth_requestпробрасывает клиенту только401и403. Любой другой код подзапроса считается ошибкой и превращается в500. - Для
vmagentиVectorответ5xxозначает «сервер моргнул, повторяй». Оба уходят в бесконечный ретрай полными телами метрик и логов.
Результат: круглосуточно около 700 ответов 500 в час, несколько суток подряд, отдано наружу меньше 10 МБ — весь объём входящий. Побочный урон — OOM у VictoriaLogs: маленькие кэши не держат шторм ретраев, и в моменты рестартов задержки логов получают все остальные тенанты. Один истёкший клиент деградирует сервис всем.
Нагрузку здесь создал собственный код ответа. Ни один сценарий k6 этого не находит: нагрузка не в rps, а в контуре обратной связи «код ответа приложения → поведение nginx → политика ретраев клиента».
Матрица кодов отказа
Разница в поведении клиентов на разные коды — то, что превращает код ответа в оружие против себя:
| Клиент | 4xx | 5xx |
|---|---|---|
vmagent | 409 — дроп блока; 400/415 — репаковка, затем дроп; 403, 429 — ретрай с экспоненциальным backoff, бесконечно, с уважением Retry-After | ретрай бесконечно, буфер на диске в -remoteWrite.tmpDataPath, при переполнении дропаются самые старые данные |
Vector (HTTP sink) | ретраит только 408 и 429; прочие 4xx не ретраит — без disk-buffer батч теряется | ретраит всё ≥ 500, кроме 501 |
Отсюда рабочий инвариант: любой отказ на путях приёма обязан быть 401, 403 или 429. Всё остальное клиентский агент увидит как 500 и будет долбить полными телами. После замены 402 на 403 на всех путях приёма (метрики, логи, трейсы) поток на приём падает примерно в десять раз: Vector сбрасывает батчи сразу, vmagent продолжает стучаться, но мелкими телами.
Ещё одна деталь про 429. Для отсечения слишком болтливых агентов удобна зона limit_req в nginx, но limit_req_status по умолчанию 503 — код, который агент воспримет как «сервер сломался». Переключается одной строкой:
map $http_authorization $agent_token {
default $http_authorization;
}
limit_req_zone $agent_token zone=agents:10m rate=1200r/m;
limit_req_status 429; # по умолчанию 503 — агент прочитает его как «сервер моргнул»
Чек-лист прогонов
- Посмотрите
RestartCountвсех контейнеров прямо сейчас, без нагрузки. Тридцать секунд и самый вероятный источник неприятного открытия. - Замерьте запас до cgroup-лимита в пике, а не средний расход. Компонент на 84% лимита уже одноразовый.
- Прогон по кардинальности при фиксированном rps: ищется ёмкость в сериях. Инструменты —
prometheus-benchmark,avalanche. - Матрица кодов отказа: на каждый способ отказать клиенту проверьте, что реально видит агент после прохода через nginx, а не что вернуло приложение.
- «Злой клиент»: агент, которому отвечают ошибкой, оставленный на час. Смотреть на трафик и на соседних тенантов — деградация соседей это отдельный класс дефектов.
- Soak на сутки вместо получасового прогона: утечки, рост churn и переполнение дисковой очереди на коротком тесте не видны.
- Открытая модель нагрузки. Если генератор ждёт ответа перед следующей итерацией, при деградации он сам снижает rps и не измерит те медленные ответы, ради которых тест затевался. В k6 это
constant-arrival-rateиramping-arrival-rate. - Тест на чтение, а не только на запись: тяжёлый запрос из Grafana по широкому диапазону — легальный способ выбить хранилище за лимит памяти, потому что запросы не учитываются в
-memory.allowedPercent.
Что забрать в CI
Ценнее всего — превратить разбор в проверку, которая упадёт при повторении. Минимальный набор для барьера перед выкаткой:
- истёкший тенант на путях
/insert,/logs/jsonlineи трейсах получает403; любой5xxроняет тест с явным текстом про бесконечный ретрай; - после продления подписки приём возвращается — без этой проверки набор остаётся зелёным даже при полностью мёртвом приёме;
- хранилище логов живо,
RestartCountне растёт по ходу прогона.
Если в проде стоят vmagent, Vector или fluent-bit, потратьте пять минут и проверьте, какой код они получают на каждом способе отказать. Это дешевле, чем трое суток самообстрела.