xk6-sip: SIP-телефония как код
- суббота, 26 сентября 2026 г. в 00:00:14
На дворе 2026 год. Веб-сервисы тестируют облачные оркестраторы: тысячи виртуальных пользователей поднимаются из пайплайна, метрики падают в Prometheus, деградации ловит алерт до того, как их заметит клиент. А в телефонии, которая до сих пор держит колл-центры, банки, скорую помощь и диспетчерские, релиз АТС часто проверяют так же, как двадцать лет назад: тестировщик берёт два телефона и звонит сам себе. Нагрузку дают SIPp со сценариями в XML, которые проверяют сигнализацию, но не слышат, прошёл ли голос.
xk6-sip — моя попытка закрыть этот разрыв. Это расширение k6/x/sip для Grafana k6, в котором звонок описывается кодом на JavaScript: абоненты регистрируются, звонят друг другу через тестируемую АТС, отвечают, переводят, ставят на удержание, передают DTMF и проверяют, что слышат друг друга. Один и тот же скрипт работает как функциональный тест из одного звонка и как нагрузочный тест на тысячи одновременных разговоров.
По сути это инфраструктурный сдвиг: SIP-коммуникации начинают описываться так же, как инфраструктура в IaC. Сценарий лежит в Git, проходит ревью, запускается из CI и оставляет после себя метрики, а не устный отчёт «вроде звонит».
Расширение подано в официальный реестр расширений k6 Grafana Labs и проходит там ревью. Код открыт под Apache-2.0: github.com/Dmitry-Fedotov-Dev/xk6-sip.
Я работал 4 года в телефонии и прошёл в путь от стажёра-ручника QA в команде ядра до Performance Engineer продукта. Идея описывать тест действиями абонентов вдохновлена внутренним инструментом, который я написал во время работы в телекоме. Всё остальное — код, нагрузка, медиа, метрики, SIP-стек — написано в xk6-sip заново.
О чём статья:
Зачем новый инструмент, если есть SIPp и свободные руки — сравнение ручного тестирования, SIPp и xk6-sip по основным критериям.
Звонок как код — полный тест звонка, подход к описанию шагов звонка в формате кода: «методы действия не ждут, ждут только методы ожидания» и почему синхронный на вид JS не тормозит нагрузку.
Схемы тестирования звонков — АОН, занято и неответ, слышимость в обе стороны, DTMF и IVR, удержание, переводы, перехват.
Один сценарий — и регресс, и нагрузка — JUnit и Xray в CI, закон Литтла для расчёта VU и почему constant-arrival-rate честнее ramping-vus.
Экономика — сколько часов и денег экономит автоматизация и почему она забирает рутину, а не работу.
Под капотом — свой диалоговый слой поверх sipgo, память на абонента, планировщик RTP и бенчмарк на 1000 медиапотоков.
Будущее проекта — дашборды, каталог бенчмарков по версиям АТС, автоматическая ферма генерации синтетических звонков с мониторингом состояния версии продукта и алертингом аномалий.
SIPp хорошо даёт нагрузку на сигнализацию, ручной прозвон надежнее по выполнению логики и определению слышимости в трубке. xk6-sip собирает оба свойства в одном скрипте и добавляет то, чего нет ни у одного: интеграция метрик и CI.
Критерий | 🧍 Ручное тестирование | 📜 SIPp (стандарт отрасли) | 🚀 xk6-sip |
|---|---|---|---|
Парадигма описания | тест-кейс для человека | SIP-диалоги в XML | Действия абонентов на JavaScript |
Синхронизация сторон | 1–3 человека с телефонами | Отдельные процессы UAC и UAS, синхронизация вручную | Обе стороны в одном JS-потоке |
Анализ медиа (RTP) | На слух | Воспроизведение pcap без проверки, что звук дошёл | JS-метод |
Масштабирование | Только увеличением штата | Высокое | 1000 RTP-потоков на 62% одного ядра CPU и 222 МБ RAM |
Интеграция с CI/CD | Ручные отметки в трекере | Внешняя обвязка скриптами | Из коробки: JUnit, thresholds, код возврата k6 |
Метрики и observability | Нет | Статистика в CSV | Метрики k6 → Prometheus и Grafana |
Главное отличие — в первой строке. В SIPp вы пишете сообщения протокола, в xk6-sip — поведение людей. Протоколом занимается расширение: 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 млн ₽ ФОТ в год. Ниже расчёт по шагам; цифры — моя оценка по опыту, подставьте свои.
Регресс — 300 звонковых тест-кейсов; опытный тестировщик выполняет до 100 кейсов в день.
Один релиз в неделю и два полных прогона на релиз: по ходу тестирования приходят правки.
Каждый рабочий день — фичи минимум на 40 кейсов смока и функциональных проверок.
Регресс за месяц: 4 релиза × 2 прогона × 300 кейсов = 2400 кейсов, или 192 часа ручного труда. Смок и фичи добавляют ещё 67 часов. Всего 259 часов, 1,6 ставки только на то, чтобы позвонить и поставить галочку.
Часы на прозвоны — видимая часть. Есть и невидимая:
Время до релиза. Ручной регресс — три дня одного тестировщика, и каждая правка сдвигает релиз на такой же срок.
Разбор падений. Чтобы отдать баг разработчику, звонок воспроизводят руками снова, уже с дампом трафика.
Ожидание стенда. Тестовая АТС и телефоны — общий ресурс, на него стоит очередь.
Недосмотр. К триста сороковому звонку внимание садится, и односторонняя слышимость на одном маршруте уходит в прод.
Работа за месяц | Вручную | С 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) новых фич и поиск сценариев, которые ещё никто не придумал;
проверка того, что не сводится к «да/нет»: удобство, качество звука на слух, поведение реальных телефонов;
написание новых сценариев и развитие самого инструмента;
рост в нагрузку, метрики и SIP глубже уровня «позвонить и посмотреть».
Тестировщик перестаёт быть исполнителем чек-листа и становится автором проверок. Для бизнеса это те же люди, но с большим покрытием и без выгорания от рутины.
Движок написан на Go и состоит из трёх слоёв: транспорт и транзакции SIP из sipgo, свой диалоговый слой поверх него и свой 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
GitHub: github.com/Dmitry-Fedotov-Dev
Почта: dmitry.fedotov.dev@gmail.com