Containerizzare LLM self-hosted su VPS con GPU: nvidia-container-toolkit, orchestrazione di modelli multipli
Ho preso possesso del mio primo server GPU dedicato il 17 gennaio 2026: un Hetzner GEX44 (Intel Xeon Gold 5412U, 64 GB RAM DDR5, 2x NVMe 1,92 TB, NVIDIA RTX 4000 Ada Generation con 20 GB di VRAM GDDR6), distribuzione Ubuntu 22.04 LTS con kernel 5.15, driver NVIDIA 550.127.05, CUDA 12.4, Docker 27. L'obiettivo nella mia pipeline personale non era un singolo modello di chat, ma un'infrastruttura che facesse girare contemporaneamente tre modelli per ruoli diversi: un generalista Llama 3.1 8B Instruct per conversazione e classificazione, un embedding model Nomic v1.5 per retrieval RAG, e un modello di reranking BGE Reranker Large per risultati migliori sul retrieval. Sommando i footprint di VRAM - 5,8 GB per il generalista in 4-bit, 0,6 GB per l'embedding, 1,4 GB per il reranker, più overhead di contesto - stavo per impacchettare circa 12-14 GB sui 20 GB disponibili, lasciandomi margine per il KV cache e per carichi paralleli. La domanda tecnica interessante non era "ci stanno?" ma "come li orchestro senza che uno rubi VRAM all'altro, senza che uno rompa l'altro, e senza che debba riscaricare 50 GB di weights ogni volta che ricostruisco un container?". Containerizzare PHP-FPM richiede un Dockerfile banale. Containerizzare una batteria di LLM su GPU richiede una disciplina che tocca quattro aree ortogonali - GPU passthrough, runtime di inferenza, storage dei pesi, monitoring - e in ciascuna esistono più opzioni con trade-off concreti.
Quali approcci esistono per containerizzare LLM con GPU su VPS del mercato europeo?
La risposta breve è che, nel 2026 su VPS e server dedicati europei, il pattern vincente per la maggior parte delle PMI italiane è Docker con nvidia-container-toolkit come backend di passthrough, un runtime di inferenza scelto in base al profilo di carico (Ollama per sviluppo e carichi misti, vLLM per inferenza ad alta concorrenza, llama.cpp per latenza sottile su modelli quantizzati) e un volume persistente dedicato ai weights. Kubernetes con GPU Device Plugin resta un buon target quando hai più di un nodo e bisogno di schedulazione seria, ma è over-engineering su un singolo server. Soluzioni più esotiche - NVIDIA Triton Inference Server, Ray Serve, BentoML - hanno il loro posto in ambienti enterprise ma complicano più di quanto semplifichino quando il tuo parco modelli è sotto i cinque.
La tabella che segue è quella che uso negli audit su PMI italiane per inquadrare le opzioni. Include provider che offrono GPU su server dedicati europei - tema già approfondito nell'articolo su GPU cloud inference self-hosted su Scaleway, Lambda Labs e RunPod - con focus qui sulla containerizzazione su istanze rent-a-box.
| Provider | GPU tipica | VRAM | Pricing indicativo | Residenza dati | Container setup |
|---|---|---|---|---|---|
| Hetzner GEX44 | RTX 4000 Ada | 20 GB | ~220 €/mese | UE (Germania/Finlandia) | Ubuntu + Docker + nvidia-container-toolkit |
| Scaleway H100 | H100 PCIe | 80 GB | ~2,8 €/ora | UE (Francia/Olanda) | Preset image con driver CUDA |
| OVH GPU L4 | NVIDIA L4 | 24 GB | ~0,9 €/ora | UE (Francia/Germania) | Managed Kubernetes GPU-enabled |
| Lambda Labs | A100/H100 | 40/80 GB | ~1,3-2,5 €/ora | US primarily | On-demand, non GDPR-first |
| RunPod Community | RTX 4090/3090 | 24 GB | ~0,3-0,5 €/ora | Global (opzionale EU) | Spot, no SLA |
Se vuoi vedere come progetto infrastruttura AI self-hosted tenendo insieme costo, compliance e data sovereignty, nel mio hub sull'integrazione AI per aziende trovo articoli tecnici su MCP, vector DB, e orchestrazione di modelli - con criterio comune di evitare vendor lock-in americano per dati soggetti a GDPR.
Runtime di inferenza: Ollama, vLLM, llama.cpp, TGI a confronto
Non esiste "il runtime LLM giusto": esiste il runtime giusto per il profilo di carico. La differenza dominante è tra batching dinamico (un processo che serve molte richieste contemporanee consolidando le forward-pass) e esecuzione sequenziale (un processo che serve una richiesta alla volta con latenza minima). La scelta sbagliata produce OOM su hardware che in teoria avrebbe capacità, o performance frazionarie rispetto alla GPU nominale.
| Runtime | Profilo ideale | VRAM efficiency | Throughput concorrenza | Setup complexity |
|---|---|---|---|---|
| Ollama | Dev, carichi misti, switch rapido modelli | Media (GGUF quantized) | Bassa | Minima: un binario |
| vLLM | Inferenza ad alta concorrenza, API scalabile | Alta (PagedAttention) | Molto alta | Moderata: container Python + config |
| llama.cpp | Latenza sottile, hardware modesto, 4-8 bit | Altissima | Bassa | Moderata: build o binario + quantized |
| NVIDIA TGI | Enterprise multi-GPU, safetensors full precision | Media | Alta | Alta: image Hugging Face + config |
| Text Generation Inference | Enterprise, Hugging Face Pro alignment | Media-alta | Alta | Alta |
Nella mia configurazione attuale sul GEX44 uso Ollama per il generalista Llama 3.1 8B perché lo switch fra modelli (Llama 3.1, Qwen 2.5, Mistral) è questione di un comando, e la disciplina GGUF 4-bit è perfetta per modelli sotto i 10B su GPU consumer. Per il servizio di embedding, uso un container dedicato text-embeddings-inference di Hugging Face, che sotto il cofano usa CUDA kernel ottimizzati ed è il 3-4x più veloce di far embedding via Ollama. Per casi di test vLLM, ho uno Compose profile separato che lo tira su per benchmark e lo spegne dopo - convive con Ollama sullo stesso host ma non nello stesso orario.
GPU passthrough: nvidia-container-toolkit è lo standard de facto
Il nvidia-container-toolkit è il meccanismo ufficiale NVIDIA per esporre la GPU a container Docker/Podman. Installazione e verifica si riducono a tre step su Ubuntu 22.04:
# Installa il toolkit
sudo apt-get install -y nvidia-container-toolkit
# Configura Docker runtime
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
# Smoke test
docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smiSe nvidia-smi dentro il container vede la RTX 4000 Ada come la vede l'host, il passthrough funziona. La directory pubblica del progetto (nvidia/nvidia-container-toolkit su GitHub) ha esempi di configurazione per ogni distribuzione.
Nel Compose dei servizi che uso, la parte di device reservation è questa - è la sintassi moderna (Docker Compose v2.21+) che sostituisce il vecchio runtime: nvidia con deploy.resources.reservations.devices:
services:
ollama:
image: ollama/ollama:0.5.7
ports:
- "127.0.0.1:11434:11434"
volumes:
- ollama_models:/root/.ollama
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu, compute, utility]
restart: unless-stopped
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 30s
timeout: 10s
retries: 3
embeddings:
image: ghcr.io/huggingface/text-embeddings-inference:1.5
command:
- --model-id=nomic-ai/nomic-embed-text-v1.5
- --port=8080
ports:
- "127.0.0.1:8080:8080"
volumes:
- embed_cache:/data
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
volumes:
ollama_models:
driver: local
driver_opts:
type: none
o: bind
device: /srv/ollama/models
embed_cache:
driver: local
driver_opts:
type: none
o: bind
device: /srv/embeddings/cacheTre scelte meritano una riga di spiegazione. I ports sono bindati su 127.0.0.1 e non su 0.0.0.0: nessun servizio LLM self-hosted deve essere direttamente raggiungibile da Internet - l'autenticazione e il rate limiting vivono su un reverse proxy davanti. Il count: 1 sembra ridondante su una sola GPU ma serve nel caso in cui domani aggiunga una seconda GPU: ogni servizio dichiara esplicitamente quante GPU riserva. I volumi sono bind mount verso /srv/ollama/models e /srv/embeddings/cache: spostare /srv su un filesystem dedicato ti salva la vita quando i modelli totalizzano 80 GB.
Orchestrazione di modelli multipli: il router per caso d'uso davanti a tutto
Avere tre container LLM attivi non significa avere un'architettura di inferenza. Significa avere tre endpoint. Serve un router davanti che instradi ogni richiesta al modello corretto in base al task richiesto, imponga cost-cap, autentichi il chiamante e aggiunga tracing. Nella mia pipeline personale uso un piccolo gateway Python basato su FastAPI che espone un'unica superficie HTTP e fa task-based routing.
from fastapi import FastAPI, Depends
import httpx
app = FastAPI()
TASK_TO_BACKEND = {
"chat": ("http://ollama:11434/api/chat", {"model": "llama3.1:8b-instruct-q4_K_M"}),
"classify": ("http://ollama:11434/api/generate", {"model": "qwen2.5:7b-instruct-q4_K_M"}),
"embed": ("http://embeddings:8080/embed", {}),
"rerank": ("http://reranker:8080/rerank", {}),
}
@app.post("/infer")
async def infer(payload: dict, user = Depends(require_api_key)):
backend_url, extra = TASK_TO_BACKEND[payload["task"]]
body = {**extra, **payload["args"]}
async with httpx.AsyncClient(timeout=120) as client:
r = await client.post(backend_url, json=body)
return r.json()Il routing task-based è importante per due ragioni. La prima: permette di cambiare il backend senza toccare il codice chiamante. Se domani switch da Ollama a vLLM per il generalista, l'unico file che cambia è il mapping del router. La seconda: abilita il fallback elegante. Se ollama fallisce l'health-check, il router può instradare su un modello più piccolo residente in memoria - performance ridotte ma zero downtime visibile al chiamante. Questo è il pattern che lego al layer di streaming descritto nell'articolo su Node.js e TypeScript per streaming real-time di LLM: lì il client browser parla a Node.js, Node.js parla a un endpoint HTTP, e quell'endpoint può essere tanto Claude via Anthropic quanto questo router locale - stessa API, backend diverso, stessi governance controls.
Persistent volume dei weights: non scaricare modelli da 50 GB due volte
Un Llama 3.1 70B Q4 pesa circa 40 GB. Scaricare quei 40 GB costa 15-20 minuti di bandwidth anche su una connessione da 1 Gbps e, se il container viene ricostruito e i modelli vivono dentro la image layer, li riscarichi a ogni deploy. La soluzione è avere i modelli fuori dall'image, su un volume persistente mountato dentro il container. La tabella seguente riassume le opzioni che ho valutato.
| Storage model | Pro | Contro | Uso ideale |
|---|---|---|---|
| Bind mount su NVMe locale | I/O veloce (>3 GB/s), setup triviale | Non portable fra nodi | Single host, single GPU |
| Named volume Docker | Integrazione Compose pulita | Stessa performance del backend sottostante | Single host, managed lifecycle |
| NFS condiviso | Condivisione tra nodi | Latenza alta al cold load | Multi-host con storage appliance |
| S3 + cache locale | Backup versionato | Serve layer di cache | Multi-host cloud-native |
Per un GEX44 con 1,92 TB di NVMe, il bind mount su /srv/ollama/models è la scelta obbligatoria. Il caso interessante è il warm cache preload - uno script di post-install che tira giù i modelli la prima volta e li tiene nel volume; da quel momento, docker compose up riparte in 4-5 secondi e il modello è già caricabile nella VRAM senza network traffic. Il backup del volume su storage S3-compatibile (Hetzner Storage Box, Backblaze, Wasabi) avviene notturno via rclone sync a costo marginale.
Monitoring VRAM e risposta alle condizioni OOM
La GPU ha 20 GB di VRAM e tre modelli che ne usano 12-14 in stato statico. Se lanci una richiesta con contesto da 32k token su Llama 3.1 mentre embedding e reranker stanno facendo carico, il KV cache si espande e il CUDA out of memory è dietro l'angolo. Il monitoring minimale necessario è questo.
Raccolgo le metriche GPU con NVIDIA DCGM Exporter - componente ufficiale NVIDIA - che espone metriche Prometheus standard (utilizzo GPU, utilizzo memoria, temperatura, power draw, errori ECC). Il container dcgm-exporter si aggancia al driver NVIDIA dell'host e pubblica metriche su :9400/metrics.
dcgm-exporter:
image: nvcr.io/nvidia/k8s/dcgm-exporter:3.3.7-3.5.0-ubuntu22.04
cap_add: [SYS_ADMIN]
ports:
- "127.0.0.1:9400:9400"
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu, utility]Prometheus scrapa dcgm-exporter ogni 15 secondi. In Grafana ho tre alert semantici. VRAM saturation warning: se DCGM_FI_DEV_FB_USED / DCGM_FI_DEV_FB_TOTAL supera 0,85 per più di 3 minuti, scatta un alert Slack. GPU power throttling: se DCGM_FI_DEV_CLOCK_THROTTLE_REASONS rileva termal throttling, segnale di caldo eccessivo - probabile ventola o data center hot-spot. CUDA error: qualunque entry non-zero in DCGM_FI_DEV_XID_ERRORS è critico e lascia uno stack trace nel log.
La risposta automatica all'OOM è un pattern che ho codificato nel router: se il modello generalista fa CUDA out of memory per tre richieste consecutive entro 60 secondi, il router entra in modalità "degraded" - risposte servite dal modello più piccolo, log con flag, alert al team. Dopo 15 minuti senza errori il sistema torna full service. Questo circuit breaker a livello di routing è la differenza tra un incidente di 3 ore con richieste fallite e un inconveniente di 3 minuti con performance degradate.
Quando questo stack non si giustifica
Se hai un caso d'uso con meno di 500 richieste LLM al giorno, il self-hosting GPU è over-engineering: la tariffa Anthropic o OpenAI ti costa 10-20 euro al mese, il GEX44 ne costa 220. Se i tuoi dati non hanno vincoli di data sovereignty reali - non processi dati sanitari, non hai accordi con clienti che ti obbligano a non usare US cloud - non c'è motivo di self-hostare: lasci il SRE ad Anthropic e dedichi il tuo tempo a ciò che crea valore. Se hai bisogno di modelli frontier (Claude Sonnet 4.6, GPT-5, Gemini Ultra) per ragionamento complesso, non c'è self-hosting possibile: la classe è 300B+ parametri, non la ospiti su un GEX44 nemmeno in sogno. Per questo l'articolo sull'LLM self-hosted su Hetzner con Ollama è chiaro sul posizionamento: self-hosting è per lavori ripetitivi di classificazione, embedding, summarization su documenti sensibili - non per sostituire Claude sul ragionamento complesso.
Lo stack di containerizzazione LLM con nvidia-container-toolkit, Docker Compose, router task-based e monitoring DCGM si giustifica quando hai simultaneamente volume significativo di richieste LLM giornaliere (5.000+), requisito di residenza dati italiano o europeo, use case ripetitivi (classificazione, estrazione entità, embedding, summarization) che possono usare modelli 7-70B senza perdere qualità, e budget mensile che tollera 200-500 euro di infrastruttura dedicata. In quel punto di ottimo, il costo per inferenza crolla di un ordine di grandezza rispetto all'API a pagamento, il controllo sui dati diventa assoluto, e la superficie di attacco esterna si riduce ai confini del tuo datacenter.
Un server GPU dedicato con tre modelli self-hosted non è un "esperimento di laboratorio": è una primitiva di infrastruttura AI che le PMI italiane possono permettersi oggi, e che tra due anni molte avranno. La differenza tra chi lo usa bene e chi ci spreca soldi sta tutta nei pezzi che non si vedono: il routing task-based che abilita il fallback, il volume persistente che non ti fa scaricare 40 GB a ogni deploy, il monitoring DCGM che alerta prima dell'OOM, il circuit breaker che salva il servizio quando un modello va in degraded. Senza questi pezzi, hai speso 2.600 euro all'anno per un cimitero di container instabili. Con questi pezzi, hai una primitiva AI produttiva che non ti obbliga a esportare nemmeno un byte al di fuori del tuo perimetro.
Se stai valutando il self-hosting di LLM per la tua azienda - per compliance, per costi, o per evitare lock-in americano - e vuoi capire se il GEX44 (o equivalente) è dimensionato per il tuo carico, il modulo di preventivo gratuito ti dà una prima lettura in 7 domande, 2 minuti. Ti dico se il tuo progetto rientra nelle cose che so fare bene e, se il caso richiede un profilo diverso, te lo dico e ti indico una direzione utile quando posso.