Despliegue de LLMs a Escala con Kubernetes

¿Has intentado llevar un modelo de lenguaje de un simple script de Python a un entorno de producción real? Pasar de «funciona en mi Jupyter Notebook» a «soporta cientos de peticiones concurrentes con baja latencia» es un salto cuántico. Si te lanzas a desplegar LLMs sin una estrategia de orquestación, te encontrarás rápidamente con un caos de errores de memoria, costos de GPU disparados y servicios que se caen en cuanto llega el primer pico de tráfico.

Para escalar de verdad, necesitas la combinación ganadora: Kubernetes para la orquestación y vLLM para la inferencia de alto rendimiento. En esta guía, vamos a construir una infraestructura robusta para desplegar y escalar modelos de lenguaje grandes (LLMs) utilizando estrategias de optimización avanzadas, desde el auto-scaling basado en métricas reales hasta la gestión de costos con instancias Spot.

🎯 Objetivos de Aprendizaje

Después de completar esta guía, podrás:

  • Desplegar LLMs usando vLLM en Kubernetes
  • Configurar auto-scaling basado en métricas de GPU
  • Implementar estrategias de caching y optimización
  • Gestionar múltiples modelos en producción
  • Monitorear rendimiento y costos de inferencia

📋 Prerrequisitos

  • Conocimientos básicos de Kubernetes
  • Experiencia con Docker y Helm
  • Familiaridad con LLMs y vLLM
  • Cluster Kubernetes con GPUs (opcional pero recomendado)

🏗️ Arquitectura de Despliegue

Componentes Principales

Antes de escribir una sola línea de YAML, necesitamos entender cómo encajan las piezas en el ecosistema. No es solo levantar un contenedor; es crear un flujo donde el tráfico entra, se distribuye y se escala según la demanda real de inferencia.

Diagrama Mermaid

Estrategias de Despliegue

Dependiendo de tu carga de trabajo, puedes elegir entre distintos patrones:

  1. Single Model per Pod: Aislamiento completo para máxima estabilidad.
  2. Multi-Model per Pod: Optimización de recursos si tienes modelos pequeños.
  3. Model Sharding: Distribución de modelos grandes (como Llama 3 70B) en múltiples GPUs.
  4. Dynamic Loading: Carga de modelos bajo demanda para ahorrar costes.

🚀 Despliegue Básico con vLLM

1. Preparación del Cluster

Lo primero es asegurarnos de que Kubernetes «ve» tus GPUs. Si no tienes instalado el operador de NVIDIA, el cluster tratará a tus nodos como máquinas CPU estándar y el despliegue fallará.

# Verificar GPUs disponibles
kubectl get nodes -o json | jq '.items[].status.capacity."nvidia.com/gpu"'

# Instalar NVIDIA GPU Operator (si no está instalado)
helm repo add nvidia https://nvidia.github.io/gpu-operator
helm repo update
helm install gpu-operator nvidia/gpu-operator \
  --create-namespace \
  --namespace gpu-operator

2. Crear Namespace y ConfigMaps

Vamos a organizar el caos creando un espacio dedicado y una configuración centralizada. Usar un ConfigMap nos permite cambiar parámetros del modelo (como el DTYPE o la longitud máxima) sin tener que reconstruir la imagen de Docker.

# vllm-namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: vllm-system
  labels:
    name: vllm-system
# vllm-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: vllm-config
  namespace: vllm-system
data:
  MODEL_NAME: "microsoft/DialoGPT-medium"
  MODEL_REVISION: "main"
  DTYPE: "float16"
  MAX_MODEL_LEN: "2048"
  GPU_MEMORY_UTILIZATION: "0.9"
  MAX_NUM_SEQS: "256"
  TENSOR_PARALLEL_SIZE: "1"

3. Despliegue con Helm

Aquí es donde ocurre la magia. Al definir el Deployment, es crucial configurar correctamente los recursos y, sobre todo, los probes.

# vllm-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-deployment
  namespace: vllm-system
spec:
  replicas: 1
  selector:
    matchLabels:
      app: vllm
  template:
    metadata:
      labels:
        app: vllm
    spec:
      containers:
      - name: vllm
        image: vllm/vllm-openai:latest
        ports:
        - containerPort: 8000
        envFrom:
        - configMapRef:
            name: vllm-config
        resources:
          limits:
            nvidia.com/gpu: 1
          requests:
            nvidia.com/gpu: 1
        # startupProbe: cubre la carga del modelo. Hasta que no pasa,
        # liveness y readiness ni siquiera se evalúan.
        # 60 fallos x 10s = 10 minutos de margen para cargar los pesos.
        startupProbe:
          httpGet:
            path: /health
            port: 8000
          failureThreshold: 60
          periodSeconds: 10
        livenessProbe:
          httpGet:
            path: /health
            port: 8000
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /health
            port: 8000
          periodSeconds: 5

