- BrainTools - https://www.braintools.ru -
На дворе 2026 год. Веб-сервисы тестируют облачные оркестраторы: тысячи виртуальных пользователей поднимаются из пайплайна, метрики падают в Prometheus, деградации ловит алерт до того, как их заметит клиент. А в телефонии, которая до сих пор держит колл-центры, банки, скорую помощь и диспетчерские, релиз АТС часто проверяют так же, как двадцать лет назад: тестировщик берёт два телефона и звонит сам себе. Нагрузку дают SIPp со сценариями в XML, которые проверяют сигнализацию, но не слышат, прошёл ли голос.
xk6-sip — моя попытка закрыть этот разрыв. Это расширение k6/x/sip для Grafana k6 [1], в котором звонок описывается кодом на JavaScript: абоненты регистрируются, звонят друг другу через тестируемую АТС, отвечают, переводят, ставят на удержание, передают DTMF и проверяют, что слышат друг друга. Один и тот же скрипт работает как функциональный тест из одного звонка и как нагрузочный тест на тысячи одновременных разговоров.
По сути это инфраструктурный сдвиг: SIP-коммуникации начинают описываться так же, как инфраструктура в IaC. Сценарий лежит в Git, проходит ревью, запускается из CI и оставляет после себя метрики, а не устный отчёт «вроде звонит».
Расширение подано в официальный реестр расширений k6 [2] Grafana Labs и проходит там ревью. Код открыт под Apache-2.0: github.com/Dmitry-Fedotov-Dev/xk6-sip [3].
Я работал 4 года в телефонии и прошёл в путь от стажёра-ручника QA в команде ядра до Performance Engineer продукта. Идея описывать тест действиями абонентов вдохновлена внутренним инструментом, который я написал во время работы в телекоме. Всё остальное — код, нагрузка, медиа, метрики, SIP-стек — написано в xk6-sip заново.
О чём статья:
Зачем новый инструмент, если есть SIPp и свободные руки — сравнение ручного тестирования, SIPp и xk6-sip по основным критериям.
Звонок как код — полный тест звонка, подход к описанию шагов звонка в формате кода: «методы действия не ждут, ждут только методы ожидания» и почему синхронный на вид JS не тормозит нагрузку.
Схемы тестирования звонков — АОН, занято и неответ, слышимость в обе стороны, DTMF и IVR, удержание, переводы, перехват.
Один сценарий — и регресс, и нагрузка — JUnit и Xray в CI, закон Литтла для расчёта VU и почему constant-arrival-rate честнее ramping-vus.
Экономика — сколько часов и денег экономит автоматизация и почему она забирает рутину, а не работу.
Под капотом — свой диалоговый слой поверх sipgo, память [4] на абонента, планировщик RTP и бенчмарк на 1000 медиапотоков.
Будущее проекта — дашборды, каталог бенчмарков по версиям АТС, автоматическая ферма генерации синтетических звонков с мониторингом состояния версии продукта и алертингом аномалий.
SIPp хорошо даёт нагрузку на сигнализацию, ручной прозвон надежнее по выполнению логики и определению слышимости в трубке. xk6-sip собирает оба свойства в одном скрипте и добавляет то, чего нет ни у одного: интеграция метрик и CI.
|
Критерий |
🧍 Ручное тестирование |
📜 SIPp (стандарт отрасли) |
🚀 xk6-sip |
|---|---|---|---|
|
Парадигма описания |
тест-кейс для человека |
SIP-диалоги в XML |
Действия абонентов на JavaScript |
|
Синхронизация сторон |
1–3 человека с телефонами |
Отдельные процессы UAC и UAS, синхронизация вручную |
Обе стороны в одном JS-потоке |
|
Анализ медиа (RTP) |
На слух [5] |
Воспроизведение pcap без проверки, что звук дошёл |
JS-метод |
|
Масштабирование |
Только увеличением штата |
Высокое |
1000 RTP-потоков на 62% одного ядра CPU и 222 МБ RAM |
|
Интеграция с CI/CD |
Ручные отметки в трекере |
Внешняя обвязка скриптами |
Из коробки: JUnit, thresholds, код возврата k6 |
|
Метрики и observability |
Нет |
Статистика в CSV |
Метрики k6 → Prometheus и Grafana |
Главное отличие — в первой строке. В SIPp вы пишете сообщения протокола, в xk6-sip — поведение [6] людей. Протоколом занимается расширение: digest-авторизация, CANCEL, re-INVITE, PRACK, session timers, REFER и Replaces.
Вот полный тест: абонент A звонит B, B видит правильный АОН и отвечает, оба слышат друг друга, A кладёт трубку.
import sip from 'k6/x/sip';
import { check } from 'k6';
const A = new sip.Device({ device: 'phone1', registrar: 'sip:pbx:5060', user: '701@pbx', pass: 'secret', ext: '701' });
const B = new sip.Device({ device: 'phone2', registrar: 'sip:pbx:5060', user: '702@pbx', pass: 'secret', ext: '702' });
export default function () {
const out = A.call({ callee: B }); // действие: INVITE ушёл, управление сразу вернулось
const inc = B.expectCall({ caller: A, timeout: '10s' }); // ожидание: ждём входящий
if (!check(inc, { 'входящий пришёл с АОН 701': (c) => c })) return;
inc.accept();
check(out, {
'соединились': (c) => c.expectConnected('5s'),
'A слышит B': (c) => c.isHeard('3s'),
});
check(inc, { 'B слышит A': (c) => c.isHeard('3s') });
out.hangup();
check(inc, { 'B получил BYE': (c) => c.expectDisconnected('5s') });
}
export function teardown() { sip.shutdown(); }
Весь сценарий держится на одном правиле: действие никогда не ждёт шага сценария на другом устройстве, ждут только ожидания. Именно оно позволяет одному виртуальному пользователю k6 (VU) играть за обе стороны звонка.
Код выглядит синхронным, но сеть в нём асинхронна. JavaScript в k6 исполняет sobek — интерпретатор на Go, форк goja. У каждого VU свой рантайм, и скрипт в нём идёт строка за строкой. А SIP-стек, транзакции, таймеры и RTP живут в горутинах движка и работают независимо от того, что сейчас делает JS.
A.call() — действие. Оно передаёт INVITE движку и сразу возвращает объект звонка, не дожидаясь ответа. Дальше авторизацию, 100 Trying и 180 Ringing обрабатывает горутина звонка.
B.expectCall() — ожидание. Оно блокирует только этот VU, пока движок не положит в канал входящий INVITE, подходящий под условия, или не истечёт таймаут. По таймауту возвращается false, а не исключение.
Если бы call() ждал ответа, тест бы завис: B не может ответить, пока JS не дойдёт до inc.accept(). Поэтому все действия — call, accept, hangup, hold, transfer, sendDTMF — возвращают управление сразу, а ждут только методы expect* и isHeard.
В Node.js тот же сценарий превратился бы в подписки на события on('invite'), on('ringing') и цепочку await с Promise.race против таймера на каждом шаге. Порядок событий пришлось бы восстанавливать по коду обработчиков. Здесь тест читается как тест-кейс в Xray: шаг — ожидаемый результат — шаг.
Производительность от этого не страдает. Заблокированный VU не занимает поток ОС: это горутина, которая спит на канале, и планировщик Go тем временем отдаёт ядро другим VU. Тысячи параллельных звонков — это тысячи спящих горутин, а не тысячи потоков.
В итоге тесты становятся обычным кодом: их ревьюят в merge request, выносят общие шаги в библиотеку, смотрят дифф между версиями. Если проверка упала, out.trace() печатает лесенку SIP-сообщений этого звонка, и открывать Wireshark не нужно.
Например, A звонит на номер, которого нет на АТС:
const out = A.call({ callee: '1999' });
if (!check(out, { 'A соединился': (c) => c.expectConnected('5s') })) {
console.warn(out.trace());
}
+0.000s -> INVITE sip:1999@test.local SIP/2.0
+0.001s <- SIP/2.0 407 Proxy Authentication Required
+0.002s -> INVITE (with credentials)
+0.003s <- SIP/2.0 404 Not Found
Стрелка -> — сообщение отправлено, <- — получено, время отсчитывается от первого сообщения. Сразу видно, что авторизация прошла, а звонок отбила маршрутизация. В лесенку попадают и события диалога — вот так выглядит звонок, в котором B поставил A на удержание и вернул:
+0.000s -> INVITE sip:1002@test.local SIP/2.0
+0.000s <- SIP/2.0 407 Proxy Authentication Required
+0.000s -> INVITE (with credentials)
+0.001s <- SIP/2.0 100 Trying
+0.001s <- SIP/2.0 180 Ringing
+0.002s <- SIP/2.0 200 OK
+0.002s -> ACK
+0.002s <- INVITE sip:user1@127.0.0.1:59161 SIP/2.0
+0.002s <- re-INVITE: remote sendonly
+0.002s -> SIP/2.0 200 OK
+0.003s <- re-INVITE: remote sendrecv
+0.003s -> BYE
+0.003s <- SIP/2.0 200 OK (BYE)
По умолчанию хранятся только стартовые строки, поэтому трасса почти ничего не стоит даже под нагрузкой. sip.options({ trace: true }) добавляет под каждой строкой сообщение целиком, с заголовками и SDP. Wireshark остаётся для разбора самих RTP-пакетов.
Каждая схема ниже занимает несколько строк и работает и как функциональный тест, и под нагрузкой. Базовый звонок, АОН, отказ, неответ, медиа, DTMF, удержание и оба перевода проверены тестами расширения на тестовой АТС. Она лежит в репозитории, так что свой Asterisk для отладки поднимать не нужно. Переадресация, IVR и перехват — функции конкретной АТС: для них показан сценарий, который запускается на вашей.
Alice, Bob и Carol на всех схемах — устройства одного VU k6. АТС и всё, что за ней, — тестируемая система.

