Files
vidconf/docs/deploy/capacity.md
Max Ronzhin 8757bec8ac
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
first commit
2026-07-23 02:38:05 +03:00

22 KiB
Raw Blame History

Нагрузочное тестирование SFU (LiveKit): методика и ёмкость

Оценивает, сколько одновременных издателей аудио+видео и подписчиков выдерживает LiveKit SFU в текущей конфигурации compose (deploy/livekit/livekit.yaml), и даёт формулу прикидки ёмкости для прод-железа из таблицы пресетов ADR-004 (docs/architecture/adr/004-ai-tier-matrix.md).

ВАЖНО: окружение — это dev-Mac, не прод-референс

Все цифры ниже получены на разработческом macOS-хосте (Apple Silicon, 10 физических ядер CPU, 16 ГБ RAM), где Docker Desktop выделяет под контейнеры лёгкую VM: 10 vCPU / 7.75 ГБ RAM (docker info). Это НЕ прод-железо и НЕ Linux-хост — абсолютные цифры (число участников, момент деградации) нельзя переносить на прод напрямую. Причины:

  1. Docker Desktop на macOS обрабатывает часть сетевого стека (в первую очередь высокочастотный UDP-трафик WebRTC) вне cgroup контейнера — в собственном сетевом прокси VM (vpnkit/gvisor-tap-vsock). Эта нагрузка не видна в docker stats (CPU% измеряется только внутри контейнера livekit), но реально потребляет ресурсы хоста. На Linux (прод, bare-metal или облачная VM с host-networking) этого прокси-слоя нет — CPU-профиль SFU там будет заметно легче при той же нагрузке.
  2. На хосте параллельно во время части прогонов выполнялась одна и та же Docker VM с другими контейнерами разработческого стека (LLM-сервер llm, Celery worker) — при их одновременной активности они отъедали до 49 vCPU из тех же 10 (см. раздел «Артефакт: конкуренция за CPU» — первый прогон 20×20 был контаминирован, повторён после docker stop llm worker).
  3. Диапазон UDP-портов SFU в dev-compose узкий: 54000-54100 (101 порт, deploy/docker-compose.yml, комментарий про конфликты с занятыми портами хоста на macOS) — на проде обычно шире.

Вывод: используйте этот документ для МЕТОДИКИ и ОТНОСИТЕЛЬНОЙ формы кривой деградации (как растут CPU/потери с числом участников), а не как источник абсолютного «сколько человек выдержит прод-сервер». Перед реальным релизом — обязательно повторить те же ступени на целевом Linux-хосте (см. docs/architecture/adr/004-ai-tier-matrix.md, таблица требований по пресетам).

Методика

Инструмент

lk load-test из livekit-cli (проверено через find-docs, github.com/livekit/livekit-cli README, актуальная версия 2.18.0, brew install livekit-cli). Команда и параметры:

lk load-test \
  --room <имя> --duration <N>s \
  --video-publishers <N> --audio-publishers <N> --subscribers <M> \
  --layout 5x5 --num-per-second 30

Ключевые флаги:

  • --video-publishers / --audio-publishers — при равном числе оба флага применяются к ОДНОМУ и тому же набору тестовых identity (не удваивают число участников): N издателей публикуют по одному аудио- и видеотреку каждый (видео — с simulcast, 3 слоя q/h/f — quarter/half/full, включён по умолчанию, флаг --no-simulcast отключает).
  • --subscribers — M подписчиков, каждый подписывается на все треки, доступные в комнате НА МОМЕНТ его подключения.
  • --layout (speaker/3x3/4x4/5x5, по умолчанию speaker) — важно: это не жёсткий лимит числа подписок, а имитация того, какое разрешение (какой simulcast-слой) подписчик запрашивает под сетку такого размера. Дефолтный speaker в тестах ограничивал реальное число подписок (см. «Находка» ниже) — для честного all-to-all fan-out используйте 5x5 (или 4x4/3x3 под нужный размер комнаты).
  • --num-per-second (по умолчанию 5) — темп присоединения тестовых участников; при большом числе участников и низком значении подписчики успевают подключиться раньше, чем опубликуются все треки, и видят усечённый набор — не итог деградации SFU, а гонка старта теста. В прогонах ниже установлен 30.

