Разберём, как устроена 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, проверка АОН, |
|
Исполнитель |
|
|
Абоненты |
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-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, использование транскодинга |
|
Растёт доля |
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/