Строим CDN для медиа-трафика или экономим трафик при помощи WebRTC P2P mesh

«Трафик! Этот чертов трафик влетит нам в копеечку!»
Томми, основатель онлайн-радио-платформы.

Массовая трансляция видео и аудио генерирует огромное количество трафика. А трафик это деньги. Иногда — большие деньги. И тогда бизнес начинает задумываться — как бы сделать так, чтобы эти деньги не платить. И желания бизнеса — плодотворная почва для интересных решений. Об одном из них мы и поговорим.

В чем суть?

Очевидная особенность медиа-трафика в WebRTC — он может вполне легально отправляться клиентом. С той же статикой в HLS такой фокус не проходит. Так почему бы не использовать эту возможность и передавать трафик не от сервера к клиенту, а от клиента к клиенту — как в пиринговой сети, используя WebRTC p2p соединения в качестве транспорта (WebRTC p2p mesh). Первые Х клиентов получают медиа от сервера и раздают следующим Y клиентов, а те отдают следующим и так далее.

Рис 1. Базовая упрощенная схема

Рис 1. Базовая упрощенная схема

Таким образом мы получаем связный граф. При этом мы хотим, чтобы степень корня графа (то бишь количество клиентов, получающих медиа непосредственно от сервера), было минимально разумным, однако без ущерба стабильности системы.

В борьбе за стабильность

Основная сложность здесь — нестабильность графа или, перефразируя Булгакова, проблема не в том, что пользователи отключаются, а в том, что они отключаются внезапно. Любой элемент графа, любой набор элементов, может в произвольный момент выйти вон. И повлиять на это нельзя. Единственное разумное решение — дублирование соединений. Пользователь А, подключаясь к сети, устанавливает P2P-соединения с несколькими пользователями, каждый из которых готов отдавать трафик (о том, как определять готовность поговорим ниже). Но реально получает трафик он только от одного — одно соединение активно и Х соединений пассивны. В случае обрыва активного соединения, клиент не тратит время на подключение к другому пользователю, а лишь активизирует (о методике управления процессом поговорим ниже) одно из пассивных соединений. В итоге перерыв в медиа-трафике будет минимальным. При обрыве пассивного соединения клиенту стоит установить новое.

Впрочем, и от этого перерыва можно избавиться. У клиента может быть не одно, а несколько активных соединений, причем только одно будет воспроизводиться, остальные будут «играть в пустоту». При этом клиент может контролировать количество пришедших пакетов (хотя частый запрос WebRTC Stats и не рекомендуется) и переключаться между активными соединениям при первых признаках потери связи. Негативным эффектом данного подхода будет рост паразитного трафика между клиентами.

Однако и проблему избыточного трафика можно отчасти решить — для этого можно разделить активные соединения на основные и резервные, безжалостно зарезая качество (например, используя WebCodecs API) у резервных — таким образом, при отключении источника, у клиента будет незначительный (зависит только от частоты проверки соединения) перерыв и после него — короткий период не самого хорошего качества — пока отправитель не переведет соединение в разряд основных. 

Рис 2. Виды активных и пассивных соединений

Рис 2. Виды активных и пассивных соединений

Как не перегрузить узел и определить, способен ли он отдавать трафик

В WebRTC каждый исходящий медиа-поток кодируется отдельно. Это и благо и проклятье, причем, в нашем случае, скорее второе. Ресурсы клиента ограничены и, что самое плохое, у нас нет четкого механизма их оценки. Поэтому единственное что мы можем использовать — косвенные признаки из WebRTC Stats.

На что стоит обратить внимание:

  • Качество входящего канала: поскольку пользователь в любом случае получает медиа, мы можем отталкиваться от метрик этого соединения. https://www.w3.org/TR/webrtc-stats/#inboundrtpstats-dict*
    Какие именно метрики использовать и какие веса им задавать — тема для отдельной статьи, однако базово не стоит доверять клиентам, у которых большое количество потерянных пакетов, большой Jitter buffer и большое же количество отправленных NACK. Не стоит забывать, что соединение — палка о двух концах и проблема может быть и на стороне отправителя, но, в любом случае, лучше не предлагать передавать медиа, если сами его получаем ненадежно.

  • Не стоит использовать соединение, если оба клиента используют симметричный NAT — в этом случае трафик пойдет через TURN-сервер. Лучше вообще не использовать TURN-сервер в данной архитектуре. Отсутствие TURN как раз и даст нам нужный механизм контроля — если клиенты не могут соединиться напрямую, то и не нужно.

  • Следует настороженно относиться к клиентам, подключенным к сотовой сети — помимо плавающей скорости также стоит иметь в виду, что у всех мобильных операторов симметричный NAT. Также мобильный трафик может быть платным, что важно для клиента. Определить тип подключения можно также исходя из WebRTC stats.

