Salta al contenuto principale

07 - Scaling ML su Kubernetes

Guida completa a Scaling ML su Kubernetes: architettura, implementazione pratica e best practices per developer e team tecnici.

Cosa ne pensi di questo articolo?

07 - Scaling ML su Kubernetes
MLOpsFoto:Yan KrukauPexels

In Breve

Guida completa a Scaling ML su Kubernetes: architettura, implementazione pratica e best practices per developer e team tecnici.

Scaling ML su Kubernetes: Deployment e Orchestrazione in Produzione

Il tuo modello di machine learning ha superato tutti i test offline, le metriche sono eccellenti, il serving via FastAPI funziona perfettamente in locale. Poi arriva il momento critico: devi gestire 10.000 richieste al secondo, scalare dinamicamente in base al carico, garantire alta disponibilità e zero downtime durante gli aggiornamenti. Un singolo container non e più sufficiente.

Kubernetes e diventato lo standard de facto per l'orchestrazione di workload ML in produzione, con il 78% delle organizzazioni enterprise che lo utilizzano per il deployment di modelli secondo il CNCF Survey 2025. Non e pero sufficiente mettere il modello in un Pod: occorre gestire GPU scheduling, autoscaling event-driven, resource quotas, canary deployments e monitoring specializzato per l'inferenza. Il mercato MLOps, che varra $4.38 miliardi nel 2026, con un CAGR del 39.8%, ha in Kubernetes il suo motore di scaling principale.

In questo articolo esploriamo l'architettura completa per portare modelli ML su Kubernetes in produzione: da KServe e Seldon Core per l'inference serving, al GPU scheduling con NVIDIA Device Plugin, all'autoscaling intelligente con HPA, VPA e KEDA, fino al monitoring con Prometheus e Grafana.

Cosa Imparerai

  • perchè Kubernetes e lo standard per ML in produzione e quando usarlo
  • GPU scheduling e sharing con NVIDIA Device Plugin e MIG
  • KServe: deploy di InferenceService con canary rollout e scale-to-zero
  • Seldon Core v2: pipeline composabili e multi-model serving
  • Autoscaling avanzato: HPA, VPA e KEDA per workload ML event-driven
  • Resource management: requests, limits, priority classes e node affinity
  • Monitoring con Prometheus + Grafana specializzato per ML inference
  • Cost optimization e best practices per cluster GPU

perchè Kubernetes per ML in Produzione

Prima di immergersi nella configurazione tecnica, e fondamentale capire perchè Kubernetes si e affermato come piattaforma di riferimento per i workload ML, superando soluzioni come bare metal, VM dedicate o servizi cloud proprietari.

I workload ML hanno caratteristiche uniche rispetto alle applicazioni web tradizionali. Il training richiede GPU massicce per ore o giorni, poi le risorse devono essere rilasciate. L'inferenza ha picchi imprevedibili e latenza critica. I modelli devono essere aggiornati senza downtime. I dataset possono essere enormi e richiedono storage specializzato. Kubernetes affronta tutti questi scenari con primitive native: Pod scheduling su nodi GPU specifici, Persistent Volumes per dataset, Jobs per training batch, HorizontalPodAutoscaler per scaling dell'inferenza.

Kubernetes vs Alternativa Cloud-Managed

Kubernetes self-managed o managed (EKS, GKE, AKS): controllo completo, portabilita multi-cloud, costi ottimizzabili, ma complessità operativa elevata.
SageMaker / Vertex AI / Azure ML: setup rapido, integrazione cloud-native, ma vendor lock-in, costi più alti a lungo termine e meno flessibilità per architetture custom.
Regola pratica: team <5 persone o budget limitato? Inizia con managed ML. Team >5 con modelli multipli in produzione? Kubernetes ripaga l'investimento in 6-12 mesi.

Architettura di Riferimento

Un cluster Kubernetes per ML in produzione si struttura tipicamente su tre layer distinti, ognuno con responsabilità ben definite:

  • Infrastructure Layer: nodi CPU per serving leggero e orchestrazione, nodi GPU per training e inferenza pesante, nodi di storage per dataset e artefatti. Le GPU pool sono separate con node labels specifici.
  • Platform Layer: KServe o Seldon Core per l'inference serving, Kubeflow per pipeline di training, MLflow per experiment tracking (vedi articolo 4), Argo Workflows per orchestrazione complessa.
  • Observability Layer: Prometheus per metriche, Grafana per dashboard, Jaeger per distributed tracing, Loki per log aggregation.
