Учебный пример WebRTC выглядит обманчиво коротко: взять поток камеры, отдать его объекту RTCPeerConnection, обменяться с собеседником парой объектов JSON и получить живое видео. В документации между третьей и четвёртой строкой спрятан целый пласт инженерной работы, который не виден в коде: сбор сетевых кандидатов, пробивка трансляторов NAT, обмен ключами через DTLS, а в неудачном сценарии ретрансляция всего медиапотока через отдельный сервер. Ниже разбор того, где заканчивается простота из туториала и начинается жизнь настоящего видеосервиса, чем симметричный NAT отличается от полного конуса, почему у части абонентов звонок без TURN не собирается вовсе и сколько гигабайт в час приносит один ретранслируемый сеанс.

Три строки из учебного примера и настоящий порядок соединения

Начало в любой документации одинаково:

const pc = new RTCPeerConnection();
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);

Пять строк действительно работают. Проблема в том, что они описывают десятую часть происходящего. После вызова setLocalDescription сетевой агент ICE опрашивает локальные интерфейсы, отправляет запросы STUN серверу из конфигурации, при наличии TURN запрашивает у него ретрансляционные адреса и собирает список кандидатов. Параллельно согласуются кодеки из набора VP8, VP9, H264 и AV1, рукопожатие DTLS готовит ключи для шифрования медиа в SRTP, а на выходе получается увесистый текстовый SDP документ, который ещё надо доставить собеседнику. Стандарт, ставший рекомендацией W3C в январе 2021 года, подробно описывает каждый из этих механизмов. Но про доставку SDP второй стороне он не говорит ни слова, и это самое дорогое умолчание в протоколе.

Есть и менее очевидные детали, которые пример скрывает. Доступ к камере работает только на страницах с HTTPS, иначе getUserMedia выбрасывает ошибку ещё до всякой сети. В объект запроса молча подставлены флаги echoCancellation, noiseSuppression и autoGainControl, и качество звука меняется от их комбинации заметнее, чем от кодека.

Сигнальный сервер которого нет в спецификации протокола

Термин "сигнальный сервер" в стандарте WebRTC не встречается. Есть постулат: приложение само выбирает способ обмена описанием сеанса. На практике команда пишет ещё один сервис: комнаты, доставка offer и answer, пересылка ICE кандидатов по мере их появления, восстановление после обрыва. Классический стек: WebSocket соединения на Node.js или Go, публикация событий через Redis, учёт сессий в PostgreSQL. Формат сообщений придумывают сами, и две разные WebRTC платформы физически не поймут друг друга без адаптера.

Минимальный сигнальный контур выполняет четыре задачи:
1. сопоставляет абонентов одного сеанса, проверяя права на комнату;
2. доставляет SDP offer и answer в неизменном виде и в правильном порядке;
3. пересылает ICE кандидаты сразу по мере сбора, не дожидаясь полного списка;
4. восстанавливает сеанс после разрыва соединения и инициирует рестарт ICE.

Отдельная головная боль это одновременный вызов, когда оба абонента нажали кнопку друг другу в одну секунду. Паттерн perfect negotiation назначает одной стороне роль вежливой: при коллизии она откатывает свой offer и принимает чужой. Без него заметная часть параллельных звонков умирает с рассинхроном состояний, который не воспроизводится у разработчиков.

Обмен кандидатами в коде выглядит скромно:

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: "stun:stun.example.net:3478" },
    {
      urls: [
        "turn:turn.example.net:3478?transport=udp",
        "turn:turn.example.net:3478?transport=tcp",
        "turns:turn.example.net:5349"
      ],
      username: "user171",
      credential: "pass172"
    }
  ]
});
pc.onicecandidate = (e) => {
  if (e.candidate) socket.send(JSON.stringify(e.candidate));
};

Каждый кандидат уезжает отдельным сообщением, и в этом нет расточительности: метод trickle ICE отдаёт найденный адрес немедленно, экономя секунды, которые иначе ушли бы на полный опрос всех интерфейсов и ожидание ответов STUN сервера.

Типы NAT и почему у части пользователей прямое соединение не проходит

Транслятор сетевых адресов внешне устроен просто: он подменяет внутренний адрес устройства внешним портом роутера. Инженерная суть в том, как NAT ведёт себя с разными получателями. Полный конус заводит одно отображение на все внешние адреса и пропускает любого, кто пишет на этот порт: достаточно, чтобы оба абонента отправили друг другу по пакету, и путь пробит. Ограниченный конус требует, чтобы внутреннее устройство уже обращалось к получателю. Порт ограниченная схема добавляет проверку порта назначения. Симметричный NAT, худший случай для одноранговых соединений, заводит новое отображение под каждую пару адрес и порт получателя.

STUN сервер, описанный в RFC 5389 и его преемнике RFC 8489, сообщает абоненту его внешний адрес и порт. Для конусных схем этого достаточно: порт передают собеседнику в кандидате srflx, и после обмена пакетами соединение оживает. На симметричном NAT порт, увиденный STUN сервером, бесполезен: для собеседника транслятор выделит другой, а входящие пакеты на старый просто отбросит. Браузер честно проверит пару, не получит ответа и пойдёт к следующей. Так выглядят половина отчётов вида "у одних работает, у других нет".

К симметричной схеме близки сотовые сети с двойной адресной трансляцией: смартфон за NAT домашнего роутера, а весь абонентский пул за общим транслятором оператора. У мобильной аудитории таких сетей заметно больше, чем у проводной. Корпоративные политики добавляют третий сюжет: полный запрет UDP в сети, и тогда живёт только ретрансляция поверх TCP. Хорошая новость в том, что поведение NAT проверяемо: один замер на пилоте показывает, какие типы кандидатов выживают у реальной аудитории продукта.

ICE и перебор кандидатов от прямого пути к ретранслятору

Протокол ICE, стандартизованный в RFC 8445 в июле 2017 года взамен RFC 5245, превращает подбор маршрута в прозаический алгоритм. Каждая сторона собирает кандидатов четырёх типов: host с локальных интерфейсов, srflx, узнанный у STUN сервера, relay, выделенный на TURN, и prflx, обнаруженный уже во время проверок. Стороны образуют все пары кандидатов, сортируют их по приоритету из формул стандарта и обмениваются STUN binding запросами. Локальный маршрут host to host всегда в верхней части списка, ретрансляционный relay в самом низу: если проходит хоть одна более дешёвая пара, браузер выберет её.

Установленное соединение само себя не поддерживает. Периодические проверки согласия из RFC 7675 ходят каждые 5 секунд, и если ответы отсутствуют 30 секунд, связь считается потерянной, а звонок разваливается. Смена сети с Wi-Fi на LTE по дороге домой рвёт отображения NAT, и тогда приложение вызывает ICE рестарт: собирается новый список кандидатов, и медиа переезжает на рабочую пару без разрыва верхней логики звонка. По ощущениям абонента это просадка на две-три секунды вместо полной пересборки вызова.

TURN серверы coturn и арифметика ретранслируемого трафика

TURN описан в RFC 8656, принятом в феврале 2020 года взамен RFC 5766 из 2010 года. Протокол выделяет клиенту ретрансляционный адрес и порт с временем жизни 10 минут, продлеваемым запросами Refresh, заводит разрешения на адрес собеседника со сроком 5 минут и сворачивает повторяющийся трафик в каналы ради экономии заголовков. Смысл прост: TURN сервер принимает медиапоток одного абонента и отдаёт его второму, становясь полноценным посредником и источником основной нагрузки.

Де-факто стандартом открытых инструментов стал coturn. Конфигурация для старта выглядит так:

listening-port=3478
tls-listening-port=5349
listening-ip=203.0.113.10
external-ip=203.0.113.10
fingerprint
lt-cred-mech
realm=video.example.net
user=user171:pass172
total-quota=2400
min-port=49160
max-port=49200
no-multicast-peers

Диапазон портов 49160-49200 открывают в брандмауэре: через него идёт сам медиатрафик. Порт 5349 с TLS сертификатом обслуживает схему turns для сетей, где UDP запрещён целиком. TURN поверх TCP на плохой линии страдает от блокировки начала очереди: потеря одного пакета задерживает все следующие, поэтому UDP остаётся первым выбором, а TCP держат как запасной этаж.

Теперь арифметика, ради которой строка про TURN всегда всплывает на встрече с финансовым директором. Поток с видео 1.5 Мбит/с и звуком 40 кбит/с занимает около 1.54 Мбит/с в одну сторону. Ретранслятор принимает и отдаёт оба направления, то есть 3.1 Мбит/с на двусторонний звонок, и за час набегает порядка 1.4 гигабайта на одну пару. Конференция из четырёх абонентов через медиасервер SFU рассылает каждому по три потока, и исходящая полоса достигает 18 Мбит/с, около 8 гигабайт за час. Тысяча одновременно ретранслируемых двусторонних звонков нагружает сервер на 3.1 Гбит/с и приносит 1.4 терабайта трафика в час. Доля абонентов, которым нужен ретранслятор, из технической подробности превращается в главный множитель счёта за трафик.

Диагностика соединений в продакшене через getStats и внутренние инструменты браузера

Браузер честно отдаёт выбранную пару кандидатов:

const report = await pc.getStats();
let okPairs = 0;
let relayed = 0;
report.forEach((r) => {
  if (r.type === "candidate-pair" && r.state === "succeeded") {
    okPairs += 1;
    const local = report.get(r.localCandidateId);
    if (local && local.candidateType === "relay") relayed += 1;
  }
});
console.log("рабочих пар:", okPairs, "ретранслируемых:", relayed);

Значение candidateType relay у выбранной пары означает, что звонок едет через TURN. Поле currentRoundTripTime показывает добавленную задержку, packetsLost и jitter подсказывают, что проблема в полосе, а не в маршруте. Встроенная страница диагностики webrtc internals в браузерах Chrome и Edge сохраняет дамп сессии со всеми событиями: служба поддержки получает файл вместо пересказа на глазок и видит, на каком кандидате всё закончилось. Состояние pc.connectionState со значением failed фиксирует исчерпание всех пар и требует ICE рестарта через pc.restartIce(). В мониторинг выводят три цифры: процент ретранслируемых сеансов, медиану времени до первого кадра и долю звонков со статусом failed. Их собирают с первого дня пилота, а не после первого инцидента.

Планирование инфраструктуры для видеозвонков и честная смета ресурсов

Опыт команд сходится в нескольких правилах. TURN серверы ставят рядом с абонентами: ретрансляция удлиняет путь до сервера и обратно, и лишние 200 мс через океан убивают разговор. Ёмкость считают в портах и мегабитах, а не в процессорах: каждый ретранслируемый сеанс занимает порт из диапазона coturn, и пара средних машин бережнее одной большой как по стоимости, так и по отказоустойчивости. Сертификат для turns обновляют так же регулярно, как сертификат основного домена. Запас закладывают по пиковой доле ретрансляции, измеренной на пилоте: если хотя бы каждый десятый сеанс уходит в relay, то на тысячу одновременных звонков нужен ресурс на сотню.

Для конференций выбор между одноранговым соединением и медиасервером SFU определяет состав участников: инструменты с открытым кодом вроде mediasoup или Janus принимают потоки и рассылают копии, разгружая TURN от групповой нагрузки. Вопрос выбора технологии острее, чем кажется: WebRTC остаётся единственным массовым способом получить задержку меньше секунды в браузере без плагинов, и за тринадцать лет, прошедших с включения API в 23 версии Chrome в конце 2012 года, альтернативы в этой нише не появились. Классические HLS потоки масштабируются на миллионы зрителей, но их задержка в секунды делает живой диалог невозможным.

Протокол в виде рекомендаций W3C и набора RFC зрел и предсказуем, подводные камни живут не в стандарте, а в сетях абонентов. Настоящая работа начинается после того, как сработал учебный пример: команда пишет сигнализацию, держит STUN и TURN инфраструктуру, считает гигабайты ретрансляции и следит за долей неудачных соединений. Кто заложил ретрансляцию в смету с первого дня, встречает симметричный NAT оператора спокойно, с графиком нагрузки и запасом портов. Кто поверил простоте примера из документации, узнаёт про RFC 8656 из тикета поддержки, когда у заметной части абонентов видео так и не подключилось.