xk6-sip: мониторинг нагрузочного тестирования VoIP/SIP-звонков

—

от автора

Разберём, как устроена observability в xk6-sip — расширении нагрузочного инструмента k6, которое позволяет автоматизировать нагрузочное и функциональное тестирование VoIP/SIP-звонков: откуда берутся метрики, как из них строятся панели и как по ним отличить деградацию АТС от деградации самого генератора.

Все графики и цифры от реального прогона нагрузочного теста: 100 одновременных звонков, 10 минут, 2000 звонков, 6,1 млн RTP-пакетов. Дашборд — 37 панелей в шести блоках; для каждого блока ниже есть источник данных, запрос, измеренное значение и признаки проблемы.

Границы статьи. Все метрики ниже — клиентские: их собирает xk6-sip на стороне генератора трафика, и они показывают АТС так, как её видят абоненты, плюс состояние самого генератора. Мониторинг серверной стороны — ресурсов АТС, её SIP-стека, медиасерверов и зависимостей — статья не затрагивает. Клиентские метрики отвечают на вопрос «что сломалось и когда»; на вопрос «почему» отвечает серверная сторона, о ней — в конце.

Профиль и ожидаемые значения

Прогон запускается двумя командами. Первая поднимает тестовую АТС testpbx на loopback и заводит в ней 200 абонентов из CSV. Вторая запускает k6 с расширением xk6-sip: метрики уходят в Prometheus через remote write, а теги testid и pbx_version помечают прогон, чтобы дашборд показывал только его. Что именно делает сценарий и на каком стенде он шёл — в таблице под командами.

testpbx -addr 127.0.0.1:5070 -users 200 -csv examples/subscribers.csvK6_PROMETHEUS_RW_SERVER_URL=http://localhost:9091/api/v1/write \K6_FEATURES=native-histograms \k6 run -o experimental-prometheus-rw \  --tag testid=load-100x10m-2 --tag pbx_version=testpbx-0.4 \  -e VUS=100 -e HOLD=30 -e DURATION=10m \  -e SIP_METRICS_ADDR=127.0.0.1:6566 examples/call.js

Параметр

Значение

Сценарий

A → B, проверка АОН, isHeard() в обе стороны, разговор 30 с + U(0, 1) с, BYE от B

Исполнитель

constant-vus, 100 VU; один VU — два абонента и один звонок за раз

Абоненты

200, digest-авторизация на REGISTER (401) и INVITE (407), Expires 300 с

Медиа

G.711 μ-law, 20 мс, 50 pps на плечо

SUT

testpbx (регистратор + B2BUA) на том же хосте, loopback

Генератор

Windows 11, 16 ГБ RAM, без изоляции от других нагрузок

Перед чтением дашборда считаем ожидаемые значения. По закону Литтла L = λW: при L = 100 и W ≈ 31 с (разговор + установление + пауза итерации) получаем λ ≈ 3,2 CAPS и ≈ 1930 звонков за 10 минут. RTP: 100 × 2 плеча × 50 pps = 10 000 pps, при 172 байтах полезной нагрузки UDP (12 байт заголовок RTP + 160 байт G.711) это 1,72 МБ/с в каждую сторону. Регистрации: 200 на старте + два обновления за 10 минут = 600.

Если дашборд на чистом прогоне воспроизводит эти числа, конвейер метрик корректен, и его показаниям можно доверять в прогоне с деградацией.

Архитектура сбора метрик

Три источника данных с разной моделью доставки. Разделение принципиальное: метрики теста описывают SUT глазами абонента, метрики процесса и хоста — состояние генератора. Без второго и третьего источника джиттер от голодающего по CPU генератора неотличим от джиттера АТС.

Источник

Модель

Что содержит

Включение

Метрики теста

push, remote write

sip_*, rtp_* с тегами testid, pbx_version, scenario

-o experimental-prometheus-rw

Процесс k6

pull, /metrics

process_*, go_*, xk6sip_network_*, xk6sip_calls_in_progress

sip.options({ metricsAddr })

Хост генератора

pull

CPU, память, сетевые интерфейсы