# Namespace structure per ML cluster
# Separare ambienti e responsabilità

kubectl create namespace ml-training      # Job di training
kubectl create namespace ml-serving       # Inference services
kubectl create namespace ml-monitoring    # Prometheus, Grafana
kubectl create namespace mlflow           # Experiment tracking
kubectl create namespace kubeflow         # Pipeline orchestration

# Label nodi per GPU scheduling
kubectl label nodes gpu-node-1 accelerator=nvidia-a100
kubectl label nodes gpu-node-2 accelerator=nvidia-t4
kubectl label nodes cpu-node-1 workload=inference-cpu

GPU Scheduling e Sharing

Le GPU sono la risorsa più costosa del cluster ML. Gestirle male significa sprecare decine di migliaia di euro al mese. Kubernetes espone le GPU come risorse schedulabili tramite il NVIDIA Device Plugin, un DaemonSet che rileva automaticamente le GPU sui nodi e le registra come nvidia.com/gpu nel kubelet.

# Installazione NVIDIA Device Plugin via Helm
helm repo add nvdp https://nvidia.github.io/k8s-device-plugin
helm repo update

helm upgrade -i nvdp nvdp/nvidia-device-plugin \
  --namespace kube-system \
  --set failOnInitError=false

# Verifica GPU disponibili sui nodi
kubectl describe nodes | grep -A5 "Allocatable:"
# Output atteso:
#   nvidia.com/gpu: 8
#   cpu: 96
#   memory: 768Gi

# Pod che richiede 1 GPU intera
apiVersion: v1
kind: Pod
metadata:
  name: ml-training-job
spec:
  containers:
  - name: trainer
    image: pytorch/pytorch:2.5.0-cuda12.4-cudnn9-runtime
    resources:
      limits:
        nvidia.com/gpu: 1   # Richiede 1 GPU intera
        cpu: "8"
        memory: "32Gi"
      requests:
        nvidia.com/gpu: 1
        cpu: "4"
        memory: "16Gi"
  nodeSelector:
    accelerator: nvidia-a100  # Forza scheduling su A100

Per workload di inferenza che non richiedono una GPU intera, NVIDIA offre due strategie di GPU sharing: Time-Slicing e Multi-Instance GPU (MIG).

# Configurazione Time-Slicing (per GPU T4/V100, sharing software)
# Ogni GPU fisica viene divisa in N repliche logiche
apiVersion: v1
kind: ConfigMap
metadata:
  name: time-slicing-config
  namespace: kube-system
data:
  any: |-
    version: v1
    flags:
      migStrategy: none
    sharing:
      timeSlicing:
        replicas: 4   # 4 pod condividono 1 GPU fisica
        failRequestsGreaterThanOne: false

# Apply al device plugin
kubectl patch clusterpolicies/cluster-policy \
  -n gpu-operator --type merge \
  -p '{"spec": {"devicePlugin": {"config": {"name": "time-slicing-config"}}}}'

# Configurazione MIG per A100/H100 (hardware isolation)
# Partiziona A100 in 7 istanze MIG da 10GB ciascuna
nvidia-smi mig -cgi 9,9,9,9,9,9,9 -C

# Pod che usa una slice MIG
resources:
  limits:
    nvidia.com/mig-1g.10gb: 1   # Usa 1 istanza MIG da 10GB

Time-Slicing vs MIG: Quando Usare Quale

Time-Slicing: adatto per workload di inferenza leggeri (modelli <2GB VRAM), introduce latenza da context switching. Funziona su qualsiasi GPU NVIDIA. MIG: isolamento hardware completo, memoria dedicata garantita, zero interference tra workload. Disponibile solo su A100, A30, H100. Ideale per SLA di latenza stringenti. Non combinare mai i due approcci sullo stesso nodo.

KServe: Inference Serving Nativo su Kubernetes

