AulaML Python · C++
Python avanzado · 50 min · Proyectos reales

Despliegue de modelos en producción

Docker, configuración, logging, monitoreo de drift y un checklist antes de exponer el servicio

Pasar de un servicio que funciona en tu portátil a uno que sobrevive en producción exige imagen reproducible, configuración externa, logs útiles, monitoreo de drift y un proceso de cambio. Esta lección cubre esas piezas con un Dockerfile real y código de monitoreo ejecutable.

Lección 2 de 2 del módulo
Al terminar esta lección podrás
  • Construir una imagen Docker reproducible y sin privilegios para una API FastAPI
  • Externalizar rutas, versiones y umbrales en variables de entorno con pydantic-settings
  • Emitir logs estructurados con identificador de petición y latencia
  • Detectar data drift comparando la distribución de producción con la de referencia
  • Definir un proceso de versionado, pruebas y despliegue progresivo
  • Aplicar un checklist de producción que cubra seguridad, límites y observabilidad

Antes necesitas: Proyecto: predicción y API con FastAPI · Type hints y dataclasses

En esta lección
  1. ¿Qué es desplegar un modelo?
  2. ¿Para qué sirve?
  3. ¿Cómo funciona por dentro?
  4. Estructura del proyecto y configuración
  5. Sintaxis: el Dockerfile
  6. Ejemplo básico: construir y ejecutar
  7. Ejemplo intermedio: logging estructurado
  8. Caso práctico: monitoreo y data drift
  9. Visualización: lote de inferencia y traza de una petición
  10. Versionado de modelos y CI/CD
  11. Errores comunes
  12. Buenas prácticas y checklist de producción
  13. Seguridad y límites del modelo
  14. Ejercicios
  15. Soluciones explicadas
  16. Resumen
  17. Evaluación

Tu API responde en tu portátil: ejecutas un comando y funciona. Desplegar es conseguir que siga funcionando cuando no estás delante: en otra máquina, con un proceso que arranca solo, con datos distintos y con alguien que pregunta por qué ayer respondió lento.

No es un trámite final. Cada decisión de despliegue —dónde vive la configuración, qué se registra, cómo se versiona el artefacto— cambia cómo escribes el código: un servicio que no se puede observar tampoco se puede operar.

¿Qué es desplegar un modelo?

Es poner el modelo donde otros puedan usarlo de forma sostenida, con cuatro piezas: una imagen con código y dependencias exactas, una configuración que cambia sin recompilar, una observación que registra qué pasó y un proceso para cambiar versiones sin romper clientes.

  • Imagen: el entorno completo, congelado. Si funciona aquí, funciona allá porque es el mismo sistema de archivos.
  • Configuración: rutas, umbrales y credenciales. Vive fuera de la imagen y se inyecta al arrancar.
  • Observación: logs, métricas y alertas. Sin ellas, un fallo silencioso tarda días en descubrirse.
  • Proceso: pruebas automáticas, versión del artefacto y una forma de volver atrás.

¿Para qué sirve?

Desplegar bien resuelve problemas concretos que aparecen la primera semana:

  • «En mi máquina funciona»: la imagen fija las versiones, la causa más común de predicciones distintas.
  • Cambiar sin recompilar: subir el umbral de drift es editar una variable, no reconstruir la imagen.
  • Diagnosticar: un log con identificador y latencia responde «¿qué pasó a las 3:12?» en un minuto.
  • Detectar el envejecimiento: el modelo no avisa cuando los datos cambian; el monitoreo sí.

¿Cómo funciona por dentro?

La petición llega a un proxy que la reenvía al contenedor. Uvicorn escucha en un puerto, FastAPI resuelve la ruta y el pipeline predice. Cada línea de logging sale por la salida estándar y el runtime la envía a un colector.

El contenedor es efímero: puede morir y recrearse en otra máquina sin avisar. Por eso nada importante vive dentro. El modelo se monta o se descarga, la configuración llega por variables de entorno y las predicciones se envían fuera si hay que conservarlas.

