# Autoscale streaming — Proxmox attuale + Hetzner Cloud **Stato:** fasi 0–4 implementate sul branch — **solo test lab; nessun deploy produzione** **Branch:** `feature/streaming-autoscale-hetzner` **Ultimo aggiornamento:** 2026-08-09 (Fase 4 hardening + runbook) **Sostituisce / estende:** il piano overflow-only [`HETZNER_CLOUD_RELAY_OVERFLOW.md`](HETZNER_CLOUD_RELAY_OVERFLOW.md). Questo documento copre **MediaMTX + ffmpeg**, warm spare, DNS, storage, disco e lab sul Proxmox di produzione. Documenti correlati: [`ARCHITECTURE.md`](../ARCHITECTURE.md), [`LIVE_STREAMING.md`](../LIVE_STREAMING.md), [`REPLAY_MODULE.md`](../REPLAY_MODULE.md), [`OPS_MONITORING.md`](../OPS_MONITORING.md). --- ## 1. Obiettivo Scalare molte dirette contemporanee (sito e/o YouTube) senza saturare un unico box: - **Control plane (ora):** Proxmox **già in produzione** (`192.168.1.146` / `/opt/matchlivetv`) — sito, API, DB, Redis, Garage, MediaMTX/ffmpeg “home”. - **Data plane elastico:** **Hetzner Cloud** — nodi `MediaMTX + ffmpeg` con warm spare (+1). - **DNS ingest:** secondo dominio su **Hetzner DNS** (API). - **Lab:** stesso Proxmox, con `ProxmoxLabProvider`, prima di affidarsi al Cloud in produzione. - **Futuro (non bloccante):** migrazione control plane su **Hetzner Auction + Proxmox** (vSwitch nativo, hardware dedicato). --- ## 2. Decisioni chiuse | # | Decisione | Scelta | |---|----------|--------| | D1 | Host primario **ora** | **Proxmox già usato in produzione** (non attendere Auction) | | D1b | Host primario **futuro** | Hetzner Server Auction + Proxmox (quando si migra) | | D2 | Overflow / warm spare | **Hetzner Cloud** (attivato per questo progetto) | | D3 | Unità di scala | Nodo **stream** = MediaMTX + worker `youtube_relay` (ffmpeg remux copy) | | D4 | Warm pool | Almeno **1 spare ready** quando il carico si avvicina al cap | | D5 | DNS ingest | Dominio **`mltv-stream.net`** (registrar Aruba, NS → Hetzner DNS). `matchlivetv.it` resta su Aruba intatto | | D6 | Aruba | Nessuna delega NS; niente Floating IP prenotate | | D7a | Rete **ora** (casa/Proxmox ↔ Cloud) | **WireGuard** (o Tailscale) privato: Redis, DB, API MediaMTX home. Non esporre Redis/Postgres su WAN | | D7b | Rete **futuro** (Auction ↔ Cloud) | vSwitch Robot + Cloud Network (stessa location) | | D8 | Object storage | **Garage resta**. **B2** = migrazione pronta quando misurato | | D9 | Staging | Segmenti su disco VM `stream-*` in live; upload Garage a fine sessione | | D10 | Prodotto | Consigliare **YouTube** per alleggerire storage/egress replay | | D11 | Disco | Separazione volumi + **monitoraggio attivo** obbligatorio (§6) | | D12 | Lab prima del Cloud prod | `ProxmoxLabProvider` + DNS lab sullo stesso Proxmox; poi `HetznerCloudProvider` | --- ## 3. Topologia — fase attuale (Proxmox prod + Cloud) ```text Internet │ HTTPS :443 · RTMP :1935 ▼ ┌──────────────────────────────────────────────────────────┐ │ Proxmox produzione (attuale) │ │ eminux@192.168.1.146 · /opt/matchlivetv │ │ │ │ edge · rails · sidekiq-core · postgres · redis │ │ garage · stream-home (MediaMTX + youtube_relay) │ │ autoscaler (Rails/Sidekiq) │ │ │ │ Lab (opz.): clone VM stream-* via ProxmoxLabProvider │ └────────────────────────┬─────────────────────────────────┘ │ WireGuard / tunnel privato ▼ ┌──────────────────────────────────────────────────────────┐ │ Hetzner Cloud — pool stream nodes │ │ stream-01 … stream-N MediaMTX + ffmpeg │ │ + 1 spare ready (warm) │ └────────────────────────┬─────────────────────────────────┘ │ Hetzner DNS (secondo dominio) ingest-XX. → IP pubblico nodo ``` **Futuro Auction:** stesso schema, tunnel sostituito da vSwitch; control plane migrato sul bare metal Hetzner. **Principio:** Rails solo controllo. Live sui MediaMTX del nodo assegnato. Replay su Garage (poi eventualmente B2). --- ## 4. DNS (secondo dominio) RTMP **non** tollera round-robin su un unico hostname. 1. Dominio **`mltv-stream.net`** registrato su Aruba; zona DNS su Hetzner Console (progetto `matchlivetv-stream`). 2. NS Aruba del dominio → `hydrogen` / `oxygen` / `helium` Hetzner (delegazione OK). 3. Autoscaler a VM ready → upsert `A` `ingest-03.mltv-stream.net` → health-check → nodo `ready`. 4. API create/start sessione → URL del nodo assegnato: ```text rtmp://ingest-03.mltv-stream.net:1935/live/match_ https://ingest-03.mltv-stream.net/hls/... # o via edge matchlivetv.it che proxya il nodo ``` 5. Scale-in: drain → delete DNS → destroy VM. Hostname pubblici dei nodi vivono sotto `*.mltv-stream.net`. In lab: `LabDnsProvider` (`/etc/hosts`, dnsmasq). --- ## 5. Autoscaler e warm spare Ogni nodo: `max_publishers` / `max_relays` (es. 4). | Regola | Comportamento | |--------|----------------| | Scale-out | `free_slots` sotto soglia soft → crea 1 nodo | | Warm spare | Se `spare_ready < 1` vicino al carico → crea spare | | Scale-in | Idle da X min e non unica spare → destroy | | Floor | `stream-home` sul Proxmox sempre on | `CloudProvider`: `HetznerCloudProvider` (prod overflow) + `ProxmoxLabProvider` (test). Code Sidekiq: coda `youtube_relay` isolata; sticky owner Redis. --- ## 6. Disco Proxmox — requisito critico **Non** mettere OS, DB e video sullo stesso volume che può riempirsi al 100%. Vale **già** sul Proxmox di produzione (rinforzare ora), e di nuovo alla migrazione Auction. ### 6.1 Layout volumi (target) | Volume / disco | Contenuto | Note | |----------------|-----------|------| | `os` | Proxmox / sistema host | Quasi statico | | `vm-system` / root stack | rails, edge, redis, … | Snapshot | | `vm-data-db` | Postgres | Separato; backup prioritari | | `vm-data-video` | Garage + staging MediaMTX | **Disco che può riempirsi** | | `backup` | Backup / PBS | **Mai** sullo stesso disco dei video | Regole: cap Garage/staging; se staging pieno → **rifiutare nuove sessioni**, non far cadere DB/API. ### 6.2 Monitoraggio attivo (obbligatorio) | Metrica | Warn | Critical | |---------|------|----------| | Uso `%` volume video | ≥ 70% | ≥ 85% | | Spazio libero GB | sotto soglia | < X GB | | Inode liberi | bassi | esaurimento | | Crescita GB/giorno Garage | anomalia | — | | Purge/retention falliti | — | immediato | | Staging per nodo `stream-*` | ≥ 70% | ≥ 85% + blocco live su quel nodo | Checklist: - [ ] Alert ntfy disco testati - [ ] Vista ops “disco video” - [ ] Test periodico “80% pieno” - [ ] Restore di prova Postgres --- ## 7. Storage: Garage ora, B2 dopo | Ora | **Garage** | | Dopo | **Backblaze B2** quando metriche/€ lo giustificano | Client S3-ready; staging locale invariato. Spinta prodotto YouTube riduce pressione replay. --- ## 8. Prodotto: YouTube consigliato Suggerire YouTube in app/dashboard quando ha senso → meno storage/egress MatchLiveTV; middle-tier (slate + relay) resta il valore. Live solo-sito restano supportate. --- ## 9. Astrazioni codice ```text CloudProvider create_node / destroy_node / list_nodes / wait_until_running / public_ip → HetznerCloudProvider | ProxmoxLabProvider DnsProvider upsert_a / delete_a → HetznerDnsProvider | LabDnsProvider StreamNodeRegistry register / mark_ready / allocate_for_session / drain / release Autoscaler reconcile!(metrics) → ensure spare / scale-in idle ``` Tag VM Cloud: `matchlivetv`, `role=stream-node`, `env=prod|lab`. --- ## 10. Lab sul Proxmox attuale Prima (o in parallelo) al Cloud “vero”: 1. Template VM `stream-node` su Proxmox. 2. `CLOUD_PROVIDER=proxmox_lab` → clone/start/stop via API Proxmox. 3. DNS lab (hosts/dnsmasq). 4. Soft cap basso; N sessioni di test; verifica assignment, spare, scale-in, alert disco. 5. Poi smoke su Hetzner Cloud (1 CX piccolo) + DNS reale. Cosa il lab **non** replica al 100%: tempi boot Hetzner, vSwitch, RTMP 4G multi-nodo (smoke WAN dopo). ### Relay ffmpeg sul nodo (lab locale / collaudo) L’overflow deve spostare **ffmpeg**, non solo l’ingest RTMP. In lab `local_lab` il MediaMTX resta quello home; si alza un secondo Sidekiq che ascolta solo `youtube_relay_` così la CPU del remux è isolata. 1. Provisiona il nodo: admin **Nodi stream** o `rails streams:nodes:provision_lab` (slug tipo `ingest-lab-01`). 2. Worker locale: `STREAM_NODE_SLUG=ingest-lab-01 bash scripts/dev_sidekiq_relay.sh` 3. Collaudo: `STREAM_NODE_SLUG=ingest-lab-01 bash scripts/deploy/collaudo_relay_worker.sh up` 4. Per assegnare sessioni al lab: riempi home (`STREAM_NODE_HOME_MAX_PUBLISHERS=1`) oppure alza `max_publishers` del lab e satura home. 5. Verifica: `docker top` / log `[YoutubeRelay] started ... worker=ingest-lab-01` sul container relay, **non** su `sidekiq` home. 6. Nodo Cloud Hetzner: stesso modello quando WireGuard espone Redis/Postgres; cloud-init dovrà avviare Sidekiq con `STREAM_NODE_SLUG` e `STREAM_NODE_LOCAL_RTMP_URL=rtmp://127.0.0.1:1935`. --- ## 11. Fasi di implementazione | Fase | Cosa | Esito | |------|------|--------| | **L — Lab Proxmox** | `LocalLab` / `ProxmoxLab`, DNS lab, admin nodi | **implementata sul branch** | | **0 — Multi-nodo ready** | Registry + URL RTMP/HLS in API (`StreamNode`, `Streams::NodeRegistry`) | **implementata sul branch** | | **1 — Hetzner Cloud + DNS** | `HetznerCloudProvider` + `HetznerDnsProvider`, `mltv-stream.net`, cloud-init, WireGuard (§16) | **implementata sul branch** (WG ops manuale) | | **2 — Routing relay** | Coda `youtube_relay_` per nodo, worker overflow senza code `default`, sticky owner, cap `RELAY_MAX_CONCURRENT` | **implementata sul branch** (lab Docker/collaudo; Cloud dopo WG) | | **3 — Autoscaler** | Soglie + warm spare (+1), kill-switch `STREAM_AUTOSCALE_ENABLED` | **implementata sul branch** | | **4 — Hardening** | Drain sicuro, budget, kill-switch Redis/admin, alert overflow, runbook | **implementata sul branch** (solo test lab — **non in prod**) | | **A — Auction (futuro)** | Migrazione control plane + vSwitch al posto di WireGuard | Hardware dedicato Hetzner | | **B2 (opz.)** | Switch object storage | Quando misurato | ### Fase 0 — dettagli implementati - Tabella `stream_nodes` + `stream_sessions.stream_node_id` - `Streams::NodeRegistry.ensure_home_from_env!` / `allocate!` (least-loaded) - `Sessions::Create` assegna il nodo e crea il path MediaMTX sul client del nodo - URL RTMP/HLS/API/internal per sessione derivati dal nodo (fallback ENV se nodo assente) - API JSON espone `stream_node` (slug) - Dominio ingest pubblico futuro: `*.mltv-stream.net` ### Fase L — dettagli implementati - `Streams::CloudProviders` (`local_lab`, `proxmox_lab`, stub `hetzner`) - `Streams::DnsProviders` (`lab` su Redis, stub `hetzner`) - `Streams::NodeProvisioner` (provision / drain / decommission) - Admin **Nodi stream** (`/admin/stream_nodes`): lista, provision lab, drain, delete - Rake: `streams:nodes:ensure_home`, `streams:nodes:provision_lab`, `streams:nodes:dns_lab_dump` - Default sicuro: `STREAM_CLOUD_PROVIDER=local_lab` (nessuna chiamata Proxmox finché non configuri token) ### Fase 1 — dettagli implementati - `Streams::CloudProviders::Hetzner` (create/destroy server, wait running, labels) - `Streams::DnsProviders::Hetzner` (upsert/delete A su zona `mltv-stream.net`) - `NodeProvisioner#provision_cloud!` + bottone admin **Provisiona nodo Hetzner** - Cloud-init: `infra/stream-node/cloud-init.yaml` (`HCLOUD_USER_DATA_FILE`) - Decommission usa il provider del nodo (non solo ENV globale) - WireGuard: vedi §16 ### Fase 2 — dettagli implementati - Coda Sidekiq dedicata `youtube_relay_` (home: `youtube_relay_home`; overflow: `youtube_relay_ingest-01`) - `YoutubeRelayEnsureJob` / `YoutubeRelayStopJob` sulla coda del **nodo assegnato** - Stop sticky: non cancella owner da Rails; lo stop gira sull’owner o requeue (`:wrong_host`) - Cap per worker: `RELAY_MAX_CONCURRENT` (default 4) + set Redis `youtube_relay:owned:HOSTNAME` - Ensure a capacità piena → requeue 5s sulla stessa coda nodo - Worker home ascolta `youtube_relay_home` (+ `youtube_relay` legacy). Worker overflow: solo la propria coda, **niente** `default`/`critical` - Intake locale: `STREAM_NODE_LOCAL_RTMP_URL` (loopback sul CPX, `mediamtx` in Docker lab) ### Fase 3 — dettagli implementati - `Streams::Autoscaler` + job chain `Streams::AutoscalerJob` (come health monitor) - Kill-switch: `STREAM_AUTOSCALE_ENABLED=1` per attivare - Scale-out se `free_slots ≤ STREAM_AUTOSCALE_SOFT_FREE_SLOTS` - Warm spare (+N) se carico/soglia e `spare_ready < WARM_SPARE` - Scale-in overflow idle da `IDLE_MINUTES` (non tocca home; rispetta warm spare) - Cap `STREAM_AUTOSCALE_MAX_NODES`; kind `lab|cloud` - Metriche in admin Nodi stream ### Fase 4 — hardening (implementata sul branch) > **NON rilasciare in produzione** finché lab + smoke Cloud non sono verdi. Su questo branch restano `STREAM_AUTOSCALE_ENABLED=0` e `STREAM_AUTOSCALE_ALLOW_CLOUD=0`. - Scale-in **sicuro**: `drain!` → attesa sessioni zero → `decommission!` - Budget soft: `STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR` + `STREAM_AUTOSCALE_NODE_EUR_PER_HOUR` (stima 24/7); scale-out bloccato se sforerebbe - Cloud autoscale solo con `STREAM_AUTOSCALE_ALLOW_CLOUD=1` **e** `HCLOUD_TOKEN` - Kill-switch runtime Redis `streams:autoscaler:kill_switch` (bottoni admin ON/OFF) oltre all’ENV - Health check `stream_overflow` → incidente Ops/ntfy su nodi idle orfani, over-budget, capacità al max - Admin: metriche budget + stato kill-switch #### Runbook operativo (lab / pre-prod) | Situazione | Azione | |------------|--------| | Dubbio / picco anomalo | Admin → **Kill-switch ON** (o `STREAM_AUTOSCALE_ENABLED=0`) | | Nodo spillato | Drain dal admin; dopo fine live → Destroy | | Budget alert Ops | Abbassa `MAX_NODES` / alza budget solo dopo review costi Hetzner | | Smoke Cloud | Provision **manuale** admin (kind cloud), non autoscaler; verifica WG + RTMP 1935 | | Scale-out lab OK | Solo dopo: considerare `ENABLED=1` + `KIND=lab` su staging/lab Proxmox | | Produzione overflow | Solo dopo checklist §12 punto 6 | Ordine di lavoro consigliato sul branch: **0 → L → 1 → 2 → 3 → 4**; **A** quando si decide di lasciare il Proxmox casa. --- ## 12. Ordine operativo “ora” (senza Auction) 1. Rinforzare dischi/alert sul Proxmox prod (§6). 2. Secondo dominio + zona Hetzner DNS. 3. Progetto Hetzner Cloud + immagine/snapshot `stream-node`. 4. Tunnel WireGuard Proxmox ↔ Cloud (Redis/DB/API private). 5. Implementare fasi 0 → L → 1… sul branch. 6. Cutover overflow in produzione solo dopo lab + smoke Cloud verdi. --- ## 13. Criteri di successo | Criterio | Misura | |----------|--------| | Lab | Scale-out/in e assignment verificati su Proxmox senza Hetzner | | Picco | N live oltre soft cap home con spare Cloud ready | | Continuità | Drop telefono → slate YT attiva | | Disco | Nessun outage DB/API per disco video; alert prima del critical | | DNS | Aruba intatto; ingest sul secondo dominio | | Futuro | Path chiaro verso Auction senza riscrivere autoscaler | --- ## 14. Aperto / da calibrare | Voce | Note | |------|------| | Nome secondo dominio | **`mltv-stream.net`** (chiuso) | | Soft cap `stream-home` | es. 4 vs 6 | | Tipo VM Cloud | CPX vs CCX | | `OVERFLOW_MAX` / budget | — | | Spare di notte | 0 vs 1 | | Dettaglio WireGuard | subnet, peer, MTU | | Soglie GB disco | da size reale volume video | --- ## 15. Riepilogo esecutivo **Ora:** restiamo sul Proxmox di produzione; attiviamo Hetzner Cloud (spare MediaMTX+ffmpeg) + Hetzner DNS (secondo dominio) + WireGuard. Testiamo prima in lab sullo stesso Proxmox. Garage resta; B2 dopo. YouTube consigliato in prodotto. Disco e alert sono vincolo non negoziabile. **Dopo:** eventuale Auction Hetzner sostituisce il control plane casa e WireGuard → vSwitch, senza cambiare il modello nodi/autoscaler. --- ## 16. WireGuard (Proxmox casa ↔ Hetzner Cloud) Obiettivo: Rails/Sidekiq sull’host di produzione raggiungono `api_base_url` / `internal_rtmp_url` dei nodi Cloud (es. `http://10.0.0.9:9997`) **senza** esporre Redis/Postgres/MediaMTX API su Internet. ### Setup consigliato (una tantum) 1. Su Hetzner Console: crea **Network** privata (es. `10.0.0.0/16`) nella location `fsn1`, subnet `10.0.1.0/24` per i server Cloud. 2. Imposta `HCLOUD_NETWORK_ID=` così i nodi stream entrano nella network al create. 3. Su Proxmox (VM o LXC gateway): installa WireGuard; peer verso una VM “gateway” Cloud **sempre on** (CX22 piccola) *oppure* peer site-to-site verso un server Cloud fisso. 4. Alternativa più semplice per i primi test: **Tailscale** su Proxmox host + su ogni stream-node (cloud-init) — stesso effetto, meno ops. 5. Firewall Hetzner Cloud: - WAN: `1935/tcp` (RTMP), `443/tcp` o `8888` se HLS diretto - Solo rete privata / WG: `9997` (MediaMTX API), niente Postgres/Redis sui nodi stream 6. Verifica da Rails: `curl http://:9997/v3/paths/list` Finché WireGuard/Tailscale non è pronto, `api_base_url` punta comunque alla private IP (o pubblica se manca private_net): il path MediaMTX da Create fallirà dal control plane se non raggiungibile — provisionare nodi Cloud solo dopo connettività privata OK, oppure smoke con API su IP pubblico temporaneo + firewall allowlist IP casa. ### ENV produzione (stream autoscale) ```bash HCLOUD_TOKEN=... HCLOUD_LOCATION=nbg1 HCLOUD_SERVER_TYPE=cpx12 HCLOUD_IMAGE=debian-12 HCLOUD_SSH_KEY=matchlivetv-stream-hetzner HCLOUD_NETWORK_ID= # opzionale HCLOUD_USER_DATA_FILE=/opt/matchlivetv/infra/stream-node/cloud-init.yaml STREAM_DNS_ZONE=mltv-stream.net STREAM_DNS_TTL=60 STREAM_CLOUD_DNS_SUFFIX=mltv-stream.net STREAM_CLOUD_MAX_PUBLISHERS=4 STREAM_NODE_ENV=prod # Default lab sicuro; in prod per overflow usa hetzner esplicitamente dal bottone admin STREAM_CLOUD_PROVIDER=local_lab STREAM_DNS_PROVIDER=lab ```