Containerizzare LLM self-hosted su VPS con GPU: nvidia-container-toolkit, orchestrazione di modelli multipli

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.

ProviderGPU tipicaVRAMPricing indicativoResidenza datiContainer setup
Hetzner GEX44RTX 4000 Ada20 GB~220 €/meseUE (Germania/Finlandia)Ubuntu + Docker + nvidia-container-toolkit
Scaleway H100H100 PCIe80 GB~2,8 €/oraUE (Francia/Olanda)Preset image con driver CUDA
OVH GPU L4NVIDIA L424 GB~0,9 €/oraUE (Francia/Germania)Managed Kubernetes GPU-enabled
Lambda LabsA100/H10040/80 GB~1,3-2,5 €/oraUS primarilyOn-demand, non GDPR-first
RunPod CommunityRTX 4090/309024 GB~0,3-0,5 €/oraGlobal (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.

RuntimeProfilo idealeVRAM efficiencyThroughput concorrenzaSetup complexity
OllamaDev, carichi misti, switch rapido modelliMedia (GGUF quantized)BassaMinima: un binario
vLLMInferenza ad alta concorrenza, API scalabileAlta (PagedAttention)Molto altaModerata: container Python + config
llama.cppLatenza sottile, hardware modesto, 4-8 bitAltissimaBassaModerata: build o binario + quantized
NVIDIA TGIEnterprise multi-GPU, safetensors full precisionMediaAltaAlta: image Hugging Face + config
Text Generation InferenceEnterprise, Hugging Face Pro alignmentMedia-altaAltaAlta

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-smi

Se 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/cache

Tre 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 modelProControUso ideale
Bind mount su NVMe localeI/O veloce (>3 GB/s), setup trivialeNon portable fra nodiSingle host, single GPU
Named volume DockerIntegrazione Compose pulitaStessa performance del backend sottostanteSingle host, managed lifecycle
NFS condivisoCondivisione tra nodiLatenza alta al cold loadMulti-host con storage appliance
S3 + cache localeBackup versionatoServe layer di cacheMulti-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.

Ultima modifica: