Files
vidconf/docs/deploy/capacity.md
Max Ronzhin fb50c5d8ea
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
docs(deploy): таблица «профиль нагрузки → железо» на реальных замерах с прода
Формула CPU/RAM/полосы для медиа-нагрузки (не AI): подписки = камер ×
(участников − 1), подтверждено точно двумя боевыми замерами (28.07 и
31.07.2026). Коэффициенты полосы на подписку и CPU на ядро — из тех же
замеров, с явными допущениями и предупреждением не путать пиковый трафик
с устойчивым. Профили — малая команда/совещание/большое собрание/
смешанная нагрузка, включая пример недостижимого профиля и как его
спасают лимит плиток и потолок качества публикации (0.0.21).

Перекрёстные ссылки из README, hardware-profiles.md, capacity.md.
2026-08-02 22:28:55 +03:00

276 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Нагрузочное тестирование SFU (LiveKit): методика и ёмкость
> Цифры здесь — с dev-Mac (см. предупреждение ниже), для реальных прод-замеров
> и готовой таблицы «профиль нагрузки → железо» см.
> [hardware-sizing.md](hardware-sizing.md).
Оценивает,
сколько одновременных издателей аудио+видео и подписчиков выдерживает
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`). Команда и параметры:
```bash
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 участников). Тогда
же с ним была цена: под каждый порт диапазона Docker держал отдельный
процесс `docker-proxy` — 101 порт-101 процесс на медиапути, весь трафик
шёл лишним userland-хопом. С переходом на `rtc.udp_port` (один порт,
мультиплексирование по ICE ufrag внутри LiveKit) рекомендация «держать
диапазон шире пикового числа участников» больше не актуальна — портов
для планирования ёмкости не остаётся вовсе, 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).
## Как повторить
```bash
# 1. Инструмент
brew install livekit-cli # или бинарь с github.com/livekit/livekit-cli/releases
# 2. Локальный стек (без AI-профилей)
docker compose -f deploy/docker-compose.yml --env-file .env --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-хосте,
где артефакт не проявляется.