windows_exporter / node-exporter

Метрики теста идут по push: тест живёт конечное время, и pull-модель потеряла бы хвост после последнего scrape. Процесс и хост — долгоживущие цели, для них стандартный scrape раз в 5 с. Метрики процесса и хоста не несут testid, корреляция с прогоном — по времени.

Почему native histograms и счётчики

При экспорте в Prometheus k6 по умолчанию отдаёт готовые перцентили trend-метрик и доли rate-метрик, накопленные с начала теста. Из них нельзя получить значение за окно: 10 минут деградации после часа нормы почти не сдвигают накопленный p95. Поэтому:

  • Времена экспортируются как native histograms (K6_FEATURES=native-histograms) и считаются за скользящее окно:

histogram_quantile(0.95, sum(rate(k6_sip_call_setup_time_seconds{testid=~"$testid"}[$window])))

Native histogram — один ряд на комбинацию меток с экспоненциальными бакетами: границы не подбираются заранее, разрешение подстраивается под данные, и деградация до секунд не упирается в последний бакет, как у классической гистограммы с le. Из неё rate() + histogram_quantile() дают перцентиль за любое окно, а гистограммы нескольких генераторов корректно суммируются — готовые p95 разных инстансов k6 сложить нельзя. Встречающаяся в старых руководствах переменная K6_PROMETHEUS_RW_TREND_AS_NATIVE_HISTOGRAM в k6 v2 устарела, включает гистограммы K6_FEATURES=native-histograms. Prometheus 3.x принимает их с флагами --web.enable-remote-write-receiver и --enable-feature=native-histograms; без второго дашборд покажет пустые панели времён.

  • Доли строятся по счётчикам расширения: sip_call_results{result, status}, sip_calls{phase}, rtp_legs{heard}. Rate-метрики sip_call_success и rtp_audio_heard остаются для порогов в скрипте и итогового отчёта.

Известное ограничение: remote write в k6 отправляет счётчик только при изменении. Для серий с редкими всплесками (REGISTER на старте и при обновлении) на окно попадает одна точка, и rate() возвращает пустоту. Такие панели построены на накопленных значениях.

Ниже — снимки дашборда за окно прогона 15:19–15:30, $window = 1 минута.

Обзор: 12 индикаторов

Во время прогона первым делом смотрят на 12 плиток верхнего блока. Каждая отвечает на один вопрос: идёт ли нагрузка как задумано, проходят ли звонки, слышен ли голос, укладывается ли АТС в SLA. Если значения совпадают с ожидаемыми из раздела выше, в нижние блоки можно не спускаться; если какая-то плитка отклонилась, она подскажет, куда смотреть дальше — в сигнализацию, медиа или генератор. В таблице ниже для каждого индикатора — как он считается и что показал этот прогон.

Верхний блок — система раннего предупреждения. Все доли и перцентили считаются за $window — это переменная дашборда Grafana, длина скользящего окна (в этом прогоне 1 минута), а не весь тест. То есть каждая точка на панели показывает последнюю минуту: CAPS — сколько вызовов в секунду завершилось за эту минуту, p95 — по звонкам этой минуты.

Индикатор

Что это простыми словами

Определение

Прогон

CAPS

Сколько звонков в секунду завершается — фактическая интенсивность нагрузки

sum(rate(k6_sip_call_results_total[$window]))

3,1 (1,8–4,9)

Calls in progress

Сколько разговоров идёт прямо сейчас

sip_calls{phase="answered"} − sip_calls{phase="ended"}

100

ASR

Доля звонков, на которые ответили

answered / все попытки

100%

SEER (RFC 6076)

Доля звонков, которые АТС отработала без сбоя: «занято», «не отвечает» и отказ абонента тоже считаются успехом сети

(200 + 480 + 486 + 600 + 603) / попытки без 3xx

100%

One-way audio

Доля сторон разговора, до которых не дошёл голос собеседника

rtp_legs{heard="false"} / rtp_legs

0%

Dropped iterations

Сколько запланированных звонков генератор не успел запустить

k6_dropped_iterations_total

0

