La semana pasada, un equipo de ML en Barcelona lanzó su modelo de recomendación en Kubernetes. En local, todo perfecto; pasó los tests, y el staging fue un éxito. Sin embargo, tres días después, en producción, el sistema colapsó: latencias de 4 segundos, pods reiniciándose en loop y los costos de GKE disparados un 340%. ¿El problema? No era el modelo. Era la arquitectura.
Photo: Growtika on Unsplash
Kubernetes no optimiza automáticamente tus modelos de IA. Si solo envuelves TensorFlow Serving en un contenedor y lanzas un Deployment básico, prepararás un cuello de botella listo para explotar con tráfico real. Este artículo desglosa la arquitectura que Uber, Spotify y Airbnb utilizan para servir millones de predicciones por hora sin problemas.
El error que cometen el 80% de equipos al desplegar modelos en Kubernetes
La mayoría de los tutoriales dicen: "Empaqueta tu modelo en un contenedor, crea un Deployment, expón un Service, y listo". Técnicamente es cierto, pero operativamente, es un desastre.
El problema clave es que TensorFlow carga todo el grafo del modelo en memoria al iniciar. Si tu modelo pesa 2GB y tu pod se reinicia cada vez que recibe 100 requests concurrentes, Kubernetes tardará 30-40 segundos en cada reinicio solo para cargar el modelo. Mientras tanto, tus usuarios experimentan timeouts.
Cold start: el asesino invisible
Los pods en producción se reinician por varias razones: actualizaciones, rebalanceo, errores de memoria, health checks fallidos. Cada reinicio significa cargar de cero el modelo.
Las empresas que escalan implementan model warming desde el propio contenedor. Antes de que Kubernetes marque el pod como "Ready", el contenedor ejecuta predicciones dummy para cargar completamente el grafo y las operaciones principales en GPU/CPU.
# warm_model.py
import tensorflow as tf
import numpy as np
import time
def warm_model(model_path, input_shape, iterations=50):
"""
Pre-caliente el modelo antes de que Kubernetes lo marque como ready
"""
model = tf.saved_model.load(model_path)
infer = model.signatures['serving_default']
# Genera inputs dummy realistas
dummy_input = tf.constant(
np.random.randn(*input_shape).astype(np.float32)
)
print(f"Warming model with {iterations} iterations...")
start = time.time()
for i in range(iterations):
_ = infer(dummy_input)
if i % 10 == 0:
print(f"Completed {i} warm-up iterations")
elapsed = time.time() - start
print(f"Model warmed in {elapsed:.2f}s. Ready for traffic.")
return model
if __name__ == "__main__":
model = warm_model(
model_path="/models/recommender/1",
input_shape=(1, 128), # Ajusta a tu modelo
iterations=50
)
Este script debe ejecutarse antes de que tu servidor de inferencia esté listo. En el Dockerfile:
FROM tensorflow/serving:2.13.0-gpu
COPY ./model /models/recommender/1
COPY ./warm_model.py /scripts/warm_model.py
# Ejecuta warming antes de levantar TF Serving
CMD python /scripts/warm_model.py && \
tensorflow_model_server \
--rest_api_port=8501 \
--model_name=recommender \
--model_base_path=/models/recommender
Pero hay un detalle: si el warming toma 40 segundos y tu readinessProbe tiene un initialDelaySeconds de 10, Kubernetes matará el pod antes de terminar de calentar. Configurar bien el probe es crucial.
Configuración de recursos: el equilibrio que Google no documenta bien
Photo: Growtika on Unsplash
Asignar recursos para modelos de IA no es igual a una API REST. Los modelos de TensorFlow tienen patrones de consumo específicos que rompen las reglas convencionales.
CPU vs GPU: cuando la GPU no es la respuesta
Curiosamente, no todos los modelos se benefician de GPU en inferencia. Si tu modelo es pequeño (<500MB) y procesas requests individuales en lugar de batches grandes, una CPU optimizada puede resultar 3x más económica y ofrecer latencias comparables.
Esto lo descubrió Uber en 2024 cuando migró modelos de recomendación de instancias GPU a CPU de alto rendimiento. El overhead de mover datos entre CPU y GPU para requests pequeños anulaba la ventaja.
La regla práctica:
- Usa GPU si procesas batches grandes (>32 inputs simultáneos) o tu modelo tiene >1B parámetros.
- Usa CPU para modelos pequeños con inferencia de latencia ultra-baja (<50ms) en requests individuales.
En Kubernetes, esto se traduce en distintas configuraciones de recursos:
# Para modelos en GPU
resources:
requests:
memory: "8Gi"
cpu: "2"
nvidia.com/gpu: 1
limits:
memory: "12Gi"
cpu: "4"
nvidia.com/gpu: 1
# Para modelos optimizados en CPU
resources:
requests:
memory: "4Gi"
cpu: "4"
limits:
memory: "6Gi"
cpu: "8"
El problema de los límites de memoria
TensorFlow suele reservar memoria de forma agresiva. Por defecto, ocupará toda la memoria GPU posible, y en CPU intentará usar toda la RAM para cachear operaciones.
Si defines limits.memory: "8Gi" pero tu modelo y TensorFlow runtime realmente necesitan 9Gi bajo carga, el pod será OOMKilled (Out Of Memory Killed) y reiniciado. Esto puede generar un loop de muerte: reinicio → cold start → carga máxima → OOM → reinicio.
Spotify solucionó esto para sus modelos de recomendación musical con:
env:
- name: TF_GPU_MEMORY_FRACTION
value: "0.7" # Reserva solo 70% de GPU memory disponible
- name: TF_FORCE_GPU_ALLOW_GROWTH
value: "true" # Permite crecimiento gradual, no reserva todo al inicio
Este enfoque previene que TensorFlow acapare recursos innecesarios y ofrece margen para picos de tráfico.
Autoscaling inteligente: HPA con métricas custom
El Horizontal Pod Autoscaler (HPA) estándar escala basándose en CPU o memoria. Para modelos de IA, estas métricas pueden ser engañosas.
Un modelo al 90% de CPU puede funcionar bien gracias a un batch grande, mientras que otro al 40% puede estar saturado procesando 500 requests pequeños secuencialmente.
Métricas que importan
Quienes escalan modelos de IA en producción usan métricas nível aplicación:
- Request queue depth: Número de requests en espera
- P95 latency: Latencia del percentil 95
- Throughput: Predicciones por segundo por pod
Implementación con Prometheus y custom metrics:
# metrics_server.py con Flask + prometheus_client
from prometheus_client import Counter, Histogram, Gauge
from flask import Flask, request, jsonify
import tensorflow as tf
import time
app = Flask(__name__)
# Métricas custom
REQUEST_COUNT = Counter('model_requests_total', 'Total de requests')
REQUEST_LATENCY = Histogram('model_request_duration_seconds', 'Latencia de inferencia')
QUEUE_SIZE = Gauge('model_queue_depth', 'Requests en cola')
model = None
request_queue = []
@app.route('/v1/predict', methods=['POST'])
def predict():
global request_queue
QUEUE_SIZE.set(len(request_queue))
REQUEST_COUNT.inc()
start = time.time()
# Tu lógica de inferencia
data = request.json
predictions = run_inference(data)
duration = time.time() - start
REQUEST_LATENCY.observe(duration)
return jsonify(predictions)
def run_inference(data):
# Implementación real de inferencia
pass
Configuración del HPA con estas métricas:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: model-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: tf-model
minReplicas: 3
maxReplicas: 20
metrics:
- type: Pods
pods:
metric:
name: model_queue_depth
target:
type: AverageValue
averageValue: "10" # Escala cuando queue > 10 por pod
- type: Pods
pods:
metric:
name: model_request_duration_seconds
target:
type: AverageValue
averageValue: "0.2" # Escala cuando P50 > 200ms
behavior:
scaleDown:
stabilizationWindowSeconds: 300 # Espera 5min antes de bajar pods
policies:
- type: Percent
value: 50
periodSeconds: 60
La clave está en stabilizationWindowSeconds. Modelos de ML tienen warmup costoso, por lo que escalar hacia abajo de forma agresiva puede destruir el rendimiento. Spotify usa entre 5 y 10 minutos de estabilización antes de eliminar pods.
Batching dinámico: la técnica que reduce latencia un 60%
Uno de los secretos mejor guardados para servir millones de predicciones es el dynamic batching. Acumulas múltiples requests en milisegundos y los procesas como un batch.
TensorFlow Serving tiene esta característica, pero viene desactivada. Configurarla correctamente puede reducir la latencia promedio hasta un 60% bajo carga alta.
# batching_config.txt
max_batch_size { value: 32 }
batch_timeout_micros { value: 5000 } # 5ms de espera máxima
max_enqueued_batches { value: 100 }
num_batch_threads { value: 4 }
pad_variable_length_inputs: true
Monta este archivo en el contenedor:
volumes:
- name: batching-config
configMap:
name: tf-batching-config
volumeMounts:
- name: batching-config
mountPath: /config/batching_config.txt
subPath: batching_config.txt
Y arranca TensorFlow Serving con:
tensorflow_model_server \
--model_name=recommender \
--model_base_path=/models/recommender \
--enable_batching=true \
--batching_parameters_file=/config/batching_config.txt
El tradeoff de latencia
Dynamic batching añade latencia de hasta batch_timeout_micros. Si tu SLA requiere menos de 50ms, un timeout de 5ms es manejable. Pero, si necesitas menos de 10ms, el batching puede no ser la mejor opción.
Airbnb usa un enfoque híbrido: para modelos de búsqueda, desactiva batching. Para recomendaciones en background, usa batches de hasta 64 con timeout de 10ms.
Logging y observabilidad: cuando el modelo falla silenciosamente
Los modelos en producción fallan de manera diferente a las APIs tradicionales. No verás un 500 o un stacktrace; obtienes predicciones erráticas, drifts silenciosos, y degradación gradual que pasa desapercibida hasta que las métricas de negocio se desploman.
Structured logging con context
Implementa logging estructurado que capture no solo el request/response, sino también el contexto del modelo:
import logging
import json
from datetime import datetime
class ModelLogger:
def __init__(self, model_version, deployment):
self.model_version = model_version
self.deployment = deployment
self.logger = logging.getLogger(__name__)
def log_inference(self, request_id, input_features, prediction, confidence, latency):
log_entry = {
'timestamp': datetime.utcnow().isoformat(),
'request_id': request_id,
'model_version': self.model_version,
'deployment': self.deployment,
'input_shape': input_features.shape,
'prediction': prediction.tolist(),
'confidence': float(confidence),
'latency_ms': latency * 1000,
'input_hash': hash(input_features.tobytes()) # Para detectar duplicados
}
self.logger.info(json.dumps(log_entry))
# Alertas si confianza es baja
if confidence < 0.6:
self.logger.warning(f"Low confidence prediction: {confidence} for request {request_id}")
# Uso
model_logger = ModelLogger(model_version="v2.3.1", deployment="production-us-east")
model_logger.log_inference(
request_id="req_abc123",
input_features=input_data,
prediction=pred,
confidence=conf,
latency=0.082
)
Estos logs se envían a sistemas como Google Cloud Logging o Elasticsearch, donde puedes crear dashboards para detectar anomalías:
- Descenso repentino de la confianza promedio
- Aumento en latencia P99
- Distribución de predicciones desviada de lo histórico
Circuit breaker para modelos degradados
Si el modelo comienza a fallar sistemáticamente (ej: 20% de requests con errores), implementa un circuit breaker que haga rollback automáticamente a la versión anterior:
class ModelCircuitBreaker:
def __init__(self, error_threshold=0.15, window_size=100):
self.error_threshold = error_threshold
self.window = []
self.window_size = window_size
self.state = "closed" # closed = normal, open = fallback activo
def record_request(self, success):
self.window.append(success)
if len(self.window) > self.window_size:
self.window.pop(0)
error_rate = 1 - (sum(self.window) / len(self.window))
if error_rate > self.error_threshold and self.state == "closed":
self.state = "open"
self.trigger_rollback()
def trigger_rollback(self):
# Cambia el Service de Kubernetes para apuntar al Deployment anterior
print("Circuit breaker activated! Rolling back to v2.2.0")
La arquitectura completa: blueprint de Uber para 15M predicciones/hora
Dicho todo esto, así es la arquitectura real de producción:
-
Multi-versioning: Deployments separados para cada versión del modelo (v2.2.0, v2.3.0, canary). Un Service con weighted routing distribuye 95% del tráfico a stable, 5% a canary.
-
Resource isolation: Modelos críticos (ej: pricing en tiempo real) operan en node pools dedicados con taints, evitando que otros workloads interfieran.
-
Caching layer: Redis cachea predicciones para inputs frecuentes, usando un hash del input como key. TTL de 1-5 minutos, según la sensibilidad del modelo a cambios.
-
A/B testing nativo: El ingress puede enrutar usuarios específicos a versiones específicas del modelo, permitiendo experimentos controlados.
-
Automated retraining pipeline: Cloud Composer (Airflow) orquesta reentrenamiento semanal. Si las métricas del nuevo modelo superan al actual en staging, se promociona automáticamente a producción.
Este tipo de arquitectura separa un modelo que "funciona en local" de uno que escala en producción sin arruinar tu presupuesto de GKE ni tu tranquilidad operativa.
¿Tu modelo ya está en producción en Kubernetes? ¿Cuál fue la métrica que más te sorprendió descubrir mal configurada? Honradamente, muchos aprenden sobre batching o resource limits solo después de su primer incidente.