Привет, хабарчане! Это четвертая статья про разработку RUSEON-core, Zero-Copy сервера потокового видео для AI-платформ и Edge-видеоинфраструктуры. В предыдущих статьях (первая, вторая, третья), были разобраны моменты с оптимизацией раздачи потоков при больших одновременных нагрузках, а также узкие места ОС в контексте потоковой записи на диск и реализация оптимизации HLS-мультиплексирования on-demand. Сегодня поговорим о кейсе с WebRTC и WHEP.

FATAL: crypto/rand: blocked on getrandom() syscall или CPU 100% на 25-м клиенте

Броский заголовок есть, а теперь к сути. Сие ошибка, стандартный привет от дефолтного модуля pion/webrtc в Go. Воспроизводится просто – читаем обычные мануалы по использованию pion, выкатываешь красивый WHEP-хендлер, открываешь пару (ладно, ладно, не пару, просто красивый оборот) вкладок с плеером в браузере, и сервер начинает захлебываться. Хендшейки виснут по секундам, ICE отваливается по таймауту, а в pprof половина флеймграфа забита crypto/elliptic.p256OrdSqr и аллокациям мап внутри движка. Сюрприз? Да никакого сюрприза, если более вдумчиво почитать большинство «туториалов» по WebRTC на Go. Мне кажется, что они впринципе написаны теми, кто никогда не тестировал свою реализацию под нагрузкой даже полсотни одновременных зрителей – максимум пару-тройку потоков и успокоились на этом. Так вот, там на каждый POST-запрос с SDP-оффером создают новый webrtc.NewAPI(), регистрируют дефолтные кодеки, дергают api.NewPeerConnection() и со спокойной душой отдают ответ. На локалхосте, с парой клиентов это летает и работает без проблем. А вот на проде – превращается в катастрофу. Проблема здесь не в самом WebRTC (и уж тем более библиотеке pion`a, она очень крутая) и не в Go. Проблема в том, что глобальную и дорогую «инфраструктуру» создают на каждый(!) запросом, вместо того чтобы просто ее переиспользовать.

Углубимся в суть, и посмотрим, что на самом деле происходит

Небольшое отступление, далее я не буду четко расписывать каждый термин, если что-то непонятно, то лучше обратится к документации или написать в комментарии, иначе получится уж очень большой «длиннопост».

Первая дорогая операция: генерация криптографии для DTLS. Для этого генерируется ECDSA P-256 ключ, который впоследствии используется для создания DTLS-сертификата. Важно понимать, генерация ключа – это не простые вызовы нескольких конструкторов и запись пары байтов в память. Под капотом используется crypto/ecdsa, тяжелая математика на эллиптических кривых, а криптографически стойка случайность берется из crypto/rand.

Вторая дорогая операция: выделение в куче сотнями мелких структур для всех возможных кодеков, интерцепторов RTCP и RTP-заголовков, расширениями заголовков и прочими внутренними компонентами. Каждая такая аллокация – ни разу не дешевая.

Третья дорогая операция: под конец лезет в ОС и открывает пачку случайных UDP-портов для сбора ICE-кандидатов, выполнить биндинг. В привычной конфигурации – это означает выделение новых ресурсов для каждого PeerConnection.

То есть на каждое подключение схема примерно такая:

Новый зритель

    │

    ├── ECDSA P-256

    │   └── crypto/ecdsa + crypto/rand

    │

    ├── Создание и инициализация WebRTC-структур

    │   └── сотни мелких heap allocations

    │

    ├── Регистрация кодеков и interceptor'ов

    │

    ├── Создание сетевого транспорта

    │

    └── UDP sockets + ICE candidate gathering

Что в итоге – тридцать зрителей постучались одномоментно – цп ложится на генерации ключей, коннтрак таблица распухла как на дрожжах, а сборщик мусора встал в позу, пытаясь вычистить тонны мертвых объектов после каждого закрыто соединения.

Строить костыли тут нечего, архитектуру надо сразу строить по-человечески: всё, что можно переиспользовать — должно быть переиспользовано.

Какое решение было использовано

В ruseon-core, была вынесена вся тяжелая работа в изолированный сиглтон-движок internal/webrtc/engine.go. Генерация сертификата теперь использует ровно один вызов ecdsa.GenerateKey(elliptic.P256(), rand.Reader), при старте сервера. Браузеру глубоко плевать, создан сертификат секунды назад или он живет в озу уже месяцы – ему нужна только криптографическая валидность отпечатка в SDP-оффере, и ничего более. Реестр кодеков так же инициализируется при старте и больше не трогает кучу.

Так же обычно узким местом является сетевой уровень, но вместо открытия десятков случайных портов – мы привязали webrtc.NewICEUDPMux к единственному UDP-сокету. Один порт используется на всю систему. Ядро ОС мультиплексирует весь входящий STUN, DTLS и SRTP трафик через один дескриптор, а Pion уже в юзерспейсе раскидывает пакеты между ICE и WebRTC сессиями. Никакого расхода портов, никаких плясок с открытием диапазона 50000–60000 в файрволе.

Поверх этого так же натягивается пул движков через sync.Pool:

var WebRTCEnginePool = sync.Pool{

    New: func() interface{} {

        return newWebRTCEngine()

    },

}

Важно: sync.Pool здесь используется не как истинное хранилище заранее созданных объектов. Его задача — дать возможность переиспользовать временные объекты движка и снизить количество лишних аллокаций на горячем пути пайплайна. Если происходит очистка пула, New просто создаст новый экземпляр.

Когда в internal/api/handler.go, прилетает WHEP-запрос, то хендлер не создает ничего лишнего. Он забирает уже прогретый и готовый инстанс, скармливает ему также готовый сертификат через baseConfig.Certificates = []webrtc.Certificate{*e.certificate} и мнгновееной возвращает SDP-ответ. По сути время от запроса до ответа сокращается до пары системных вызовов (не считая конечно создание самого PeerConnection, DTLS/ICE-конфигурация и SDP negotiation).

Итог такой что теперь новый зритель не заставляет создавать по новой весь WebRTC-стек, он создает только собственное состояние соединения поверх уже готового движка.

Подводные камни при использовании H.264 кодека

Если тупо брать NALU-пакеты из кольцевого буффера, и просто скармливать их в поток – то видео у клиента будет постоянно фризить. В нашем случае, браузерному аппаратному декодеру нужен чистый Annex B с четкими разделителями 0x00 0x00 0x00 0x01, а на каждом ключевом кадре обязательно должны быть вшиты SPS и PPS параметры стрима. Одно из решений что было использовано, это в internal/webrtc/muxer.go аллоцировать уже нарезанный байтовый буффер на 100кб чтобы точно гарантированно исключить реаллокации слайсов при склейке заголовков на 30 фпс. Почему это вообще имеет значение? Потому что append() в горячем пути – страшная штука. Пока хватает емкости – все хорошо. Как только нет то - Go выделяет новый массив, тащит туда старые данные, а старый массив со временем улетает на ужин в сборщик мусора. При тех же 30 кадрах в секунду это может означать множество ненужных аллокаций на каждый поток. И неизвестно - когда накопленный мусор станет критическим для включения сборщика. Если же умножить это на количество потоков и зрителей – то исход думаю ясен. Вся суть сводится к тому что если размер рабочего буфера примерно известен и ограничен, то тогда мы выделяем его один раз и переиспользуем. Не позволяя расти динамически на каждом участке горячего пайплайна.

Цифры

Для тестов использовалась своя утилита для нагрузочного тестирования cmd/loadtest/main.go, где параллельно было запущено 50 синтетик камер, REST API, и по 30 HLS-клиентов и активных 30 WebRTC-зрителей:

[WebRTC WHEP] Sessions OK: 30 (err: 0) | RTP Packets: 488 214 | Egress: 9.04 MB/s

Handshake Latency: p50=3.21ms | p95=6.84ms | max=11.20ms

HeapAlloc: 34MB | GC Pause Total: 3.82ms | Goroutines: 142

Почти полмиллиона пакетов, честные 9 МБ/с отдачи и ноль ошибок согласования. Задержка хендшейка упала с 800+ мс до 3 миллисекунд.

Конечная мысль этой статьи – если WebRTC не «тянет» реальное видео, то не стоит доверяться туториалам. Тем более если в этих туториалах решением подобных проблем предлагается тянуть тяжелый CGO с биндигами к плюсам. Как показывает наша практика, узким местом не является райнтайм, а бездумная криптографий на каждый чих и дергание аллокатора на горячей части пути. WebRTC – не медленный, медленная архитектура вокруг него.

Исходники: https://github.com/RUSEGAL/ruseon-core