Sin startupProbe el pod entra en CrashLoopBackOff

Un livenessProbe con initialDelaySeconds: 30 da por muerto al contenedor a los 30 segundos. Cargar los pesos de un modelo de 13B desde disco a la GPU tarda varios minutos, así que Kubernetes lo mata justo antes de que termine de arrancar, lo reinicia, y vuelve a matarlo: un bucle infinito que además consume la GPU en cada intento.

El startupProbe existe precisamente para esto: mientras no tiene éxito, los otros dos probes quedan suspendidos. Ajusta failureThreshold al tiempo real de carga de tu modelo con margen — es preferible pasarse que quedarse corto.

4. Servicio e Ingress

Para que tu aplicación pueda hablar con el modelo, necesitamos exponerlo mediante un Service y, para el acceso externo, un Ingress.

# vllm-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: vllm-service
  namespace: vllm-system
spec:
  selector:
    app: vllm
  ports:
  - port: 80
    targetPort: 8000
  type: ClusterIP
# vllm-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: vllm-ingress
  namespace: vllm-system
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  rules:
  - host: vllm.yourdomain.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: vllm-service
            port:
              number: 80

📊 Auto-Scaling con HPA

Si tu servicio recibe un pico de tráfico, no quieres que la latencia se dispare. Pero ojo: escalar LLMs no es igual que escalar una web de microservicios.

Configuración de Métricas

Primero, asegúrate de tener el metrics-server para que el HPA tenga datos de dónde tirar.

# metrics-server.yaml (si no está instalado)
apiVersion: v1
kind: ServiceAccount
metadata:
  name: metrics-server
  namespace: kube-system
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: metrics-server
  namespace: kube-system
spec:
  template:
    spec:
      containers:
      - name: metrics-server
        image: k8s.gcr.io/metrics-server/metrics-server:v0.6.3
        args:
        - --kubelet-insecure-tls
        - --kubelet-preferred-address-types=InternalIP

HPA para vLLM

El truco está en las métricas. No queremos escalar por CPU, queremos escalar por la carga real de inferencia.

# vllm-hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: vllm-hpa
  namespace: vllm-system
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: vllm-deployment
  minReplicas: 1
  maxReplicas: 10
  metrics:
  # Métrica principal: peticiones esperando en la cola del scheduler.
  # Es la que refleja de verdad que el servicio no da abasto.
  - type: Pods
    pods:
      metric:
        name: vllm:num_requests_waiting
      target:
        type: AverageValue
        averageValue: "5"
  # Secundaria: ocupación de la KV cache. Cerca del 100% significa
  # que no caben más peticiones concurrentes por mucha GPU libre que haya.
  - type: Pods
    pods:
      metric:
        name: vllm:kv_cache_usage_perc
      target:
        type: AverageValue
        averageValue: "900m"   # 0.9 = 90%
  behavior:
    scaleUp:
      # Un pod nuevo tarda minutos en cargar el modelo: no sirve
      # de nada escalar agresivamente, solo duplica el consumo de GPU.
      stabilizationWindowSeconds: 120
    scaleDown:
      stabilizationWindowSeconds: 600

No escales por CPU en un servicio de inferencia

Es el error más habitual al reutilizar el HPA de una aplicación web. Durante la generación de tokens el proceso está esperando a la GPU, no calculando: la CPU se queda baja aunque el servicio esté saturado y la cola creciendo. Un HPA por CPU al 70% sencillamente no dispara nunca, o dispara cuando ya da igual.

Las dos métricas que sí correlacionan con saturación real son la longitud de la cola (vllm:num_requests_waiting) y la ocupación de la KV cache, que es lo que limita de verdad la concurrencia. Requieren prometheus-adapter para exponerlas al HPA. Tienes el detalle de ambas en Monitoreo de LLMs.

🔧 Optimizaciones Avanzadas

Una vez que el despliegue básico funciona, el siguiente paso es exprimir cada ciclo de la GPU y cada GB de memoria.

1. Model Caching y Warm-up

Evita que cada nuevo Pod tenga que descargar gigabytes de pesos desde Hugging Face. Usar un initContainer para precargar el modelo en un volumen compartido acelera el arranque drásticamente.

