Files
XC_VM/docs/adr/0001-plan-tmpfs-free-streaming.ru.md
T
Divarion_D 6821f06627 docs(streaming): ADR 0001 — tmpfs-free streaming design + phased plan
Records the decision to take PHP out of the streaming byte path and remove
the tmpfs mounts: native xc_fanout daemon for live fan-out + in-RAM HLS,
nginx X-Accel/proxy_pass with auth_request, connection telemetry in Redis.

Root cause from P0 load test: the ~400-concurrent ceiling is a linear RAM
law (~25 MB per viewer) because each proxy viewer pins a PHP-FPM worker in a
socket_read loop — not tmpfs pressure and not pm.max_children.
2026-08-16 14:03:14 +03:00

18 KiB
Raw Blame History

План миграции: отказ от tmpfs и вынос PHP из байтового пути

Компаньон к docs/adr/0001-tmpfs-free-streaming.md. Здесь — детальный, пофазный план работ на русском: что делаем, какие файлы трогаем, критерии приёмки, флаги, откат и риски.

Область этой итерации: сначала MAIN, LB — отдельной фазой в конце. Каждая фаза самостоятельна, выкатывается за флагом в таблице settings, откатывается без передеплоя.


0. Кратко: зачем и что меняем

Проблема. Потолок ~400 конкурентных соединений. Причина — не давление tmpfs на RAM, а PHP в потоке байт:

  • Proxy-режим (Public/stream/live.php:352-389) держит один PHP-FPM воркер на зрителя на всю сессию (цикл socket_read→echo→flush). При pm.max_children ≈ 400–512 это и есть стена ровно на 400.
  • HLS-режим отдаёт каждый .ts через PHP readfile из tmpfs — потому что X-Accel-Redirect в коде отсутствует, nginx сегменты сам не раздаёт.
  • Везде двойное копирование ядро→PHP→ядро без zero-copy.

Решение. «Произвести один раз → раздать nginx-ом / нативным демоном → PHP только авторизует». После выноса PHP из байтов удаление tmpfs идёт следом.

Целевые компоненты:

  • xc_fanout — новый нативный демон (Go, один процесс, много потоков): ingest одного ffmpeg -f mpegts - на поток → zero-copy fan-out live + HLS-сегментер в RAM.
  • nginx — единственное перед клиентами: proxy_pass на демон по unix-сокету + auth_request в лёгкий PHP-эндпоинт.
  • Redis (уже в дистрибутиве) — реестр соединений и счётчики битрейта вместо tmpfs-файлов opened_cons/divergence.

1. Целевая архитектура (потоки данных)

Live (MPEG-TS, непрерывный):

источник → ffmpeg -f mpegts -  →(unix pipe/socket)→  xc_fanout (ring buffer)
                                                          │
клиент ──HTTP──> nginx ──auth_request──> PHP (204/403)   │
клиент <──chunked mpegts── nginx ──proxy_pass(unix)──────┘   (fan-out на N клиентов)

HLS:

  • Этап B (быстрый, малый риск): ffmpeg-муксер HLS пишет сегменты, но отдаёт их nginx через X-Accel-Redirect/sendfile; STREAMS_PATH переносится с tmpfs на реальный NVMe (горячие сегменты живут в page cache). PHP — только auth. Маунта tmpfs больше нет.
  • Этап A (чистый, дороже): xc_fanout нарезает сегменты из того же mpegts-фида в памяти, отдаёт .m3u8/.ts из RAM. Файлов нет вообще. Требует кодеко-зависимого разбора TS (H264/H265 access units, ADTS AAC, PTS).

Сначала B (снимает бутылочное горлышко и убирает маунт), затем A — когда live-путь демона проверен.


2. Спецификация xc_fanout

Язык/поставка. Go, статический бинарь. Кладётся в binaries-релиз рядом с ffmpeg/nginx/redis, ставится и супервизится тем же путём, что и остальные бинари. Один процесс — много потоков (никогда не процесс-на-поток — это возвращает накладные расходы, от которых уходим).