KServe (ex KFServing) e lo standard CNCF per l'inference serving su Kubernetes, nato dalla collaborazione tra Google, IBM, Bloomberg e altri. Fornisce un'astrazione InferenceService che nasconde la complessità del deployment, gestendo automaticamente canary rollout, scale-to-zero, autoscaling basato su richieste, e supporto per molteplici framework (PyTorch, TensorFlow, scikit-learn, XGBoost, ONNX, Hugging Face).

# Installazione KServe (versione 0.13+)
kubectl apply -f https://github.com/kserve/kserve/releases/download/v0.13.0/kserve.yaml

# Verifica installazione
kubectl get pods -n kserve
# kserve-controller-manager-xxx   Running
# kserve-gateway-xxx              Running

# InferenceService per modello scikit-learn
apiVersion: "serving.kserve.io/v1beta1"
kind: "InferenceService"
metadata:
  name: "churn-predictor"
  namespace: ml-serving
  annotations:
    serving.kserve.io/enable-prometheus-scraping: "true"
spec:
  predictor:
    minReplicas: 1
    maxReplicas: 10
    scaleTarget: 50          # Target: 50 req/sec per replica
    scaleMetric: rps         # Scala in base a requests-per-second
    sklearn:
      storageUri: "gs://my-ml-bucket/models/churn-model/v3"
      runtimeVersion: "1.5.2"
      resources:
        requests:
          cpu: "500m"
          memory: "1Gi"
        limits:
          cpu: "2"
          memory: "4Gi"

Uno dei punti di forza di KServe e il supporto nativo per i Canary Rollout: puoi inviare una percentuale del traffico alla nuova versione del modello mentre il resto continua a usare quella stabile, esattamente come l'A/B testing che abbiamo visto nell'articolo precedente di questa serie.

# Canary Rollout: 20% traffico alla nuova versione
apiVersion: "serving.kserve.io/v1beta1"
kind: "InferenceService"
metadata:
  name: "churn-predictor"
  namespace: ml-serving
spec:
  predictor:
    # Versione stabile (80% traffico)
    minReplicas: 2
    maxReplicas: 8
    sklearn:
      storageUri: "gs://my-ml-bucket/models/churn-model/v3"
      runtimeVersion: "1.5.2"
    canaryTrafficPercent: 80

  # Versione canary (20% traffico)
  predictor:
    # NOTA: nella specifica KServe, il canary si gestisce con
    # l'annotation traffic-split
    # Ecco la sintassi corretta tramite Knative revisions:

---
# Alternativa: InferenceGraph per traffic splitting esplicito
apiVersion: "serving.kserve.io/v1alpha1"
kind: "InferenceGraph"
metadata:
  name: "churn-ab-split"
  namespace: ml-serving
spec:
  nodes:
    root:
      routerType: WeightedEnsemble
      routes:
      - serviceName: churn-predictor-v3
        weight: 80
      - serviceName: churn-predictor-v4
        weight: 20

# Test dell'endpoint
curl -X POST \
  http://churn-predictor.ml-serving.svc.cluster.local/v1/models/churn-predictor:predict \
  -H 'Content-Type: application/json' \
  -d '{"instances": [[35, 12000, 2, 1, 0.8, 3]]}'

La feature di scale-to-zero di KServe (basata su Knative Serving) e particolarmente preziosa per modelli usati occasionalmente: il pod si spegne dopo un periodo di inattivita configurabile e si riavvia automaticamente alla prima richiesta, con un cold start tipicamente inferiore ai 30 secondi per modelli pre-cached.

# Configurazione scale-to-zero con timeout personalizzato
apiVersion: "serving.kserve.io/v1beta1"
kind: "InferenceService"
metadata:
  name: "batch-analyzer"
  namespace: ml-serving
  annotations:
    # Scale-to-zero dopo 5 minuti di inattivita
    autoscaling.knative.dev/scaleToZeroGracePeriod: "300s"
    # Window per calcolo scale-up
    autoscaling.knative.dev/window: "60s"
    # Utilization target (in percentuale)
    autoscaling.knative.dev/targetUtilizationPercentage: "70"
spec:
  predictor:
    minReplicas: 0   # Abilita scale-to-zero
    maxReplicas: 5
    pytorch:
      storageUri: "gs://my-ml-bucket/models/analyzer/v1"
      runtimeVersion: "2.5.0"

