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.
- 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
- ¿Qué es desplegar un modelo?
- ¿Para qué sirve?
- ¿Cómo funciona por dentro?
- Estructura del proyecto y configuración
- Sintaxis: el Dockerfile
- Ejemplo básico: construir y ejecutar
- Ejemplo intermedio: logging estructurado
- Caso práctico: monitoreo y data drift
- Visualización: lote de inferencia y traza de una petición
- Versionado de modelos y CI/CD
- Errores comunes
- Buenas prácticas y checklist de producción
- Seguridad y límites del modelo
- Ejercicios
- Soluciones explicadas
- Resumen
- 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.
| Aspecto | En tu portátil | En producción |
|---|---|---|
| Proceso | Lo arrancas tú en una terminal | Lo arranca el runtime y lo reinicia |
| Configuración | Ruta relativa y un .env local | Variables de entorno inyectadas |
| Registro | print y la consola | Logs estructurados a un colector |
| Datos | El CSV que tienes a mano | Peticiones reales, con distribución cambiante |
| Cambios | Guardas el archivo | Imagen 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.
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
└── .dockerignoreLa 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.
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.
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.
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 20Si 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.
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 respuestaLa 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.
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ó.
Recorre los pasos para ver cómo cambian las celdas.
import timeimport uuidimport logginglog = logging.getLogger("api")async def atender(request, call_next): inicio = time.perf_counter() pid = str(uuid.uuid4())[:8] respuesta = await call_next(request) latencia = (time.perf_counter() - inicio) * 1000 log.info("peticion", extra={"datos": {"pid": pid, "latencia_ms": round(latencia, 1)}}) return respuesta—
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:
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-smokeEse 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íntoma | Causa | Solución |
|---|---|---|
curl no conecta y el log dice running on http://127.0.0.1:8000 | Uvicorn escucha solo en loopback | Añade --host 0.0.0.0 |
| El contenedor arranca y muere en bucle | El modelo no existe en la ruta configurada | Copia models/ o monta el volumen |
ModuleNotFoundError: No module named 'api' | Falta el WORKDIR o el contexto | Construye desde la raíz |
| La imagen pesa 2 GB | Sin .dockerignore entran .venv y datos | Excluye lo que no se usa |
| Predicciones distintas a las locales | Otra versión de scikit-learn | Fija versiones exactas |
El HEALTHCHECK marca unhealthy | /health tarda más que el timeout | Responde 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, noscikit-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
/healthque 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
POSTcon 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
Prueba de humo al arrancar
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.
Informe de drift por columna
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.
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.0y ejecutarse sin--reloaden producción. - Un
HEALTHCHECKsobre/healthdistingue 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
curly ten un rollback probado.
Evaluación
¿Dónde debe vivir la ruta del artefacto y la versión del modelo en producción?
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?
En la prueba de Kolmogorov-Smirnov, ¿qué indica un p-valor menor que el umbral?
Documentación oficial
Docker: referencia del Dockerfile instrucciones, capas y HEALTHCHECK pydantic-settings configuración desde variables de entorno FastAPI: middleware latencia e identificadores por petición logging: configuración avanzada formatters, handlers y niveles scipy.stats.ks_2samp prueba de dos muestras¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.