AspectoEn tu portátilEn producción
ProcesoLo arrancas tú en una terminalLo arranca el runtime y lo reinicia
ConfiguraciónRuta relativa y un .env localVariables de entorno inyectadas
Registroprint y la consolaLogs estructurados a un colector
DatosEl CSV que tienes a manoPeticiones reales, con distribución cambiante
CambiosGuardas el archivoImagen nueva, pruebas y despliegue progresivo

Estructura del proyecto y configuración

Añade a la estructura anterior tres carpetas: monitor/ para la vigilancia, deploy/ para la infraestructura y .github/workflows/ para la integración continua. El servicio no cambia; cambia lo que lo rodea.

SHELL Estructura para producción
precios-api/
├── api/
│   ├── config.py        # ajustes desde el entorno
│   ├── esquemas.py
│   ├── modelo.py
│   └── main.py
├── models/
│   └── precio_v1.joblib
├── monitor/
│   ├── drift.py         # data drift por columna
│   └── referencia.csv   # distribución del entrenamiento
├── tests/
│   └── test_api.py
├── deploy/
│   └── Dockerfile
├── .env.example
├── requirements.txt
└── .dockerignore

La configuración se declara una vez y se lee tipada. Con pydantic-settings, cada campo se rellena desde una variable de entorno con prefijo APP_, desde un .env o con su valor por defecto. Si una variable llega mal, el proceso falla al arrancar.

PYTHON api/config.py
from pathlib import Path

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Ajustes(BaseSettings):
    """Configuración externa. Cada campo se puede sobrescribir con APP_<CAMPO>."""

    model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_", extra="ignore")

    modelo_ruta: Path = Path("models/precio_v1.joblib")
    modelo_version: str = "1.0.0"
    log_level: str = "INFO"
    max_lote: int = Field(default=50, ge=1, le=500)
    umbral_drift: float = Field(default=0.05, gt=0, lt=1)


ajustes = Ajustes()
print(ajustes.model_dump())

APP_MODELO_VERSION=1.1.0 o APP_UMBRAL_DRIFT=0.01 sobrescriben los valores sin tocar código. El .env.example documenta las variables y se versiona; el .env real contiene secretos y nunca se sube al repositorio ni se copia en la imagen.

Sintaxis: el Dockerfile

Un Dockerfile es una receta de capas y cada instrucción se cachea. Si requirements.txt no cambia, la instalación no se repite al reconstruir. Por eso el orden importa y el código se copia al final.

SHELL deploy/Dockerfile
FROM python:3.12-slim AS base

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY api ./api
COPY models ./models

RUN useradd --create-home --uid 10001 appuser \
    && chown -R appuser:appuser /app
USER appuser

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"

CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]

Cuatro decisiones que conviene entender. python:3.12-slim fija la versión base; latest convierte cada reconstrucción en una sorpresa. PYTHONUNBUFFERED=1 evita que los logs se queden en un búfer. appuser impide ejecutar como root y el HEALTHCHECK distingue proceso vivo de servicio útil.

El .dockerignore es tan importante como el Dockerfile. Si copias .venv, data/ o notebooks/, la imagen crece cientos de megas y la construcción se vuelve lenta.

Ejemplo básico: construir y ejecutar

Construye desde la raíz —el contexto debe incluir requirements.txt, api/ y models/— y ejecuta publicando el puerto. --env-file inyecta las variables y --rm borra el contenedor al detenerlo.

SHELL Ciclo completo con Docker
docker build -f deploy/Dockerfile -t precios-api:1.0.0 .
docker run --rm -p 8000:8000 --env-file .env precios-api:1.0.0

# INFO:     Started server process [1]
# INFO:     Application startup complete.
# INFO:     Uvicorn running on http://0.0.0.0:8000

curl -s http://127.0.0.1:8000/health
# {"status":"ok"}

docker ps --filter name=precios-api
docker logs <id-del-contenedor> --tail 20

Si curl no responde, revisa en orden: que el puerto esté publicado con -p, que Uvicorn escuche en 0.0.0.0 y no en 127.0.0.1 —dentro del contenedor, localhost es el contenedor— y que el modelo exista en la ruta configurada.

Ejemplo intermedio: logging estructurado

Un print con texto libre es inservible con cien peticiones por segundo. Los logs estructurados escriben una línea JSON por evento, con campos que el colector puede filtrar: así respondes «latencia media de /predict en la última hora» sin expresiones regulares.