# vllm-deployment-optimized.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-deployment-optimized
  namespace: vllm-system
spec:
  template:
    spec:
      initContainers:
      - name: model-cache
        image: vllm/vllm-openai:latest
        command: ["/bin/sh", "-c"]
        args:
        - |
          python -c "
          from vllm import LLM
          llm = LLM(model='microsoft/DialoGPT-medium', download_dir='/tmp/models')
          print('Model cached successfully')
          "
        volumeMounts:
        - name: model-cache
          mountPath: /tmp/models
      containers:
      - name: vllm
        image: vllm/vllm-openai:latest
        env:
        - name: VLLM_CACHE_DIR
          value: /tmp/models
        volumeMounts:
        - name: model-cache
          mountPath: /tmp/models
      volumes:
      - name: model-cache
        emptyDir: {}

2. Multi-Model Deployment

Si tienes recursos sobrantes, puedes servir varios modelos ligeros desde una misma configuración.

# multi-model-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: multi-model-config
  namespace: vllm-system
data:
  models.json: |
    [
      {
        "name": "gpt2-medium",
        "model": "microsoft/DialoGPT-medium",
        "max_model_len": 1024
      },
      {
        "name": "gpt2-large",
        "model": "microsoft/DialoGPT-large",
        "max_model_len": 1024
      }
    ]

3. GPU Memory Optimization

Para maximizar la concurrencia, ajustamos cómo vLLM gestiona la memoria de la GPU. El chunked_prefill es clave para evitar picos de latencia.

# vllm-gpu-optimized.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-gpu-optimized
  namespace: vllm-system
spec:
  template:
    spec:
      containers:
      - name: vllm
        env:
        - name: VLLM_GPU_MEMORY_UTILIZATION
          value: "0.95"
        - name: VLLM_MAX_NUM_SEQS
          value: "128"
        - name: VLLM_MAX_NUM_BATCHED_TOKENS
          value: "4096"
        - name: VLLM_ENABLE_CHUNKED_PREFILL
          value: "true"
        resources:
          limits:
            nvidia.com/gpu: 1
            memory: 32Gi
          requests:
            nvidia.com/gpu: 1
            memory: 16Gi

📈 Monitoreo y Observabilidad

No puedes optimizar lo que no puedes medir. vLLM expone métricas nativas que debemos capturar.

Métricas de vLLM

Configuramos un ServiceMonitor para que Prometheus recoja las métricas automáticamente.

# prometheus-service-monitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: vllm-monitor
  namespace: vllm-system
spec:
  selector:
    matchLabels:
      app: vllm
  endpoints:
  - port: metrics
    path: /metrics
    interval: 30s

Dashboard de Grafana

Con estos datos, puedes montar un panel en Grafana que te diga exactamente qué está pasando con la latencia y la utilización de la GPU.

{
  "dashboard": {
    "title": "vLLM Performance Dashboard",
    "panels": [
      {
        "title": "GPU Utilization",
        "type": "graph",
        "targets": [
          {
            "expr": "nvidia_gpu_utilization{namespace=\"vllm-system\"}",
            "legendFormat": "{{pod}}"
          }
        ]
      },
      {
        "title": "Request Latency",
        "type": "graph",
        "targets": [
          {
            "expr": "histogram_quantile(0.95, rate(vllm_request_duration_seconds_bucket[5m]))",
            "legendFormat": "95th percentile"
          }
        ]
      }
    ]
  }
}

🔄 Estrategias de Actualización

Actualizar un modelo o la versión de vLLM en producción sin interrumpir el servicio es vital.

Rolling Updates

La forma estándar de Kubernetes para actualizar gradualmente sin downtime.

# vllm-deployment-rolling.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-deployment
  namespace: vllm-system
spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    # ... resto de la configuración

Blue-Green Deployment

Para cambios críticos, preferimos un despliegue Blue-Green donde levantamos la versión nueva por completo antes de redirigir el tráfico.

# blue-green-deployment.sh
#!/bin/bash

# Crear nueva versión (green)
kubectl apply -f vllm-deployment-green.yaml

# Esperar a que esté listo
kubectl wait --for=condition=available --timeout=300s deployment/vllm-deployment-green -n vllm-system

# Cambiar el servicio al green
kubectl patch service vllm-service -n vllm-system -p '{"spec":{"selector":{"version":"green"}}}'

# Verificar que funciona
# ... tests ...

# Eliminar blue
kubectl delete deployment vllm-deployment-blue -n vllm-system

🛡️ Seguridad y Compliance