Setup time p95

За сколько соединяется звонок — от набора до ответа абонента; 95% звонков укладываются в это время

INVITE → финальный ответ

2,9 мс

Setup within SLA

Доля звонков, соединившихся быстрее порога SLA

histogram_fraction(0, $sla_setup, …)

100% при SLA 300 мс

PDD within SLA

Доля звонков, где пауза между набором номера и звонком у вызываемого (post-dial delay) короче порога SLA

histogram_fraction(0, $sla_pdd, …)

100% при SLA 2 с

ALOC

Средняя длительность разговора

histogram_sum / histogram_count по sip_call_duration

30,6 с

First response p95

Как быстро АТС подтверждает, что получила звонок; 95% звонков укладываются в это время

INVITE → первый ответ транзакции

1,1 мс

Retransmissions

Сколько раз в секунду сообщение SIP пришлось отправить повторно, потому что АТС не ответила вовремя

sum(rate(k6_sip_retransmissions_total[$window]))

0

ASR против SEER. ASR зависит от профиля трафика: «занято» и «не ответил» снижают его без вины АТС. SEER из RFC 6076 считает отказ абонента успехом сети и падает только от 404, 5xx и таймаутов. В этом прогоне оба по 100%. На контрольном прогоне с 5% звонков на несуществующий номер и 5% «занято» получилось ASR 84% и SEER 92%: разница — это 486, 404 остался в SEER как отказ маршрутизации. Пара «ASR падает, SEER стоит» указывает на профиль абонентов, пара «падают оба» — на АТС.

ALOC совпадает с заданным в скрипте 30 + E[U(0,1)] = 30,5 с (+0,1 с на BYE). Падение ALOC при постоянной нагрузке — прямой признак того, что SUT рвёт сессии (session timers, лимиты каналов, сбои RTP-прокси).

CAPS скачет от 1,8 до 4,9 при среднем 3,1. Это свойство закрытой модели constant-vus: 100 VU стартовали одновременно и завершают звонки волнами, которые расплываются случайной добавкой к длительности. Для поиска пропускной способности нужна открытая модель (constant-arrival-rate), где CAPS задан и не зависит от скорости ответа SUT.

Сигнализация

Панель

Метрика и точка измерения

Прогон

Calls per second by final status / by class

sip_call_results{result, status}, финальный ответ на INVITE; классы через label_replace

только 200

Call setup time

sip_call_setup_time: отправка INVITE → 200 OK, включая цикл 407 + повтор с credentials

p50 1,9 / p95 2,9 мс

Post-dial delay

sip_post_dial_delay: INVITE → первый 18x

p95 2,5 мс

INVITE routing time

sip_invite_delivery_time: отправка INVITE устройством A → получение устройством B

p95 1,9 мс

INVITE first response time

sip_invite_first_response_time: INVITE → первый ответ транзакции, по сэмплу на каждую транзакцию (407 и 100 Trying)

p95 1,1 мс

SIP retransmissions

sip_retransmissions{method, kind, status}

0

Failed calls by status

increase(...[$__range]) по status

—

Routing time — метрика, которую не получить генератором с разнесёнными UAC и UAS: обе отметки времени снимаются одним процессом, без рассинхрона часов. Это чистое время работы маршрутизации SUT.

Первый ответ и ретрансмиссии

По RFC 3261 (17.1.1.2) клиентская INVITE-транзакция по UDP повторяет запрос по таймеру A: через T1 = 500 мс, затем 1, 2, 4 с — до первого ответа или до таймаута B = 64·T1. Поэтому панель первого ответа идёт с порогом 500 мс: пока p99 ниже T1, АТС успевает подтверждать транзакции; выше — каждый медленный ответ удваивает входящий поток INVITE. Порог на setup time тут не годится: в него входит время до ответа абонента, а ретрансмиссии останавливает уже 100 Trying.

Ретрансмиссии выполняет транзакционный слой sipgo, а расширение их наблюдает: SIP-сокет устройства обёрнут, и повторная запись сообщения с тем же Via branch, CSeq и стартовой строкой считается ретрансмиссией. Считаются и запросы (kind="request"), и финальные ответы, не подтверждённые ACK (kind="response", таймер G).