PYTHON api/logs.py
import json
import logging
import time
import uuid

from fastapi import FastAPI, Request


class FormatoJSON(logging.Formatter):
    """Una línea JSON por evento; los campos extra van en `datos`."""

    def format(self, record: logging.LogRecord) -> str:
        base = {
            "nivel": record.levelname,
            "mensaje": record.getMessage(),
            "logger": record.name,
        }
        extra = getattr(record, "datos", {})
        return json.dumps({**base, **extra}, ensure_ascii=False)


log = logging.getLogger("api")
log.setLevel(logging.INFO)
log.propagate = False
manejador = logging.StreamHandler()
manejador.setFormatter(FormatoJSON())
log.addHandler(manejador)

app = FastAPI()


@app.middleware("http")
async def registrar(request: Request, call_next):
    inicio = time.perf_counter()
    peticion_id = request.headers.get("x-request-id", str(uuid.uuid4()))

    try:
        respuesta = await call_next(request)
    except Exception:
        log.exception(
            "error no controlado",
            extra={"datos": {"peticion_id": peticion_id, "ruta": request.url.path}},
        )
        raise

    latencia_ms = round((time.perf_counter() - inicio) * 1000, 1)
    respuesta.headers["x-request-id"] = peticion_id
    log.info(
        "peticion",
        extra={
            "datos": {
                "peticion_id": peticion_id,
                "ruta": request.url.path,
                "estado": respuesta.status_code,
                "latencia_ms": latencia_ms,
            }
        },
    )
    return respuesta

La salida es una línea como {"nivel": "INFO", "mensaje": "peticion", "peticion_id": "9f2c1a4b", "ruta": "/predict", "estado": 200, "latencia_ms": 12.4}. Este app es el de api/main.py; el middleware se registra ahí. El identificador vuelve en la cabecera x-request-id: si un cliente reporta un error, encuentras la traza exacta.

Caso práctico: monitoreo y data drift

Un modelo se entrena con una foto del pasado y predice sobre un presente que cambia. El data drift es el cambio en la distribución de las entradas: si los departamentos de hoy son más grandes o de distritos nuevos, el modelo extrapola. La validación no lo detecta porque no tienes la respuesta real.

La prueba de Kolmogorov-Smirnov compara dos muestras continuas y devuelve el máximo desvío entre sus distribuciones acumuladas, con un p-valor. Un p-valor bajo indica que difícilmente vienen de la misma distribución. Es una señal, no un veredicto: con muchas filas detecta diferencias irrelevantes.

PYTHON monitor/drift.py
import numpy as np
import pandas as pd
from scipy.stats import ks_2samp


def detectar_drift(
    referencia: pd.Series,
    produccion: pd.Series,
    umbral: float = 0.05,
    minimo: int = 30,
) -> dict[str, object]:
    """Compara la distribución de una columna con la del entrenamiento."""
    if len(produccion.dropna()) < minimo:
        return {"columna": str(referencia.name), "estado": "muestra_insuficiente"}

    estadistico, p_valor = ks_2samp(referencia.dropna(), produccion.dropna())
    return {
        "columna": str(referencia.name),
        "n_referencia": int(referencia.notna().sum()),
        "n_produccion": int(produccion.notna().sum()),
        "estadistico": round(float(estadistico), 3),
        "p_valor": round(float(p_valor), 4),
        "drift": bool(p_valor < umbral),
    }


rng = np.random.default_rng(42)
referencia = pd.Series(rng.normal(90, 20, 500), name="area_m2")
produccion = pd.Series(rng.normal(105, 22, 400), name="area_m2")
print(detectar_drift(referencia, produccion))

La salida muestra un estadistico cercano a 0,2 y un p_valor muy pequeño, así que drift es True: las áreas se corrieron unas 15 unidades. Con minimo=30 evitas concluir algo con cuatro peticiones, donde la prueba no tiene potencia. Calcula por columna y compara tendencias, no un día suelto.

¿Y cuando aparece el drift? No reentrenes en automático. Mira qué columna cambió, decide si es real o un error de captura y evalúa el modelo nuevo con datos recientes antes de promoverlo. Un reentrenamiento sin validación puede empeorar el servicio en silencio.