Находка 1: --layout speaker — не «весь мэш»

Первый прогон 10×10 с дефолтным layout дал устойчиво 12/20 треков на подписчика (не деградация, а имитация UI «спикер + несколько миниатюр» — подписчик просто не запрашивает все треки комнаты). Для честной оценки верхней границы ёмкости SFU (все видят всех — pessimistic case) использован --layout 5x5 (до 25 видимых плиток) во всех ступенях ниже.

Находка 2: lk load-test требует TURN/ICE-сервер, доступный БЫСТРО

Со штатным dev-конфигом deploy/livekit/livekit.yaml (turn.enabled: false, rtc.turn_servers не задан) LiveKit отдаёт клиенту пустой список ICE- серверов. При пустом списке клиентский SDK (livekit-cli/pion) молча подставляет свой дефолтный публичный STUN-список (stun:global.stun.twilio.com, stun:stun.l.google.com, stun:stun1.l.google.com). В песочнице этого запуска исходящий путь к этим публичным STUN-серверам идёт через виртуальную сеть Docker Desktop с заметной задержкой (наблюдались ICE-соединения за 57 с), а у lk load-test фиксированный ConnectTimeout: 5s — часть подключений (в первую очередь PUBLISHER-транспорт) не укладывалась и обрывалась с could not connect after timeout, даже когда сама SFU была не при делах.

Обход для теста: временно включить встроенный TURN-сервер LiveKit (turn.enabled: true, udp_port: 3478, tls_port: 0 — без TLS-варианта, т.к. сертификат не нужен для локального UDP-теста) через ВРЕМЕННЫЙ compose-override (не коммитился, не трогает deploy/livekit/livekit.yaml). Тогда сервер начинает отдавать клиенту непустой список ICE-серверов (собственный TURN вместо пустого списка), клиентский SDK не откатывается на публичные STUN, и ICE устанавливается за <1.5 с.

Рекомендация для прода (не реализована в рамках этой задачи — вне скоупа нагрузочного теста, требует правки deploy/livekit/livekit.yaml отдельным изменением): зарегистрировать существующий standalone-coturn (deploy/coturn/) в rtc.turn_servers LiveKit, чтобы клиенты ВСЕГДА получали явный ICE-сервер и не откатывались на публичные Google/Twilio STUN — это одновременно (а) быстрее устанавливает соединение за NAT и (б) не отдаёт IP участников третьим сторонам без необходимости (тот же дух приватности данных, что и требование локальных AI-моделей — идея та же).

Ступени

Ступени росли до чёткой деградации: 5×5 → 10×10 → 20×20 → 30×30 (число издателей аудио+видео × число подписчиков, оба со флагом --layout 5x5, --num-per-second 30, длительность 4555 c на ступень). Параллельно каждые 2 с снимался docker stats --no-stream по всем контейнерам dev-стека.

Результаты по ступеням

Ступень Участников всего Треков на подписчика (успешно) Пик CPU livekit (ядер) Пик RAM livekit Суммарный трафик подписчиков Потери пакетов (агрегат) Статус
5×5 10 10/10 0.42 145 МиБ 7.0 Мбит/с 0.016% OK
10×10 20 20/20 1.55 226 МиБ 45.3 Мбит/с 0.010% OK
20×20 (после устранения конкуренции за CPU, см. ниже) 40 40/40 2.42 574 МиБ 176.1 Мбит/с 0.075% OK, лёгкий рост
30×30 60 50/60 (9 из 30 подписчиков вообще не подключились) 6.46 815 МиБ 192.3 Мбит/с (частично) 2.30% ДЕГРАДАЦИЯ