Network Policies

No permitas que cualquier Pod en el cluster acceda a tu servicio de inferencia. Restringe el acceso solo desde tu Ingress.

# vllm-network-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: vllm-network-policy
  namespace: vllm-system
spec:
  podSelector:
    matchLabels:
      app: vllm
  policyTypes:
  - Ingress
  - Egress
  ingress:
  - from:
    - namespaceSelector:
        matchLabels:
          name: ingress-nginx
    ports:
    - protocol: TCP
      port: 8000
  egress:
  - to:
    - podSelector:
        matchLabels:
          k8s-app: kube-dns
    ports:
    - protocol: UDP
      port: 53

Secret Management

base64 no es cifrado

Un Secret de Kubernetes guarda el valor en base64, que es codificación, no cifrado: cualquiera que lea el YAML lo descodifica con un comando. Un manifiesto así no se versiona en Git jamás.

Para GitOps usa SOPS, Sealed Secrets o External Secrets Operator. Tienes la comparativa de los tres en Secretos en GitOps.

Referencia del objeto (para crearlo desde línea de comandos, no para commitearlo):

# Crea el Secret sin que el token pase nunca por un archivo del repo
kubectl create secret generic vllm-secrets \
  --namespace vllm-system \
  --from-literal=huggingface-token="$HF_TOKEN" \
  --from-literal=api-key="$VLLM_API_KEY"
# Así lo consume el Deployment: por referencia, sin exponer el valor
env:
  - name: HUGGING_FACE_HUB_TOKEN
    valueFrom:
      secretKeyRef:
        name: vllm-secrets
        key: huggingface-token

📊 Cost Optimization

La GPU es el recurso más caro. Si no la gestionas bien, la factura de la nube te sorprenderá.

Spot Instances y Preemptible

Si tu aplicación puede tolerar pequeñas interrupciones (o tienes un buen HPA), usa instancias Spot para ahorrar hasta un 70-90%.

# spot-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-spot
  namespace: vllm-system
spec:
  template:
    spec:
      tolerations:
      - key: "cloud.google.com/gke-spot"
        operator: "Equal"
        value: "true"
        effect: "NoSchedule"
      nodeSelector:
        cloud.google.com/gke-spot: "true"
      # ... resto de configuración

Auto-scaling basado en costos

Puedes incluso configurar el HPA para que sea consciente del coste horario de tu proveedor.

# cost-based-hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: vllm-cost-hpa
  namespace: vllm-system
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: vllm-deployment
  minReplicas: 1
  maxReplicas: 5
  metrics:
  - type: External
    external:
      metric:
        name: cloud_provider_cost_per_hour
      target:
        type: AverageValue
        averageValue: 2.0

🔍 Troubleshooting

Problemas Comunes

  1. Out of Memory (OOM):

    # Verificar logs
    kubectl logs -f deployment/vllm-deployment -n vllm-system
    
    # Ajustar configuración
    kubectl edit configmap vllm-config -n vllm-system
    
  2. GPU Not Available:

    # Verificar GPU allocation
    kubectl describe node <node-name>
    
    # Check GPU operator
    kubectl get pods -n gpu-operator
    
  3. Slow Inference:

    # Verificar métricas
    kubectl exec -it deployment/vllm-deployment -n vllm-system -- curl http://localhost:8000/metrics
    
    # Ajustar batch size
    kubectl edit configmap vllm-config -n vllm-system
    

🎯 Mejores Prácticas

Para resumir, si quieres un despliegue de nivel producción, sigue estos pilares:

Performance

  • Usa GPU A100/H100 para mejor rendimiento
  • Configura tensor_parallel_size para múltiples GPUs
  • Implementa caching de modelos
  • Monitorea constantemente métricas

Reliability

  • Implementa health checks apropiados
  • Usa rolling updates para zero-downtime
  • Configura resource limits y requests
  • Implementa circuit breakers

Security

  • Usa secrets para tokens de API
  • Implementa network policies
  • Audita logs de acceso
  • Mantén modelos actualizados

Cost Management

  • Usa spot instances cuando sea posible
  • Implementa auto-scaling inteligente
  • Monitorea costos en tiempo real
  • Optimiza uso de GPU

📚 Recursos Adicionales

🤝 Contribuir

Esta guía es parte del proyecto Frikiteam Docs. Si encuentras errores o quieres contribuir mejoras:

  1. Fork el repositorio
  2. Crea una rama para tu feature
  3. Envía un Pull Request

¡Gracias por contribuir al conocimiento compartido!

Deja un comentario