Visualización: lote de inferencia y traza de una petición

Monitorear empieza por mirar lo que el servicio predice. Este lote de cuatro peticiones se compara con la distribución de entrenamiento: si la media de precio_estimado sube un 40 %, algo cambió.

Mapa de datos ·Lote de inferencia registrado en producción
4 × 4 rango [1, 2041] Cada fila es una petición atendida. Las tres primeras columnas son la entrada; la última, la predicción devuelta.
area_m2
habitaciones
antiguedad
precio_estimado
petición 1
85
2
12
1243
petición 2
120
3
5
1780
petición 3
55
1
45
612
petición 4
150
4
7
2041

Recorre los pasos para ver cómo cambian las celdas.

Ejecución visual ·Qué registra una petición mientras se atiende
1 / 6
1 import time
2 import uuid
3 import logging
4
5 log = logging.getLogger("api")
6
7 async def atender(request, call_next):
8 inicio = time.perf_counter()
9 pid = str(uuid.uuid4())[:8]
10 respuesta = await call_next(request)
11 latencia = (time.perf_counter() - inicio) * 1000
12 log.info("peticion", extra={"datos": {"pid": pid, "latencia_ms": round(latencia, 1)}})
13 return respuesta
Se toma el instante de inicio con un reloj monotónico: es inmune a cambios de hora del sistema.
Variables
inicio = 18342.117
Salida
—

Avanza paso a paso: observa la línea resaltada, las variables y la salida.

Versionado de modelos y CI/CD

Un artefacto sin versión es imposible de auditar. Nombra el archivo con la versión (precio_v1.2.0.joblib), declara esa versión en /health y guarda una ficha con fecha, datos, métricas y autor. Así sabes qué modelo produjo una predicción de hace tres meses.

La integración continua ejecuta pruebas en cada cambio; el despliegue continuo publica la imagen solo si pasan. Los pasos mínimos son siempre los mismos y puedes escribirlos como comandos antes de llevarlos al sistema de CI:

SHELL Pasos de un pipeline de integración
pip install -r requirements.txt
pytest -q

docker build -f deploy/Dockerfile -t precios-api:$VERSION .
docker run -d --name api-smoke -p 8000:8000 --env-file .env precios-api:$VERSION
sleep 5
curl -fsS http://127.0.0.1:8000/health
docker rm -f api-smoke

Ese curl es una prueba de humo: verifica que la imagen arranca y responde. Muchos despliegues fallan aquí y no en los tests, porque a la imagen le falta el modelo o una dependencia del sistema. Si no devuelve {"status":"ok"}, el pipeline se detiene.

Para reemplazar una versión sin cortar el servicio, despliega de forma progresiva: un contenedor nuevo junto al anterior, un porcentaje pequeño de tráfico, observa latencia y errores y solo entonces retira el viejo. Si algo empeora, vuelve a la imagen anterior.

Errores comunes

SíntomaCausaSolución
curl no conecta y el log dice running on http://127.0.0.1:8000Uvicorn escucha solo en loopbackAñade --host 0.0.0.0
El contenedor arranca y muere en bucleEl modelo no existe en la ruta configuradaCopia models/ o monta el volumen
ModuleNotFoundError: No module named 'api'Falta el WORKDIR o el contextoConstruye desde la raíz
La imagen pesa 2 GBSin .dockerignore entran .venv y datosExcluye lo que no se usa
Predicciones distintas a las localesOtra versión de scikit-learnFija versiones exactas
El HEALTHCHECK marca unhealthy/health tarda más que el timeoutResponde sin dependencias externas

Hay un error más silencioso: desplegar con --reload. Es cómodo en desarrollo y desastroso en producción, porque reinicia el proceso al detectar cambios y vigila archivos que nunca cambian. La imagen ejecuta uvicorn sin ese indicador.

Buenas prácticas y checklist de producción

Antes de abrir el servicio a usuarios reales, recorre esta lista. Cada punto existe porque alguien lo olvidó y lo pagó:

  • Versiones fijadas en requirements.txt (scikit-learn==1.7.2, no scikit-learn).
  • Configuración externa en .env.example, sin secretos en la imagen ni en el repositorio.
  • Usuario no root y sistema base mínimo, sin compiladores en la imagen final.
  • Healthcheck en /health que no dependa de servicios externos.
  • Logs estructurados con identificador, ruta, estado y latencia; sin datos personales.
  • Límites de entrada en Pydantic y un tope de tamaño en el proxy.
  • Alerta por tasa de error y por latencia p95, no solo por proceso caído.
  • Rollback probado al menos una vez: saber volver es parte del despliegue.