// A набирает городской номер B, а B должен увидеть внешний номер A (ОНК), а не внутренний
const out = A.call({ callee: B, aon: 'gw_num' });
const inc = B.expectCall({ caller: A, aon: 'onk', timeout: '10s' });
В call() параметр aon — какой номер вызываемого набрать, в expectCall() — какой номер вызывающего должен определиться. Номера берутся из CSV с абонентами АТС. Под нагрузкой неверный АОН попадает в sip_expect_failed{expect:call}, а sip_invite_delivery_time показывает, сколько АТС везла INVITE от A до B. Эту метрику SIPp посчитать не может: у него стороны живут в разных процессах.

// B занят — АТС должна переадресовать звонок на C
const out = A.call({ callee: B });
const busy = B.expectCall({ caller: A, aon: 'ext' });
if (busy) busy.reject(486, 'Busy Here');
const fwd = C.expectCall({ caller: A, aon: 'ext', timeout: '10s' });
check(fwd, { 'переадресация по занятости': (c) => c !== false });
// B не отвечает — A отменяет звонок через 20 секунд (CANCEL)
const out2 = A.call({ callee: B, timeout: '20s' });
B.expectCall({ caller: A, aon: 'ext' });
check(out2.howCompleted(), { 'отменён со стороны A': (c) => out2.expectDisconnected('25s') && c.status === 487 });