Сигнатура перегрузки

В чистом прогоне её нет, но именно для неё блок и собран. Типичная последовательность на SIP/UDP:

  1. Растёт p99 setup time при стабильном p50 — в SUT появляется очередь.

  2. p99 первого ответа подходит к 500 мс.

  3. Появляются ретрансмиссии INVITE: фактическая нагрузка на SUT становится выше заданной.

  4. Появляются 503 / 408, падает SEER.

Шаги 1–3 дают потолок АТС раньше, чем он станет виден по отказам. На шаге 4 система уже в режиме лавинообразной перегрузки, и снижение CAPS её сразу не выводит.

Медиа

Успешная сигнализация не гарантирует голос: NAT, ошибки в SDP или исчерпание портов медиасервера дают звонок с 200 OK и тишиной в одну сторону. Поэтому каждый звонок генерирует RTP в обе стороны, а каждое плечо по окончании звонка сообщает, слышало ли оно другую сторону.

Панель

Метрика

Прогон

One-way audio

rtp_legs{heard="false"} / rtp_legs, по плечам

0 из 4000

Jitter p95 by leg

rtp_jitter, оценка interarrival jitter по RFC 3550 (6.4.1), по direction

0,6 мс на обоих плечах

RTP packet loss

rtp_packets_lost / (received + lost); потери — по разрывам sequence number

0

RTP packets per second

rtp_packets_sent, rtp_packets_received

9673 pps

Сверка. Теоретический максимум — 10 000 pps. Из 31,25 с итерации медиа идёт около 30,6 с (ALOC), то есть ~98% времени, что даёт ~9800 pps; измерено 9673. За тест отправлено 6 119 611 пакетов, принято 6 119 571. Счётчик потерь при этом нулевой: 40 пакетов находились в полёте в момент BYE и пришли после закрытия потока, разрывов последовательности не было.

Фазовый сдвиг. RTP-метрики публикуются по завершении плеча, и медиа-панели отстают от сигнализации на ALOC: в этом прогоне на ~30 с. Коррелируя всплеск джиттера с событием в сигнализации или на генераторе, сдвигайте медиа назад на длительность разговора.

Регистрации и сценарий

  • Регистрации идут всплесками, поэтому панель накопительная: 200 → 400 → 600, по ступеньке на старт и на каждое обновление при Expires 300 с. Сколько 401 — столько же 200: каждая регистрация проходит digest-цикл. p95 ответа на REGISTER — около 1 мс.

  • Failed requests by method — 0 из 7200 запросов. 401 и 407 не считаются ошибками, иначе доля «ошибок» была бы 36% на ровном месте: 2600 challenge-ответов на 1200 REGISTER, 4000 INVITE и 2000 BYE.

  • Failed expectations — 0 из 8000 проверок. Это метрика уровня сценария: call — звонок не пришёл или пришёл с чужим АОН, heard — нет звука, transferred — не прошёл перевод. Успешный 200 OK с чужим АОН виден только здесь.

  • Calls ending by who hung up — все remote: метрика пишется с стороны A, а BYE отправляет B. Аномалия — не сам remote, а расхождение со сценарием: появление error / timeout или падение ALOC.

Генератор

Нагрузочный тест невалиден, если узким местом стал генератор. Для VoIP это особенно коварно: при нехватке CPU планировщик RTP отправляет пакеты с опозданием, и приёмная сторона фиксирует это как джиттер SUT. Блок генератора нужен, чтобы валидировать сам тест.

Метрики процесса отдаёт само расширение на metricsAddr: стандартные process- и Go-коллекторы client_golang плюс собственные счётчики трафика на уровне сокетов SIP и RTP.

Панель

Что показывает

Метрика

Прогон

Dropped iterations

Сколько звонков генератор не успел запустить по плану

k6_dropped_iterations_total

0

k6 process CPU

Насколько k6 загружает процессор

rate(process_cpu_seconds_total{job="k6"})

55% ядра (максимум 63%)

Goroutines

Число параллельных задач внутри k6; рост без роста нагрузки — утечка

go_goroutines

~530, стабильно

k6 process memory

Сколько памяти занимает k6

process_resident_memory_bytes, go_memstats_heap_inuse_bytes

RSS ~100 МБ, heap 38–61 МБ

k6 process network

Сколько трафика k6 отправляет и получает: голос (RTP) и сигнализация (SIP)

rate(xk6sip_network_bytes_total) по proto, direction

RTP 1,65–1,70 МБ/с, SIP ~12 КБ/с

Machine CPU / memory

Загрузка процессора и памяти всего компьютера-генератора

windows_exporter

11% CPU, 11,2 из 16 ГБ

Стабильность. Число горутин и RSS выходят на плато после разгона и не растут с числом завершённых звонков, а heap идёт пилой сборок мусора без тренда — утечек горутин и памяти на звонок нет. Для soak-тестов это первое, что нужно проверить.

Сверка трафика. Расчёт — 1,72 МБ/с полезной нагрузки UDP в каждую сторону, измерено 1,65–1,70. rtp in и rtp out совпадают, потому что оба плеча звонка обслуживает один процесс. SIP — меньше 1% трафика.

Loopback против NIC. SUT работал на 127.0.0.1, поэтому «Machine network» теста не видит: loopback-трафик не проходит через сетевой интерфейс, и экспортёр хоста его не считает. Счётчики на сокетах видят всё. При удалённой SUT обе панели совпадут с точностью до заголовков IP/UDP (28 байт на пакет, +16% для G.711 по 20 мс). Расхождение больше этого — сторонний трафик на генераторе.

CPU. 55% ядра на 200 RTP-потоков и 3 CAPS. Это выше, чем в изолированном бенчмарке расширения (62% на 1000 потоков на Linux), и расхождение объяснимо: Windows, SUT и генератор на одном хосте, плюс remote write и экспорт метрик самого процесса. Вывод — метрики производительности генератора нужно снимать на целевой платформе, а не переносить с бенчмарка.

Память хоста. Первая попытка этого прогона была прервана на 4,5 минуте из-за нехватки RAM на машине при RSS k6 около 100 МБ. Именно такой случай и разделяют панели процесса и хоста: процесс стабилен, хост у потолка. В продакшен-испытаниях генератор должен стоять на выделенном хосте.

Итоги

Конвейер метрик воспроизводит расчётные значения по всем осям:

Величина

Расчёт

Измерено

CAPS

L/W = 100 / 31 ≈ 3,2

3,1

Звонков за прогон

≈ 1930 + довершение итераций в graceful stop

2000

Звонков в разговоре

100

98,9 в среднем

ALOC

30,5 с

30,6 с

RTP pps

~9800 с учётом скважности

9673

RTP-трафик на сторону

1,72 МБ/с

1,65–1,70 МБ/с

REGISTER 200 OK

200 × 3

600

Отказы, потери, one-way audio, ретрансмиссии

0

0

Пороги сценария: sip_call_success 100% (порог > 99%), rtp_audio_heard 100% (> 99%), sip_call_setup_time p95 2,87 мс (< 500 мс).

Миллисекундные времена — свойство тестовой SUT на loopback, а не характеристика продакшен-АТС. Кроме того, на Windows гранулярность часов Go около 0,5 мс, поэтому субмиллисекундные перцентили здесь ориентировочные. Ценность прогона — в валидации инструментов измерения перед тестом настоящей системы.

Воспроизведение

git clone https://github.com/Dmitry-Fedotov-Dev/xk6-sip && cd xk6-sipxk6 build v2.3.0 --with github.com/Dmitry-Fedotov-Dev/xk6-sip=. --output k6docker compose -f monitoring/docker-compose.yml up -d        # Linux: --profile linux-hostgo run ./cmd/testpbx -addr 127.0.0.1:5070 -users 200 -csv examples/subscribers.csv &K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9091/api/v1/write K6_FEATURES=native-histograms \./k6 run -o experimental-prometheus-rw --tag testid=my-run --tag pbx_version=<версия> \  -e VUS=100 -e HOLD=30 -e DURATION=10m -e SIP_METRICS_ADDR=127.0.0.1:6566 examples/call.js