Seldon Core v2: Pipeline ML Composabili

Mentre KServe eccelle nel serving di singoli modelli, Seldon Core v2 brilla nella gestione di architetture ML complesse: pipeline multi-step, ensemble di modelli, A/B routing con business logic, e integrazione con Kafka per stream processing. Seldon v2 utilizza MLServer come runtime di inferenza, compatibile con il protocollo V2 (KFServing Inference Protocol), e supporta natively PyTorch, scikit-learn, XGBoost, Hugging Face e modelli custom.

# Installazione Seldon Core v2 via Helm
helm repo add seldonio https://storage.googleapis.com/seldon-charts
helm install seldon-core-v2 seldonio/seldon-core-v2 \
  --namespace seldon-mesh \
  --create-namespace \
  --set controller.clusterwide=true

# Model: singolo modello XGBoost
apiVersion: mlops.seldon.io/v1alpha1
kind: Model
metadata:
  name: churn-xgb
  namespace: ml-serving
spec:
  storageUri: "gs://my-ml-bucket/models/churn-xgb/v2"
  requirements:
    - xgboost
  memory: 100Mi

# Pipeline: preprocessing + prediction + postprocessing
apiVersion: mlops.seldon.io/v1alpha1
kind: Pipeline
metadata:
  name: churn-pipeline
  namespace: ml-serving
spec:
  steps:
    - name: preprocessor
      inputs:
        - churn-pipeline.inputs
    - name: churn-xgb
      inputs:
        - preprocessor.outputs
    - name: postprocessor
      inputs:
        - churn-xgb.outputs
  output:
    steps:
      - postprocessor

  # Autoscaling basato su RPS
  replicas: 1
  scaling:
    replicas:
      minReplicas: 1
      maxReplicas: 8
      metric: rps
      target: 100

KServe vs Seldon Core: Quale Scegliere

  • KServe: migliore per modelli singoli, integrazione con Knative e Istio, scale-to-zero nativo, comunita CNCF. Ideale per team che iniziano con K8s ML.
  • Seldon Core v2: migliore per pipeline complesse, ensemble, integrazione Kafka, multi-model serving. Ideale per architetture ML avanzate con routing business logic.
  • Entrambi: supportano il protocollo V2, monitoring Prometheus, Triton per GPU serving. Non sono mutualmente esclusivi, alcune organizzazioni li usano insieme per casi d'uso diversi.

Autoscaling Intelligente: HPA, VPA e KEDA

Il scaling dei workload ML e più complesso rispetto alle applicazioni web tradizionali. Le metriche CPU e memoria spesso non riflettono accuratamente il carico reale di un modello: un modello GPU-bound può saturare la scheda grafica mentre la CPU e all'80% di idle. Kubernetes offre tre meccanismi di autoscaling complementari che, usati correttamente insieme, coprono tutti gli scenari ML.

# HPA (Horizontal Pod Autoscaler): scala il numero di repliche
# Configurazione per inference service basata su custom metrics
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: churn-predictor-hpa
  namespace: ml-serving
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: churn-predictor
  minReplicas: 2
  maxReplicas: 20
  metrics:
    # Scala su CPU (fallback)
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    # Scala su custom metric: inference latency P99
    - type: Pods
      pods:
        metric:
          name: inference_request_duration_p99
        target:
          type: AverageValue
          averageValue: "500m"  # 500ms P99 latency target
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 30    # Reazione rapida al traffico
      policies:
        - type: Percent
          value: 100
          periodSeconds: 30
    scaleDown:
      stabilizationWindowSeconds: 300   # Lento a rimuovere pod (warm models)
# VPA (Vertical Pod Autoscaler): ottimizza resources requests/limits
# NOTA: non usare VPA e HPA sulle stesse metriche CPU/Memory!
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: batch-trainer-vpa
  namespace: ml-training
spec:
  targetRef:
    apiVersion: batch/v1
    kind: Job
    name: model-training
  updatePolicy:
    updateMode: "Off"  # Solo raccomandazioni, non applica auto (Off | Initial | Recreate)
  resourcePolicy:
    containerPolicies:
      - containerName: trainer
        minAllowed:
          cpu: "1"
          memory: 4Gi
        maxAllowed:
          cpu: "16"
          memory: 128Gi
        controlledResources: ["cpu", "memory"]