const A = new sip.Device({ ...subs[0], codecs: 'PCMA', audio: sip.audio(open('./greeting.wav', 'b')) });
// ...
check(out.codec(), { 'договорились о PCMA': (c) => c === 'PCMA' });
check(inc.isHeard('3s'), { 'B слышит A': (ok) => ok });
check(out.isHeard('3s'), { 'A слышит B': (ok) => ok });
Каждый звонок шлёт настоящий RTP, а приёмник проверяет методом isHeard(), что звук дошёл. Под нагрузкой это порог rtp_audio_heard: ['rate>0.99'] — доля звонков без односторонней слышимости. Это самая неприятная поломка телефонии: по сигнализации звонок успешен, а человек ничего не слышит.

// Звонок на IVR, пункт «1» — соединение с сотрудником B
const out = A.call({ callee: '8800' });
check(out.expectConnected('10s') && out.isHeard('5s'), { 'IVR играет приветствие': (ok) => ok });
out.sendDTMF('1');
const inc = B.expectCall({ caller: A, aon: 'onk', timeout: '15s' });
check(inc, { 'меню соединило с сотрудником': (c) => c !== false });
DTMF уходит по RFC 4733, а inc.expectDTMF('123#') проверяет, что АТС пропускает сигналы между абонентами.

Схема для АТС, которая выполняет перевод сама. Если REFER доходит до Alice, её устройство само отправляет INVITE с Replaces на Carol.
// Сопровождаемый перевод: B держит A, советуется с C и соединяет их
check(inc.hold(), { 'B поставил A на удержание': (ok) => ok && out.isRemoteHold() });
const consult = B.call({ callee: C });
const atC = C.expectCall({ caller: B, aon: 'ext' });
if (atC) atC.accept();
consult.expectConnected('10s');
inc.attendedTransfer(consult);
check(inc.expectTransferred('10s'), { 'A соединён с C': (ok) => ok });
// Слепой перевод — одна строка: inc.transfer(C)
Поддержаны обе модели АТС. Хостинговые АТС и центрексы исполняют перевод сами. Прокси пропускают REFER до абонента — тогда новый звонок делает само устройство (out.expectReferredCall()), а на INVITE с Replaces оно отвечает автоматически, как настоящий телефон.