Grafana — http://localhost:3001, Prometheus — http://localhost:9091. На Windows метрики хоста отдаёт windows_exporter с --collectors.enabled=cpu,memory,net,os,system --web.listen-address=127.0.0.1:9182. Для своей АТС достаточно CSV с её абонентами вместо сгенерированного testpbx.

Дашборд лежит в репозитории как JSON — monitoring/grafana/dashboards/xk6-sip.json — и подключается provisioning’ом автоматически. В существующую Grafana его можно импортировать как есть; нужен источник данных Prometheus с uid prometheus.

Вторая половина картины: серверная сторона

Всё, что выше, — взгляд снаружи. Генератор видит, что setup p99 вырос втрое, но не видит, почему. Причина живёт на АТС, и для неё нужен свой набор метрик, снятый в той же шкале времени и с той же меткой прогона:

Клиент (xk6-sip) видит

Что смотреть на АТС

Первый ответ на INVITE растёт к 500 мс, появляются ретрансмиссии

Очередь входящих SIP-сообщений и транзакций, загрузка потоков SIP-стека, потери UDP в буфере сокета

Setup p99 растёт при ровном p50

CPU по ядрам, время ответа БД и внешних сервисов маршрутизации, паузы GC

Звонков в разговоре меньше, чем answered − ended

Число активных каналов и диалогов, лимиты лицензий и транков

One-way audio при зелёном ASR

Сессии и порты RTP-прокси или медиасервера, NAT, использование транскодинга

Растёт доля remote среди завершивших

Session timers, таймауты RTP на медиасервере, рестарты процессов

503 и 480 под нагрузкой

Срабатывание защиты от перегрузки, пределы пула каналов, CPS-лимитеры

Правило одно: клиентская метрика говорит, что и когда сломалось, серверная — почему. Если оба набора лежат в одном Prometheus с общим testid и pbx_version, по одному клику видно, какой ресурс АТС упёрся в тот момент, когда на генераторе поплыла сигнализация, — и ёмкость новой версии снимается не как «держит 30 CAPS», а как «держит 30 CAPS, упирается в CPU SIP-стека».

Серверный мониторинг сильно зависит от конкретной АТС (Asterisk, FreeSWITCH, Kamailio/OpenSIPS, проприетарные B2BUA), поэтому здесь он только очерчен. Если вы строите нагрузочное тестирование своей телефонии и хотите связать метрики генератора с метриками сервера в один дашборд — пишите, это как раз то, чем я занимаюсь.

Следующий шаг

Закрытая модель с фиксированным числом VU проверяет устойчивость на известной нагрузке, но не находит потолок. По закону Литтла при фиксированном L = 100 рост W сам снижает λ: если АТС начнёт отвечать на INVITE за 2 с вместо 3 мс, VU дольше ждут, и CAPS падает — генератор подстраивается под скорость SUT. ASR и SEER при этом могут оставаться зелёными: звонков меньше, но проходят все.

Та же синхронизация искажает времена. Пока VU ждёт медленный звонок, он не начинает следующий, и звонки, которые пришлись бы на худший момент, просто не случаются и не попадают в перцентили. Это coordinated omission: p99 на графике лучше, чем увидели бы реальные абоненты, — они друг друга не ждут. На дашборде это видно как просадка CAPS при неизменном числе VU и calls in progress, равном числу VU.

Для поиска пропускной способности нужна открытая модель: ramping-arrival-rate ступенями с остановкой по порогу (abortOnFail). В ней CAPS задаёт генератор, и при деградации SUT растёт не пауза, а число одновременных звонков (L = λW) — пока не кончатся preAllocatedVUs (dropped iterations) или каналы АТС. Именно в таком прогоне сработают панели, которые здесь остались пустыми: коды отказов, первый ответ у T1, ретрансмиссии, падение SEER и ALOC.

ссылка на оригинал статьи https://habr.com/ru/articles/1087004/