Промежуточная точка (для полноты): первый прогон 20×20 БЕЗ остановки конкурентных контейнеров llm/worker (у пользователя в этот момент выполнялась суммаризация/фоновая LLM-нагрузка, до 863% CPU у контейнера llm — почти весь бюджет VM) дал 2.111% потерь при том же трафике — то есть выглядел как «деградация на 20×20», хотя на деле это была конкуренция за CPU хоста с посторонним процессом, а не предел самого SFU. После docker stop vidconf-llm-1 vidconf-worker-1 и повторного прогона потери на 20×20 упали до 0.075% (таблица выше — уже чистый прогон). Это задокументировано как явное предупреждение: на разработческой машине любой параллельный AI-воркер (транскрибация/суммаризация/LLM) искажает результаты SFU-теста — на проде эти роли обычно разнесены по разным хостам/профилям (ADR-004), поэтому конкуренции не будет, но при тестировании на одной машине (например, пресет 3 «+AI min» на одном сервере) это стоит учитывать при планировании ёмкости.

Выводы

  1. CPU SFU растёт сублинейно, затем резко (колено кривой) — удельная стоимость ядра на 1000 подписчик-треков падает с 5×5 к 20×20 (амортизация фиксированных издержек процесса), но на 30×30 подскакивает: 6.46 ядра на 1050 успешных подписок против 2.42 ядра на 800 на предыдущей ступени — явный признак приближения к пределу однопроцессного узла LiveKit на этой VM. Часть подписчиков (9 из 30) не успели установить соединение за ConnectTimeout — при близкой к пределу загрузке CPU ICE/DTLS-хендшейк новых участников начинает конкурировать с уже идущей пересылкой медиа существующих и не укладывается в таймаут.
  2. Память не является узким местом ни на одной ступени (пик 815 МиБ на 30×30 при лимите VM 7.75 ГБ) — планировать ёмкость по CPU и сетевой пропускной способности, не по RAM.
  3. Битрейты, полученные в тесте (ориентир для планирования аплинка/ даунлинка, реальные величины при --layout 5x5, simulcast включён по умолчанию у клиентского SDK LiveKit):
    • Аудио (Opus): стабильно ~1921 кбит/с на трек — использовать 20 кбит/с как плановую цифру на одного говорящего участника.
    • Видео, «сеточный» (не приоритетный) слой simulcast, который SFU форвардит подписчикам при 10+ видимых плитках: ~200350 кбит/с на трек — это нижний/средний слой (q/h в терминах rid). Годится как плановая цифра для комнат с сеткой ≥3×3.
    • Видео, верхний слой simulcast (форвардится, когда подписчик один/ мало плиток, либо трек — «в фокусе»/спикер): ~1.32.2 Мбит/с на трек — плановая цифра для 1:1 звонков и «пришпиленного» видео.
    • Рекомендация: simulcast (3 слоя, дефолт LiveKit JS/Go SDK) уже покрывает оба сценария автоматически — адаптивная подписка (LiveKit AdaptiveStream) на фронтенде должна запрашивать нижний слой в сеточных раскладках и верхний — в раскладке «спикер»/pinned, что соответствует наблюдаемому поведению теста. Специальных ручных профилей битрейта заводить не требуется; при необходимости ограничить верхнюю границу — videoEncoding/simulcastLayers на фронтенде (клиентский SDK, вне скоупа devops-части).
  4. UDP-диапазон 54000-54100 (101 порт) не был узким местом ни на одной ступени (максимум 60 участников в тесте) — при планировании прод-узла с ожидаемым бОльшим числом одновременных участников across все комнаты узла держать port_range_end - port_range_start заметно больше пикового числа участников на узле (LiveKit резервирует пару портов на участника на медиа-транспорт).
  5. STUN/TURN-находка (см. «Методика») — рекомендуется отдельной задачей зарегистрировать deploy/coturn/ в rtc.turn_servers LiveKit и на проде, а не только для теста — иначе клиенты в вырожденном случае (или при сбое конфигурации) будут по умолчанию уходить на публичные Google/Twilio STUN.

Формула прикидки ёмкости для прод-железа

Ёмкость SFU для конкретного узла оценивается ПО CPU (наблюдение п.1-2), не по RAM/диску. Использовать данные ступеней до колена кривой (5×5, 10×10, 20×20 — линейный участок) как основу, оставляя запас до колена, а не экстраполировать до него линейно.