# Leggi le raccomandazioni VPA
kubectl describe vpa batch-trainer-vpa
# Output:
#   Recommendation:
#     Container Recommendations:
#       Container Name: trainer
#         Lower Bound:  cpu: 2, memory: 8Gi
#         Target:       cpu: 6, memory: 32Gi
#         Upper Bound:  cpu: 12, memory: 64Gi

KEDA (Kubernetes Event-Driven Autoscaler, CNCF graduated project) e lo strumento più potente per i workload ML event-driven: scala i pod basandosi su eventi da code di messaggi, database, metriche Prometheus o trigger HTTP, permettendo anche lo scale-to-zero per batch processing.

# KEDA: scala i worker ML in base alla coda di inference requests
# Installazione
helm repo add kedacore https://kedacore.github.io/charts
helm install keda kedacore/keda --namespace keda --create-namespace

# ScaledObject per batch ML processing da RabbitMQ
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: ml-batch-processor-scaler
  namespace: ml-serving
spec:
  scaleTargetRef:
    name: batch-ml-processor
  minReplicaCount: 0    # Scale-to-zero quando coda vuota
  maxReplicaCount: 30   # Max 30 worker per GPU cluster
  pollingInterval: 15   # Controlla la coda ogni 15 secondi
  cooldownPeriod: 60    # Aspetta 60s prima di scale-down
  triggers:
    - type: rabbitmq
      metadata:
        host: amqp://rabbitmq.ml-serving.svc.cluster.local
        queueName: inference-requests
        mode: QueueLength
        value: "10"    # 1 pod ogni 10 messaggi in coda

    # Trigger alternativo: Prometheus metric
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring.svc.cluster.local:9090
        metricName: inference_queue_depth
        query: sum(inference_queue_depth{namespace="ml-serving"})
        threshold: "50"   # 1 replica ogni 50 richieste pendenti

Pattern Autoscaling per ML: Raccomandazione

Combina i tre meccanismi con responsabilità distinte:
- HPA su latency/RPS per inference online (reattivo, veloce)
- VPA in modalità Off per ottimizzare le requests di training job (consulta e aggiorna manualmente)
- KEDA per batch processing e pipeline event-driven (scale-to-zero incluso)
Non usare HPA e VPA sulla stessa risorsa (CPU/Memory) contemporaneamente: i conflitti di scaling causano oscillazioni imprevedibili e sprechi di risorse.

Resource Management e Priority Classes

Su un cluster condiviso tra team ML diversi, la gestione delle risorse e fondamentale per evitare che un job di training blocchi l'inferenza in produzione o che un esperimento consumi tutte le GPU disponibili. Kubernetes offre tre strumenti: ResourceQuota, LimitRange e PriorityClass.

# ResourceQuota: limita risorse per namespace
apiVersion: v1
kind: ResourceQuota
metadata:
  name: ml-serving-quota
  namespace: ml-serving
spec:
  hard:
    requests.cpu: "40"
    requests.memory: 160Gi
    limits.cpu: "80"
    limits.memory: 320Gi
    requests.nvidia.com/gpu: "4"    # Max 4 GPU per inference namespace
    limits.nvidia.com/gpu: "4"
    pods: "50"

---
# LimitRange: imposta defaults e limiti per singolo container
apiVersion: v1
kind: LimitRange
metadata:
  name: ml-container-limits
  namespace: ml-serving
spec:
  limits:
    - type: Container
      default:           # Default limits se non specificati
        cpu: "2"
        memory: 4Gi
      defaultRequest:    # Default requests se non specificati
        cpu: "500m"
        memory: 1Gi
      max:               # Massimo per container
        cpu: "8"
        memory: 32Gi
        nvidia.com/gpu: "2"
      min:               # Minimo per container
        cpu: "100m"
        memory: 256Mi

---
# PriorityClass: garantisce che l'inferenza non venga preemptata dal training
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: ml-inference-critical
value: 1000000           # Alta priorità per serving
globalDefault: false
description: "Inference services critici - non preemptabili"

