golang

xk6-sip: SIP-телефония как код

  • суббота, 26 сентября 2026 г. в 00:00:14
https://habr.com/ru/articles/1086816/

Парадокс 2026 года

На дворе 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

SIPp хорошо даёт нагрузку на сигнализацию, ручной прозвон надежнее по выполнению логики и определению слышимости в трубке. xk6-sip собирает оба свойства в одном скрипте и добавляет то, чего нет ни у одного: интеграция метрик и CI.

Критерий

🧍 Ручное тестирование

📜 SIPp (стандарт отрасли)

🚀 xk6-sip

Парадигма описания

тест-кейс для человека

SIP-диалоги в XML

Действия абонентов на JavaScript

Синхронизация сторон

1–3 человека с телефонами

Отдельные процессы UAC и UAS, синхронизация вручную

Обе стороны в одном JS-потоке

Анализ медиа (RTP)

На слух

Воспроизведение pcap без проверки, что звук дошёл

JS-метод isHeard(), измерение потерь пакетов и джиттера по RFC 3550

Масштабирование

Только увеличением штата

Высокое

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.

  1. A.call() — действие. Оно передаёт INVITE движку и сразу возвращает объект звонка, не дожидаясь ответа. Дальше авторизацию, 100 Trying и 180 Ringing обрабатывает горутина звонка.

  2. B.expectCall() — ожидание. Оно блокирует только этот VU, пока движок не положит в канал входящий INVITE, подходящий под условия, или не истечёт таймаут. По таймауту возвращается false, а не исключение.

  3. Если бы call() ждал ответа, тест бы завис: B не может ответить, пока JS не дойдёт до inc.accept(). Поэтому все действия — call, accept, hangup, hold, transfer, sendDTMF — возвращают управление сразу, а ждут только методы expect* и isHeard.

Почему не коллбэки и не Promise

В 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'] — доля звонков без односторонней слышимости. Это самая неприятная поломка телефонии: по сигнализации звонок успешен, а человек ничего не слышит.

DTMF и голосовое меню

// Звонок на 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: закон Литтла

Число 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 звонка в секунду.

Почему constant-arrival-rate, а не ramping-vus

ramping-vus — закрытая модель: каждый VU начинает новый звонок, только когда закончил предыдущий. Когда АТС начинает тормозить и отвечает на INVITE не за 100 мс, а за 5 секунд, генератор сам снижает интенсивность и даёт ей передохнуть. Деградация прячется: это классический coordinated omission.

Реальные абоненты так не ведут себя: клиент банка не ждёт, пока дозвонится предыдущий. constant-arrival-rate — открытая модель: k6 запускает ровно λ звонков в секунду независимо от того, как справляется АТС. Если система не успевает, W растёт, L растёт вместе с ним, и k6 честно сообщает об этом метрикой dropped_iterations. Именно так ищется пропускная способность: потолок — это тот CAPS, при котором время установления соединения перестаёт быть плоским. ramping-arrival-rate делает то же ступенями.

Метрики как вопросы эксплуатации

Вопрос

Метрика

Соединяет ли АТС звонки и с какими кодами отказывает

sip_call_success, тег status

Как быстро абонент слышит гудки и ответ

sip_post_dial_delay, sip_call_setup_time

Сколько АТС маршрутизирует звонок

sip_invite_delivery_time

Сколько звонков ушло не туда или с неверным номером

sip_expect_failed{expect:call}

Слышат ли люди друг друга, качество медиа

rtp_audio_heard, rtp_jitter, rtp_packets_lost

Держит ли регистратор

sip_request_duration{method:REGISTER}, sip_registrations

Всё это — обычные метрики k6: они ставятся в пороги и уходят в Prometheus стандартным выводом -o experimental-prometheus-rw.

Экономика: сколько это экономит

На типичном продукте АТС автоматизация звонковых проверок экономит около 146 человеко-часов в месяц и около 2,6 млн ₽ ФОТ в год. Ниже расчёт по шагам; цифры — моя оценка по опыту, подставьте свои.

1. Исходное состояние

  • Регресс — 300 звонковых тест-кейсов; опытный тестировщик выполняет до 100 кейсов в день.

  • Один релиз в неделю и два полных прогона на релиз: по ходу тестирования приходят правки.

  • Каждый рабочий день — фичи минимум на 40 кейсов смока и функциональных проверок.

Регресс за месяц: 4 релиза × 2 прогона × 300 кейсов = 2400 кейсов, или 192 часа ручного труда. Смок и фичи добавляют ещё 67 часов. Всего 259 часов, 1,6 ставки только на то, чтобы позвонить и поставить галочку.

2. Скрытые издержки

Часы на прозвоны — видимая часть. Есть и невидимая:

  • Время до релиза. Ручной регресс — три дня одного тестировщика, и каждая правка сдвигает релиз на такой же срок.

  • Разбор падений. Чтобы отдать баг разработчику, звонок воспроизводят руками снова, уже с дампом трафика.

  • Ожидание стенда. Тестовая АТС и телефоны — общий ресурс, на него стоит очередь.

  • Недосмотр. К триста сороковому звонку внимание садится, и односторонняя слышимость на одном маршруте уходит в прод.

3. После внедрения

Работа за месяц

Вручную

С 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() не устаёт на триста сороковом звонке.

4. ROI

Экономия — около 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 сервера.

Один планировщик на все RTP-потоки

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 отвечает на вопрос «держит ли АТС нагрузку сегодня». Следующая цель — чтобы он отвечал на вопрос «стало ли лучше или хуже с новой версией» без ручного сравнения отчётов. Ниже план, а не сделанное.

Дашборды Grafana для нагрузочного клиента

Готовый дашборд в комплекте с расширением, который поднимается одной командой вместе с 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, с ревью, метриками и порогами.

Открыт для сотрудничества. Мои контакты и ссылки на репозиторий приведены ниже.