docker compose определяет .env для подстановки ${VAR} по каталогу
compose-файла (deploy/), а не по текущей директории — repo-root .env,
который использует install.sh и вся документация, молча не подхватывался.
Это и была причина "WARN: LIVEKIT_API_KEY not set" на боевом сервере:
секреты были в .env, но compose их не видел и подставлял небезопасные
дефолты (см. таблицу разведки сервера).
Теперь ставшие обязательными ${VAR:?} в docker-compose.yml (см. предыдущий
коммит) без этого немедленно проваливали бы конфиг на любой из
документированных команд. Добавлен `--env-file .env`/"$ENV_FILE" ко всем
вызовам docker compose в install.sh и в командах из README/docs.
install.sh дополнительно: ensure_default (аналог ensure_secret без генерации
секрета) для новых не-секретных параметров nginx/coturn/livekit
(NGINX_SERVER_NAMES, NGINX_CERT_NAME, LIVEKIT_USE_EXTERNAL_IP,
LIVEKIT_NODE_IP, TURN_EXTERNAL_IP) — дефолты только для локальной
разработки, не перезаписывают значения, заданные вручную на боевом
сервере. Плюс вызов deploy/render-templates.sh перед сборкой/подъёмом
стека.
269 lines
22 KiB
Markdown
269 lines
22 KiB
Markdown
# Нагрузочное тестирование 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`) — при их одновременной активности они отъедали
|
||
до 4–9 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-соединения за 5–7 с), а у
|
||
`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`, длительность 45–55 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): стабильно **~19–21 кбит/с** на трек — использовать
|
||
20 кбит/с как плановую цифру на одного говорящего участника.
|
||
- Видео, «сеточный» (не приоритетный) слой simulcast, который SFU
|
||
форвардит подписчикам при 10+ видимых плитках: **~200–350 кбит/с** на
|
||
трек — это нижний/средний слой (`q`/`h` в терминах rid). Годится как
|
||
плановая цифра для комнат с сеткой ≥3×3.
|
||
- Видео, верхний слой simulcast (форвардится, когда подписчик один/
|
||
мало плиток, либо трек — «в фокусе»/спикер): **~1.3–2.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.6−0.35)/0.006 ≈ 240 | ≈ 120 |
|
||
| 3 (+AI min) | 8 | ~4.0 vCPU (доля с транскрибацией/суммаризацией) | ≈ (4.0×0.6−0.35)/0.006 ≈ 342 | ≈ 170 |
|
||
| 4 (+AI medium) | 12–16 | ~5.0 vCPU | ≈ (5.0×0.6−0.35)/0.006 ≈ 458 | ≈ 230 |
|
||
| 5 (+AI max) | 16+ | ~6.0 vCPU (AI забирает GPU, CPU на SFU свободнее) | ≈ (6.0×0.6−0.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-хосте,
|
||
где артефакт не проявляется.
|