---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: ml-training-batch
value: 100000            # Bassa priorità per training
preemptionPolicy: PreemptLowerPriority
description: "Training jobs - preemptabili se necessario"

Il Node Affinity e i Taints/Tolerations permettono di controllare con precisione su quali nodi vengono schedulati i workload ML, garantendo che i job di training non competano con l'inferenza sulle stesse GPU:

# Taint nodi GPU dedicati all'inferenza
kubectl taint nodes gpu-inference-1 dedicated=inference:NoSchedule
kubectl taint nodes gpu-inference-2 dedicated=inference:NoSchedule

# Solo pod con toleration possono usare questi nodi
# InferenceService spec con affinity e toleration
spec:
  predictor:
    tolerations:
      - key: dedicated
        operator: Equal
        value: inference
        effect: NoSchedule
    affinity:
      nodeAffinity:
        requiredDuringSchedulingIgnoredDuringExecution:
          nodeSelectorTerms:
            - matchExpressions:
                - key: accelerator
                  operator: In
                  values:
                    - nvidia-a100
                    - nvidia-h100
      podAntiAffinity:
        preferredDuringSchedulingIgnoredDuringExecution:
          - weight: 100
            podAffinityTerm:
              labelSelector:
                matchLabels:
                  app: churn-predictor
              topologyKey: kubernetes.io/hostname
              # Distribuisce repliche su host diversi per HA

Monitoring con Prometheus e Grafana per ML

Il monitoring di un sistema ML su Kubernetes richiede metriche a due livelli: le metriche infrastrutturali standard di Kubernetes (CPU, memoria, rete) e le metriche specifiche dell'inferenza ML (latenza per modello, throughput, error rate, data drift signal). KServe e Seldon espongono automaticamente metriche Prometheus nel formato standard.

# Configurazione Prometheus per scraping KServe metrics
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: kserve-inference-monitor
  namespace: ml-monitoring
  labels:
    release: prometheus
spec:
  namespaceSelector:
    matchNames:
      - ml-serving
  selector:
    matchLabels:
      serving.kserve.io/inferenceservice: "true"
  endpoints:
    - port: metrics
      interval: 15s
      path: /metrics
      honorLabels: true

# Metriche KServe esposte automaticamente:
# kserve_inference_request_total{model_name, namespace, status_code}
# kserve_inference_request_duration_seconds{model_name, quantile}
# kserve_inference_request_size_bytes{model_name}
# kserve_inference_response_size_bytes{model_name}

# PrometheusRule: alert per latenza elevata
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: ml-inference-alerts
  namespace: ml-monitoring
spec:
  groups:
    - name: ml-inference.rules
      rules:
        - alert: HighInferenceLatency
          expr: |
            histogram_quantile(0.99,
              rate(kserve_inference_request_duration_seconds_bucket[5m])
            ) > 1.0
          for: 5m
          labels:
            severity: warning
          annotations:
            summary: "P99 latency > 1s per {{ $labels.model_name }}"
            description: "Modello {{ $labels.model_name }} ha latenza P99 di {{ $value }}s"

        - alert: ModelErrorRateHigh
          expr: |
            rate(kserve_inference_request_total{status_code!="200"}[5m])
            / rate(kserve_inference_request_total[5m]) > 0.05
          for: 2m
          labels:
            severity: critical
          annotations:
            summary: "Error rate > 5% per {{ $labels.model_name }}"
# Dashboard Grafana: query principali per ML inference monitoring
# (da importare come JSON o configurare manualmente)

# 1. Throughput modello (req/sec)
rate(kserve_inference_request_total[5m])

# 2. Latenza P50, P95, P99
histogram_quantile(0.50, rate(kserve_inference_request_duration_seconds_bucket[5m]))
histogram_quantile(0.95, rate(kserve_inference_request_duration_seconds_bucket[5m]))
histogram_quantile(0.99, rate(kserve_inference_request_duration_seconds_bucket[5m]))

# 3. GPU Utilization per nodo (richiede NVIDIA DCGM Exporter)
DCGM_FI_DEV_GPU_UTIL{namespace="ml-serving"}

