Puesta en producción de un modelo de deep learning
Del notebook a producción: exportaremos un modelo entrenado, crearemos una API REST con FastAPI, la serviremos con Uvicorn y dockerizaremos todo con Docker Compose. Cada línea de código está explicada.
FastAPI · Uvicorn · ONNX · Docker · Docker ComposeRequisitos previos
- Python 3.9+ instalado
- Un modelo de deep learning entrenado (PyTorch o TensorFlow/Keras)
- Docker Desktop instalado (docs.docker.com)
- Conceptos básicos de HTTP, REST APIs y línea de comandos
- Haber leído la teoría de puesta en producción (recomendado)
Visión general: del notebook a producción
Tienes un modelo entrenado en un Jupyter Notebook. Funciona bien en tu máquina. Pero, ¿cómo lo usa otro equipo, otro servicio, una app móvil? Necesitas convertirlo en un servicio accesible vía red — una API que reciba datos, ejecute la inferencia, y devuelva la predicción. Eso es puesta en producción.
1.1 ¿Por qué es difícil?
El salto de notebook a producción es más grande de lo que parece. Estos son los problemas reales a los que te enfrentarás:
- Dependencias — tu modelo necesita PyTorch 2.3, numpy 1.26, CUDA 12.1... ¿Funcionará en otra máquina?
- Formato del modelo — ¿.pt? ¿.h5? ¿SavedModel? ¿Y si el servidor no tiene PyTorch instalado?
- Serialización I/O — el modelo espera un tensor, pero el cliente envía JSON con una imagen en base64.
- Rendimiento — una request tarda 200ms, pero llegan 100 requests/segundo. ¿Cómo escalas?
- Reproducibilidad — funciona en tu máquina, falla en el servidor. Clásico.
- Monitorización — el modelo se degrada con el tiempo (data drift). ¿Cómo lo detectas?
1.2 Lo que construiremos
Modelo ONNX
Exportar desde PyTorch/TF a formato universal ONNX para inferencia eficiente.
FastAPI
API REST moderna con validación automática, docs interactivos y async support.
Docker
Contenedor con todas las dependencias. Reproducible en cualquier máquina.
Docker Compose
Orquestación de servicios con un solo comando: docker compose up.
1.3 Nuestro plan
- Modelo — exportar un clasificador MNIST a ONNX.
- API — crear endpoints
/predicty/healthcon FastAPI. - Servidor — servir con Uvicorn y probar en local.
- Docker — empaquetar todo en un contenedor optimizado.
- Compose — orquestar con docker compose y verificar.
- Mejoras — CORS, logging, batching y buenas prácticas.
El stack FastAPI + Docker que usamos aquí es el camino más didáctico y flexible, pero existen alternativas para cada capa:
- API framework: Flask, Django REST, gRPC (para alto rendimiento), BentoML (framework específico para ML), Gradio/Streamlit (para demos rápidas).
- Model serving: TF Serving (C++, muy rápido para TF), TorchServe (oficial PyTorch), Triton Inference Server (NVIDIA, multi-framework, batching dinámico), vLLM (específico para LLMs).
- Containerización: Podman (alternativa a Docker sin daemon), Buildah, containerd.
- Orquestación: Kubernetes (para escalar a muchas réplicas), Cloud Run (serverless), AWS ECS/Fargate, KServe (Kubernetes-native para ML).
- Fully managed: Vertex AI Endpoints, SageMaker Endpoints, Azure ML Managed Endpoints, HuggingFace Inference Endpoints, Replicate, Modal.
Nosotros elegimos el stack "artesanal" porque entender cada pieza te permite después migrar a cualquier plataforma managed sabiendo qué pasa por debajo.
Preparar el modelo entrenado
Antes de crear la API, necesitamos un modelo entrenado guardado en disco. Usaremos un clasificador MNIST sencillo (MLP con 3 capas). Si ya tienes un modelo entrenado, puedes saltar directamente al paso 3.
2.1 Entrenar y guardar con PyTorch
import torch
import torch.nn as nn
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
# ── Modelo ───────────────────────────────────────────────
class MNISTClassifier(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Flatten(),
nn.Linear(28 * 28, 256),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(256, 128),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(128, 10),
)
def forward(self, x):
return self.net(x)
# ── Datos ────────────────────────────────────────────────
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
train_ds = datasets.MNIST("data", train=True, download=True, transform=transform)
train_loader = DataLoader(train_ds, batch_size=64, shuffle=True)
# ── Entrenamiento ────────────────────────────────────────
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = MNISTClassifier().to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()
for epoch in range(5):
model.train()
total_loss = 0
for batch_x, batch_y in train_loader:
batch_x, batch_y = batch_x.to(device), batch_y.to(device)
optimizer.zero_grad()
loss = criterion(model(batch_x), batch_y)
loss.backward()
optimizer.step()
total_loss += loss.item()
print(f"Epoch {epoch+1}/5 — Loss: {total_loss/len(train_loader):.4f}")
# ── Guardar ──────────────────────────────────────────────
model.eval()
torch.save(model.state_dict(), "mnist_model.pt")
print("✓ Modelo guardado: mnist_model.pt")
model.eval() — ponemos el modelo en modo evaluación antes de guardarlo. Desactiva Dropout y fija BatchNorm.torch.save(model.state_dict(), ...) — guarda solo los pesos (recomendado), no el objeto completo.2.2 Alternativa: entrenar y guardar con TensorFlow/Keras
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
# ── Datos ────────────────────────────────────────────────
(x_train, y_train), _ = keras.datasets.mnist.load_data()
x_train = x_train.astype("float32") / 255.0
x_train = x_train.reshape(-1, 28, 28, 1)
# ── Modelo ───────────────────────────────────────────────
model = keras.Sequential([
layers.Input(shape=(28, 28, 1)),
layers.Flatten(),
layers.Dense(256, activation="relu"),
layers.Dropout(0.2),
layers.Dense(128, activation="relu"),
layers.Dropout(0.2),
layers.Dense(10, activation="softmax"),
])
model.compile(optimizer="adam",
loss="sparse_categorical_crossentropy",
metrics=["accuracy"])
model.fit(x_train, y_train, epochs=5, batch_size=64, verbose=1)
# ── Guardar ──────────────────────────────────────────────
model.save("mnist_model.h5") # Formato legacy HDF5
model.save("mnist_savedmodel") # Formato SavedModel (directorio)
print("✓ Modelos guardados: .h5 y SavedModel")
.save("file.h5") — formato HDF5 (un solo archivo). Simple pero legacy..save("directorio") — SavedModel (directorio con assets, variables, saved_model.pb). Formato oficial de TF.2.3 Formatos de modelo: comparativa
| Formato | Framework | Extensión | Pros | Contras |
|---|---|---|---|---|
| state_dict | PyTorch | .pt / .pth |
Ligero, flexible, estándar PyTorch | Necesita la clase del modelo para cargar |
| TorchScript | PyTorch | .pt |
Serializa modelo + código, C++ runtime | No toda op de Python es compatible |
| SavedModel | TensorFlow | directorio | Formato oficial TF, TF Serving nativo | Solo TF ecosystem |
| HDF5 | Keras | .h5 |
Un solo archivo, legacy | Deprecated en TF 2.x, features limitados |
| ONNX | Universal | .onnx |
Framework-agnostic, runtimes optimizados | Conversión puede fallar en ops custom |
| SafeTensors | HuggingFace | .safetensors |
Seguro (no pickle), rápido loading | Ecosistema HF, relativamente nuevo |
onnxruntime (~50 MB vs ~2 GB de PyTorch).
Esto reduce drásticamente el tamaño de la imagen Docker.
torch.save(model.state_dict(), path) guarda solo los pesos. Para cargar
necesitas redefinir la clase del modelo y llamar a model.load_state_dict().
torch.save(model, path) guarda el modelo completo (clase + pesos) usando pickle.
Es más cómodo, pero:
- El archivo es más grande.
- Pickle tiene riesgos de seguridad: cargar un archivo .pt malicioso puede ejecutar código arbitrario.
- Es frágil: si renombras la clase o cambias su ubicación, la carga falla.
Recomendación: usa siempre state_dict + weights_only=True en
torch.load() (PyTorch 2.6+ lo requiere por defecto). Para producción, exporta a ONNX.
Los archivos .pt de PyTorch usan pickle internamente. Un archivo malicioso puede
ejecutar código arbitrario al cargarse con torch.load(). Por eso:
- Nunca cargues modelos de fuentes no confiables sin
weights_only=True. - Usa
safetensors(HuggingFace) para intercambiar modelos de forma segura. - ONNX es seguro por diseño: no contiene código ejecutable, solo el grafo computacional + pesos.
- Escanea modelos descargados con herramientas como picklescan.
Convertir a ONNX (formato universal)
ONNX (Open Neural Network Exchange) es un formato abierto que permite
exportar modelos desde cualquier framework y ejecutarlos con runtimes optimizados.
La ventaja clave: en producción solo necesitas onnxruntime (~50 MB) en vez de
PyTorch (~2 GB) o TensorFlow (~1.5 GB).
3.1 Instalar dependencias
# Para exportar desde PyTorch + ejecutar ONNX
pip install onnx onnxruntime
# Para exportar desde TensorFlow (opcional)
pip install tf2onnx
3.2 Exportar desde PyTorch
import torch
import torch.nn as nn
# ── Redefinir la arquitectura (misma que en train_model.py) ──
class MNISTClassifier(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Flatten(),
nn.Linear(28 * 28, 256), nn.ReLU(), nn.Dropout(0.2),
nn.Linear(256, 128), nn.ReLU(), nn.Dropout(0.2),
nn.Linear(128, 10),
)
def forward(self, x):
return self.net(x)
# ── Cargar pesos ─────────────────────────────────────────
model = MNISTClassifier()
model.load_state_dict(torch.load("mnist_model.pt", weights_only=True))
model.eval()
# ── Exportar a ONNX ─────────────────────────────────────
dummy_input = torch.randn(1, 1, 28, 28) # Batch de 1 imagen
torch.onnx.export(
model,
dummy_input,
"mnist_model.onnx",
input_names=["image"],
output_names=["logits"],
dynamic_axes={
"image": {0: "batch_size"},
"logits": {0: "batch_size"},
},
opset_version=17,
)
print("✓ Exportado: mnist_model.onnx")
weights_only=True — carga segura: solo pesos, no código ejecutable (pickle).dummy_input — un tensor de ejemplo con la forma esperada. ONNX lo usa para trazar el grafo computacional.input_names / output_names — nombres legibles para los tensores de entrada/salida. Los usaremos en la API.dynamic_axes — permite batch size variable. Sin esto, el modelo solo acepta exactamente 1 imagen.opset_version=17 — versión del estándar ONNX. Usa la más reciente compatible con tu onnxruntime.3.3 Alternativa: exportar desde TensorFlow
# Desde SavedModel
python -m tf2onnx.convert --saved-model mnist_savedmodel --output mnist_model.onnx --opset 17
# Desde .h5
python -m tf2onnx.convert --keras mnist_model.h5 --output mnist_model.onnx --opset 17
3.4 Verificar la conversión
import onnx
import onnxruntime as ort
import numpy as np
# ── Verificar estructura ────────────────────────────────
onnx_model = onnx.load("mnist_model.onnx")
onnx.checker.check_model(onnx_model)
print("✓ Modelo ONNX válido")
print(f" Inputs: {[i.name for i in onnx_model.graph.input]}")
print(f" Outputs: {[o.name for o in onnx_model.graph.output]}")
# ── Inferencia de prueba ─────────────────────────────────
session = ort.InferenceSession("mnist_model.onnx")
# Crear imagen de prueba (ruido aleatorio, 1×1×28×28)
test_input = np.random.randn(1, 1, 28, 28).astype(np.float32)
result = session.run(["logits"], {"image": test_input})
logits = result[0]
predicted_class = np.argmax(logits, axis=1)[0]
print(f" Test inference → clase predicha: {predicted_class}")
print(f" Shape output: {logits.shape}")
# ── Comparar tamaño ─────────────────────────────────────
import os
pt_size = os.path.getsize("mnist_model.pt") / 1024
onnx_size = os.path.getsize("mnist_model.onnx") / 1024
print(f"\n PyTorch: {pt_size:.0f} KB")
print(f" ONNX: {onnx_size:.0f} KB")
onnxruntime. Esto es lo que lo hace ideal para producción.
ONNX Runtime puede optimizar el modelo antes de la inferencia:
- Graph optimization:
ort.SessionOptions().graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL— fusiona operaciones, elimina nodos redundantes. Gratis y sin pérdida de accuracy. - Cuantización dinámica: Convierte pesos FP32 a INT8 on-the-fly. Reduce tamaño ~4x y
acelera en CPU. Mínima pérdida de accuracy.
from onnxruntime.quantization import quantize_dynamic; quantize_dynamic("model.onnx", "model_q.onnx") - Cuantización estática: Calibra con datos reales para mejor accuracy que dinámica, pero requiere un dataset de calibración.
- TensorRT EP: En GPUs NVIDIA, usa el TensorRT Execution Provider para speedups de 2-5x:
ort.InferenceSession("model.onnx", providers=["TensorrtExecutionProvider"])
- Op no soportada: Algunas operaciones custom de PyTorch no tienen equivalente ONNX. Solución: reescribir con ops estándar o registrar un op custom.
- Dynamic shapes: Si usas
if/elsebasados en shapes del tensor dentro delforward(), el tracer puede fallar. Solución: usatorch.onnx.export(..., dynamo=True)(PyTorch 2.1+) para el nuevo exportador basado en TorchDynamo. - Diferencias numéricas: Pequeñas diferencias FP32 entre PyTorch y ONNX Runtime son normales (ε < 1e-5). Si son grandes, hay un bug en la conversión.
- Opset version: Si onnxruntime falla, prueba con un opset más bajo (13-15 son los más compatibles).
Crear la API con FastAPI
FastAPI es el framework Python más popular para crear APIs de ML en producción. Es rápido (basado en Starlette + Uvicorn), tiene validación automática con Pydantic, genera documentación interactiva (Swagger UI) automáticamente, y soporta async nativo.
4.1 Estructura del proyecto
ml-api/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app + endpoints
│ ├── model.py # Carga del modelo ONNX + inferencia
│ └── schemas.py # Pydantic schemas (request/response)
├── models/
│ └── mnist_model.onnx # Modelo exportado
├── requirements.txt
├── Dockerfile
└── docker-compose.yml
4.2 Requirements
fastapi==0.115.*
uvicorn[standard]==0.34.*
onnxruntime==1.20.*
numpy==2.1.*
Pillow==11.*
python-multipart==0.0.*
fastapi — el framework web. Ligero, 0 dependencias de ML.uvicorn[standard] — servidor ASGI de alto rendimiento. El [standard] añade uvloop + httptools.onnxruntime — runtime de inferencia. ~50 MB vs ~2 GB de PyTorch. Solo CPU por defecto; para GPU: onnxruntime-gpu.Pillow — para decodificar imágenes enviadas por el cliente.python-multipart — necesario para recibir archivos (form-data) en FastAPI.4.3 Schemas: definir entrada y salida
from pydantic import BaseModel, Field
class PredictionResponse(BaseModel):
"""Respuesta del endpoint /predict."""
predicted_class: int = Field(..., ge=0, le=9, description="Dígito predicho (0-9)")
confidence: float = Field(..., ge=0.0, le=1.0, description="Confianza de la predicción")
probabilities: list[float] = Field(..., description="Probabilidades para cada clase (0-9)")
class HealthResponse(BaseModel):
"""Respuesta del endpoint /health."""
status: str
model_loaded: bool
model_name: str
Field(..., ge=0, le=9) — el campo es obligatorio (...), valor entre 0 y 9. FastAPI usa esto para validar Y para la documentación.4.4 Carga del modelo
import numpy as np
import onnxruntime as ort
from PIL import Image
import io
import logging
logger = logging.getLogger(__name__)
# ── Sesión ONNX (singleton) ─────────────────────────────
_session: ort.InferenceSession | None = None
MODEL_PATH = "models/mnist_model.onnx"
def load_model() -> ort.InferenceSession:
"""Carga el modelo ONNX. Se llama una sola vez al inicio."""
global _session
if _session is None:
logger.info(f"Cargando modelo desde {MODEL_PATH}...")
opts = ort.SessionOptions()
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
_session = ort.InferenceSession(MODEL_PATH, opts)
logger.info("✓ Modelo cargado correctamente")
return _session
def preprocess_image(image_bytes: bytes) -> np.ndarray:
"""Preprocesa una imagen para el modelo MNIST."""
img = Image.open(io.BytesIO(image_bytes)).convert("L") # Grayscale
img = img.resize((28, 28)) # Resize
arr = np.array(img, dtype=np.float32)
# Normalizar igual que durante el entrenamiento
arr = (arr / 255.0 - 0.1307) / 0.3081
# Reshape a (1, 1, 28, 28): batch, channels, height, width
arr = arr.reshape(1, 1, 28, 28)
return arr
def predict(image_bytes: bytes) -> dict:
"""Ejecuta inferencia sobre una imagen."""
session = load_model()
input_array = preprocess_image(image_bytes)
# Ejecutar inferencia ONNX
logits = session.run(["logits"], {"image": input_array})[0]
# Softmax para obtener probabilidades
exp = np.exp(logits - np.max(logits, axis=1, keepdims=True))
probs = (exp / exp.sum(axis=1, keepdims=True))[0]
predicted_class = int(np.argmax(probs))
confidence = float(probs[predicted_class])
return {
"predicted_class": predicted_class,
"confidence": round(confidence, 4),
"probabilities": [round(float(p), 4) for p in probs],
}
ORT_ENABLE_ALL — activa todas las optimizaciones del grafo: fusión de operaciones, eliminación de nodos redundantes, constant folding..convert("L") — convierte cualquier imagen a escala de grises. Así la API acepta imágenes RGB, RGBA, etc.session.run(["logits"], {"image": input_array}) — ONNX Runtime espera un dict de inputs y una lista de nombres de outputs.exp() para evitar overflow.4.5 La aplicación FastAPI
from contextlib import asynccontextmanager
from fastapi import FastAPI, File, UploadFile, HTTPException
import logging
from app.model import load_model, predict
from app.schemas import PredictionResponse, HealthResponse
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# ── Lifespan: cargar modelo al iniciar ───────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Carga el modelo al arrancar, libera al cerrar."""
load_model()
logger.info("🚀 API lista para recibir requests")
yield
logger.info("👋 Cerrando API")
app = FastAPI(
title="MNIST Classifier API",
description="API de clasificación de dígitos con ONNX Runtime",
version="1.0.0",
lifespan=lifespan,
)
# ── Health check ─────────────────────────────────────────
@app.get("/health", response_model=HealthResponse)
def health():
"""Comprueba que la API y el modelo están operativos."""
from app.model import _session
return HealthResponse(
status="ok",
model_loaded=_session is not None,
model_name="mnist_model.onnx",
)
# ── Predicción ───────────────────────────────────────────
@app.post("/predict", response_model=PredictionResponse)
async def predict_endpoint(
file: UploadFile = File(..., description="Imagen de un dígito (PNG/JPG)")
):
"""Recibe una imagen y devuelve el dígito predicho."""
# Validar tipo de archivo
if file.content_type not in ("image/png", "image/jpeg", "image/bmp", "image/webp"):
raise HTTPException(
status_code=400,
detail=f"Tipo de archivo no soportado: {file.content_type}. Usa PNG, JPG, BMP o WebP."
)
# Leer y predecir
image_bytes = await file.read()
if len(image_bytes) == 0:
raise HTTPException(status_code=400, detail="Archivo vacío")
result = predict(image_bytes)
return PredictionResponse(**result)
lifespan — el patrón moderno de FastAPI (reemplaza @app.on_event). El modelo se carga una sola vez al arrancar el servidor, no en cada request.FastAPI(...) — el constructor genera automáticamente documentación Swagger en /docs y ReDoc en /redoc.@app.get("/health") — endpoint GET para health checks. Docker y load balancers lo llaman periódicamente.@app.post("/predict") — endpoint POST que recibe un archivo. File(...) indica que es obligatorio.await file.read() — lectura async del archivo. FastAPI maneja esto eficientemente con Starlette.En vez de recibir un archivo, puedes recibir la imagen como base64 en JSON:
class PredictRequest(BaseModel):
image_base64: str
Y en el endpoint:
image_bytes = base64.b64decode(request.image_base64)
Pros: Más fácil de integrar con frontends JavaScript (fetch con JSON body).
Contras: Base64 aumenta el tamaño ~33%. Para imágenes grandes, multipart es más eficiente.
Puedes ofrecer ambos endpoints: /predict (multipart) y /predict/base64 (JSON).
FastAPI ejecuta funciones async def directamente en el event loop, y funciones
def normales en un thread pool. Para inferencia ML:
- I/O (leer archivo): async es mejor →
await file.read(). - Inferencia CPU-bound: ONNX Runtime es puro cómputo (CPU-bound), así que
predict()es sync. FastAPI lo ejecuta en un thread pool automáticamente (no bloquea el event loop). - Regla: Usa
async defpara el endpoint si necesitasawait. La parte de cómputo puede ser sync — FastAPI lo maneja. - Para alto throughput: Mete la inferencia en un
ProcessPoolExecutoro usarun_in_executor()para evitar el GIL.
Probar la API en local con Uvicorn
Uvicorn es un servidor ASGI (Asynchronous Server Gateway Interface) ultrarrápido escrito en Python. Es el servidor recomendado para FastAPI. Vamos a arrancar la API y probarla en local antes de dockerizar.
5.1 Instalar dependencias y arrancar
# Instalar todo
pip install -r requirements.txt
# Arrancar el servidor (modo desarrollo)
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
app.main:app — le dice a Uvicorn: "del módulo app.main, importa el objeto app".--reload — recarga automáticamente al editar código. Solo para desarrollo, nunca en producción.--host 0.0.0.0 — escucha en todas las interfaces (necesario para Docker). 127.0.0.1 solo acepta conexiones locales.5.2 Swagger UI: documentación interactiva
Abre http://localhost:8000/docs en tu navegador. FastAPI genera
automáticamente una interfaz Swagger donde puedes probar los endpoints
directamente desde el navegador:
5.3 Probar con curl
# Health check
curl http://localhost:8000/health
# → {"status":"ok","model_loaded":true,"model_name":"mnist_model.onnx"}
# Predicción con una imagen
curl -X POST http://localhost:8000/predict \
-F "file=@test_digit.png" \
| python -m json.tool
# → {
# "predicted_class": 7,
# "confidence": 0.9834,
# "probabilities": [0.0001, 0.0002, 0.0012, ...]
# }
5.4 Probar con Python
import requests
# ── Health check ─────────────────────────────────────────
r = requests.get("http://localhost:8000/health")
print(f"Health: {r.json()}")
# ── Predicción ───────────────────────────────────────────
with open("test_digit.png", "rb") as f:
r = requests.post(
"http://localhost:8000/predict",
files={"file": ("digit.png", f, "image/png")}
)
result = r.json()
print(f"Clase: {result['predicted_class']}")
print(f"Confianza: {result['confidence']:.2%}")
# ── Benchmark rápido ─────────────────────────────────────
import time
times = []
for _ in range(100):
with open("test_digit.png", "rb") as f:
t0 = time.perf_counter()
requests.post("http://localhost:8000/predict",
files={"file": ("d.png", f, "image/png")})
times.append(time.perf_counter() - t0)
print(f"\nLatencia media: {sum(times)/len(times)*1000:.1f} ms")
print(f"Latencia p95: {sorted(times)[94]*1000:.1f} ms")
print(f"Throughput: {len(times)/sum(times):.0f} req/s")
--reload de Uvicorn es solo para desarrollo.
En producción, usaremos Uvicorn sin --reload y con múltiples workers
para aprovechar todos los cores de la CPU.
Uvicorn por defecto usa un solo proceso. Para escalar en producción:
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
O con Gunicorn como process manager (más robusto para producción):
gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
-w 4— 4 worker processes. Regla:2 × CPU_cores + 1.-k uvicorn.workers.UvicornWorker— usa Uvicorn como worker class (ASGI).- Cada worker tiene su propia copia del modelo en memoria.
- Para modelos grandes (>1 GB), esto puede ser un problema de memoria → usa 1 worker + async o soluciones como Ray Serve.
Añade gunicorn al requirements.txt si decides usar este enfoque.
- WSGI (Flask, Django): síncrono. Cada request bloquea un worker hasta que termina.
- ASGI (FastAPI, Starlette): asíncrono. Un worker puede manejar miles de conexiones concurrentes (ideal para I/O-bound: leer archivos, hacer requests a otros servicios).
- Para inferencia ML (CPU-bound), la diferencia de velocidad es mínima. La ventaja real de ASGI es poder hacer streaming, WebSockets, y manejar requests lentos sin bloquear los rápidos.
- En la práctica, FastAPI + Uvicorn es el combo estándar. Gunicorn se usa como process manager encima (maneja el ciclo de vida de los workers: restarts, graceful shutdown, etc.).
Escribir el Dockerfile
Docker empaqueta nuestra API + modelo + todas las dependencias en un contenedor aislado que funciona de forma idéntica en cualquier máquina. Esto elimina el clásico "funciona en mi máquina" y nos da reproducibilidad total.
6.1 Dockerfile básico
# ── Base image ───────────────────────────────────────────
FROM python:3.11-slim AS base
# ── Metadatos (buena práctica) ───────────────────────────
LABEL maintainer="tu-nombre"
LABEL description="MNIST Classifier API with ONNX Runtime"
# ── Variables de entorno ─────────────────────────────────
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
# ── Dependencias del sistema ─────────────────────────────
RUN apt-get update && \
apt-get install -y --no-install-recommends libgomp1 && \
rm -rf /var/lib/apt/lists/*
# ── Directorio de trabajo ────────────────────────────────
WORKDIR /app
# ── Instalar dependencias Python ─────────────────────────
# Copiamos requirements.txt PRIMERO para cachear esta capa
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ── Copiar código y modelo ───────────────────────────────
COPY app/ ./app/
COPY models/ ./models/
# ── Puerto ───────────────────────────────────────────────
EXPOSE 8000
# ── Crear usuario no-root (seguridad) ───────────────────
RUN adduser --disabled-password --gecos "" appuser
USER appuser
# ── Comando de arranque ──────────────────────────────────
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
python:3.11-slim — imagen base ligera (~50 MB). Alternativas: alpine (más pequeña pero problemas con wheels) o imagen completa (más grande pero más compatible).PYTHONDONTWRITEBYTECODE — no genera archivos .pyc (innecesarios en Docker).
PYTHONUNBUFFERED — los logs se imprimen inmediatamente (no se bufferean).
PIP_NO_CACHE_DIR — no cachea paquetes (reduce tamaño de imagen).libgomp1 — librería OpenMP necesaria para ONNX Runtime. rm -rf /var/lib/apt/lists/* limpia la caché de apt para reducir tamaño.requirements.txt antes que el código. Así, si solo cambiamos código Python, Docker reutiliza la capa de pip install (que es la más lenta, ~30s).CMD — el comando que se ejecuta al iniciar el contenedor. Sin --reload en producción.6.2 .dockerignore
__pycache__
*.pyc
.git
.gitignore
.env
*.md
data/
*.pt
*.pth
*.h5
venv/
.venv/
.pytest_cache/
.mypy_cache/
.onnx en models/.6.3 Build y test
# Construir la imagen
docker build -t mnist-api:1.0 .
# Verificar tamaño
docker images mnist-api
# → REPOSITORY TAG IMAGE ID SIZE
# → mnist-api 1.0 abc123def456 178MB
# Ejecutar (prueba rápida)
docker run --rm -p 8000:8000 mnist-api:1.0
# Probar desde otra terminal
curl http://localhost:8000/health
Si quieres minimizar aún más, usa un multi-stage build que separa el build de las dependencias de la imagen final:
FROM python:3.11-slim AS builder
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM python:3.11-slim
COPY --from=builder /root/.local /root/.local
COPY app/ ./app/
COPY models/ ./models/
Esto elimina las herramientas de build (gcc, pip wheels, etc.) de la imagen final. Puede ahorrar 20-40 MB adicionales.
Para usar la GPU dentro del contenedor:
- Instala nvidia-container-toolkit en el host.
- Cambia la imagen base a
nvidia/cuda:12.2.0-runtime-ubuntu22.04. - Usa
onnxruntime-gpuen vez deonnxruntime. - Ejecuta con:
docker run --gpus all -p 8000:8000 mnist-api:1.0
Para nuestro modelo MNIST (tiny), GPU no es necesaria. Para modelos grandes (ResNet, BERT, LLMs), la GPU reduce la latencia de inferencia 10-50x.
python:3.11-alpine es más pequeña (~20 MB), pero:
- Usa
muslen vez deglibc— muchas wheels de PyPI no son compatibles. - ONNX Runtime, NumPy y Pillow necesitan compilarse desde source → el build tarda 10-20 minutos.
- La imagen final puede ser incluso más grande por las dependencias de build (gcc, musl-dev, etc.).
Recomendación: usa slim para ML. Es el mejor compromiso tamaño/compatibilidad.
Alpine solo merece la pena para apps sin dependencias C (puro Python).
Docker Compose: orquestar el servicio
Docker Compose nos permite definir, construir y ejecutar nuestro servicio con un solo comando. Además, facilita añadir servicios adicionales (monitoring, cache, base de datos) en el futuro sin cambiar nada de la aplicación.
7.1 docker-compose.yml
services:
api:
build:
context: .
dockerfile: Dockerfile
image: mnist-api:1.0
container_name: mnist-api
ports:
- "8000:8000"
environment:
- PYTHONUNBUFFERED=1
- LOG_LEVEL=info
volumes:
- ./models:/app/models:ro
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
cpus: "2.0"
reservations:
memory: 256M
build — Docker Compose construye la imagen automáticamente desde nuestro Dockerfile. No necesitas hacer docker build manualmente.ports: "8000:8000" — mapea el puerto 8000 del contenedor al 8000 del host. Formato: HOST:CONTAINER.volumes: ./models:/app/models:ro — monta la carpeta de modelos como solo lectura (:ro). Permite actualizar el modelo sin reconstruir la imagen.healthcheck — Docker verifica periódicamente que la API responde. Si falla 3 veces seguidas, marca el contenedor como unhealthy. Crucial para orquestadores (Kubernetes, Swarm).restart: unless-stopped — reinicia automáticamente el contenedor si crashea (pero no si lo paras manualmente).deploy.resources — limita CPU y memoria. Evita que un memory leak o una carga inesperada tumbe el host.7.2 Variables de entorno con .env
# ── Configuración de la API ──────────────────────────────
API_PORT=8000
LOG_LEVEL=info
MODEL_PATH=models/mnist_model.onnx
WORKERS=1
Y en el compose referenciamos las variables:
ports:
- "${API_PORT:-8000}:8000"
environment:
- LOG_LEVEL=${LOG_LEVEL:-info}
- MODEL_PATH=${MODEL_PATH:-models/mnist_model.onnx}
${API_PORT:-8000} — usa la variable API_PORT del .env; si no existe, default a 8000. Patrón estándar en Compose..env a Git. Añade .env a tu
.gitignore. En producción, las variables se inyectan vía el orquestador
(Kubernetes Secrets, AWS Parameter Store, etc.).
7.3 Compose extendido: con monitorización
Docker Compose brilla cuando añadimos más servicios. Aquí un ejemplo con Prometheus para recolectar métricas:
services:
api:
build: .
ports:
- "8000:8000"
volumes:
- ./models:/app/models:ro
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
restart: unless-stopped
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
depends_on:
api:
condition: service_healthy
restart: unless-stopped
depends_on con condition: service_healthy asegura que la API esté lista antes de que Prometheus intente scrapearla.Gracias al volumen montado (./models:/app/models:ro), puedes actualizar el
modelo sin reconstruir la imagen Docker:
- Entrena un nuevo modelo y expórtalo a
models/mnist_model.onnx. - Reinicia el contenedor:
docker compose restart api. - La API carga el nuevo modelo al iniciarse.
Para hot reload sin reiniciar, puedes implementar un endpoint
POST /reload-model que recargue la sesión ONNX. Requiere un lock para
evitar inferencias durante la recarga.
| Criterio | Docker Compose | Kubernetes |
|---|---|---|
| Complejidad | Baja — un YAML | Alta — múltiples YAMLs, networking, RBAC |
| Scaling | Manual (scale: 3) | Auto (HPA, VPA, KEDA) |
| Rolling updates | Manual | Nativo |
| Self-healing | Restart | Restart + reschedule + node replace |
| Ideal para | 1 servidor, dev, staging, MVP | Multi-nodo, alta disponibilidad, producción a escala |
Regla: Empieza con Docker Compose. Migra a Kubernetes cuando necesites autoscaling, multi-nodo o alta disponibilidad. Alternativa intermedia: Docker Swarm (más simple que K8s).
Construir, ejecutar y probar en contenedor
Ahora juntamos todo: construimos la imagen, levantamos el servicio con Compose, y verificamos que funciona correctamente — desde el health check hasta la predicción.
8.1 Levantar con Docker Compose
# Construir y levantar en foreground (para ver logs)
docker compose up --build
# O en background (detached)
docker compose up --build -d
# Ver los logs en tiempo real
docker compose logs -f api
# Ver el estado del servicio
docker compose ps
8.2 Verificar el servicio
# ── 1. Health check ──────────────────────────────────────
curl -s http://localhost:8000/health | python -m json.tool
# {
# "status": "ok",
# "model_loaded": true,
# "model_name": "mnist_model.onnx"
# }
# ── 2. Documentación interactiva ─────────────────────────
# Abre en el navegador: http://localhost:8000/docs
# ── 3. Predicción ────────────────────────────────────────
curl -s -X POST http://localhost:8000/predict \
-F "file=@test_digit.png" | python -m json.tool
# {
# "predicted_class": 7,
# "confidence": 0.9834,
# "probabilities": [0.0001, 0.0002, 0.0012, ...]
# }
# ── 4. Verificar health check de Docker ──────────────────
docker inspect --format='{{.State.Health.Status}}' mnist-api
# → healthy
# ── 5. Ver recursos usados ───────────────────────────────
docker stats mnist-api --no-stream
# CONTAINER CPU % MEM USAGE / LIMIT MEM %
# mnist-api 0.50% 89MiB / 512MiB 17.38%
8.3 Test end-to-end con Python
"""Test end-to-end de la API dockerizada."""
import requests
import numpy as np
from PIL import Image
import io
API_URL = "http://localhost:8000"
# ── Test 1: Health ───────────────────────────────────────
r = requests.get(f"{API_URL}/health")
assert r.status_code == 200
data = r.json()
assert data["status"] == "ok"
assert data["model_loaded"] is True
print("✓ Health check OK")
# ── Test 2: Predicción con imagen real ───────────────────
with open("test_digit.png", "rb") as f:
r = requests.post(f"{API_URL}/predict", files={"file": ("d.png", f, "image/png")})
assert r.status_code == 200
result = r.json()
assert 0 <= result["predicted_class"] <= 9
assert 0 <= result["confidence"] <= 1
assert len(result["probabilities"]) == 10
print(f"✓ Predicción OK: clase={result['predicted_class']}, "
f"confianza={result['confidence']:.2%}")
# ── Test 3: Predicción con imagen generada ───────────────
# Crear un "7" sintético
img = Image.new("L", (28, 28), 0)
pixels = np.array(img)
pixels[6:22, 10:20] = 255 # Línea horizontal superior
pixels[6:22, 16:20] = 255 # Línea vertical derecha
img = Image.fromarray(pixels)
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
r = requests.post(f"{API_URL}/predict", files={"file": ("synth.png", buf, "image/png")})
assert r.status_code == 200
print(f"✓ Predicción sintética OK: clase={r.json()['predicted_class']}")
# ── Test 4: Error handling ───────────────────────────────
r = requests.post(f"{API_URL}/predict",
files={"file": ("test.txt", b"not an image", "text/plain")})
assert r.status_code == 400
print(f"✓ Error handling OK: {r.json()['detail']}")
print("\n🎉 Todos los tests pasaron")
8.4 Troubleshooting: errores comunes
| Error | Causa | Solución |
|---|---|---|
Connection refused |
El contenedor no está corriendo o el puerto no está mapeado | docker compose ps para verificar estado; revisar ports en compose |
Model file not found |
El modelo .onnx no está en models/ |
Verificar que models/mnist_model.onnx existe y el volumen está bien montado |
OOM (Out of Memory) |
El límite de memoria en Compose es muy bajo | Aumentar deploy.resources.limits.memory en compose |
Permission denied |
El usuario appuser no tiene permisos sobre los archivos |
Verificar ownership con docker exec mnist-api ls -la /app/models/ |
Health: unhealthy |
El health check falla porque curl no está instalado en slim |
Instalar curl en Dockerfile: apt-get install -y curl o cambiar test a Python: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] |
| Build muy lento | Docker reconstruye las capas de pip install en cada build | Asegurarse de que requirements.txt se copia ANTES del código (cacheo de capas) |
8.5 Comandos útiles
# Ver logs
docker compose logs -f api
# Entrar al contenedor (debugging)
docker compose exec api bash
# Reiniciar el servicio
docker compose restart api
# Parar todo
docker compose down
# Parar y eliminar volúmenes
docker compose down -v
# Reconstruir sin cache (si algo falla)
docker compose build --no-cache
Para simular carga real, usa Locust:
pip install locust
Crea locustfile.py:
from locust import HttpUser, task
class MLUser(HttpUser):
@task
def predict(self):
with open("test_digit.png", "rb") as f:
self.client.post("/predict", files={"file": f})
Ejecuta: locust -f locustfile.py --host=http://localhost:8000
Abre http://localhost:8089 y configura usuarios concurrentes. Con 1 worker Uvicorn
deberías ver ~80-120 req/s para nuestro modelo MNIST.
Mejoras para producción real
El servicio funciona, pero para producción real necesitamos CORS, logging estructurado, batching, rate limiting y monitorización. Esta sección cubre las mejoras más importantes, ordenadas de más fácil a más compleja.
9.1 CORS (Cross-Origin Resource Sharing)
Si un frontend web accede a tu API, necesitas habilitar CORS para permitir requests desde otros dominios:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://tu-frontend.com"], # En producción: lista explícita
allow_methods=["GET", "POST"],
allow_headers=["*"],
allow_credentials=False,
)
allow_origins=["*"] en producción. Lista explícitamente los dominios permitidos.9.2 Logging estructurado
import logging
import time
import uuid
from fastapi import Request
logger = logging.getLogger("mnist-api")
@app.middleware("http")
async def log_requests(request: Request, call_next):
"""Middleware que logea cada request con timing y request ID."""
request_id = str(uuid.uuid4())[:8]
start = time.perf_counter()
response = await call_next(request)
duration_ms = (time.perf_counter() - start) * 1000
logger.info(
f"[{request_id}] {request.method} {request.url.path} "
f"→ {response.status_code} ({duration_ms:.1f}ms)"
)
response.headers["X-Request-ID"] = request_id
return response
X-Request-ID permite al cliente correlacionar su request con los logs del servidor.9.3 Rate limiting
# pip install slowapi
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@app.post("/predict")
@limiter.limit("30/minute") # Máximo 30 predicciones por minuto por IP
async def predict_endpoint(request: Request, file: UploadFile = File(...)):
...
key_func=get_remote_address — limita por dirección IP. Para APIs con autenticación, usarías el API key o user ID."30/minute" — permite 30 requests por minuto por IP. Ajusta según tu caso. Formatos: "10/second", "100/hour".9.4 Batching: procesar múltiples imágenes
from fastapi import File, UploadFile
from typing import List
@app.post("/predict/batch", response_model=list[PredictionResponse])
async def predict_batch(
files: list[UploadFile] = File(..., description="Múltiples imágenes")
):
"""Procesa múltiples imágenes en un solo request."""
if len(files) > 32:
raise HTTPException(400, "Máximo 32 imágenes por batch")
results = []
for file in files:
image_bytes = await file.read()
result = predict(image_bytes)
results.append(PredictionResponse(**result))
return results
9.5 Métricas con Prometheus
# pip install prometheus-fastapi-instrumentator
from prometheus_fastapi_instrumentator import Instrumentator
# Después de crear app:
Instrumentator().instrument(app).expose(app, endpoint="/metrics")
Con una linea, expones métricas en /metrics: latencia por endpoint, requests
totales, errores, tamaño de respuesta. Prometheus las recolecta y Grafana las
visualiza.
9.6 Checklist de producción
| Mejora | Prioridad | Implementación | |
|---|---|---|---|
| ✅ | Health check endpoint | Crítica | Ya implementado en paso 4 |
| ✅ | Input validation | Crítica | Pydantic + validación de content-type |
| ✅ | Non-root container | Crítica | USER appuser en Dockerfile |
| ☐ | CORS | Alta | CORSMiddleware |
| ☐ | Structured logging | Alta | Middleware con request ID |
| ☐ | Rate limiting | Alta | slowapi |
| ☐ | Métricas | Media | prometheus-fastapi-instrumentator |
| ☐ | Batch inference | Media | Endpoint /predict/batch |
| ☐ | Model versioning | Media | MLflow / W&B Model Registry |
| ☐ | CI/CD pipeline | Media | GitHub Actions: test → build → push → deploy |
| ☐ | HTTPS / TLS | Alta | Reverse proxy (Nginx/Traefik) o cloud LB |
| ☐ | Data drift monitoring | Baja | Evidently AI / NannyML |
9.7 Widget: arquitectura de deployment interactiva
Un pipeline típico para ML APIs:
- Test:
pytest— ejecuta tests unitarios y de integración. - Build:
docker build— construye la imagen. - Push: Sube la imagen a un registry (Docker Hub, ECR, GCR, GHCR).
- Deploy: Actualiza el servicio (docker compose pull + up, o kubectl rollout).
Ejemplo simplificado de GitHub Actions:
on: push: branches: [main]
jobs:
deploy:
steps:
- uses: actions/checkout@v4
- run: docker build -t ghcr.io/user/mnist-api:$GITHUB_SHA .
- run: docker push ghcr.io/user/mnist-api:$GITHUB_SHA
Los tags por commit SHA garantizan trazabilidad: siempre puedes saber qué código exacto está corriendo en producción.
Cuando necesites autoscaling y alta disponibilidad, migra a Kubernetes. El Deployment YAML mínimo:
apiVersion: apps/v1
kind: Deployment
metadata: { name: mnist-api }
spec:
replicas: 3
template:
spec:
containers:
- name: api
image: ghcr.io/user/mnist-api:latest
ports: [{ containerPort: 8000 }]
resources:
limits: { memory: 512Mi, cpu: "1" }
Añade un Service + Ingress para exponerlo, y un HPA para
autoscaling basado en CPU/latencia.
Referencias y próximos pasos
Hemos construido un pipeline completo de producción: modelo → ONNX → FastAPI → Docker → Compose. Aquí recopilamos las referencias clave y los siguientes pasos para escalar.
10.1 Resumen de lo construido
| Capa | Tecnología | Qué resuelve |
|---|---|---|
| Modelo | ONNX + onnxruntime | Inferencia eficiente sin dependencia de framework (~50 MB vs ~2 GB) |
| API | FastAPI + Pydantic | Endpoint REST con validación automática, docs, async |
| Servidor | Uvicorn (ASGI) | Servidor de alto rendimiento, escalable con workers |
| Contenedor | Docker (python-slim) | Reproducibilidad, portabilidad, aislamiento (~180 MB) |
| Orquestación | Docker Compose | Un comando para levantar todo: docker compose up |
| Producción | CORS, logging, rate limiting, health checks | Seguridad, observabilidad, resiliencia |
10.2 Documentación y herramientas
| Recurso | Enlace | Descripción |
|---|---|---|
| FastAPI docs | fastapi.tiangolo.com | Tutorial oficial, deployment guide, best practices |
| ONNX Runtime | onnxruntime.ai | Docs, performance tuning, execution providers (CPU/GPU/TensorRT) |
| Docker docs | docs.docker.com | Dockerfile reference, Compose spec, best practices |
| Docker Compose spec | Compose file reference | Especificación completa servicios, volumes, networks, deploy |
| Uvicorn | uvicorn.org | Configuration, deployment con Gunicorn, SSL |
| Pydantic V2 | docs.pydantic.dev | Validación, serialización, settings management |
| ONNX spec | onnx.ai | Especificación ONNX, herramientas, modelos pre-convertidos (Model Zoo) |
| Prometheus | prometheus.io/docs | Recolección de métricas, alerting, integración con Grafana |
10.3 Papers y lecturas recomendadas
| Paper / Recurso | Descripción | Año |
|---|---|---|
| Hidden Technical Debt in ML Systems | El paper seminal de Google sobre la deuda técnica en sistemas ML. El código del modelo es una fracción: la infraestructura alrededor es lo complejo. | 2015 |
| Challenges in Deploying ML | Revisión sistemática de 626 papers sobre retos de deployment: reproducibilidad, testing, monitoring. | 2022 |
| Google MLOps Guide | Niveles de madurez MLOps (0-2): desde manual hasta CI/CD/CT completamente automatizado. | 2023 |
| Made With ML (Goku Mohandas) | Curso gratuito completo de MLOps: design, develop, deploy, iterate. Muy práctico. | 2024 |
| Full Stack Deep Learning | Curso de UC Berkeley sobre todo el stack: data management, training, deployment, monitoring. | 2024 |
| ml-ops.org | Colección de recursos, herramientas y definiciones del ecosistema MLOps. | 2024 |
10.4 Próximos pasos
Kubernetes
Migra de Compose a K8s cuando necesites autoscaling, rolling updates y multi-nodo.
Triton / TorchServe
Frameworks de serving dedicados: batching dinámico, model ensemble, GPU sharing.
CI/CD Pipeline
Automatiza test → build → push → deploy con GitHub Actions o GitLab CI.
Monitoring + Drift
Grafana dashboards, alerting con Prometheus y detección de data drift con Evidently.
Autenticación
API keys, OAuth2 con FastAPI Security, rate limiting por usuario.
Cloud Deploy
Despliega en Cloud Run, ECS Fargate, Azure Container Instances o Fly.io.
docker compose up. Los mismos principios
aplican a modelos de cualquier complejidad: un clasificador de imágenes, un detector
de objetos, un modelo de NLP o un LLM. Lo que cambia es el tamaño de la imagen Docker,
la necesidad de GPU y el preprocesamiento — pero la arquitectura FastAPI → Docker →
Compose es universal.