Содержание статьи +
- TL;DR
- Зачем это знать
- Где эта статья в общей картине
- Что такое ICE – за пределами учебного определения
- Четыре типа кандидатов – что каждый из них есть на самом деле
- Как сбор кандидатов разворачивается во времени
- Connectivity check – снова STUN, теперь peer-to-peer
- TURN в WebRTC – учётки, lifetime и channels, о которых вы не хотели знать
- ICE-серверы в `RTCPeerConnection` – конфигурация, которая важна
- ICE restart – что происходит при смене сети посреди звонка
- Consent freshness – как держать путь живым
- Сигнальный канал – что должно нести ваше приложение
- Рабочий пример – сайзинг TURN на 5 000 мест B2B-конференций
- Выбор TURN-сервера в 2026 – четыре продакшен-паттерна
- Восемь продакшен-ловушек и как их избежать
- Где здесь Фора Софт
- Ключевые выводы
- Что почитать дальше
Последняя проверка: 2026-05-25 по документам IETF RFC 8445 (ICE, июль 2018), RFC 8489 (STUN, февраль 2020), RFC 8656 (TURN, февраль 2020), RFC 8838 (Trickle ICE, январь 2021), RFC 8839 (атрибуты SDP для ICE, январь 2021), RFC 7675 (Consent Freshness, октябрь 2015), RFC 5780 (NAT Behaviour Discovery using STUN, май 2010), RFC 9429 (JSEP, апрель 2024), W3C WebRTC 1.0 Recommendation (март 2023), исходники Chromium libwebrtc 122+, Pion v3.x, mediasoup v3.14, LiveKit v1.7, документация Cloudflare Realtime TURN (апрель 2026), документация Twilio STUN/TURN (Q1 2026) и release notes coturn v4.8.0 (январь 2026).
TL;DR
ICE, STUN и TURN – это три протокола, которые позволяют WebRTC-звонку соединиться через публичный интернет. STUN сообщает устройству, какой адрес видит внешний мир; TURN пересылает медиа, когда прямой путь невозможен; ICE – это алгоритм, который собирает все возможные маршруты, гоняет их параллельно и оставляет тот, что работает. Глубинный взгляд, который важен в 2026 году: ICE – это не столько про NAT, сколько про конечный автомат установления соединения внутри RTCPeerConnection, который генерирует события icecandidate в сигнальный канал, переключает iceconnectionstate между состояниями checking → connected → disconnected → failed → closed и выживает при смене сети через ICE restart и пробы consent freshness. Продакшен-продукт ломается на ICE тремя предсказуемыми способами: недоинвестирует в TURN (тогда 15–20% сеансов потребителей и 100% сессий ИИ-голосовых агентов молча не подключаются), неправильно настраивает политику сбора кандидатов (тогда корпоративные пользователи за firewall'ом не могут попасть в звонок), и игнорирует события iceconnectionstate (тогда приложение не восстанавливается, когда Wi-Fi переключается на 5G). Эта статья проходит весь жизненный цикл: математика приоритета кандидатов из RFC 8445, тайминг trickle из RFC 8838, TURN-учётки из RFC 8656, четыре продакшен-паттерна и восемь ловушек, на которых продукты ломаются именно на тех сетях, что важны больше всего.
Зачем это знать
Любой современный продукт видеоконференций, телемедицинской консультации, браузерный просмотрщик видеонаблюдения, ИИ-голосовой агент и интерактивный live shopping зависят от того, что ICE работает корректно. Пользователь нажимает «присоединиться», браузер открывает камеру, сигналинг обменивается offer и answer – и дальше ничего не происходит. Экран бесконечно показывает «соединяемся…». Почти всегда причина – неверно настроенный список ICE-серверов, отсутствующий TURN-кандидат или переход iceconnectionstate, который код приложения не обработал. Цена ошибки проявляется двумя способами: пользователи в корпоративном Wi-Fi, университетских сетях и мобильных сетях с carrier-grade NAT уходят из продукта, потому что он не соединяется, а счёт за TURN-трафик у тех, кто всё-таки соединился, тихо доходит до шестизначных сумм в год на egress-метре облачного провайдера. Эта статья – для продакт-менеджера, прорабатывающего WebRTC-проект, основателя, выбирающего между Twilio и self-hosted coturn, инженера, который впервые читает RFC 8445, и архитектора, которому нужно знать точно, сколько TURN-регионов нужно глобальному продукту в 2026 году. Транспортный фон – что такое NAT, как STUN работает на байтовом уровне, четыре классические разновидности NAT – лежит в нашей сопровождающей статье про NAT, STUN, TURN, ICE для стриминга; эта статья уходит на уровень глубже: WebRTC-специфика сигналинга, JavaScript API и продакшен-паттерны развёртывания.
Где эта статья в общей картине
Полная картина состоит из трёх слоёв, и нужны все три. Первый – сетевая теория: что такое NAT, четыре классические разновидности NAT, STUN binding request, TURN allocate request. Это сопровождающая статья про NAT, STUN, TURN, ICE для стриминга. Второй – сигналинг: как SDP offer и answer переносят ICE-кандидатов и учётные данные. Это подробный разбор SDP offer/answer. Третий – тот, которым владеет эта статья – поведение, специфичное для WebRTC: как RTCPeerConnection управляет ICE, как JavaScript API выставляет наружу конечный автомат, как trickle ICE реализован на практике, как работает ICE restart при смене сети и какие конфигурации реально живут в продакшене 2026 года.
Если вы не читали ни одной сопровождающей статьи, эта всё равно самодостаточна. Она определяет каждый термин при первом упоминании. Сопровождающие статьи позволяют уйти глубже в транспортный слой или в SDP-слой, если нужно.
Что такое ICE – за пределами учебного определения
Учебное определение прямо из спецификации: «Interactive Connectivity Establishment – это техника обхода NAT для UDP-based медиапотоков, устанавливаемых моделью offer/answer» – RFC 8445, июль 2018. Это правильно, но опускает половину истории, важную на практике.
Полезнее так: ICE – это конечный автомат плюс конвейер сбора кандидатов плюс протокол проверки связности, всё это работает одновременно внутри WebRTC-стека. Алгоритм берёт вопрос «могут ли эти два peer достучаться друг до друга?» и отвечает на него за несколько сотен миллисекунд, хотя нижележащая сеть не приспособлена для этой цели. Каждый из компонентов решает одну задачу, а магия – в том, как они перекрываются во времени.
Конечный автомат – в браузере он называется iceconnectionstate у объекта RTCPeerConnection – проходит состояния new, checking, connected, completed, disconnected, failed и closed. Конвейер сбора кандидатов параллельно: для каждого сетевого интерфейса стек создаёт host-кандидата, отправляет STUN binding request, чтобы узнать server-reflexive кандидата, затем отправляет TURN allocate request, чтобы получить relay-кандидата. Каждый собранный кандидат вызывает событие icecandidate в вашем JavaScript, который пробрасывает его через сигнальный канал другому peer. Протокол проверки связности – STUN binding requests, на этот раз peer-to-peer, а не клиент-сервер – запускается, как только хотя бы одна пара кандидатов доступна с обеих сторон, и гоняет все возможные маршруты параллельно, пока один не сработает.
Ключевая мысль: ICE спроектирован под три ограничения. Первое – ни одна стратегия не работает на каждой сети: корпоративные firewall'ы блокируют UDP, мобильные операторы используют симметричный CGNAT, бытовые роутеры ведут себя непредсказуемо, IoT-устройства вообще не имеют публичного адреса – поэтому алгоритм обязан попробовать все правдоподобные пути. Второе – задержка установления соединения важнее любой отдельной оптимизации: звонок, соединившийся за 1,5 с, лучше звонка, соединившегося за 3 с, даже если второй нашёл лучший маршрут – поэтому алгоритм обязан совмещать сбор, сигналинг и проверку. Третье – сетевые условия меняются прямо во время звонка: телефон переключается с Wi-Fi на 5G, ноутбук меняет VPN, отельная сеть рубит UDP – поэтому алгоритм обязан поддерживать прозрачное пере-согласование.
Как только вы внутренне примете эти три ограничения, каждая деталь RFC 8445 перестанет казаться произвольным выбором и станет очевидным ответом на сложную задачу.
Четыре типа кандидатов – что каждый из них есть на самом деле
ICE собирает четыре типа кандидатов. Таксономия старше WebRTC – она появилась ещё в RFC 5245 в 2010 году – но любая реализация WebRTC в 2026 году использует её без изменений.
Host-кандидат – это реальный сетевой адрес устройства на одном из его интерфейсов. Ноутбук с включённым Wi-Fi и подключённым Ethernet производит два host-кандидата. Телефон с Wi-Fi и 5G – два. Адрес выглядит как 192.168.1.42 (приватный IPv4) или fe80::1a:b3 (link-local IPv6). Host-кандидаты собираются мгновенно – никаких сетевых round-trip не нужно – и пробуются первыми, потому что если пара host-host сработает, вы получаете самый дешёвый возможный путь: ноль хопов через локальную сеть.
Server-reflexive кандидат, сокращённо srflx, – это публичный адрес, который STUN-сервер увидел, когда binding request от устройства до него дошёл. STUN-обмен – один UDP round-trip: устройство шлёт 20-байтовый binding request на STUN-сервер, сервер копирует source address-and-port, который он увидел, в атрибут XOR-MAPPED-ADDRESS, и отвечает. Адрес, который узнаёт устройство, – это адрес, который видит внешний мир после того, как все промежуточные NAT'ы переписали пакет. Server-reflexive кандидаты собираются за 20–200 мс в зависимости от RTT до STUN-сервера.
Peer-reflexive кандидат, сокращённо prflx, – это публичный адрес, обнаруженный прямо во время ICE connectivity check, а не во время сбора. Механика: peer A шлёт STUN binding request peer'у B по паре кандидатов. NAT peer'а B переписывает source-адрес запроса – но создаваемое NAT'ом mapping не видел ни один STUN-сервер, потому что назначение – peer, а не STUN-сервер. Когда RTCPeerConnection peer'а B получает запрос, он замечает source-адрес и добавляет новый peer-reflexive кандидат в свой checklist. Peer-reflexive кандидаты – это то, как ICE обнаруживает mappings, создаваемые посреди звонка симметричными NAT'ами с per-destination портами. Их не нужно настраивать; они появляются как побочный эффект connectivity check.
Relay-кандидат, сокращённо relay, – это TURN-allocated transport address на TURN-сервере. Устройство шлёт TURN allocate request, сервер резервирует публичный address-and-port для эксклюзивного использования устройством, и устройство добавляет зарезервированный адрес как relay-кандидата. Relay-кандидаты – это запасной вариант последнего шанса, потому что каждый байт медиа должен пройти через TURN-сервер в обоих направлениях; задержка – двойной round-trip до TURN-сервера, а счёт за полосу платите вы.
Четыре типа кандидатов имеют разные приоритеты в алгоритме ICE. RFC 8445 §5.1.2.2 задаёт type preference – целое число от 0 до 126 – и рекомендация реализации: host 126, peer-reflexive 110, server-reflexive 100, relay 0. Ноль у relay – сознательное решение: гарантирует, что пары relay-relay лежат в самом низу приоритетного списка и используются только когда ничего больше не работает. Внутри каждого типа кандидаты дополнительно упорядочены по local preference (индекс интерфейса – проводной Ethernet обычно бьёт Wi-Fi, который бьёт сотовую сеть) и по component ID (компонент 1 RTP бьёт компонент 2 RTCP, когда они не мультиплексированы).
Полная формула приоритета из RFC 8445 §5.1.2.1:
priority = (2^24) × type_preference
+ (2^8) × local_preference
+ (2^0) × (256 - component_id)Host-кандидат на проводном Ethernet с local preference 65535 и component 1 получает приоритет (2^24 × 126) + (2^8 × 65535) + 255 = 2_130_706_431. Relay-кандидат на том же интерфейсе с теми же параметрами получает (2^24 × 0) + (2^8 × 65535) + 255 = 16_776_960. Отношение примерно 127:1 – ровно то, чего хочет алгоритм: host-кандидаты доминируют с огромным запасом, а relay-кандидаты соревнуются только друг с другом.
Как сбор кандидатов разворачивается во времени
В учебнике сбор кандидатов выглядит последовательным – сначала host, потом STUN, потом TURN. На практике браузер совмещает всё, что можно совместить, и тайминг важен, потому что пользователь смотрит на спиннер «соединяемся…».
В момент, когда JavaScript вызывает pc.createOffer() и затем pc.setLocalDescription(offer), браузер начинает сбор. Для каждого сетевого интерфейса, который сообщила ОС, браузер:
- Немедленно создаёт host-кандидата (round-trip не нужен) и генерирует событие icecandidate.
- Шлёт STUN binding request на каждый STUN-сервер из конфигурации iceServers. Каждый ответ, когда он приходит, становится server-reflexive кандидатом и генерирует ещё одно событие icecandidate.
- Шлёт TURN allocate request на каждый TURN-сервер из iceServers. Каждый успешный allocate становится relay-кандидатом.
Код приложения слушает эти события:
pc.addEventListener('icecandidate', (event) => {
if (event.candidate) {
// Отправляем этого одного кандидата другому peer через сигнальный канал.
signaling.send({ type: 'ice-candidate', candidate: event.candidate });
} else {
// event.candidate === null означает конец сбора.
// Большинство современного кода на это не опирается — Trickle ICE обрабатывает инкрементальные кандидаты.
}
});На принимающей стороне peer добавляет каждого кандидата по мере прихода:
signaling.on('ice-candidate', async ({ candidate }) => {
try {
await pc.addIceCandidate(candidate);
} catch (err) {
// Частая причина: кандидат пришёл раньше setRemoteDescription.
// Поставьте кандидата в очередь и проиграйте после setRemoteDescription.
}
});Перекрытие во времени выглядит так. В момент 0 оба peer создают offer'ы и начинают сбор. К моменту ≈ 5 мс host-кандидаты произведены и отправлены через сигналинг. К ≈ 50–200 мс приходят server-reflexive кандидаты от STUN. К ≈ 100–500 мс приходят relay-кандидаты от TURN. Connectivity checks стартуют в тот же момент, как только хотя бы одна пара известна – обычно первый host-кандидат с каждой стороны – и первая валидная пара (часто host-host или host-srflx) завершается за 300 мс. Спиннер «соединяемся…» уходит задолго до того, как TURN allocate'ы вообще закончат.
Это перекрытие – главная инженерная мысль Trickle ICE, спецификация в RFC 8838 (январь 2021). Без trickle браузер ждал бы каждого кандидата, прежде чем отправить хоть одного, добавляя 200–500 мс лишней задержки к каждому соединению. С trickle первая валидная пара выигрывает, как только появилась, а остальные кандидаты проверяются в фоне на случай, если найдут путь получше.
Текущее состояние Trickle ICE: все современные браузеры (Chrome 122+, Firefox 125+, Safari 17+) трикл по умолчанию. Все современные SFU-стэки (mediasoup, LiveKit, Janus, Pion, Jitsi) триклят по умолчанию. Нетрикл-ветки кода остались в основном для совместимости с SIP-шлюзами легаси-корпоративных PBX, которые старше этого RFC. Если вы пишете WebRTC-продукт в 2026 году, можно считать, что Trickle ICE работает, и писать простой код «шлём каждого кандидата по мере появления»; если интегрируетесь с SIP-шлюзом, проверьте документацию шлюза на поддержку a=ice-options:trickle и подготовьте fallback.
Connectivity check – снова STUN, теперь peer-to-peer
Пара кандидатов становится «валидной», когда одна сторона шлёт STUN binding request другой стороне и получает обратно соответствующий binding response с ожидаемого адреса. Формат STUN-сообщения тот же, что в варианте клиент-сервер – 20-байтовый header с magic cookie 0x2112A442 и transaction ID – но запрос идёт по transport-адресу пары кандидатов, а не на STUN-сервер.
Запрос несёт три важных атрибута. USERNAME – конкатенация ufrag удалённого peer'а и ufrag локального, разделённых двоеточием. Ufrag и пароль обмениваются в SDP offer/answer (a=ice-ufrag: и a=ice-pwd:) – полный контекст SDP смотрите в подробном разборе SDP offer/answer. MESSAGE-INTEGRITY – HMAC-SHA1 над всем сообщением, подписанный паролем удалённого peer'а. Так каждая сторона доказывает, что знает секрет из SDP, отсекая off-path injection атаки. PRIORITY – приоритет локального кандидата, отправляющего проверку.
Темп управляется таймером Ta, по умолчанию 50 мс согласно RFC 8445 §14.2. Каждые 50 мс агент выбирает из своего checklist пару с наивысшим приоритетом в состоянии «frozen» или «waiting» и шлёт binding request. Первый пришедший ответ помечает пару как «succeeded». Если у управляющего агента пара завершается с более высоким приоритетом, чем текущая selected pair, ICE заменяет selected pair, и трафик идёт по лучшему пути. Назначение controlling/controlled определяется STUN-атрибутами ICE-CONTROLLING и ICE-CONTROLLED; в WebRTC offerer – controlling, answerer – controlled, согласно RFC 9429 §3.5.5.
Проверка двунаправленная. Пара становится selected только когда обе стороны успешны на ней. Этот четырёхэтапный handshake важен, потому что асимметричным NAT'ом, ломающим обратное направление, может быть NAT любой из сторон.
Когда первая selected pair начинает нести медиа, iceconnectionstate переходит из checking в connected. Когда все пары в checklist завершены и больше проверок не ожидается, состояние переходит в completed. Большинство приложений обрабатывают connected и completed одинаково; разница имеет значение только для controlling агента, который может продолжать искать лучшие пары.
TURN в WebRTC – учётки, lifetime и channels, о которых вы не хотели знать
В WebRTC у TURN-протокола три эксплуатационные детали, на которых каждая команда спотыкается в первый раз.
Учётки короткоживущие и подписаны HMAC. Механизм «long-term credential» из RFC 8489 – статические username и password – теоретически допустим, но на практике ужасен: утёкшая учётка позволяет кому угодно использовать ваш TURN-кластер как бесплатный egress. Индустриальный стандарт WebRTC, принятый каждым крупным TURN-сервером – паттерн time-bounded credential: сервер приложения выдаёт клиенту username вида <unix-timestamp>:<user-id> и пароль base64(HMAC-SHA1(static-secret, username)). TURN-сервер хранит тот же static-secret, пересчитывает HMAC над присланным username и принимает клиента, если HMAC совпал. Username содержит timestamp истечения; TURN-сервер отбрасывает запросы с timestamp'ом в прошлом. Утёкшая учётка истекает за минуты.
Клиентский код, обращающийся к вашему credential-эндпоинту:
async function fetchIceServers() {
const response = await fetch('/api/turn-credentials');
const { iceServers } = await response.json();
return iceServers;
}
const pc = new RTCPeerConnection({
iceServers: await fetchIceServers(),
iceTransportPolicy: 'all', // или 'relay' — см. ниже
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require'
});Серверная функция выдачи учётки (Node.js, десять строк):
import crypto from 'crypto';
function mintTurnCredentials(userId, ttlSeconds = 600) {
const username = `${Math.floor(Date.now() / 1000) + ttlSeconds}:${userId}`;
const hmac = crypto.createHmac('sha1', process.env.TURN_STATIC_SECRET);
hmac.update(username);
const password = hmac.digest('base64');
return { username, password, ttl: ttlSeconds };
}TTL в 600 секунд – самый частый дефолт; некоторые команды ставят 24 часа для длинных встреч, но чем больше TTL, тем больше окно злоупотребления при утечке. Учётка авторизует allocate; после allocate TURN-сервер продлевает аллокацию по refresh-запросам клиента, и истечение учётки не прерывает уже идущий звонок.
Lifetime аллокации по умолчанию 600 секунд, клиент должен её продлевать. Когда TURN-сервер отвечает на Allocate, в ответе есть атрибут LIFETIME, по умолчанию 600 секунд (RFC 8656 §7.2). Клиент шлёт Refresh каждые LIFETIME / 2 секунд, чтобы аллокация жила. Браузеры делают это автоматически, кода писать не нужно. Но знать стоит: если TURN-сервер перегружен и теряет refresh-запросы, аллокация истекает и звонок молча падает. Мониторинг количества аллокаций и error rate refresh-запросов – первая метрика на дашборд.
Channels и permissions – оптимизация, о которой обычно не нужно думать. RFC 8656 описывает два способа отправки медиа через TURN. Первый – Send-indication, оборачивающая каждый медиа-пакет в STUN-сообщение со встроенным адресом peer'а – добавляет 36 байт оверхеда на пакет. Второй – channel: клиент один раз биндит канал на peer'а (ChannelBind), потом использует крошечный 4-байтовый ChannelData header для каждого следующего пакета. Channels экономят 32 байта на пакет – при 50 пакетах/с на стрим это 1,6 КБ/с на peer'а на направление, помноженные на тысячи параллельных relay-звонков. Все современные WebRTC TURN-клиенты по умолчанию используют channels. Браузер сам биндит. Знать стоит: когда смотрите пакетный capture и видите ChannelData вперемешку со STUN – это нормально.
ICE-серверы в `RTCPeerConnection` – конфигурация, которая важна
Объект конфигурации iceServers – самая важная единственная настройка в любом WebRTC-продукте. Ошибка – и соединения молча падают на тех сетях, что важны больше всего.
Минимально корректная конфигурация в 2026:
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: [
'turn:turn.example.com:3478?transport=udp',
'turn:turn.example.com:3478?transport=tcp',
'turns:turn.example.com:443?transport=tcp'
],
username: shortLivedUsername,
credential: shortLivedPassword
}
],
iceTransportPolicy: 'all',
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require'
});У каждой из этих настроек своя история. Один STUN никогда не достаточен – STUN-only конфигурация это причина номер один, почему демо работает локально и падает у первого пользователя с мобильного оператора. Всегда включайте TURN. Всегда выставляйте TURN на UDP/3478, TCP/3478 и TLS/443. Путь UDP – самый дешёвый. Путь TCP покрывает сети, блокирующие UDP. Путь TLS/443 покрывает сети, блокирующие всё, кроме HTTPS – большинство корпоративных firewall'ов, университетских сетей и агрессивных captive portals подпадают сюда. Браузер пробует все три в порядке приоритета и выбирает самый дешёвый, который работает.
iceTransportPolicy: 'all' говорит браузеру собирать все типы кандидатов. Альтернатива 'relay' ограничивает сбор только relay-кандидатами – браузер даже не пытается host или server-reflexive пути. Используйте 'relay' для приватных продуктов, где надо скрыть реальные IP пользователей друг от друга (de-anonymising chat, телемедицинский продукт с HIPAA-требованиями, которым нужно логировать каждое соединение через одну точку). Используйте 'all' везде ещё, потому что цена принудительного 100% relay равна цене 100%-relayed-развёртывания – в пять раз выше baseline 15–20%.
bundlePolicy: 'max-bundle' говорит браузеру мультиплексировать каждый track (audio, video, screen-share, data) на один ICE-уровневый transport. Без неё каждый track открывает свою ICE-сессию, браузер собирает кандидатов пять раз, соединение занимает в пять раз больше. С ней один transport несёт всё через расширение BUNDLE из RFC 9143. Всегда max-bundle.
rtcpMuxPolicy: 'require' мультиплексирует RTP и RTCP на один порт. Без него ICE поднимает два transport'а на track – один для медиа, один для контроля. С ним один. Всегда require.
В массиве iceServers может быть сколько угодно записей. Несколько STUN-серверов добавляют резервирование. Несколько TURN-регионов улучшают задержку для географически распределённой аудитории – TURN-сервер во Франкфурте не должен релеить звонок между двумя нью-йоркскими телефонами, если в Нью-Йорке есть свой TURN. Браузер соберёт кандидатов со всех серверов параллельно; формула приоритета поставит relay-кандидатов из региона с наименьшим RTT наверх relay-тира.
ICE restart – что происходит при смене сети посреди звонка
Пользователь подключается к видеозвонку по домашнему Wi-Fi. Посредине выходит из зоны, и телефон передаёт соединение на 5G. IP-адрес телефона меняется. NAT-mapping, что был на Wi-Fi, мёртв. Все существующие пары ICE-кандидатов невалидны. Без ICE restart звонок падает; с ним пере-согласование незаметно, и пользователь не замечает.
ICE restart инициируется controlling агентом – в WebRTC это offerer – генерирующим новый ufrag и пароль и выдающим свежий SDP offer с a=ice-ufrag: и a=ice-pwd:, отличающимися от предыдущего offer. Другой peer видит новые учётки в setRemoteDescription, собирает свежий набор кандидатов и запускает новый connectivity check. Текущая selected pair продолжает нести медиа, пока новая пара не станет валидной, и потом медиа переключается.
В коде приложения ICE restart запускается через { iceRestart: true } в createOffer:
async function restartIce() {
const offer = await pc.createOffer({ iceRestart: true });
await pc.setLocalDescription(offer);
signaling.send({ type: 'offer', sdp: pc.localDescription });
// Другой peer отвечает answer'ом; запускается новый сбор; выбирается новая пара.
}Когда код приложения запускает ICE restart? Три типичных случая. Событие смены сети. Браузер генерирует событие networkchange или connectionchange, когда ОС сообщает о up/down интерфейса. Код приложения слушает и проактивно зовёт restartIce(). Состояние ICE перешло в disconnected. iceconnectionstate идёт в disconnected, когда пробы consent freshness начинают падать; если состояние остаётся disconnected дольше 5 секунд, приложение делает restart. Состояние ICE перешло в failed. Это происходит, когда все пары в checklist провалились. На этом этапе ICE мёртв, и restart обязателен – без него звонок ушёл.
Современные браузеры (Chrome 122+, Firefox 125+, Safari 17+) реализуют более агрессивный вариант: «ICE restart при каждом re-negotiation» – любой offer/answer с новыми треками тоже рестартит ICE. Это консервативнее, чем требует спецификация, но упрощает ментальную модель приложения: каждое пере-согласование даёт свежее ICE-состояние.
Удобный метод RTCPeerConnection.restartIce() добавлен в W3C WebRTC Recommendation (март 2023) и поддержан всеми современными браузерами. Не требует явного createOffer({ iceRestart: true }); вызов restartIce() сразу генерирует negotiationneeded, и существующий поток re-negotiation в приложении делает остальное.
Consent freshness – как держать путь живым
Звонок подключился, пользователь начал говорить, медиа течёт. На двенадцатой минуте корпоративный firewall, за которым сидит пользователь, решает почистить NAT-mapping, по которому 30 секунд не было активности, и тихо его удаляет. Следующий медиа-пакет от удалённого peer'а тихо дропается. Пользователь не слышит ничего. Экран удалённого peer'а замирает. Без механизма обнаружения этого приложение не знает, что что-то пошло не так.
Этот механизм – consent freshness, спецификация в RFC 7675 (октябрь 2015). Каждые 15 секунд каждая сторона шлёт STUN binding request по текущей selected pair и ждёт binding response в течение 30 секунд. Ответ есть – consent свежий, пара жива. Если три подряд запроса вышли по таймауту, ICE объявляет пару мёртвой, iceconnectionstate идёт в disconnected, и логика restart в приложении вступает в дело.
Consent freshness – также механизм, обнаруживающий, что peer вышел, не вызвав close(). Если у peer A выдернули сетевой кабель, проба consent freshness peer'а B к A падает, B понимает, что A ушёл, и B чистит свою сессию.
Код приложения должен слушать iceconnectionstatechange и рассуждать о переходах:
pc.addEventListener('iceconnectionstatechange', () => {
switch (pc.iceConnectionState) {
case 'disconnected':
// Consent freshness падает. Может быть транзитный сбой — подождём несколько секунд.
scheduleIceRestartAfter(5000);
break;
case 'failed':
// Все пары провалились. Restart обязателен.
restartIce();
break;
case 'connected':
case 'completed':
// Восстановились. Отменяем pending restart.
cancelScheduledIceRestart();
break;
}
});Частая ловушка: приложения, которые трактуют disconnected как фатальное состояние и сразу рвут соединение. На самом деле состояние восстановимо примерно в 80% случаев – транзитная потеря пакетов, кратковременный беспроводной handover, двухсекундный сотовый dropout – и правильная реакция: подождать 5 секунд перед restart и 10 секунд перед тем, как объявить звонок потерянным.
Сигнальный канал – что должно нести ваше приложение
ICE не указывает протокол сигналинга. RFC 8445 полностью оставляет вопрос «как кандидаты доберутся до другого peer'а» на усмотрение приложения. WebRTC-продукты используют то, что у них уже есть – WebSocket к Node.js, gRPC-stream, HTTP long-poll, кастомный протокол поверх MQTT. Транспорт не важен; важно, чтобы каждый ICE-кандидат и каждый offer/answer надёжно и в порядке дошёл до другой стороны.
Минимальный протокол несёт пять типов сообщений:
// 1. Offer — начальный SDP от controlling peer
{ type: 'offer', from: 'alice', to: 'bob', sdp: '...' }
// 2. Answer — ответный SDP от controlled peer
{ type: 'answer', from: 'bob', to: 'alice', sdp: '...' }
// 3. ICE-кандидат — один из многих, trickled по мере сбора
{ type: 'ice-candidate', from: 'alice', to: 'bob', candidate: '...' }
// 4. End-of-candidates — сигнализирует завершение сбора
{ type: 'end-of-candidates', from: 'alice', to: 'bob' }
// 5. Hangup — peer закрывает сессию
{ type: 'hangup', from: 'alice', to: 'bob' }Единственная задача сигнального сервера – пересылать сообщения между peer'ами; он не интерпретирует SDP и не ICE-кандидатов. На практике каждый сигнальный сервер также делает аутентификацию, членство в комнатах, presence и собственные сообщения приложения – чат, реакции, поднятые руки – но это задачи приложения, не WebRTC.
Для стандартизированного WHIP/WHEP случая – когда одна сторона это медиасервер (например, live-энкодер, пушащий в ingest-сервер, или зритель, тянущий с SFU) – протокол сигналинга HTTP-based и стандартизован в RFC 9725 (WHIP, март 2025) и драфте WHEP. Смотрите наш подробный разбор WHIP для WHIP-специфичного паттерна сигналинга, включая обмен TURN-учётками поверх одного HTTP POST.
Рабочий пример – сайзинг TURN на 5 000 мест B2B-конференций
Команда прорабатывает инфраструктуру B2B-конференц-продукта с расчётным пиком 5 000 одновременных встреч, в среднем 4 участника на встречу, 1,5 Мбит/с видео плюс 50 кбит/с аудио на участника на направление. Relay-rate зависит от аудитории. Потребительские продукты дают 15–20%. B2B с тяжёлым корпоративным трафиком – 30–40%. Возьмём 35%.
Шаг 1: общее число участников и трафик на одного. 5 000 × 4 = 20 000 участников. Uplink на участника: 1,55 Мбит/с. Downlink на участника: 3 × 1,55 = 4,65 Мбит/с (в 4-человеческом SFU mesh каждый получает три потока).
Шаг 2: участники, чей медиа идёт через TURN. 20 000 × 35% = 7 000 relayed.
Шаг 3: полоса TURN на relay-уровне. На каждого relayed-участника TURN видит полный uplink + downlink. Uplink на TURN: 7 000 × 1,55 Мбит/с = 10,85 Гбит/с. Downlink на TURN: 7 000 × 4,65 Мбит/с = 32,55 Гбит/с. Итого на TURN: 43,4 Гбит/с sustained на пике.
Шаг 4: месячный egress. 43,4 Гбит/с × 86 400 с/день × 30 дней × 12,5% busy-hour-to-average ≈ 14 000 ТБ/мес = 14 ПБ/мес. (Множитель 12,5% – отношение busy-hour-throughput к 24×7-average для business-hours B2B. Потребительские с вечерними пиками – 20%. Always-on индустриальные – 100%.)
Шаг 5: стоимость у hyperscaler. AWS EC2 egress в 2026, средний тариф на этом масштабе после объёмных скидок ~$0,04/ГБ. 14 000 ТБ × $0,04/ГБ ≈ $560 000/мес.
Шаг 6: стоимость на bare-metal колокейшен с peering. Bare-metal transit на 95-м перцентиле ~$0,50–$1,00/Мбит/мес. 43,4 Гбит/с пик ≈ $20 000–$40 000/мес за транзит плюс стойка и амортизация железа.
Шаг 7: стоимость у managed TURN-провайдера. Cloudflare Realtime TURN по $0,05/ГБ (апрель 2026): 14 000 ТБ × $0,05/ГБ = $700 000/мес – близко к ценам hyperscaler, но с anycast в 330+ городов. Twilio с объёмным контрактом – в той же зоне.
Разрыв 15–20× между hyperscaler-egress и bare-metal колокейшеном – причина, по которой каждый WebRTC-продукт значимого масштаба в итоге уносит TURN с hyperscaler. Большинство команд проходят путь: прототип на одном coturn на AWS EC2 → растёт до многорегионального coturn-кластера → бенчмарк egress-биллинга → переход на managed-провайдера (Cloudflare, Twilio, Daily.co, Subspace) до момента, когда внутренняя инфраструктурная зрелость достаточна → в итоге bare-metal кластер в двух-трёх колокейшен-точках с приватными interconnect к региональным операторам.
Для ИИ-голосового агента, где 100% трафика идёт через TURN (одна сторона – сервер без публичного пути), та же арифметика даёт примерно 3× полосы на участника (только одно направление, но каждый байт) и примерно 3× стоимость. Именно эта форма развёртывания превратила TURN-полосу в экзистенциальную строку расходов AI-voice продуктов в 2025–2026.
Выбор TURN-сервера в 2026 – четыре продакшен-паттерна
Каждая WebRTC-команда рано или поздно сталкивается с одним решением по TURN: managed, self-hosted, hybrid или cloud-native. У четырёх паттернов разные кривые стоимости, разный операционный профиль и разный fit с зрелостью команды.
Паттерн 1: managed TURN-as-a-service. Twilio Network Traversal Service, Cloudflare Realtime TURN, Xirsys, Subspace, ExpressTURN. Платите за гигабайт relayed; провайдер управляет кластером. Floor цены в 2026 – $0,05/ГБ (Cloudflare). Лучше всего для: продуктов до 10K MAU, команд без выделенных инфраструктурных инженеров, продуктов, которым с первого дня нужно глобальное anycast-покрытие. Хуже всего для: продуктов, приближающихся к 1 ПБ/мес relayed-трафика, где маржинальная стоимость relay начинает доминировать в gross margin.
Паттерн 2: self-hosted coturn или eturnal. Традиционный дефолт. coturn – самый развёрнутый open source TURN-сервер в 2026 году, присутствует в каждом крупном SFU-стэке: LiveKit, mediasoup, Janus и большинство корпоративных видеоконференций. coturn v4.8.0 (январь 2026) включает усиленный DDoS handling, настраиваемые socket buffer sizes, патч CVE-2025-69217 и быструю валидацию пакетов. eturnal от ProcessOne – активно поддерживаемая альтернатива с более чистым конфигом и более отзывчивой командой. Оба работают на commodity Linux. Лучше всего для: команд минимум с одним инфраструктурным инженером, кто умеет в iptables, тюнинг Linux network stack и PagerDuty-ротации. Хуже всего для: команд, не желающих on-call нагрузки.
Паттерн 3: cloud-native Pion TURN на Kubernetes. Pion – Go-библиотека для TURN-клиентов и серверов; STUNner – Kubernetes-native развёртывание TURN-кластера за load balancer на основе Pion. Лучше всего для: команд, уже использующих Kubernetes для всего и желающих, чтобы TURN вписывался в ту же операционную модель. Go-runtime даёт меньший memory footprint, чем coturn на высоких allocation counts; Kubernetes-native развёртывание даёт autoscaling, которого у coturn нет нативно.
Паттерн 4: hybrid – managed для fallback, self-hosted для primary. Растущий паттерн 2026: разворачиваем self-hosted coturn или Pion как primary и конфигурируем iceServers так, чтобы managed-провайдер был третьей или четвёртой записью. Если primary-кластер не отвечает или регион недоступен, браузер падает на managed. Цена: вы платите провайдеру только за relayed-трафик, доставшийся ему – обычно меньше 1% от общего. Выгода: вы не пейджитесь, когда TURN одного региона упал.
Рамка решения для выбора между четырьмя:
| Критерий | Managed | Self-hosted coturn | Pion/STUNner на K8s | Hybrid |
|---|---|---|---|---|
| Время до первого звонка | 1 день | 1 неделя | 1–2 недели | 1–2 недели |
| Стоимость при 100 ТБ/мес | $$$$ | $ | $ | $ + малое $$ |
| Стоимость при 10 ПБ/мес | $$$$$ | $$ | $$ | $$ + малое $$ |
| Дежурная нагрузка | нет | высокая | средняя | низкая |
| Глобальный anycast | встроен | DIY | DIY | встроен |
| Лучше всего для | < 10K MAU | зрелая инфра-команда | Kubernetes-native | belt-and-braces |
Восемь продакшен-ловушек и как их избежать
Ловушка 1: STUN-only ICE-серверы. Самая частая ошибка конфигурации WebRTC. Продукт работает на разработческом Wi-Fi, молча падает на каждом мобильном операторе с симметричным CGNAT. Всегда включайте минимум один TURN в iceServers. STUN-only – это демо, а не продукт.
Ловушка 2: TURN только по UDP. Корпоративные firewall'ы, отельные сети и агрессивные captive portals режут UDP целиком. URL turn:host:3478?transport=udp не достигает ни одной из этих сетей. Всегда экспонируйте turn:host:3478?transport=tcp и turns:host:443?transport=tcp (TLS-over-TCP на 443) рядом с UDP-записью. Браузер сам выберет самый дешёвый, который работает.
Ловушка 3: долгоживущие TURN-учётки в клиентском JavaScript. Несколько туториалов до сих пор хардкодят username и credential в браузерном коде. Кто угодно с DevTools извлечёт их и будет использовать ваш TURN-кластер как бесплатный egress. Минтите короткоживущие HMAC-SHA1 учётки на сервере, возвращайте через аутентифицированный API-эндпоинт, обновляйте по мере того, как звонок выходит за их TTL.
Ловушка 4: игнорирование iceconnectionstatechange. Код приложения, который никогда не слушает disconnected или failed, не может восстановиться после сетевого сбоя. Пользователь вышел из зоны Wi-Fi, звонок умер, UI бесконечно показывает «connected», хотя медиа не течёт. Всегда обрабатывайте переходы.
Ловушка 5: рестарт ICE на каждый транзитный disconnect. Зеркало ловушки 4. Код, вызывающий restartIce() в момент перехода в disconnected, вызывает шторм renegotiation на каждом беспроводном hiccup'е. Подождите 5 секунд перед рестартом – около 80% disconnect'ов сами проходят.
Ловушка 6: один регион TURN. Один TURN-кластер в us-east-1 добавляет 150 мс one-way задержки каждому европейскому звонку. К тому моменту, когда relay-путь выбран (потому что прямые не работают для корпоративного пользователя за firewall'ом), суммарная задержка 300+ мс one-way – и качество деградировано на весь звонок. Разворачивайте TURN в каждом регионе, где живут пользователи. Три региона (US-East, EU-West, AP-Southeast) покрывают 95% потребительского глобального трафика; пять регионов (плюс US-West и AP-South) – 99%.
Ловушка 7: непланирование 100%-relay-сценария. Продукт, добавляющий ИИ-голосового агента или SIP-шлюз, внезапно начинает релеить 100% звонков – потому что одна сторона это сервер без публичного пути. Счёт за TURN утраивается за ночь. Просчитайте этот сценарий до добавления фичи; архитектурная диаграмма и cost-model должны явно показывать relay-percentage.
Ловушка 8: доверие iceTransportPolicy: 'relay' как гарантии анонимности. Установка iceTransportPolicy в 'relay' гонит каждый байт через TURN, скрывая ваш реальный IP от peer'а – но TURN-сервер видит оба IP, и application-сервер, выдающий TURN-учётки, обычно логирует mapping. Если нужна реальная сетевая анонимность (например, для deanonymising chat), iceTransportPolicy: 'relay' необходим, но недостаточен; нужно ещё контролировать, что логирует ваш TURN.
Где здесь Фора Софт
ICE, STUN и TURN сидят в ядре каждого WebRTC-проекта, который мы делаем – видеоконференции, телемедицина, e-learning, браузерные просмотрщики видеонаблюдения, ИИ-голосовые агенты и интерактивный live shopping все зависят от того, что этот слой корректно работает под враждебными реальными сетями. Мы держим coturn-кластеры в проде с 2014 года, разворачиваем Pion-based STUNner на Kubernetes для cloud-native заказчиков, интегрируем managed TURN-провайдеров (Twilio, Cloudflare Realtime, Xirsys) как fallback-пути командам, которым с первого дня нужно глобальное покрытие, и строим сервисы выдачи учётных данных, стоящие перед каждым продакшен TURN-кластером. Жёсткие уроки – когда рестартить ICE, как сайзить TURN-кластер под B2B vs потребительский продукт, как держать 100%-relay-сценарий для ИИ-голосового агента – приходят из проектов, не из RFC.
Ключевые выводы
- ICE – это конечный автомат плюс конвейер сбора кандидатов плюс протокол проверки связности, всё параллельно внутри RTCPeerConnection.
- Четыре типа кандидатов – host, server-reflexive, peer-reflexive, relay – имеют приоритеты RFC 8445 126, 100, 110, 0; host доминирует в соотношении 127:1.
- Trickle ICE (RFC 8838) сокращает time-to-connected с 2–3 секунд до менее 500 мс, совмещая сбор, сигналинг и проверку.
- TURN-учётки должны быть короткоживущими HMAC-SHA1 подписями, выдаваемыми на сервере; долгоживущие учётки в клиентском JS – безопасностный провал.
- Всегда включайте URL'ы turn:, turn:transport=tcp и turns:port=443 – браузер сам выберет самый дешёвый, который работает.
- Слушайте iceconnectionstatechange; рестартите ICE на failed, ждите 5 секунд перед рестартом на disconnected.
- TURN-полоса доминирует в строке расходов на масштабе – managed-сервисы ~$0,05/ГБ; bare-metal колокейшен снижает маржинальную стоимость в 15–20 раз.
Что почитать дальше
- SDP offer/answer подробно – протокол сигналинга, который несёт ICE-кандидатов и учётки.
- SFU vs MCU vs Mesh: три топологии WebRTC – как ICE ведёт себя по-разному в трёх WebRTC-топологиях.
- Безопасность WebRTC: DTLS, SRTP, fingerprints, identity – шифровальный слой, лежащий поверх ICE.