# 4. GPU Memory in uso
DCGM_FI_DEV_FB_USED{namespace="ml-serving"} /
DCGM_FI_DEV_FB_TOTAL{namespace="ml-serving"} * 100

# 5. Numero repliche attive per modello
kube_deployment_status_replicas_available{
  namespace="ml-serving",
  deployment=~".*-predictor.*"
}

# 6. Scaling events (utile per debug autoscaler)
kube_horizontalpodautoscaler_status_desired_replicas{
  namespace="ml-serving"
}

Cost Optimization per Cluster ML

Le GPU sono la voce di costo dominante di un cluster ML: una NVIDIA A100 SXM4 costa circa $2-3/ora su cloud, un H100 $4-5/ora. Su un cluster di 20 GPU, il costo mensile può superare i $100.000. L'ottimizzazione dei costi non e opzionale.

Strategie di Cost Optimization

  • Spot/Preemptible instances per training: risparmio del 60-80% per job tolleranti alle interruzioni. Usa checkpoint frequenti e Argo Workflows per resume automatico.
  • Scale-to-zero per modelli occasionali: KServe con minReplicas=0 azzera il costo delle GPU quando il modello non riceve traffico.
  • GPU Time-Slicing per inferenza leggera: 4-8 modelli per GPU fisica riducono il costo per modello di 4-8x.
  • Cluster Autoscaler con node pools misti: nodi GPU aggiunti/rimossi automaticamente in base al carico reale del cluster.
  • Node consolidation con Karpenter: consolida pod su meno nodi prima di terminare i nodi vuoti (risparmio 20-40% su cluster con utilizzo variabile).
# Cluster Autoscaler per node pool GPU
# (esempio GKE, ma concettualmente uguale per EKS/AKS)
apiVersion: v1
kind: ConfigMap
metadata:
  name: cluster-autoscaler-config
  namespace: kube-system
data:
  config.yaml: |
    nodeGroups:
      - name: gpu-a100-pool
        minSize: 0           # Scala a zero se nessun workload GPU
        maxSize: 10          # Max 10 nodi A100
        machineType: a2-highgpu-1g
    expander: least-waste    # Usa il nodo che spreca meno risorse
    scaleDownUnneededTime: 10m    # Rimuovi nodo inutilizzato dopo 10 min
    scaleDownUtilizationThreshold: 0.5  # Scala down se utilizzo < 50%
    skipNodesWithSystemPods: false

# Cost tracking con labels obbligatorie su tutti i workload
# Ogni Job/Deployment deve avere questi labels per tracking
metadata:
  labels:
    cost-center: "data-science"
    project: "churn-prediction"
    environment: "production"
    model-version: "v3"

Deploy Completo: Pipeline End-to-End

Vediamo come integrare tutti i componenti in un deployment Helm completo per un modello di produzione, combinando KServe, Prometheus monitoring e autoscaling con KEDA:

# Helm Chart structure per ML service completo
# charts/ml-inference-service/
# ├── Chart.yaml
# ├── values.yaml
# └── templates/
#     ├── inference-service.yaml
#     ├── hpa.yaml
#     ├── service-monitor.yaml
#     └── network-policy.yaml

# values.yaml
model:
  name: "churn-predictor"
  version: "v3"
  storageUri: "gs://my-ml-bucket/models/churn-model/v3"
  framework: "sklearn"
  runtimeVersion: "1.5.2"

scaling:
  minReplicas: 2
  maxReplicas: 20
  targetRPS: 50
  targetLatencyP99: "500m"

resources:
  requests:
    cpu: "500m"
    memory: "1Gi"
  limits:
    cpu: "2"
    memory: "4Gi"

monitoring:
  enabled: true
  prometheusNamespace: "ml-monitoring"
  grafanaDashboard: true

canary:
  enabled: false
  weight: 0

# Deploy
helm upgrade --install churn-predictor ./charts/ml-inference-service \
  --namespace ml-serving \
  --values production-values.yaml \
  --wait --timeout 10m

# Verifica deployment
kubectl get inferenceservice -n ml-serving
# NAME               URL                      READY   PREV   LATEST   ...
# churn-predictor    http://churn-predictor   True    0      100      ...

Best Practices e Anti-Pattern

Dopo aver visto l'implementazione tecnica, ecco le lezioni apprese da deployment ML su Kubernetes in ambienti enterprise:

Best Practices

  • Sempre specificare resource requests E limits: senza requests, il scheduler non può piazzare correttamente i pod. Senza limits, un modello OOM può destabilizzare l'intero nodo.
  • Usa readiness probe specifiche per ML: un container può essere "running" ma il modello ancora in fase di loading. La readiness probe deve verificare che il modello sia effettivamente pronto a servire.
  • Pre-pull delle immagini sui nodi GPU: le immagini PyTorch con CUDA superano spesso i 10GB. Usa DaemonSet o image pre-caching per evitare cold start elevati.
  • Separa namespace per ambienti: staging e production su namespace diversi con ResourceQuota distinte evita interferenze accidentali.
  • Implementa circuit breaker: se il modello ha error rate > 10%, interrompi il traffico automaticamente con Istio o un sidecar proxy.
  • Versioning esplicito dei modelli: ogni InferenceService deve avere un tag di versione nel nome o nelle label. Mai usare "latest" in produzione.

Anti-Pattern da Evitare

  • Training su nodi di inferenza: i job di training saturano CPU/GPU e causano latency spike sui modelli in produzione. Usa sempre node pools separati con taints.
  • HPA su CPU per modelli GPU-bound: la CPU può essere bassa mentre la GPU e satura. Usa sempre metriche custom (latency, RPS, GPU utilization) per GPU workloads.
  • Nessun graceful shutdown: i pod ML devono completare le richieste in corso prima di terminare. Configura sempre terminationGracePeriodSeconds >= 30s.
  • Modelli in immagini Docker: includere i pesi del modello nell'immagine Docker rende gli aggiornamenti lenti e le immagini enormi. Usa model storage separato (S3, GCS).
  • Nessun budget di disruption: senza PodDisruptionBudget, un aggiornamento del cluster può rimuovere tutte le repliche del modello simultaneamente.

Budget per PMI: Iniziare con Meno di 5.000 EUR/Anno

Non serve un cluster enterprise da $100K/mese per iniziare con ML su Kubernetes. Ecco uno stack realistico per una PMI con budget limitato:

  • Cluster K3s su VM cloud (2 nodi, 8 vCPU, 32GB RAM): circa 150-200 EUR/mese. K3s e la distribuzione Kubernetes lightweight di Rancher, perfetta per cluster piccoli.
  • 1 nodo GPU NVIDIA T4 (spot instance): 0.35-0.50 EUR/ora, circa 120-180 EUR/mese se usato 12 ore/giorno. Scale-to-zero quando non serve.
  • KServe + MLflow + Prometheus: tutti gratuiti, open-source, installabili con Helm in 30 minuti.
  • Storage S3-compatible (MinIO self-hosted): 0 costi di licensing, solo storage. 100GB di modelli e dataset: circa 2-5 EUR/mese su object storage cloud.

Totale stimato: 300-400 EUR/mese, meno di 5.000 EUR/anno per un ambiente production-ready con GPU, monitoring completo e autoscaling. Per confronto, SageMaker con configurazione equivalente costerebbe 3-5x di più.

Conclusioni

Kubernetes e lo standard industriale per il deployment di modelli ML in produzione non per caso: offre la combinazione unica di GPU scheduling, autoscaling event-driven, isolamento dei workload e ecosystem di tool specializzati (KServe, Seldon, KEDA) che nessun'altra piattaforma può eguagliare in termini di flessibilità e costo a lungo termine.

Il percorso ottimale per chi inizia: configura un cluster K3s con un nodo GPU, installa KServe per il serving del primo modello, aggiungi Prometheus e Grafana per il monitoring, e solo quando il cluster cresce oltre 5-10 modelli in produzione investi in KEDA e Seldon Core per architetture più complesse. La complessità di Kubernetes si ripaga solo quando il volume di workload la giustifica.

Nel prossimo articolo della serie esploriamo la Governance ML: come garantire compliance con l'AI Act EU, implementare explainability con SHAP e LIME, gestire audit trail e fairness dei modelli in produzione.

Articoli Correlati in Questa Serie

Cross-Serie

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti