💻 Tutorial paso a paso

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.

⏱️ ~75 min 📊 Nivel: intermedio
FastAPI · Uvicorn · ONNX · Docker · Docker Compose

Requisitos 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)
1

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.

🧠 Modelo entrenado .pt / .h5 / .onnx 🚀 FastAPI + Uvicorn API REST → /predict 🐳 Docker Container App + Deps + Model Reproducible & portable 📦 Docker Compose API svc Monitoring docker compose up → ✓ en producción Pipeline completo: modelo → API → contenedor → deploy

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

1.3 Nuestro plan

  1. Modelo — exportar un clasificador MNIST a ONNX.
  2. API — crear endpoints /predict y /health con FastAPI.
  3. Servidor — servir con Uvicorn y probar en local.
  4. Docker — empaquetar todo en un contenedor optimizado.
  5. Compose — orquestar con docker compose y verificar.
  6. Mejoras — CORS, logging, batching y buenas prácticas.
💡 ¿Por qué MNIST? Usamos un clasificador MNIST sencillo para centrarnos en el proceso de producción, no en el modelo. Los mismos pasos aplican a cualquier modelo: ResNet, BERT, YOLOv8, Stable Diffusion — solo cambia el preprocesamiento y el tamaño de la imagen Docker.

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.

2

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

Python train_model.py
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")
L7-21MLP sencillo: Flatten → 784→256→128→10 con ReLU y Dropout. Arquitectura suficiente para ~98% en MNIST.
L26-27Normalización estándar de MNIST: media=0.1307, std=0.3081. Estos valores se calcularon sobre el dataset completo.
L50model.eval() — ponemos el modelo en modo evaluación antes de guardarlo. Desactiva Dropout y fija BatchNorm.
L51torch.save(model.state_dict(), ...) — guarda solo los pesos (recomendado), no el objeto completo.
Salida esperada Epoch 1/5 — Loss: 0.3842 Epoch 2/5 — Loss: 0.1594 Epoch 3/5 — Loss: 0.1152 Epoch 4/5 — Loss: 0.0927 Epoch 5/5 — Loss: 0.0769 ✓ Modelo guardado: mnist_model.pt

2.2 Alternativa: entrenar y guardar con TensorFlow/Keras

Python train_model_tf.py
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")
L7Normalizamos dividiendo por 255 (rango [0,1]). Diferente a la normalización de PyTorch pero igualmente válido.
L29.save("file.h5") — formato HDF5 (un solo archivo). Simple pero legacy.
L30.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
💡 Nuestra elección: ONNX. Convertiremos a ONNX porque nos permite servir el modelo sin instalar PyTorch ni TensorFlow en el servidor de producción — solo necesitamos 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.
3

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).

🔥 PyTorch .pt torch.onnx 📦 ONNX .onnx (framework-agnostic) onnxruntime ⚡ Inferencia rápida TF .h5 / SavedModel CPU optimizado GPU CUDA / TensorRT DirectML / CoreML

3.1 Instalar dependencias

Terminal instalar
# Para exportar desde PyTorch + ejecutar ONNX
pip install onnx onnxruntime

# Para exportar desde TensorFlow (opcional)
pip install tf2onnx

3.2 Exportar desde PyTorch

Python export_onnx.py
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")
L19weights_only=True — carga segura: solo pesos, no código ejecutable (pickle).
L23dummy_input — un tensor de ejemplo con la forma esperada. ONNX lo usa para trazar el grafo computacional.
L28-29input_names / output_names — nombres legibles para los tensores de entrada/salida. Los usaremos en la API.
L30-33dynamic_axes — permite batch size variable. Sin esto, el modelo solo acepta exactamente 1 imagen.
L34opset_version=17 — versión del estándar ONNX. Usa la más reciente compatible con tu onnxruntime.

3.3 Alternativa: exportar desde TensorFlow

Terminal convertir con tf2onnx
# 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

Python verificar ONNX
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")
Salida esperada ✓ Modelo ONNX válido Inputs: ['image'] Outputs: ['logits'] Test inference → clase predicha: 7 Shape output: (1, 10) PyTorch: 856 KB ONNX: 843 KB
💡 El archivo ONNX es autosuficiente. Contiene la arquitectura completa (grafo computacional) + los pesos. No necesitas redefinir la clase del modelo para usarlo — solo 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/else basados en shapes del tensor dentro del forward(), el tracer puede fallar. Solución: usa torch.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).
4

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

Text estructura de archivos
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

Text requirements.txt
fastapi==0.115.*
uvicorn[standard]==0.34.*
onnxruntime==1.20.*
numpy==2.1.*
Pillow==11.*
python-multipart==0.0.*
L1fastapi — el framework web. Ligero, 0 dependencias de ML.
L2uvicorn[standard] — servidor ASGI de alto rendimiento. El [standard] añade uvloop + httptools.
L3onnxruntime — runtime de inferencia. ~50 MB vs ~2 GB de PyTorch. Solo CPU por defecto; para GPU: onnxruntime-gpu.
L5Pillow — para decodificar imágenes enviadas por el cliente.
L6python-multipart — necesario para recibir archivos (form-data) en FastAPI.

4.3 Schemas: definir entrada y salida

Python app/schemas.py
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
L1Pydantic valida tipos automáticamente. Si la API devuelve algo incorrecto, lanza error (no silently fails).
L6Field(..., ge=0, le=9) — el campo es obligatorio (...), valor entre 0 y 9. FastAPI usa esto para validar Y para la documentación.
L11-14Health check endpoint — útil para Docker health checks y load balancers.

4.4 Carga del modelo

Python app/model.py
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],
    }
L10Patrón singleton: la sesión ONNX se carga una vez y se reutiliza para todas las requests. Crear una sesión es costoso (~100ms), ejecutar inferencia es rápido (~5ms).
L20ORT_ENABLE_ALL — activa todas las optimizaciones del grafo: fusión de operaciones, eliminación de nodos redundantes, constant folding.
L29.convert("L") — convierte cualquier imagen a escala de grises. Así la API acepta imágenes RGB, RGBA, etc.
L34Normalización idéntica a la del entrenamiento (media=0.1307, std=0.3081). Si esto no coincide, las predicciones serán incorrectas.
L47session.run(["logits"], {"image": input_array}) — ONNX Runtime espera un dict de inputs y una lista de nombres de outputs.
L50Softmax manual con el truco de estabilidad numérica: restar el máximo antes de exp() para evitar overflow.

4.5 La aplicación FastAPI

Python app/main.py
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)
L13-19lifespan — 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.
L22-27FastAPI(...) — el constructor genera automáticamente documentación Swagger en /docs y ReDoc en /redoc.
L31@app.get("/health") — endpoint GET para health checks. Docker y load balancers lo llaman periódicamente.
L44@app.post("/predict") — endpoint POST que recibe un archivo. File(...) indica que es obligatorio.
L49-53Validación del content-type. Rechazamos archivos que no sean imágenes antes de procesarlos.
L57await file.read() — lectura async del archivo. FastAPI maneja esto eficientemente con Starlette.
💡 ¿Por qué FastAPI y no Flask? FastAPI es 2-5x más rápido que Flask, tiene validación automática (Pydantic), genera documentación interactiva sin código extra, y soporta async nativo. Flask requiere extensiones para todo esto (marshmallow, flask-swagger, etc.). Para APIs de ML en 2024+, FastAPI es el estándar.

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 def para el endpoint si necesitas await. La parte de cómputo puede ser sync — FastAPI lo maneja.
  • Para alto throughput: Mete la inferencia en un ProcessPoolExecutor o usa run_in_executor() para evitar el GIL.
5

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

Terminal instalar 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
L5app.main:app — le dice a Uvicorn: "del módulo app.main, importa el objeto app".
L5--reload — recarga automáticamente al editar código. Solo para desarrollo, nunca en producción.
L5--host 0.0.0.0 — escucha en todas las interfaces (necesario para Docker). 127.0.0.1 solo acepta conexiones locales.
Salida esperada INFO: Loading model from models/mnist_model.onnx... INFO: ✓ Modelo cargado correctamente INFO: 🚀 API lista para recibir requests INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)

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:

localhost:8000/docs MNIST Classifier API API de clasificación de dígitos con ONNX Runtime — v1.0.0 GET /health Comprueba que la API y el modelo están operativos POST /predict Recibe una imagen y devuelve el dígito predicho Try it out Schemas PredictionResponse { predicted_class: int, confidence: float, probabilities: float[] } HealthResponse { status: str, model_loaded: bool, model_name: str }

5.3 Probar con curl

Terminal probar endpoints
# 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

Python test_api.py
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")
Salida esperada Health: {'status': 'ok', 'model_loaded': True, 'model_name': 'mnist_model.onnx'} Clase: 7 Confianza: 98.34% Latencia media: 12.3 ms Latencia p95: 18.7 ms Throughput: 81 req/s
⚠️ Importante: El --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.).
6

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.

🐳 Capas de la imagen Docker (de abajo a arriba) python:3.11-slim (~50 MB) — Sistema operativo + Python libgomp1 (~2 MB) — Dependencias del sistema pip install (~120 MB) — fastapi, uvicorn, onnxruntime, numpy, Pillow COPY app/ + models/ (~1 MB) — Código + modelo ONNX Total ~180 MB

6.1 Dockerfile básico

Dockerfile Dockerfile
# ── 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"]
L2python: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).
L9-11Variables de entorno: 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).
L14-16libgomp1 — librería OpenMP necesaria para ONNX Runtime. rm -rf /var/lib/apt/lists/* limpia la caché de apt para reducir tamaño.
L22-24El truco de cachear capas: copiamos 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).
L33-34Usuario no-root: nunca ejecutes contenedores de producción como root. Si un atacante explota una vulnerabilidad, tiene acceso limitado.
L37CMD — el comando que se ejecuta al iniciar el contenedor. Sin --reload en producción.

6.2 .dockerignore

Text .dockerignore
__pycache__
*.pyc
.git
.gitignore
.env
*.md
data/
*.pt
*.pth
*.h5
venv/
.venv/
.pytest_cache/
.mypy_cache/
L1-3Evitamos copiar archivos innecesarios al contexto de build. Esto acelera el build y reduce el tamaño.
L8-9Excluimos los modelos PyTorch originales — solo necesitamos el .onnx en models/.

6.3 Build y test

Terminal construir imagen
# 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
💡 ~180 MB total. Si hubiéramos incluido PyTorch en vez de ONNX Runtime, la imagen pesaría ~2.5 GB. Con TensorFlow, ~2 GB. ONNX Runtime nos ahorra ~10x en tamaño de imagen.

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:

  1. Instala nvidia-container-toolkit en el host.
  2. Cambia la imagen base a nvidia/cuda:12.2.0-runtime-ubuntu22.04.
  3. Usa onnxruntime-gpu en vez de onnxruntime.
  4. 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 musl en vez de glibc — 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).

7

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

YAML 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
L3-5build — Docker Compose construye la imagen automáticamente desde nuestro Dockerfile. No necesitas hacer docker build manualmente.
L8-9ports: "8000:8000" — mapea el puerto 8000 del contenedor al 8000 del host. Formato: HOST:CONTAINER.
L13-14volumes: ./models:/app/models:ro — monta la carpeta de modelos como solo lectura (:ro). Permite actualizar el modelo sin reconstruir la imagen.
L15-20healthcheck — Docker verifica periódicamente que la API responde. Si falla 3 veces seguidas, marca el contenedor como unhealthy. Crucial para orquestadores (Kubernetes, Swarm).
L21restart: unless-stopped — reinicia automáticamente el contenedor si crashea (pero no si lo paras manualmente).
L22-28deploy.resources — limita CPU y memoria. Evita que un memory leak o una carga inesperada tumbe el host.

7.2 Variables de entorno con .env

Text .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:

YAML fragmento docker-compose.yml
    ports:
      - "${API_PORT:-8000}:8000"
    environment:
      - LOG_LEVEL=${LOG_LEVEL:-info}
      - MODEL_PATH=${MODEL_PATH:-models/mnist_model.onnx}
L2${API_PORT:-8000} — usa la variable API_PORT del .env; si no existe, default a 8000. Patrón estándar en Compose.
⚠️ Nunca subas el .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:

YAML docker-compose.prod.yml (extendido)
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
L16-25Servicio Prometheus: recolecta métricas de la API periódicamente. 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:

  1. Entrena un nuevo modelo y expórtalo a models/mnist_model.onnx.
  2. Reinicia el contenedor: docker compose restart api.
  3. 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.

CriterioDocker ComposeKubernetes
ComplejidadBaja — un YAMLAlta — múltiples YAMLs, networking, RBAC
ScalingManual (scale: 3)Auto (HPA, VPA, KEDA)
Rolling updatesManualNativo
Self-healingRestartRestart + reschedule + node replace
Ideal para1 servidor, dev, staging, MVPMulti-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).

8

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

Terminal levantar servicio
# 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
Salida esperada (docker compose up --build) [+] Building 32.5s (12/12) FINISHED => [base 1/6] FROM docker.io/library/python:3.11-slim@sha256:... => [base 2/6] RUN apt-get update ... => [base 3/6] COPY requirements.txt . => [base 4/6] RUN pip install ... => [base 5/6] COPY app/ ./app/ => [base 6/6] COPY models/ ./models/ [+] Running 1/1 ✔ Container mnist-api Created Attaching to mnist-api mnist-api | INFO: Loading model from models/mnist_model.onnx... mnist-api | INFO: ✓ Modelo cargado correctamente mnist-api | INFO: 🚀 API lista para recibir requests mnist-api | INFO: Uvicorn running on http://0.0.0.0:8000

8.2 Verificar el servicio

Terminal verificar
# ── 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

Python test_docker.py
"""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")
Salida esperada ✓ Health check OK ✓ Predicción OK: clase=7, confianza=98.34% ✓ Predicción sintética OK: clase=7 ✓ Error handling OK: Tipo de archivo no soportado: text/plain. Usa PNG, JPG, BMP o WebP. 🎉 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

Terminal comandos Docker Compose
# 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.

9

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:

Python app/main.py (añadir CORS)
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,
)
L5Nunca uses allow_origins=["*"] en producción. Lista explícitamente los dominios permitidos.
L6Solo permitimos los métodos HTTP necesarios. No exponer PUT, DELETE, etc. si no los usas.

9.2 Logging estructurado

Python logging con contexto
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
L11Request ID único para cada petición. Permite rastrear issues en los logs cuando hay múltiples requests concurrentes.
L17-20Log con método, path, status code y latencia. En producción real usarías JSON logging para que herramientas como ELK o Loki puedan parsearlo.
L22El header X-Request-ID permite al cliente correlacionar su request con los logs del servidor.
Ejemplo de logs [a3f2b1c8] GET /health → 200 (1.2ms) [7e9d4a12] POST /predict → 200 (15.8ms) [b5c6e3f1] POST /predict → 400 (0.5ms)

9.3 Rate limiting

Python rate limiting con slowapi
# 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(...)):
    ...
L6key_func=get_remote_address — limita por dirección IP. Para APIs con autenticación, usarías el API key o user ID.
L11"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

Python endpoint de batch prediction
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
L9Limitar el tamaño del batch es importante para evitar OOM (out of memory). 32 es un buen default para MNIST.
💡 Batching eficiente: En este ejemplo procesamos secuencialmente (simple pero no óptimo). Para alto rendimiento, deberías concatenar todas las imágenes en un solo array NumPy y hacer una sola inferencia ONNX con batch dimension > 1. Esto aprovecha la paralelización de ONNX Runtime en CPU/GPU.

9.5 Métricas con Prometheus

Python métricas 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

🏗️ Deployment Architecture Builder

Activa/desactiva componentes para ver cómo cambia la arquitectura, latencia y tamaño estimados de tu stack de deployment.

3
Componentes
28 ms
Latencia est.
1.17 GB
Imagen total

Un pipeline típico para ML APIs:

  1. Test: pytest — ejecuta tests unitarios y de integración.
  2. Build: docker build — construye la imagen.
  3. Push: Sube la imagen a un registry (Docker Hub, ECR, GCR, GHCR).
  4. 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.

10

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

RecursoEnlaceDescripció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 / RecursoDescripciónAñ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

🏁 Resumen final: Hemos cubierto el pipeline completo de producción de un modelo de deep learning — desde exportar los pesos a ONNX hasta tener una API dockerizada funcionando con un solo 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.