Seguridad y límites del modelo

Un endpoint público recibe tráfico hostil el primer día. Como mínimo: autenticación por token, límite de peticiones por minuto, validación estricta del cuerpo y ningún detalle interno en los errores. Mantén las dependencias al día: la mayoría de incidentes vienen de una biblioteca, no del modelo.

  • Autentica aunque el dato parezca público; sin credencial, cualquiera consume tu CPU.
  • Limita el tamaño del lote: un POST con 10 000 filas puede tumbar el servicio.
  • No prometas certeza: devuelve una estimación y, si es crítico, un intervalo.
  • Revisa sesgos: si ciertos distritos están poco representados, sus predicciones son peores.
  • Documenta los límites: el rango de áreas y antigüedades del entrenamiento. Fuera de él, extrapolas.

Y un límite que no es técnico: el modelo asesora, no decide. Si la API se convierte en la única voz, sus errores se convierten en decisiones injustas que nadie revisa.

Ejercicios

Ejercicio 1

Prueba de humo al arrancar

Escribe verificar_arranque(modelo): predice un caso conocido —85 m², 2 habitaciones, 12 años, Miraflores— y comprueba que el resultado cae entre 300 y 5000 USD. Si no, lanza RuntimeError con un mensaje claro. Llámala desde el lifespan para que el contenedor no arranque con un modelo roto.
Pista

Construye el DataFrame con el mismo orden de columnas del entrenamiento y compara con if not 300 <= precio <= 5000.

Ejercicio 2

Informe de drift por columna

Amplía monitor/drift.py con informe(referencia, produccion, umbral): recorre las columnas numéricas, aplica la prueba y devuelve una lista de diccionarios. Añade el porcentaje de columnas con drift y una recomendación: revisar si supera el 30 %, estable en caso contrario.
Pista

Selecciona con referencia.select_dtypes("number").columns y comprueba que la columna exista en ambos marcos.

Completar código Completa el Dockerfile
FROM 

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r 

COPY api ./api
COPY models ./models

EXPOSE 8000
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]

La imagen base fija versión y variante ligera: `python:3.12-slim`. `pip install -r` necesita el nombre del archivo de dependencias.

Soluciones explicadas

La prueba de humo complementa a los tests. Los tests comprueban el contrato con un modelo de desarrollo; el humo comprueba que el artefacto que se va a desplegar produce algo sensato. Si alguien sube un .joblib de otro proyecto, los tests pasan y el humo falla.

El informe de drift tiene una limitación que conviene decir en voz alta: solo mira las entradas. Si cambia la relación entre entrada y precio —una norma que encarece el metro cuadrado— ninguna prueba sobre X lo detecta. Ese es el concept drift, y verlo exige las respuestas reales, aunque lleguen tarde.

Resumen

  • Desplegar es sostener el servicio: imagen, configuración externa, observación y proceso de cambio.
  • La imagen fija versiones; la configuración llega por variables de entorno, sin secretos en el repositorio.
  • Uvicorn debe escuchar en 0.0.0.0 y ejecutarse sin --reload en producción.
  • Un HEALTHCHECK sobre /health distingue proceso vivo de servicio útil.
  • Los logs estructurados con identificador y latencia hacen diagnosticable cada petición.
  • El data drift se vigila comparando distribuciones; el concept drift necesita las respuestas reales.
  • Versiona el artefacto, prueba la imagen con un curl y ten un rollback probado.

Evaluación

Evaluación 1

¿Dónde debe vivir la ruta del artefacto y la versión del modelo en producción?

Evaluación 2

El contenedor arranca, pero curl desde tu máquina no conecta. El log dice Uvicorn running on http://127.0.0.1:8000. ¿Cuál es la causa?

Evaluación 3

En la prueba de Kolmogorov-Smirnov, ¿qué indica un p-valor menor que el umbral?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.