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.csv
K6_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, проверка АОН, |
Исполнитель |
|
Абоненты | 200, digest-авторизация на REGISTER (401) и INVITE (407), Expires 300 с |
Медиа | G.711 μ-law, 20 мс, 50 pps на плечо |
SUT |
|
Генератор | 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 |
|
|
Процесс k6 | pull, |
|
|
Хост генератора | 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 | Сколько звонков в секунду завершается — фактическая интенсивность нагрузки |
| 3,1 (1,8–4,9) |
Calls in progress | Сколько разговоров идёт прямо сейчас |
| 100 |
ASR | Доля звонков, на которые ответили | answered / все попытки | 100% |
SEER (RFC 6076) | Доля звонков, которые АТС отработала без сбоя: «занято», «не отвечает» и отказ абонента тоже считаются успехом сети | (200 + 480 + 486 + 600 + 603) / попытки без 3xx | 100% |
One-way audio | Доля сторон разговора, до которых не дошёл голос собеседника |
| 0% |
Dropped iterations | Сколько запланированных звонков генератор не успел запустить |
| 0 |
Setup time p95 | За сколько соединяется звонок — от набора до ответа абонента; 95% звонков укладываются в это время | INVITE → финальный ответ | 2,9 мс |
Setup within SLA | Доля звонков, соединившихся быстрее порога SLA |
| 100% при SLA 300 мс |
PDD within SLA | Доля звонков, где пауза между набором номера и звонком у вызываемого (post-dial delay) короче порога SLA |
| 100% при SLA 2 с |
ALOC | Средняя длительность разговора |
| 30,6 с |
First response p95 | Как быстро АТС подтверждает, что получила звонок; 95% звонков укладываются в это время | INVITE → первый ответ транзакции | 1,1 мс |
Retransmissions | Сколько раз в секунду сообщение SIP пришлось отправить повторно, потому что АТС не ответила вовремя |
| 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 |
| только 200 |
Call setup time |
| p50 1,9 / p95 2,9 мс |
Post-dial delay |
| p95 2,5 мс |
INVITE routing time |
| p95 1,9 мс |
INVITE first response time |
| p95 1,1 мс |
SIP retransmissions |
| 0 |
Failed calls by 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:
Растёт p99 setup time при стабильном p50 — в SUT появляется очередь.
p99 первого ответа подходит к 500 мс.
Появляются ретрансмиссии INVITE: фактическая нагрузка на SUT становится выше заданной.
Появляются 503 / 408, падает SEER.
Шаги 1–3 дают потолок АТС раньше, чем он станет виден по отказам. На шаге 4 система уже в режиме лавинообразной перегрузки, и снижение CAPS её сразу не выводит.
Медиа
Успешная сигнализация не гарантирует голос: NAT, ошибки в SDP или исчерпание портов медиасервера дают звонок с 200 OK и тишиной в одну сторону. Поэтому каждый звонок генерирует RTP в обе стороны, а каждое плечо по окончании звонка сообщает, слышало ли оно другую сторону.

Панель | Метрика | Прогон |
|---|---|---|
One-way audio |
| 0 из 4000 |
Jitter p95 by leg |
| 0,6 мс на обоих плечах |
RTP packet loss |
| 0 |
RTP packets per second |
| 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 | Сколько звонков генератор не успел запустить по плану |
| 0 |
k6 process CPU | Насколько k6 загружает процессор |
| 55% ядра (максимум 63%) |
Goroutines | Число параллельных задач внутри k6; рост без роста нагрузки — утечка |
| ~530, стабильно |
k6 process memory | Сколько памяти занимает k6 |
| RSS ~100 МБ, heap 38–61 МБ |
k6 process network | Сколько трафика k6 отправляет и получает: голос (RTP) и сигнализация (SIP) |
| 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-sip
xk6 build v2.3.0 --with github.com/Dmitry-Fedotov-Dev/xk6-sip=. --output k6
docker compose -f monitoring/docker-compose.yml up -d # Linux: --profile linux-host
go 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.jsGrafana — 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, использование транскодинга |
Растёт доля | 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.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.