Несколько недель назад мы выкатили статью про Докер и WebRTC сервер и рассказали в ней о нюансах запуска. Читатели справедливо усомнились в пригодности докера для продакшена по следующим причинам:
Docker не оптимально занимает и синхронизирует ресурсы CPU между контейнерами из-за чего могут сдвигаться тайминги RTP потока.
Для нескольких контейнеров придется мапить порты каждого контейнера на порты хоста. Таким образом получится NAT за NATом, и это не всегда удобно и корректно работает.
Во время тестов для прошлой статьи нам не пришлось столкнуться с обозначенными трудностями. Но если проблемы обозначены, значит кто-то уже наступал на эти грабли. Мы решили проверить это все на практике и найти пути решения этих проблем.
Делим сеть
Перед тем, как создавать контейнеры нужно настроить Docker сеть. Поэтому мы начнем с решения второй задачи --- с настройки сети для Docker.
Для статьи мы использовали "белые" публичные IP адреса. В случае, если у вас контейнеры все же будут за NATом, то работа несколько усложнится из-за настройки правил проброса портов для контейнеров на вашем пограничном шлюзе.
При использовании драйвера ipvlan каждый контейнер — это полноценный участник сети, поэтому можно создавать правила проброса портов сквозь NAT, точно так же, как если бы WCS был развернут на реальном железе. Поэтому обратите внимание --- никакого NATa для контейнеров за основным NATом не создается.
У своего провайдера мы запросили сеть из 8 белых Elastic IP
1 адрес - адрес сети: 147.75.76.160/29
2 адрес - адрес основного шлюза: 147.75.76.161
3 адрес - адрес хоста: 147.75.76.162
4 адрес диапазона для Docker 147.75.76.163/30
5 адрес первого контейнера 147.75.76.164
6 адрес второго контейнера 147.75.76.165
7 и 8 - резервные адреса.
Создаем Docker сеть с именем new-testnet на основе драйвера ipvlan
docker network create -d ipvlan -o parent=enp0s3 \
--subnet 147.75.76.160/29 \
--gateway 147.75.76.161 \
--ip-range 147.75.76.163/30 \
new-testnet
где:
ipvlan — тип сетевого драйвера;
parent=enp0s3 — физический сетевой интерфейс (enp0s3), через который будет идти трафик контейнеров;
--subnet — подсеть;
--gateway — шлюз по умолчанию для подсети;
--ip-range — диапазон адресов в подсети, которые Docker может присваивать контейнерам.
Готовим нагрузку для теста
Нагрузку на контейнеры будем подавать при помощи нагрузочного теста WebRTC. Подготовим настройки для теста:
На хосте в каталоге
/opt/wcs/conf/
Создаем файлы flashphoner.properties и wcs-core.properties такого содержания:
Файл flashphoner.properties:
#server ip
ip =
ip_local =
#webrtc ports range
media_port_from =31001
media_port_to =40000
#codecs
codecs =opus,alaw,ulaw,g729,speex16,g722,mpeg4-generic,telephone-event,h264,vp8,flv,mpv
codecs_exclude_sip =mpeg4-generic,flv,mpv
codecs_exclude_streaming =flv,telephone-event
codecs_exclude_sip_rtmp =opus,g729,g722,mpeg4-generic,vp8,mpv
#websocket ports
ws.port =8080
wss.port =8443
wcs_activity_timer_timeout=86400000
wcs_agent_port_from=44001
wcs_agent_port_to=55000
global_bandwidth_check_enabled=true
zgc_log_parser_enable=true
zgc_log_time_format=yyyy-MM-dd'T'HH:mm:ss.SSSZ
Этот файл заменит оригинальный flashphoner.properties при запуске контейнера. Переменные ip и ip_local будут заполнены значениями, указанными при создании контейнера в переменных EXTERNAL_IP и LOCAL_IP соответственно.
Из специальных настроек здесь мы расширяем диапазон портов:
media_port_from=31001
media_port_to=40000
wcs_agent_port_from=44001
wcs_agent_port_to=55000
увеличиваем время на прохождение теста:
wcs_activity_timer_timeout=86400000
и включаем вывод на страницу статистики данных о скорости сетевого адаптера и работе ZGC:
zgc_log_parser_enable=true
zgc_log_time_format=yyyy-MM-dd'T'HH:mm:ss.SSSZ
В файле wcs-core.properties настраиваем использование ZGC и указываем размер хипа:
### SERVER OPTIONS ###
# Set this property to false to disable session debug
-DsessionDebugEnabled=false
# Disable SSLv3
-Djdk.tls.client.protocols="TLSv1,TLSv1.1,TLSv1.2"
### JVM OPTIONS ###
-Xmx16g
-Xms16g
#-Xcheck:jni
# Can be a better GC setting to avoid long pauses
# Uncomment to fix multicast crosstalk problem when streams share multicast port
-Djava.net.preferIPv4Stack=true
# Default monitoring port is 50999. Make sure the port is closed on firewall. Use ssh tunel for the monitoring.
-Dcom.sun.management.jmxremote=true
-Dcom.sun.management.jmxremote.local.only=false
-Dcom.sun.management.jmxremote.ssl=false
-Dcom.sun.management.jmxremote.authenticate=false
-Dcom.sun.management.jmxremote.port=50999
-Dcom.sun.management.jmxremote.host=localhost
-Djava.rmi.server.hostname=localhost
#-XX:ErrorFile=/usr/local/FlashphonerWebCallServer/logs/error%p.log
-Xlog:gc*:/usr/local/FlashphonerWebCallServer/logs/gc-core-:time
# ZGC
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC
# Use System.gc() concurrently in CMS
-XX:+ExplicitGCInvokesConcurrent
# Disable System.gc() for RMI, for 10000 hours
-Dsun.rmi.dgc.client.gcInterval=36000000000
-Dsun.rmi.dgc.server.gcInterval=36000000000
Этот файл так же заменит собой файл wcs-core.properties, который находится в контейнере по умолчанию.
Обычно мы рекомендуем выставить размер хипа равным 50% объема доступной оперативной памяти. Но в этом варианте запуск двух контейнеров займет всю оперативную память сервера и может привести к нестабильной работе. Поэтому поступим иначе. Выделим контейнерам по 25% доступной оперативной памяти:
### JVM OPTIONS ###
-Xmx16g
-Xms16g
Теперь, когда настройки готовы, можно запускать контейнеры. (Примеры файлов wcs-core.properties и flashphoner.properties можно скачать в разделе "Полезные файлы").
Делим ресурсы и запускаем контейнеры
Теперь возвращаемся к решению первой задачи - Проблемы распределения ресурсов между контейнерами и смещения таймингов.
Действительно, при разделении ресурсов CPU между контейнерами через cgroups, софт внутри контейнера может выбирать квоту, установленную планировщиком, в результате чего возникает Jitter (нежелательные отклонения передаваемого сигнала), который негативно влияет на воспроизведение RTP потока.
Проблема с разделением CPU и появлением Jitter существует не только для Docker, но и для "классических" виртуалок на гипервизорах, и для железных машин поэтому назвать ее специфической именно для Docker нельзя. В случае WebRTC Jitter гасится адаптивным jitter-буфером, который работает на клиенте достаточно в широком диапазоне (до 1000 мс).
Проблема с таймингами в нашем случае не актуальна, т.к. RTP поток у нас не привязан к времени сервера. RTP пакеты не обязательно идут четко по времени энкодера, т.к. сеть не бывает идеальной. Это все нормально отрабатывается буферами в WebRTC. Опять же, не важно в Docker или нет.
Для того, чтобы минимизировать борьбу за ресурсы между контейнерами мы принудительно укажем какие процессорные ядра выделены контейнеру для работы. В этом случае контейнеры не конфликтуют, нет квот на выделение процессорного времени. Это можно сделать при создании контейнера с помощью ключа:
--cpuset-cpus=
В качестве значения можно указать список ядер, разделенный запятыми, или диапазон ядер через дефис. Первое ядро обознается как "0".
Запускаем первый контейнер:
docker run --cpuset-cpus=0-15 \
-v /opt/wcs/conf:/conf \
-e PASSWORD=123Qwe \
-e LICENSE=xxxx-xxxx-xxxx-xxxx-xxxx \
-e LOCAL_IP=147.75.76.164 \
-e EXTERNAL_IP=147.75.76.164 \
--net new-testnet \
--ip 147.75.76.164 \
--name wcs-docker-test-1 \
-d flashphoner/webcallserver:latest
ключи здесь:
--cpuset-cpus=0-15 - указываем, что для работы контейнер должен использовать ядра хоста с 0 по 15;
-v /opt/wcs/conf:/conf - монтируем директорию с файлами настройки к контейнеру;
PASSWORD — пароль на доступ внутрь контейнера по SSH. Если эта переменная не определена, попасть внутрь контейнера по SSH не удастся;
LICENSE — номер лицензии WCS. Если эта переменная не определена, лицензия может быть активирована через веб-интерфейс;
LOCAL_IP — IP адрес контейнера в сети докера, который будет записан в параметр ip_local в файле настроек flashphoner.properties;
EXTERNAL_IP — IP адрес внешнего сетевого интерфейса. Записывается в параметр ip в файле настроек flashphoner.properties;
в ключе --net указывается сеть, в которой будет работать запускаемый контейнер. Запускаем контейнер в сети testnet;
--ip 147.75.76.164 - адрес контейнера в сети докера;
--name wcs-docker-test-1 - имя контейнера;
-d flashphoner/webcallserver:latest - образ на основе которого будет развернут контейнер
Практически аналогичной командой запускаем второй контейнер:
docker run --cpuset-cpus=15-31 \
-v /opt/wcs/conf:/conf \
-e PASSWORD=123Qwe \
-e LICENSE=xxxx-xxxx-xxxx-xxxx-xxxx \
-e LOCAL_IP=147.75.76.165 \
-e EXTERNAL_IP=147.75.76.165 \
--net new-testnet \
--ip 147.75.76.165 \
--name wcs-docker-test-2 \
-d flashphoner/webcallserver:latest
Здесь мы указываем другой диапазон процессорных ядер и другой IP адрес для контейнера. Также можно указать другой пароль для ssh контейнера, но не принципиально.
Проверяем и оцениваем
В веб-интерфейсе первого контейнера запускаем консоль для проведения WebRTC теста с захватом потоков http://147.75.76.164:9091/client2/examples/demo/streaming/console/console.html:
В веб-интерфейсе второго контейнера запускаем пример «Two-way Streaming»:
Затем запускаем нагрузочный тест:
Метрики работы контейнеров оцениваем при помощи графиков от системы мониторинга Prometheus + Grafana. Для получения данных о загрузке CPU мы установили Prometheus Node Exporter на хост. Информация о загруженности контейнеров и состоянии стримов собирается со страниц статистики WCS серверов в контейнерах:
http://147.75.76.164:8081/?action=stat
http://147.75.76.165:8081/?action=stat
Панель для Grafana можно скачать в разделе полезные файлы.
Результат теста с разделением контейнеров по ядрам:
Как видно, нагрузка на контейнер, который был тестирующим и забирал потоки была несколько выше (единичные пики до 20-25 единиц) чем на тестируемый контейнер, который потоки отдавал. При этом нет деградировавших стримов и контрольный поток (скриншот ниже) воспроизводился с приемлемым качеством - без артефактов и приостановок звука, поэтому можно говорить, что контейнеры с нагрузкой справились.
Если посмотреть вывод утилиты htop на хосте во время тестирования, то видно, что 32 ядра, выделенные контейнерам, работают, а оставшиеся ядра не задействованы:
Теперь перезапустим контейнеры без настроек распределения по ядрам.
Первый контейнер:
docker run \
-v /opt/wcs/conf:/conf \
-e PASSWORD=123Qwe \
-e LICENSE=xxxx-xxxx-xxxx-xxxx-xxxx \
-e LOCAL_IP=147.75.76.164 \
-e EXTERNAL_IP=147.75.76.164 \
--net new-testnet \
--ip 147.75.76.164 \
--name wcs-docker-test-1 \
-d flashphoner/webcallserver:latest
Второй контейнер:
docker run \
-v /opt/wcs/conf:/conf \
-e PASSWORD=123Qwe \
-e LICENSE=xxxx-xxxx-xxxx-xxxx-xxxx \
-e LOCAL_IP=147.75.76.165 \
-e EXTERNAL_IP=147.75.76.165 \
--net new-testnet \
--ip 147.75.76.165 \
--name wcs-docker-test-2 \
-d flashphoner/webcallserver:latest
Снова запустим нагрузочный тест с теми же условиями и смотрим графики результата:
В этом случае, контейнеры получились "мощнее" - ведь мы не ограничивали ядра принудительно и каждый контейнер мог использовать все 40 ядер хоста. Поэтому в тесте получилось захватить больше потоков, чем в прошлом, но ближе к окончанию теста деградировал 1% стримов. Поэтому можно сделать вывод, что при распределении по ядрам контейнеры работают стабильнее.
Итак, по результатам проведенных тестов мы убедились, что контейнеры можно настроить так, чтобы они был полноценными участниками как локальной, так и глобальной сети. И смогли настроить распределение ресурсов хоста между контейнерами, что бы они не мешали работе друг друга. Напоминаем, что количественные результаты ваших тестов могут отличаться, это связано с конкретной задачей, техническими характеристиками системы и ее окружением.
Хорошего стриминга!
Полезные файлы
Ссылки
Документация по развертыванию WCS в Docker
10 важных метрик WebRTC стриминга и настройка мониторинга Prometheus +Grafana