// Звонит у B, трубку снимает C кодом перехвата
const out = A.call({ callee: B });
B.expectCall({ caller: A, aon: 'ext' });
const pick = C.call({ aon: '*6' + B.identity('ext') });
check(pick.expectConnected('10s') && out.isHeard('3s'), { 'A разговаривает с C': (ok) => ok });
В aon можно передать любую строку набора, так же проверяются любые сервисные коды АТС.
Любая схема выше — это функция default. Как её исполнять, решают options.
Регресс в CI. Один VU, один прогон, все проверки обязаны пройти:
import { jUnit, textSummary } from 'https://jslib.k6.io/k6-summary/0.1.0/index.js';
export const options = {
vus: 1, iterations: 1,
thresholds: { checks: ['rate==1'] }, // любой упавший шаг — ненулевой код выхода
};
export function handleSummary(data) {
return { 'junit.xml': jUnit(data), stdout: textSummary(data) };
}
Любой CI понимает это без обвязки, а Xray импортирует JUnit и сам проставляет статусы тест-кейсов. На боевую АТС те же сценарии наводятся переменными окружения.
Нагрузка — та же функция, другие options:
export const options = {
scenarios: {
caps: { executor: 'constant-arrival-rate', rate: 20, timeUnit: '1s', // 20 новых звонков в секунду
duration: '10m', preAllocatedVUs: 1300 },
},
thresholds: {
sip_call_success: ['rate>0.99'],
sip_call_setup_time: ['p(95)<300'],
rtp_audio_heard: ['rate>0.99'],
},
};
Число VU не подбирается на глаз, его даёт теория массового обслуживания. Закон Литтла связывает среднее число заявок в системе L, интенсивность их поступления λ и время пребывания W:

