./run runpod-fatto-in-casa-due-h200-rename-gateway
Un rename silenzioso, metà delle app a 404: RunPod fatto in casa su due H200
Ho ricostruito l'esperienza RunPod + Weights & Biases per un piccolo team su due H200, sopra Slurm. Poi un rename di modelli ha rotto tutto in silenzio.
- status
- draft
- project
- hpc
- updated
- 2026-07-07
- tags
Un martedì sono arrivate le segnalazioni: l’app di OCR rispondeva 404, il servizio voce era morto, un backend NER dava errore su ogni chiamata. Nessuno aveva toccato quelle app da settimane. Il colpevole stava a monte: settimane prima avevo rinominato i modelli serviti dal gateway — dagli alias interni ai loro veri nomi Hugging Face — e ogni app che aveva l’alias vecchio hardcoded era andata a sbattere contro un modello che non esisteva più. Nessun crash, nessun log rosso vistoso: solo un 404 educato e silenzioso, ripetuto per giorni.
Questo diario parte da lì, ma la storia più grande è cosa c’era sotto: aver ricostruito, per un team piccolo e un paio di GPU, l’esperienza che di solito paghi a RunPod e Weights & Biases.
L’idea
Il team ha bisogno di GPU self-service: prendere una scheda dal browser, aprire un notebook, lanciare un fine-tuning, vedere la loss in tempo reale, servire un modello via API. La roba che RunPod e W&B ti danno pronta. Ma qui c’è un solo nodo con due H200 NVL (~140 GB l’una, driver 595.71.05, CUDA 13, RHEL 10.1) e sei utenti. Troppo poco per giustificare una piattaforma cloud, troppo per gestirlo a mano via SSH.
La scelta di fondo: non riscrivere lo scheduler. Sul nodo Slurm già girava (cluster atlas, Gres=gpu:h200:2,shard:h200:28, 1 TB di RAM, 128 CPU). Slurm resta l’allocatore; io ci ho costruito sopra la UX.
Architettura: un ingresso, tutto in localhost
Il piano di telemetria e web gira in Podman (5.6, quadlet systemd), tutto legato a 127.0.0.1; l’unico ingresso è l’edge tunnel. Firewalld tiene aperti solo 22 e 443.
- Telemetria (il clone del tab RunPod): il pezzo chiave è il DCGM exporter di NVIDIA, che espone esattamente ciò che vedi su RunPod — util per-GPU, VRAM usata/totale, temperatura, potenza, clock:
# /etc/containers/systemd/dcgm-exporter.container
[Container]
Image=nvcr.io/nvidia/k8s/dcgm-exporter:3.3.9-3.6.1-ubuntu22.04
PublishPort=127.0.0.1:9400:9400
PodmanArgs=--device nvidia.com/gpu=all # accesso GPU via CDI (nvidia-container-toolkit)
Accanto: node_exporter (CPU/RAM/disco) e uno slurm_exporter (coda, job per utente, GPU-hours — il pezzo che RunPod non ha ma che serve in multi-utente). Prometheus li scrapa, Grafana rende: sei dashboard, di cui una è la copia letterale del pannello telemetria di RunPod.
-
Tracking esperimenti (il clone di W&B): MLflow con backend PostgreSQL. Il bello è che l’HF
Trainerci logga senza una riga di codice — bastano due variabili d’ambiente ereport_to="mlflow". Da lì arrivanotrain/loss,train/learning_rate,train/grad_norm… esattamente i campi che vedi nello screenshot di W&B. -
Portale (il clone di Deploy/Connect): qui è saltato fuori il primo scarto tra i miei documenti di design e la realtà. Avevo progettato Open OnDemand. Peccato che OOD non abbia pacchetti per RHEL 10. Ho ripiegato sul JupyterHub + SlurmSpawner già presente, aggiungendoci un selettore di risorse stile RunPod con
wrapspawner.ProfilesSpawner:
c.ProfilesSpawner.profiles = [
("1× H200 — 8h (default)", "gpu1_8h", "batchspawner.SlurmSpawner",
dict(req_partition="gpu", req_runtime="8:00:00", req_memory="64gb",
req_nprocs="16", req_options="--gres=gpu:1")),
("2× H200 — 4h (training multi-GPU)", "gpu2_4h", "batchspawner.SlurmSpawner",
dict(req_partition="gpu", req_runtime="4:00:00", req_memory="128gb",
req_nprocs="32", req_options="--gres=gpu:2")),
("Shard GPU ~½ — 6h (notebook leggero)", "shard_6h", "batchspawner.SlurmSpawner",
dict(req_partition="interactive", req_runtime="6:00:00",
req_options="--gres=shard:7")),
("Solo CPU — 8h", "cpu_8h", "batchspawner.SlurmSpawner",
dict(req_partition="cpu", req_runtime="8:00:00", req_options="")),
]
Scegli un profilo dal menu a tendina, JupyterHub sottomette un job Slurm che lancia il notebook su quel budget. È il “pick a GPU / deploy” di RunPod, ma dietro c’è una vera allocazione di scheduler. L’SSO arriva a livello di edge (un header con l’email autenticata) mappato all’utente unix — così SlurmSpawner lancia i job con l’identità giusta.
La spina: il gateway che si è rinominato da solo
Sopra a tutto c’è un gateway modelli OpenAI-compatible: un servizio che tiene un catalogo di LLM, li carica on-demand su Slurm alla prima richiesta, li scarica dopo un idle_timeout per liberare la GPU, e fa da API unica per tutti. Il registro MLflow oggi conta 53 modelli: 15 serviti dal gateway (tag served_by=gateway, alias @production), 38 a catalogo su disco.
Il 17 giugno ho fatto una cosa che sembrava pulita: ho tolto gli alias interni finti (svc-ocr, svc-tts, …) e ho fatto servire i modelli con i loro veri nomi Hugging Face. Ordine, coerenza, meno indirezione. Solo che le app client avevano quegli alias hardcoded. Da lì il 404 silenzioso.
La lezione vera non è “non rinominare”. È che non esisteva una fonte di verità su quale modello chiama ogni app. Ho dovuto ricostruirla dal basso, leggendo la usage_log del gateway — chi ha chiamato cosa e con quale nome:
SELECT model, count(*), sum(CASE WHEN status_code>=400 THEN 1 ELSE 0 END) AS errori
FROM usage_log GROUP BY model;
Le righe con errori alti e un nome-modello che non era più nel catalogo erano le mie vittime. Il fix, applicato caso per caso, è stato togliere l’alias hardcoded e leggerlo da una variabile d’ambiente, con default sul nome reale:
OCR_MODEL = os.environ.get("OCR_MODEL", "dots.ocr") # niente più alias sepolto nel codice
Poi ho scritto un piccolo exporter Prometheus read-only che scrapa /v1/models (stato live) e la usage_log (richieste, errori, token, latenza per modello e per utente), così su Grafana si vede quale modello gira e come viene usato. Il prossimo 404 da rename lo becco su una dashboard, non da una mail di un collega.
Gotcha onesti
- README vs realtà: i documenti di design dicono “Open OnDemand” e “nginx come unico ingresso”. La realtà è JupyterHub e un tunnel che va diretto alle porte dei servizi. Ho lasciato un file
AS-BUILTche, dove diverge dal design, vince lui. - Un exporter deve bindare
0.0.0.0, non127.0.0.1, perché il Prometheus in container lo raggiunge viahost.containers.internal. Firewalld tiene comunque la porta fuori dall’edge. Piccola cosa, mezz’ora persa. - Una GPU, un LoRA da 130 GB: servire un adapter LoRA che occupa ~130 dei 140 GB significa che, mentre gira, scarica gli altri modelli. Su un nodo solo è fisiologico, ma va detto agli utenti.
- MLflow non ha auth nativa: la sicurezza è tutta al perimetro. La porta
:5000non deve mai uscire da localhost + vhost. - Il rename ha ucciso un servizio senza rimpiazzo: il modello TTS del servizio voce è stato disinstallato nello stesso giro. Non è un fix da una riga: il gateway ora non ha nessun modello TTS, va prima deciso quale installare. Resta rotto, e va bene ammetterlo.
Come va
Shipped e in uso: telemetria, MLflow con promozione @production eval-gated e hot-reload (il gateway ri-risolve l’alias di produzione a ogni start del modello, promozione senza restart), JupyterHub col selettore risorse, backup giornaliero del Postgres. In corso: il ripristino del servizio voce (bloccato sulla scelta del modello TTS) e le quote GPU-hour per-utente, rimandate di proposito.
Cosa ho imparato
Che il pezzo più fragile di una piattaforma non è la GPU né lo scheduler: sono i nomi. Un rename che sulla carta è “pulizia” diventa un incidente distribuito se non hai un registro di chi-chiama-cosa. La telemetria che ho aggiunto dopo non serviva a mostrare le GPU calde — quelle si vedevano già. Serviva a rendere visibile l’invisibile: la mappa app→modello che, finché tutto funziona, nessuno guarda. La prossima volta quel registro esiste prima del rename, non dopo il primo 404.