Ollama en Kubernetes: Despliega tu LLM local en un cluster real

Antes de empezar
En el post anterior construimos una app tipo ChatGPT que corre 100% en tu máquina con Ollama, FastAPI y streaming en tiempo real. Al final dejamos una pregunta abierta: ¿y si metemos esto en un cluster de Kubernetes?
Hoy vamos a responder esa pregunta. Vamos a desplegar Ollama como un servicio dentro de Kubernetes, conectar nuestra app de chat, configurar recursos, autoescalado y hablar de algo fundamental: la GPU.
Y al final, vamos a ser honestos sobre lo que falta para que esto sea “producción de verdad” — porque desplegar un LLM es solo la mitad del camino.
Arquitectura
Esto es lo que vamos a construir:
- Ingress expone la app al exterior.
- Chat App es nuestra FastAPI del post anterior, con HPA para escalar según demanda.
- Ollama corre el modelo LLM, idealmente en un nodo con GPU.
- PVC persiste los modelos descargados para que no se re-descarguen en cada reinicio.
Pre-requisitos
| Herramienta | Versión | Para qué |
|---|---|---|
| OrbStack | 1.9+ | Kubernetes local en macOS |
| kubectl | 1.28+ | Gestión del cluster |
| Helm | 3.x | Instalación de charts (opcional) |
| Ollama local | 0.18+ | Para probar antes de desplegar |
Paso 1: Preparar el cluster local con OrbStack
OrbStack incluye un cluster de Kubernetes que puedes activar desde la app o por terminal:
# Verificar que Kubernetes esté activo en OrbStack
kubectl cluster-infoDeberías ver algo como:
Kubernetes control plane is running at https://127.0.0.1:26443Si usas otra herramienta, estos son los equivalentes:
| Herramienta | Comando para crear cluster | Plataforma |
|---|---|---|
| OrbStack | Activar en Settings → Kubernetes | macOS |
| Rancher Desktop | Activar en Preferences → Kubernetes | macOS, Windows, Linux |
| minikube | minikube start --memory=8g --cpus=4 | macOS, Windows, Linux |
| kind | kind create cluster | macOS, Windows, Linux |
| Docker Desktop | Activar en Settings → Kubernetes | macOS, Windows |
Paso 2: Crear el namespace y el PVC
Primero, un namespace dedicado y un volumen persistente para los modelos:
# ollama-namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: llm
labels:
app: ollama
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ollama-models
namespace: llm
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gikubectl apply -f ollama-namespace.yaml¿Por qué un PVC? Porque los modelos de Ollama pesan varios GB. Sin persistencia, cada vez que el pod se reinicie, tendría que descargar el modelo de nuevo. Con el PVC, el modelo se descarga una vez y queda guardado.
Paso 3: Desplegar Ollama
Ahora el deployment de Ollama:
# ollama-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ollama
namespace: llm
labels:
app: ollama
spec:
replicas: 1
selector:
matchLabels:
app: ollama
template:
metadata:
labels:
app: ollama
spec:
containers:
- name: ollama
image: ollama/ollama:latest
ports:
- containerPort: 11434
resources:
requests:
cpu: "2"
memory: 4Gi
limits:
cpu: "4"
memory: 8Gi
volumeMounts:
- name: models
mountPath: /root/.ollama
readinessProbe:
httpGet:
path: /
port: 11434
initialDelaySeconds: 10
periodSeconds: 5
livenessProbe:
httpGet:
path: /
port: 11434
initialDelaySeconds: 30
periodSeconds: 10
volumes:
- name: models
persistentVolumeClaim:
claimName: ollama-models
---
apiVersion: v1
kind: Service
metadata:
name: ollama
namespace: llm
spec:
selector:
app: ollama
ports:
- port: 11434
targetPort: 11434
type: ClusterIPkubectl apply -f ollama-deployment.yamlSobre los recursos: ¿por qué estos valores?
Aquí vale la pena detenernos y hablar de los números:
| Recurso | Request | Limit | Por qué |
|---|---|---|---|
| CPU | 2 cores | 4 cores | La inferencia de un LLM es CPU-intensiva. 2 cores es el mínimo para que no se arrastre; 4 le da espacio para picos |
| Memoria | 4 Gi | 8 Gi | Llama 3.1 (8B) necesita ~4.7 GB solo para cargar el modelo en memoria. El request garantiza que el scheduler le asigne un nodo con suficiente RAM |
- Request = lo mínimo que el pod necesita para funcionar. El scheduler usa esto para decidir en qué nodo colocar el pod.
- Limit = el máximo que puede consumir. Si lo supera, Kubernetes lo mata (OOMKilled).
Para Ollama, el request de memoria es crítico: si es muy bajo, el scheduler puede colocar el pod en un nodo sin suficiente RAM y el modelo no carga. Si el limit es muy bajo, el pod muere en cuanto empiece a procesar un prompt largo.
Tabla de referencia por modelo
| Modelo | Parámetros | RAM mínima | CPU mínima | GPU recomendada |
|---|---|---|---|---|
| Llama 3.1 | 8B | 4.7 GB | 2 cores | 6 GB VRAM |
| Llama 3.1 | 70B | 40 GB | 8 cores | 48 GB VRAM |
| Mistral | 7B | 4.1 GB | 2 cores | 6 GB VRAM |
| Phi-4 | 14B | 8.5 GB | 2 cores | 10 GB VRAM |
| Gemma 3 | 12B | 7.5 GB | 2 cores | 10 GB VRAM |
Paso 4: Descargar el modelo
Una vez que Ollama esté corriendo, descarga el modelo:
# Ejecutar dentro del pod de Ollama
kubectl exec -it -n llm deploy/ollama -- ollama pull llama3.1Esto descarga el modelo al PVC. La próxima vez que el pod se reinicie, el modelo ya estará ahí.
Verifica que funcione:
kubectl exec -it -n llm deploy/ollama -- ollama run llama3.1 "Hola, ¿estás corriendo en Kubernetes?"Paso 5: Desplegar la Chat App
Ahora desplegamos la app de chat del post anterior:
# chat-app-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: chat-app
namespace: llm
labels:
app: chat-app
spec:
replicas: 2
selector:
matchLabels:
app: chat-app
template:
metadata:
labels:
app: chat-app
spec:
containers:
- name: chat-app
image: ghcr.io/your-org/ollama-chat-python:latest
ports:
- containerPort: 8000
env:
- name: OLLAMA_HOST
value: "http://ollama.llm.svc.cluster.local:11434"
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 5
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: chat-app
namespace: llm
spec:
selector:
app: chat-app
ports:
- port: 80
targetPort: 8000
type: ClusterIPFíjate en la variable de entorno OLLAMA_HOST: apunta al Service de Ollama usando el DNS interno de Kubernetes (ollama.llm.svc.cluster.local). La app no necesita saber dónde corre Ollama físicamente.
kubectl apply -f chat-app-deployment.yamlPaso 6: Configurar el HPA
La app de chat puede escalar horizontalmente. Ollama no — un modelo cargado en memoria no se puede “partir” entre pods fácilmente. Pero la app sí:
# chat-app-hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: chat-app-hpa
namespace: llm
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: chat-app
minReplicas: 2
maxReplicas: 5
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
behavior:
scaleUp:
stabilizationWindowSeconds: 30
policies:
- type: Pods
value: 1
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 120
policies:
- type: Pods
value: 1
periodSeconds: 120kubectl apply -f chat-app-hpa.yaml¿Por qué NO escalamos Ollama con HPA?
Buena pregunta. Hay varias razones:
- Carga del modelo: Cada nueva réplica de Ollama necesita cargar el modelo en memoria (~5 GB para Llama 3.1 8B). Eso toma tiempo y recursos.
- Estado: Ollama mantiene el modelo en memoria. No es stateless como una API REST típica.
- GPU: Si usas GPU, cada réplica necesita su propia GPU. No puedes compartir una GPU entre dos pods de Ollama eficientemente.
- Costo: Más réplicas = más GPUs = más dinero. Mucho más dinero.
La estrategia correcta para escalar Ollama es escalar verticalmente (más CPU/RAM/GPU) o usar una solución como vLLM o TGI (Text Generation Inference) que están diseñadas para servir modelos de forma eficiente con batching de requests.
La GPU: el elefante en la habitación
Hablemos de lo más importante para producción: la GPU.
Sin GPU (CPU-only)
Esto es lo que estamos haciendo en nuestro ejemplo con OrbStack. Funciona, pero es lento. Un prompt simple puede tomar 10-30 segundos en generar la respuesta completa con CPU. Para desarrollo y pruebas está bien, pero para producción con usuarios reales es inaceptable.
Con GPU
Con una GPU, el mismo prompt toma 1-3 segundos. La diferencia es brutal.
Para usar GPU en Kubernetes necesitas:
- Nodos con GPU (NVIDIA es el estándar).
- NVIDIA Device Plugin instalado en el cluster.
- Drivers NVIDIA en los nodos.
# Agregar a la spec del container de Ollama:
resources:
requests:
cpu: "1"
memory: 4Gi
nvidia.com/gpu: "1" # Solicitar 1 GPU
limits:
cpu: "2"
memory: 8Gi
nvidia.com/gpu: "1" # Limitar a 1 GPUReferencia rápida de GPUs
| GPU | VRAM | Modelos que soporta | Precio cloud (aprox/hora) |
|---|---|---|---|
| NVIDIA T4 | 16 GB | Hasta 13B parámetros | ~$0.50 |
| NVIDIA A10G | 24 GB | Hasta 30B parámetros | ~$1.00 |
| NVIDIA A100 (40GB) | 40 GB | Hasta 70B parámetros | ~$3.00 |
| NVIDIA A100 (80GB) | 80 GB | 70B+ parámetros | ~$5.00 |
| NVIDIA H100 | 80 GB | 70B+ (más rápido) | ~$8.00 |
host.docker.internal. Para producción con GPU real, necesitas un cluster en la nube (GKE, EKS, AKS) con nodos GPU.Probar el setup completo
Expón la app localmente y pruébala:
# Port-forward para probar
kubectl port-forward -n llm svc/chat-app 8000:80Abre http://localhost:8000 en tu navegador y listo — tu chat de IA corriendo en Kubernetes.
Para verificar el estado de todo:
# Ver todos los recursos
kubectl get all -n llm
# Ver los logs de Ollama
kubectl logs -n llm deploy/ollama -f
# Ver el HPA en acción
kubectl get hpa -n llm -wLo que falta: el camino a producción
Ok, ya tenemos Ollama corriendo en Kubernetes. ¿Significa que estamos listos para producción? Ni de cerca. Desplegar un LLM es solo el primer paso. Para que sea “producción de verdad” necesitas cubrir varias áreas que a menudo se pasan por alto.
1. Observabilidad de prompts (Langfuse)
¿Qué prompts están enviando tus usuarios? ¿Cuánto tarda cada respuesta? ¿Qué modelo genera mejores respuestas? Sin observabilidad, estás volando a ciegas.
Langfuse es una plataforma open source de observabilidad para LLMs que te permite:
- Ver cada prompt y su respuesta en un dashboard.
- Medir latencia, tokens consumidos y costos.
- Evaluar la calidad de las respuestas.
- Hacer tracing de cadenas complejas (RAG, agents, etc.).
2. Protección de datos sensibles (Presidio)
Este es uno de los riesgos más grandes y menos discutidos. ¿Qué pasa si un usuario envía su número de tarjeta de crédito en un prompt? ¿O datos personales como su RUT, DNI, dirección, historial médico?
Microsoft Presidio es un framework open source de detección y anonimización de datos personales (PII). Lo pones como middleware entre tu app y Ollama para:
- Detectar PII antes de que llegue al modelo (nombres, emails, tarjetas, documentos de identidad).
- Anonimizar o redactar la información sensible.
- Loggear qué tipo de datos intentaron pasar.
Ejemplo del flujo:
3. Métricas y monitoreo
Necesitas saber:
- Latencia por request: ¿cuánto tarda la inferencia? (p50, p95, p99)
- Tokens por segundo: ¿qué tan rápido genera texto?
- Memoria GPU/CPU: ¿estás cerca del límite?
- Queue depth: ¿cuántos requests están esperando?
- Error rate: ¿cuántos requests fallan?
Esto se resuelve con Prometheus + Grafana (si ya leíste el post de Prometheus, ya sabes cómo).
4. Guardrails de contenido
¿Qué pasa si alguien le pide a tu LLM que genere contenido ofensivo, instrucciones peligrosas o desinformación? Necesitas guardrails que filtren tanto la entrada como la salida.
Herramientas como NVIDIA NeMo Guardrails o Guardrails AI permiten definir reglas que:
- Bloquean prompts que intentan hacer jailbreak del modelo.
- Filtran respuestas que contengan contenido inapropiado.
- Mantienen la conversación dentro del dominio esperado.
5. Costos y optimización
Correr un LLM en Kubernetes no es barato. Una GPU A100 puede costar $3/hora. Si la tienes corriendo 24/7, son **$2,160 al mes**. Para optimizar:
- Cuantización: Modelos cuantizados (Q4, Q5) usan menos memoria y son más rápidos, con pérdida mínima de calidad.
- Apagar cuando no se usa: Si tu LLM solo recibe tráfico en horario laboral, apágalo de noche.
- Batching: Herramientas como vLLM agrupan múltiples requests para procesar en paralelo, maximizando el uso de la GPU.
- Modelos más pequeños: ¿Realmente necesitas un modelo de 70B? Para muchos casos, Phi-3 (3.8B) o Llama 3.1 (8B) son más que suficientes.
Resumen
| Tema | Estado | Herramienta |
|---|---|---|
| Despliegue de Ollama en K8s | ✅ Cubierto hoy | Kubernetes + OrbStack |
| Recursos y HPA | ✅ Cubierto hoy | Requests/Limits + HPA |
| GPU | ✅ Explicado | NVIDIA Device Plugin |
| Observabilidad de prompts | 🔜 Próximo post | Langfuse |
| Protección de datos | 🔜 Próximo post | Presidio |
| Métricas del LLM | 🔜 Próximo post | Prometheus + Grafana |
| Guardrails | 🔜 Próximo post | NeMo Guardrails |
| Optimización | 🔜 Próximo post | vLLM, cuantización |
Este post es el primero de una serie sobre LLMs en Kubernetes para producción. En los próximos posts vamos a ir cubriendo cada una de las piezas que faltan, empezando por Langfuse para observabilidad y Presidio para protección de datos.
Porque desplegar un LLM es fácil. Lo difícil es hacerlo bien.