На каждый активный поток:

  1. Ingest. Принимает один mpegts-фид от ffmpeg -f mpegts - через unix-сокет/пайп — одно соединение-продюсер.
  2. Live fan-out. Кольцевой буфер, выровненный по 188 байт (размер конфигурируем, напр. 2–10 МБ), с отслеживанием PAT/PMT. Новый подписчик присоединяется на границе PAT/PMT + keyframe, дальше следует за «хвостом». Медленные клиенты получают ограниченную персональную буферизацию, затем дропаются (конфиг) — продюсер никогда не блокируется.
  3. HLS в RAM (этап A). Нарезка того же фида по keyframe в байтовые слайсы в памяти, поддержка .m3u8, скользящее окно N сегментов (соответствует текущим hls_list_size/hls_delete_threshold). Опционально AES-128 (.enc) в демоне на существующих key/iv.
  4. Отдача в nginx по unix-сокету (proxy_pass http://unix:/run/xc_fanout.sock):
    • GET /live/<id> → chunked mpegts;
    • GET /hls/<id>.m3u8 → плейлист из памяти;
    • GET /hls/<id>_<seq>.ts → байты сегмента из кольца.
  5. Телеметрия. Демон владеет сокетами → он источник истины по connect/disconnect/bytes; пишет события в Redis (см. §4).

Fan-out. Go netpoller (epoll) + writev/sendfile-класс записи; 400+ подписчиков на поток — с большим запасом выше текущего PHP-потолка.

Управление жизненным циклом. Старт/стоп потока инициирует существующий супервизор (Domain/Stream/StreamProcess, Cli/Commands/MonitorCommand). Здоровье/PID демона и потоков — в Redis, а не в STREAMS_PATH/<id>_.pid.

Открытые вопросы спеки (решить до P2/P3):

  • Протокол ingest: unix-сокет (демон слушает, ffmpeg-обёртка коннектится) vs именованный пайп. Предпочтение — unix-сокет (проще реконнект/супервизия).
  • Стратегия медленного клиента: размер персонального буфера и порог дропа.
  • Джойн-семантика: сколько истории PAT/PMT/keyframe держать для чистого старта плеера (сверить с текущим prebuffer ProxyCommand: STORE_PREBUFFER/MAX_PREBUFFER).

3. Изменения nginx

  • Клиентские локации /stream/* для live/hls → proxy_pass на unix-сокет xc_fanout (вместо PHP readfile/socket_read).
  • auth через auth_request: подзапрос в лёгкий внутренний PHP-эндпоинт, возвращающий 204/403 и заголовки (разрешённый stream_id, подсказка AES-ключа и т.п.). PHP байты не трогает.
  • Для этапа B: internal location поверх STREAMS_PATH + X-Accel-Redirect из PHP; sendfile on (уже есть).
  • Сохранить proxy_max_temp_file_size 0 / fastcgi_max_temp_file_size 0 (уже стоят) — nginx не сбрасывает на диск.
  • Файлы: src/bin/nginx/conf/nginx.conf (локации ^/stream/(auth|segment|live|...)), позже lb_configs/nginx.conf.

4. Телеметрия соединений → Redis

Заменяем tmpfs-файлы реестра на Redis (обёртка Infrastructure/Redis/RedisManager, Core/Cache/RedisCache уже есть). Пишет xc_fanout (авторитет по connect/disconnect), читают панель и кроны.

Схема (черновик):

  • conn:<uuid> (hash: stream_id, user_id, ip, started_at, bytes, bitrate) + TTL-heartbeat — замена CONS_TMP_PATH/<uuid> и DIVERGENCE_TMP_PATH/<uuid>.
  • stream:<id>:conns (set из uuid) — замена glob(CONS_TMP_PATH/<id>/*) для подсчёта ёмкости/фан-аута.

Куда перенаправить потребителей:

  • Domain/Stream/ConnectionTracker::getCapacity (:37), reaping (:934-956);
  • Cli/CronJobs/UsersCronJob — битрейт/анти-фрод (:499, :613);
  • Cli/CronJobs/StreamsCronJob — reaping сокетов (:257-265);
  • Cli/Commands/SignalsCommand (:123);
  • админ-монитор процессов.

Писатели, которые убираем (после переезда): live.php:296/350/398, segment.php:101, vod.php:194/197, timeshift.php:199/211/307/309.


5. Пофазный план

P0 — Базовый замер и инструментирование

  • Цель: воспроизвести стену 400 на MAIN, снять «до»-цифры.
  • Задачи: нагрузочный тест (live proxy + HLS); зафиксировать насыщение FPM-воркеров, syscalls (strace/perf), RAM, load average.
  • Приёмка: есть воспроизводимый сценарий и профиль узкого места (ожидаем: воркеры FPM в proxy-режиме, readfile в HLS).
  • Код: нет.

P1 — HLS без PHP в байтах (этап B) ← самый большой эффект/малое изменение

  • Цель: убрать PHP readfile из HLS, сделать tmpfs-маунт необязательным.
  • Задачи:
    • В Public/stream/segment.php (и HLS-ветке live.php) заменить readfile() на выдачу X-Accel-Redirect в internal location; оставить только auth/лимиты.
    • Добавить internal location в src/bin/nginx/conf/nginx.conf поверх STREAMS_PATH.
    • Сохранить AES-128: либо .enc через тот же internal-редирект, либо отдавать key через key.php как сейчас.
  • Флаг: нет. X-Accel — единственный путь (рантайм-переключателя PHP/nginx намеренно нет, чтобы не усложнять код). Откат — git revert + передеплой.
  • Приёмка: HLS-плеер работает; при 400 клиентах HLS число занятых FPM-воркеров не растёт линейно; сегменты отдаёт nginx (sendfile).
  • Откат: git revert коммита P1 + передеплой segment.php/nginx.conf (бэкапы .bak на узле).
  • Риск: внутренние пути должны точно соответствовать auth (нельзя дать угадать URL сегмента мимо авторизации) — location internal (клиентам недоступна) + авторизация в segment.php до участка выдачи.

P2 — xc_fanout: live fan-out ← снимает потолок воркер-на-зрителя

  • Цель: заменить socket_read-цикл в PHP на nginx→демон.
  • Задачи:
    • Реализовать демон: ingest -f mpegts -, ring buffer, GET /live/<id>, PAT/PMT-джойн.
    • Обёртка запуска ffmpeg-фида в демон вместо ProxyCommand popen+socket_sendto.
    • nginx: proxy_pass на демон + auth_request для /live/*.
    • Ретайр proxy-режима на datagram-сокетах (ProxyCommand.php, socket-ветка live.php:352-389).
  • Флаг: live_fanout.
  • Приёмка: live-поток идёт через демон; при 400+ клиентах на поток FPM-воркеры не заняты байтами; картинка без артефактов на джойне; латентность не хуже текущей.
  • Откат: флаг off → PHP socket-путь (пока не удалён).
  • Риск: корректность джойна PAT/PMT+keyframe; поведение медленного клиента.

P3 — xc_fanout: HLS в RAM (этап A)

  • Цель: нарезка HLS в памяти демона, ноль файлов.
  • Задачи: TS-демукс + сегментация по keyframe; .m3u8/.ts из RAM; nginx /hls/* → демон; убрать -hls_segment_filename из дефолтного пути.
  • Флаг: hls_inmem.
  • Приёмка: HLS полностью из памяти; сегментные границы совпадают с текущими (seg_time/list_size); AES-128 сохранён.
  • Откат: флаг off → этап B (nginx sendfile с диска).
  • Риск: самый кодеко-зависимый и рискованный кусок — потому отложен за B.

P4 — Телеметрия → Redis

  • Цель: убрать opened_cons/divergence из tmpfs.
  • Задачи: демон эмитит connect/disconnect/bytes в Redis (§4); перенаправить ConnectionTracker, UsersCronJob, StreamsCronJob, SignalsCommand, админ-монитор; двойная запись (tmpfs+Redis) на время сверки, затем срез.
  • Флаг: conn_registry_redis.
  • Приёмка: лимиты соединений и анти-фрод по битрейту работают по Redis; цифры сходятся с tmpfs на этапе двойной записи.
  • Откат: флаг off → tmpfs-реестр.
  • Риск: трогает лимиты/анти-фрод — обязательна фаза сверки перед срезом.

P5 — Снять tmpfs

  • Цель: убрать маунты и тоглы.
  • Задачи: удалить fstab-записи (install, LbInstallFlow) и ramdisk-тогл (RootSignalsCronJob:500-527); остаточный scratch TMP_PATH → реальный диск/Redis; проверить, что ни один путь не рассчитывает на tmpfs-маунт.
  • Приёмка: система работает без tmpfs-маунтов; RAM освобождён под page cache/приложение.
  • Откат: вернуть fstab + mount -a.

P6 — Раскатка на LB

  • Цель: тот же слой на LB-нодах.
  • Задачи: упаковать xc_fanout в binaries-релиз; расширить Cli/Commands/LbInstallFlow (установка/супервизия демона + новый nginx-конфиг); канареечно на части LB.
  • Приёмка: LB-нода на новом слое держит нагрузку; LB-архив остаётся без привилегированного кода (гейты verify-lb-archive).

6. Тестирование и приёмка (общее)

  • Функциональные: live (proxy и direct), HLS (clear + AES-128), timeshift/VOD не сломаны, EPG/лимиты/гео-проверки в auth_request идентичны текущим.
  • Нагрузочные: цель — уверенно держать значительно выше 400 конкурентных на поток; замер FPM-воркеров, RAM, syscalls «до/после» (из P0).
  • Регресс auth: порт auth-логики без переписывания; тесты на токен/лимит/гео до включения флага.
  • Сверка телеметрии: двойная запись P4, диф tmpfs vs Redis.

7. Риски и принципы

  • Каждая фаза — за флагом в settings; откат = переключение флага без передеплоя. xc_fanout упал → nginx откатывается на PHP-путь, пока флаг off.
  • Самый рискованный кусок (HLS-A, §P3) отложен за дешёвым и доказательным HLS-B.
  • auth_request должен сохранить все текущие проверки — портируем, не переписываем.
  • Новый нативный бинарь в доверенном binaries-релизе → security-review + воспроизводимая сборка; LB-архив остаётся без привилегий.
  • Джойн live-кольца (PAT/PMT + keyframe) должен быть корректным, иначе артефакты у плеера при подключении.

8. Порядок захода

Рекомендую начать с P1 — самый дешёвый и самый доказательный кусок: он один снимает HLS-горлышко и делает 90%-RAM-маунт необязательным, не трогая демон. Параллельно можно писать спеку xc_fanout (§2, закрыть открытые вопросы), чтобы к P2 демон был готов.

Альтернатива — если хочется бить в самое больное: сразу проектировать xc_fanout и заходить с P2 (proxy-режим — источник стены 400).