vCPU_на_SFU ≈ 0.35 (базовые издержки процесса LiveKit)
            + 0.006 × T_total

где T_total = подписчики × видимыхреков_на_подписчика
            (видимыхреков = 2 × издателей при полном мэше,
             или меньше — при layout speaker/3x3/4x4/реальном UI)

Коэффициент 0.006 ядра/трек — среднее по трём чистым линейным ступеням (5×5: 0.0084; 10×10: 0.0078; 20×20: 0.0030 — усреднено консервативно в пользу меньшего числа участников, т.к. именно там эффективность на трек ниже). Прикидка ДЛЯ ЭТОГО compose/host-стека; на bare-metal Linux допустимо ожидать меньший коэффициент (нет прокси-сети Docker Desktop) — подтверждать реальным прогоном.

Правило безопасного запаса: держать целевую загрузку vCPU_на_SFU не выше 60% от доступных ядер узла — в тесте деградация проявилась уже на ~65% формальной квоты VM (6.46 из 10 vCPU), с учётом невидимых docker-stats издержек виртуализации сети реальный физический потолок был ещё ближе. На bare-metal Linux запас может быть меньше, но без отдельной валидации закладывать 60% как консервативный ориентир.

Пример применения к таблице пресетов ADR-004 (docs/architecture/adr/ 004-ai-tier-matrix.md) — эти vCPU общие на весь стек (backend, БД, AI-воркеры и SFU), поэтому реальный бюджет SFU меньше указанного в таблице на объём, потребляемый остальными сервисами:

Пресет vCPU узла Ориентир бюджета SFU (после вычета backend/БД/AI, груб.) T_total (при коэф. 0.006 и запасе 60%) Примерно участников (2 трека/чел., полный мэш)
1/2 (MVP/+чат, без AI) 4 ~3.0 vCPU ≈ (3.0×0.60.35)/0.006 ≈ 240 ≈ 120
3 (+AI min) 8 ~4.0 vCPU (доля с транскрибацией/суммаризацией) ≈ (4.0×0.60.35)/0.006 ≈ 342 ≈ 170
4 (+AI medium) 1216 ~5.0 vCPU ≈ (5.0×0.60.35)/0.006 ≈ 458 ≈ 230
5 (+AI max) 16+ ~6.0 vCPU (AI забирает GPU, CPU на SFU свободнее) ≈ (6.0×0.60.35)/0.006 ≈ 575 ≈ 285

Это цифры участников на весь узел суммарно по всем одновременным комнатам, не на одну комнату — при типичных размерах комнат VidConf (рабочие созвоны, не масштабные вебинары) практический потолок числа одновременных КОМНАТ на узел определяется делением на среднее число участников в комнате. Таблица — грубая прикидка для первичного sizing; обязательна проверка реальным lk load-test на целевом железе перед production-релизом крупной инсталляции (пресеты 4/5).

Как повторить

# 1. Инструмент
brew install livekit-cli   # или бинарь с github.com/livekit/livekit-cli/releases

# 2. Локальный стек (без AI-профилей)
docker compose -f deploy/docker-compose.yml --profile media up -d livekit coturn

# 3. Прогон одной ступени (пример 10×10)
export LIVEKIT_URL=ws://localhost:7880
export LIVEKIT_API_KEY=devkey
export LIVEKIT_API_SECRET=<значение LIVEKIT_API_SECRET из .env>
lk load-test --room capacity-10x10 --duration 45s \
  --video-publishers 10 --audio-publishers 10 --subscribers 10 \
  --layout 5x5 --num-per-second 30

# Параллельно в соседнем терминале — снимать нагрузку:
watch -n2 'docker stats --no-stream'

Для честного измерения ICE-задержки на macOS-хосте с Docker Desktop — см. «Находка 2» выше: либо временно включить turn.enabled: true (без TLS, udp_port: 3478) через compose-override, либо тестировать на Linux-хосте, где артефакт не проявляется.