Данных критериев достаточно чтобы понять — способен ли клиент отдать хотя бы один исходящий поток. И на базе исходящего потока мы можем сделать гораздо более точную оценку. Здесь стоит упомянуть следующие критерии из WebRTC Stats:

  • qualityLimitationReason и qualityLimitationDurations — если мы видим, что качество исходящего медиа ограничено из-за использования процессора или памяти, значит данному узлу уже достаточно получателей, а лучше одного переключить куда-то еще. Если же ограничение вызвано каналом — нужно проверять во всех исходящие соединения, возможно, проблема на стороне получателя.

  • retransmittedPacketsSent, nackCount и roundTripTime нужно оценивать в среднем по всем исходящим соединениям, дабы уменьшить вероятность ложного срабатывания из-за проблем на стороне получателя. 

Также весьма и весьма полезно проводить небольшой тест сразу после подключения — клиент начинает получать от сервера медиа и отправлять ему же в несколько потоков, до достижения некоего лимита подключений или возникновения ограничений из списка выше. Это позволит понять, чего мы можем ждать от клиента в самом начале сессии.

Ранжирование клиентов

Не все клиенты одинаково полезны системе и не лишним будет ввести некую систему ранжирования. Клиенты, способные раздавать больше соединений логично должны подключаться непосредственно к медиа-серверу, будучи на первой линии. Те же кто не может много раздавать, подключен к мобильному Интернету или просто нам не нравится — определяется в аутсайдеры и помещается на нижние ветви дерева, дабы свести ущерб от его возможной нестабильности к минимуму. Как правило, четырех уровней вполне достаточно — «готов в первую линию», «симметричный NAT, готов раздавать тем, кто готов принять», «здесь что-то не так, но попробуем» и «никаких раздач». Критерии, по которым клиент попадает в нужную группу сильно зависят от задачи — нужно ли нам обеспечить максимальное качество, либо максимальное количество.

Управление деревом

На первый взгляд может показаться правильным завести все управление в Data Channel между клиентами, однако здесь есть подводный камень — сигнальный сервер должен знать все о всех соединениях, дабы корректно подключать новых клиентов в граф. Поэтому все управление графом должно идти из сигнального сервера. И да, он при этом должен быть чертовски быстрым.

Отказоустойчивость и масштабируемость

Чтобы система была стабильна, необходимо использовать несколько медиа-серверов. В этом случае все клиенты первого эшелона устанавливают активные соединения со всеми медиа-серверами, используя ту же логику, что и между клиентами.

Рис 3. Использование нескольких медиа-серверов

Рис 3. Использование нескольких медиа-серверов

Задержка для клиента

Поскольку медиа будет странствовать по графу, у него будет накапливаться задержка и, в зависимости от размеров графа, она может достигать нескольких секунд. Что кратно лучше HLS, для которого приемлемые результаты начинаются от десятков секунд. Для уменьшения задержки нужно увеличивать количество зрителей на узел, что приведет у ухудшению качества / стабильности. Другим способом уменьшения задержки является выбор оптимального узла на стороне получателя — не просто «дай мне свободный источник», но «дай мне свободный источник с наименьшей задержкой», саму же задержку можно достаточно точно определять по roundTripTime.

Так сколько же клиентов будет получать медиа от сервера?

Здесь необходимо искать баланс между надежностью и ресурсами. Удобно устанавливать пороговые значения — условно, первые Х клиентов подключаются только к медиа-серверу (серверам). Все следующие клиенты подключаются к уже существующим клиентам, а, если никто не готов отдавать медиа — к медиа-серверу. Причем, клиентов желательно менять местами, чтобы более «надежные» были в первом ряду. Такой подход позволит автоматически балансировать топологию сети, предоставляя видео от других клиентов, а, при невозможности — от медиа-сервера. 

А как же HLS?

При всей элегантности HLS на момент его создания (а идея была прекрасна — загоняем потоковое медиа в классический HTTP), при всей простоте развертывания на огромные аудитории, его родовые болезни, увы, неистребимы — достаточно большая и, к тому же, произвольная задержка при воспроизведении, отсутствие современных кодеков, отсутствие обратной связи и возможности отправлять трафик. Своя ниша у HLS есть и он будет еще использоваться, но время его уходит, уступая гораздо более сложным, но более эффективным решениям.

Немного вежливости

Поскольку данный подход беспардонно использует ресурсы пользователей, было бы крайне невежливо делать это в тайне — минимально стоит информировать пользователя. Также хорошим тоном является предоставление возможности отключить участие в пиринговой сети, если пользователю по какой-то причине это неудобно или нежелательно. 

Подводя итог

Описанная выше модель, при кажущейся на первый взгляд сложности, способна значимо снизить расходы на трафик при массовом стриминге. Это не серебряная пуля, способная по щелчку пальцев решить все проблемы, но вполне рабочий механизм, существенно сокращающий расходы. Да и потом, это просто инженерно красиво.

© Habrahabr.ru