В телефонии заявка — это звонок, λ — CAPS (call attempts per second), W — время от INVITE до BYE. 20 звонков в секунду по 60 секунд дают 1200 одновременных звонков. Один VU ведёт один звонок и держит двух абонентов, поэтому нужно не меньше 1200 VU и 2400 абонентов в CSV. Запас в preAllocatedVUs: 1300 покрывает установление соединения и отбой. Та же формула работает в обратную сторону: если лицензия АТС даёт 500 каналов, а средний разговор длится 3 минуты, то устойчивый поток — не больше 2,8 звонка в секунду.
ramping-vus — закрытая модель: каждый VU начинает новый звонок, только когда закончил предыдущий. Когда АТС начинает тормозить и отвечает на INVITE не за 100 мс, а за 5 секунд, генератор сам снижает интенсивность и даёт ей передохнуть. Деградация прячется: это классический coordinated omission.
Реальные абоненты так не ведут себя: клиент банка не ждёт, пока дозвонится предыдущий. constant-arrival-rate — открытая модель: k6 запускает ровно λ звонков в секунду независимо от того, как справляется АТС. Если система не успевает, W растёт, L растёт вместе с ним, и k6 честно сообщает об этом метрикой dropped_iterations. Именно так ищется пропускная способность: потолок — это тот CAPS, при котором время установления соединения перестаёт быть плоским. ramping-arrival-rate делает то же ступенями.
|
Вопрос |
Метрика |
|---|---|
|
Соединяет ли АТС звонки и с какими кодами отказывает |
|
|
Как быстро абонент слышит гудки и ответ |
|
|
Сколько АТС маршрутизирует звонок |
|
|
Сколько звонков ушло не туда или с неверным номером |
|
|
Слышат ли люди друг друга, качество медиа |
|
|
Держит ли регистратор |
|
Всё это — обычные метрики k6: они ставятся в пороги и уходят в Prometheus стандартным выводом -o experimental-prometheus-rw.
На типичном продукте АТС автоматизация звонковых проверок экономит около 146 человеко-часов в месяц и около 2,6 млн ₽ ФОТ в год. Ниже расчёт по шагам; цифры — моя оценка по опыту [7], подставьте свои.
Регресс — 300 звонковых тест-кейсов; опытный тестировщик выполняет до 100 кейсов в день.
Один релиз в неделю и два полных прогона на релиз: по ходу тестирования приходят правки.
Каждый рабочий день — фичи минимум на 40 кейсов смока и функциональных проверок.
Регресс за месяц: 4 релиза × 2 прогона × 300 кейсов = 2400 кейсов, или 192 часа ручного труда. Смок и фичи добавляют ещё 67 часов. Всего 259 часов, 1,6 ставки только на то, чтобы позвонить и поставить галочку.
Часы на прозвоны — видимая часть. Есть и невидимая:
Время до релиза. Ручной регресс — три дня одного тестировщика, и каждая правка сдвигает релиз на такой же срок.
Разбор падений. Чтобы отдать баг разработчику, звонок воспроизводят руками снова, уже с дампом трафика.
Ожидание стенда. Тестовая АТС и телефоны — общий ресурс, на него стоит очередь.
Недосмотр. К триста сороковому звонку внимание [8] садится, и односторонняя слышимость на одном маршруте уходит в прод.
|
Работа за месяц |
Вручную |
С xk6-sip |
|---|---|---|
|
Регресс, 2400 кейсов |
192 ч |
54 ч: 80% автоматизировано, 480 ручных кейсов (38 ч) + разбор падений по 2 ч на прогон (16 ч) |
|
Смок и фичи, 840 кейсов |
67 ч |
34 ч: смок идёт сам, вручную — новые фичи в первый раз |
|
Написание и поддержка автотестов |
— |
25 ч: ~80 новых кейсов × 15 мин на автотест (20 ч) + ~5 ч на правки старых тестов после изменений в АТС |
|
Итого |
259 ч, 1,6 ставки |
113 ч, 0,7 ставки |
Скрытые издержки уходят вместе с ручными прозвонами. Автоматический регресс идёт меньше часа машинного времени, повтор после правок укладывается в тот же день. Упавший звонок приходит сразу с SIP-лесенкой из trace(), а тестовая АТС из репозитория снимает очередь на стенд для отладки сценариев. isHeard() не устаёт на триста сороковом звонке.
Экономия — около 146 человеко-часов в месяц, 56% звонкового тестирования и около 1750 часов в год. При полной стоимости часа тестировщика 1500 ₽ это около 220 тысяч ₽ в месяц и 2,6 млн ₽ ФОТ в год. Это расчёт на один продукт: если доменов несколько и у каждого свои ветки, ручные затраты и экономия растут кратно числу доменов. Доля автоматизации 80% — осторожная оценка; при 90% экономия растёт до около 165 часов в месяц. Сам инструмент бесплатен и работает на обычном CI-раннере, так что окупаемость упирается только в первые 25 часов на написание сценариев.
Инструмент экономит деньги компании, но не забирает работу у тестировщика. Автоматизируется именно то, что никто не любит: триста раз позвонить, посмотреть на номер, сказать «алло» и поставить галочку в Xray. Освободившиеся 146 часов уходят туда, где человек незаменим:
исследовательское тестирование (exploratory testing) новых фич и поиск сценариев, которые ещё никто не придумал;
проверка того, что не сводится к «да/нет»: удобство, качество звука на слух, поведение [9] реальных телефонов;
написание новых сценариев и развитие самого инструмента;
рост в нагрузку, метрики и SIP глубже уровня «позвонить и посмотреть».
Тестировщик перестаёт быть исполнителем чек-листа и становится автором проверок. Для бизнеса это те же люди, но с большим покрытием и без выгорания от рутины.
Движок написан на Go и состоит из трёх слоёв: транспорт и транзакции SIP из sipgo [10], свой диалоговый слой поверх него и свой RTP-стек. Ниже — три инженерных решения, которые определили производительность, и замер, который их подтверждает.
sipgo — хорошая библиотека для парсинга и транзакций, но её готовые диалоги рассчитаны на клиента с одним-двумя звонками. Нагрузочный тест устроен иначе: тысячи диалогов живут одновременно, события приходят с обеих сторон в произвольном порядке, а скрипт может положить трубку в любой момент. Первые же тесты под такой нагрузкой нашли в sipgo три проблемы:
Сломанный ACK после авторизации. При ответе 407 библиотека добавляла учётные данные прямо в исходный INVITE. В итоге ACK и CANCEL строились от изменённого запроса и переставали совпадать с транзакцией. В своей реализации повтор идёт клоном с новым CSeq, а оригинал не меняется.
CANCEL мимо прокси. CANCEL уходил на хост из Request-URI, а не туда, куда ушёл INVITE. За прокси это значит, что отмена звонка теряется и телефон вызываемого звонит дальше. Свой CANCEL идёт по адресу назначения INVITE, как требует RFC 3261.
Гонки при старте. Первый запрос мог обогнать запуск чтения сокета, а глобальная настройка буфера чтения менялась из разных потоков. Оба случая поймал race detector, а не чтение кода.
Из sipgo остались парсер, транспорт и транзакции. Всё, что выше, своё: повторы с digest-авторизацией, CANCEL, re-INVITE с обработкой 491, PRACK, session timers с 422 и Min-SE, REFER с NOTIFY и Replaces. Заодно диалоговый слой стал единственной точкой, через которую проходит каждое сообщение звонка. Поэтому trace() стоит почти ноль, а метрики вроде sip_invite_delivery_time считаются без слежки за сетью.
Абонент — это один UDP-сокет, одна горутина чтения и состояние регистрации. По умолчанию sipgo выделяет на сокет буфер чтения 32 КБ. SIP-сообщение по UDP помещается в 4 КБ, и одна эта настройка сэкономила около 20 КБ на абонента. Итог замера — около 21 КБ на зарегистрированного абонента.
|
Абонентов |
SIP-часть движка |
|---|---|
|
1 000 |
~21 МБ |
|
10 000 |
~210 МБ |
|
100 000 |
~2,1 ГБ |
Сам SIP-стек перестаёт быть узким местом. Остаётся стоимость VU: у каждого свой JS-рантайм, и это уже единицы мегабайт. На практике это значит, что тест на тысячи одновременных звонков с медиа помещается в обычный CI-раннер или под Kubernetes. Для десятков тысяч абонентов нагрузка раскладывается на несколько таких подов, а не требует выделенного bare-metal сервера.
G.711 отправляет пакет каждые 20 мс. Тысяча потоков — это 50 000 пакетов в секунду, и каждый должен уйти вовремя: опоздание генератора выглядит в метриках как джиттер АТС.
Наивное решение — свой time.Ticker и своя горутина на каждый поток. Таймеры Go дешёвые и не создают таймеров ядра, но тысяча независимых тикеров будит тысячу горутин в случайных фазах. Это 50 000 пробуждений и переключений контекста в секунду, конкуренция за планировщик и дрожание момента отправки.
В xk6-sip потоки разбиты на шарды по числу ядер (GOMAXPROCS). У каждого шарда один тикер на 20 мс, который за один проход отправляет пакеты всех своих активных потоков. Есть ещё два решения на горячем пути:
Звук кодируется в G.711 один раз на весь процесс. Тысяча звонков с одним WAV читают один и тот же буфер.
Заголовок RTP пишется вручную в переиспользуемый буфер, так что отправка пакета не выделяет память. Сборщик мусора не вмешивается в тайминги отправки.
Приём считает потери и джиттер по RFC 3550 и слышимость для isHeard(). RTP симметричный: пакеты идут туда, откуда реально приходит медиа, и тест не ломается за NAT.
Linux, 12 ядер, 500 VU, разговор по 10 секунд, около тысячи одновременных RTP-потоков через тестовую АТС.
|
Метрика |
Значение |
Архитектурное значение |
|---|---|---|
|
Virtual Users |
500, разговор по 10 с |
Высокая плотность: каждый VU ведёт обе стороны звонка |
|
Успешных звонков |
100%, 2289 звонков |
Диалоговый слой не теряет события при конкурентном доступе |
|
Плеч со слышимым звуком |
4578 из 4578 |
Медиа идёт в обе стороны на каждом звонке |
|
RTP-пакетов принято / потеряно |
2 424 988 / 0 |
Планировщик и отправка без аллокаций успевают за 50 000 пакетов в секунду |
|
Джиттер p95 |
0,9 мс |
Сборщик мусора почти не влияет на момент отправки медиа |
|
CPU и память k6 |
62% одного ядра, 222 МБ |
Тест помещается на дешёвый CI-инстанс |
Генератор не становится узким местом и почти не добавляет собственного джиттера, поэтому в метриках видна АТС, а не стенд. Одно практическое правило: точные замеры делаются на Linux. Часы Go на Windows идут шагами около 0,5 мс, и всё, что быстрее, там округляется до нуля.
Качество держит CI на каждый push. Там идут тесты с race detector, сборка k6 с расширением и xk6 lint с gosec и govulncheck — тот же набор проверок, который требует реестр расширений Grafana.
Сейчас xk6-sip отвечает на вопрос «держит ли АТС нагрузку сегодня». Следующая цель — чтобы он отвечал на вопрос «стало ли лучше или хуже с новой версией» без ручного сравнения отчётов. Ниже план, а не сделанное.
Готовый дашборд в комплекте с расширением, который поднимается одной командой вместе с Prometheus. На нём — звонки в секунду и одновременные каналы, время установления и PDD перцентилями, доля успешных звонков по кодам ответа, односторонняя слышимость, потери и джиттер. Отдельно — здоровье самого генератора, чтобы сразу было видно, что упёрлась АТС, а не стенд.
Каждый прогон помечается версией тестируемой АТС и профилем нагрузки:
k6 run --tag pbx_version=4.2.1 --tag profile=caps20 examples/call.js
Из этих тегов в Grafana складывается каталог: для каждой версии — потолок CAPS, p95 установления и доля односторонней слышимости на эталонном профиле. Релизы отмечаются аннотациями, и деградация видна на графике как ступенька, а не как ощущение тестировщика.
Эталонный прогон предыдущей версии становится базовой линией. CI прогоняет тот же профиль на новой сборке АТС и сравнивает: если p95 времени установления вырос больше чем на 10% или доля успешных звонков упала, сборка не проходит. Регресс производительности ловится до релиза — так же, как функциональный, и с тем же отчётом в Xray.
Ступенчатый ramping-arrival-rate до отказа с автоматической остановкой по порогам (abortOnFail) даёт главную цифру для эксплуатации: сколько звонков в секунду и одновременных каналов выдерживает конкретная конфигурация АТС на конкретном железе. Планирование мощностей опирается на замер, а не на паспорт вендора.
Soak-тесты на сутки: утечки памяти в АТС, накопление зависших диалогов, штормы перерегистраций.
Плохая сеть: потери, задержки и джиттер через tc netem между генератором и АТС.
Отказы узлов кластера под нагрузкой: сколько звонков теряется при переключении и за сколько система приходит в себя.
Тот же функциональный сценарий, запущенный по расписанию против боевой АТС, становится синтетическим мониторингом. Раз в пять минут робот звонит сам себе через АТС, проверяет АОН, звук и DTMF и поднимает алерт, если что-то сломалось. О проблеме узнают раньше, чем позвонит первый клиент, а история проверок становится основой для SLO телефонии.
Распределённая нагрузка через k6-operator в Kubernetes: десятки тысяч абонентов с нескольких генераторов.
RTCP-отчёты и оценка MOS по E-модели рядом с потерями и джиттером.
SIP поверх TCP и TLS, SRTP, кодеки G.722 и Opus.
Сравнение записи с эталоном: проверка, что IVR проиграл именно то приветствие.
Справочник API уже оформлен в формате документации k6: по странице на каждый метод, с параметрами и примерами. Если вашей АТС нужно что-то из плана раньше другого, напишите issue — порядок я буду строить по ним.
xk6-sip вырос на стыке четырёх областей, и ни одну из них нельзя было пропустить:
Протоколы SIP и RTP — главные киты отрасли телефонии.
Go и конкурентность — тысячи диалогов без гонок, интеграция с JS-рантаймом k6.
CI/CD — JUnit, Xray, пороги как критерий приёмки сборки, статический анализ и проверка уязвимостей на каждый push.
Observability — метрики, которые отвечают на вопросы эксплуатации, и их путь в Prometheus и Grafana.
Проект развивается дальше: дашборды, каталог бенчмарков по версиям, синтетический мониторинг. Но главная идея шире телефонии. Любую асинхронную систему с несколькими участниками — очереди сообщений, чаты, IoT, платёжные цепочки — можно описать тем же языком действий и ожиданий. Тогда она тестируется как инфраструктура в IaC: кодом в Git, с ревью, метриками и порогами.
Открыт для сотрудничества. Мои контакты и ссылки на репозиторий приведены ниже.
Репозиторий: github.com/Dmitry-Fedotov-Dev/xk6-sip [3]
GitHub: github.com/Dmitry-Fedotov-Dev [11]
Почта: dmitry.fedotov.dev@gmail.com
Автор: fedotov-dev
Источник [12]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/36061
URLs in this post:
[1] Grafana k6: https://k6.io
[2] реестр расширений k6: https://github.com/grafana/k6-extension-registry
[3] github.com/Dmitry-Fedotov-Dev/xk6-sip: https://github.com/Dmitry-Fedotov-Dev/xk6-sip
[4] память: http://www.braintools.ru/article/4140
[5] слух: http://www.braintools.ru/article/6251
[6] поведение: http://www.braintools.ru/article/9372
[7] опыту: http://www.braintools.ru/article/6952
[8] внимание: http://www.braintools.ru/article/7595
[9] поведение: http://www.braintools.ru/article/5593
[10] sipgo: https://github.com/emiago/sipgo
[11] github.com/Dmitry-Fedotov-Dev: https://github.com/Dmitry-Fedotov-Dev
[12] Источник: https://habr.com/ru/articles/1086816/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1086816
Нажмите здесь для печати.