Introducción: La Containerización como Puente entre Desarrollo y Producción
Hasta este punto en el curso, has aprendido a containerizar aplicaciones y servicios de manera aislada. En esta lección integradora, daremos el salto crucial: encapsular un pipeline de Machine Learning completo dentro de contenedores Docker, preparándolo para su orquestación con Kubernetes. El objetivo final es transformar tu script de Jupyter Notebook o tu entorno de desarrollo local en un sistema reproducible, escalable y confiable. Ya no se trata solo de tener un modelo con un buen accuracy, sino de un sistema que pueda entrenarse de nuevo automáticamente y servir predicciones las 24 horas del día, los 7 días de la semana.
Containerizar un pipeline ML implica dividir el flujo de trabajo en componentes lógicos e independientes. Los dos pilares fundamentales que abordaremos son: el entrenamiento (training) y el servicio (serving/inference). Cada uno tiene requisitos distintos: el primero es batch-oriented, intensivo en cómputo y puede ser esporádico; el segundo es online-oriented, debe tener baja latencia y estar siempre disponible. Al containerizarlos por separado, obtenemos modularidad, permitiendo actualizar el servicio de predicción sin tocar la lógica de entrenamiento, y viceversa. Esta separación es la base para implementar prácticas de MLOps más avanzadas.
Concepto Clave: Separación de Responsabilidades en Contenedores
Imagina una fábrica de automóviles moderna. Existe una planta de fabricación (nuestro contenedor de entrenamiento) donde, a partir de materias primas (datos), planos (algoritmos) y energía (potencia de CPU/GPU), se ensambla y pinta un nuevo modelo de coche. Este proceso es complejo, lleva tiempo y no se realiza continuamente, sino en lotes cuando hay nuevos diseños o materiales. Por otro lado, existe el concesionario (nuestro contenedor de servicio). Su trabajo es tener el último modelo de coche disponible, permitir que los clientes (aplicaciones cliente) vengan, prueben sus características (envíen datos) y reciban una experiencia de conducción (una predicción) inmediata. El concesionario no necesita saber cómo se soldó el chasis, solo necesita el producto final listo para usar.
En términos técnicos, esta separación se traduce en dos imágenes Docker distintas. La imagen de entrenamiento contendrá todo el código para preprocesar datos, entrenar el modelo, evaluarlo y serializarlo (por ejemplo, un archivo .pkl o .joblib). La imagen de servicio será típicamente más ligera, conteniendo el modelo serializado, un entorno mínimo para ejecutar la inferencia y un servidor web (como Flask, FastAPI o gunicorn) que exponga una API. La comunicación entre ellos suele ser a través de un almacenamiento persistente (un volumen Docker, un bucket en la nube) donde la imagen de entrenamiento "deposita" el modelo entrenado y la de servicio lo "recoge".
Tip Arquitectónico: Diseña tus contenedores como "inmutables". La imagen de servicio debe incluir una versión específica del modelo en el momento de su construcción. Para actualizar el modelo, se construye una nueva imagen (v1.0.1, v1.0.2), no se modifica la que está en ejecución. Esto garantiza rollbacks sencillos y un historial claro de despliegues.
Cómo Funciona en la Práctica: Un Flujo Paso a Paso
Vamos a desglosar el proceso completo, desde el código hasta un sistema en ejecución. El primer paso es estructurar tu proyecto. Una organización típica incluye directorios como /src para el código, /data para los conjuntos de datos (o referencias a ellos), /models para el output del entrenamiento, y los archivos Dockerfile.train y Dockerfile.serve en la raíz. También es crucial un archivo de requisitos (requirements.txt o pyproject.toml) que liste las dependencias de Python de manera precisa, fijando versiones para garantizar la reproducibilidad.
El siguiente paso es la construcción. Ejecutarías docker build -f Dockerfile.train -t ml-model-trainer:latest . para crear la imagen del entrenador. Luego, ejecutarías un contenedor a partir de ella, montando un volumen para que pueda guardar el modelo serializado en tu máquina local: docker run -v $(pwd)/models:/app/models ml-model-trainer:latest. Una vez completado el entrenamiento, el archivo del modelo estará en tu directorio local ./models. Ahora, construyes la imagen de servicio, que copiará este modelo específico durante su construcción: docker build -f Dockerfile.serve -t ml-model-api:latest .. Finalmente, pones en marcha el servicio con docker run -p 5000:5000 ml-model-api:latest, teniendo ahora una API escuchando en http://localhost:5000/predict.
En un entorno de Kubernetes, este flujo se automatiza. El Dockerfile de entrenamiento podría ser ejecutado como un Job de Kubernetes que se programa periódicamente (por ejemplo, con un CronJob). Este Job guardaría el modelo en un almacenamiento persistente compartido, como un PersistentVolumeClaim o un bucket de cloud storage (S3, GCS). Luego, el Deployment para el servicio de predicción podría ser actualizado (mediante una estrategia de rollout) para usar una nueva imagen que referencie el modelo recién entrenado, logrando una actualización continua del pipeline sin tiempo de inactividad.
Código en Acción: Entrenamiento y Servicio con Scikit-Learn y FastAPI
A continuación, presentamos un ejemplo funcional y simplificado de un clasificador de flores Iris. Este es un proyecto completo que puedes adaptar a problemas más complejos.
Estructura del Proyecto
proyecto-ml/
├── Dockerfile.train
├── Dockerfile.serve
├── requirements.txt
├── src/
│ ├── train.py
│ └── serve.py
├── models/ # (Se crea después del entrenamiento)
└── data/ # (Podría contener datos de entrada)
1. Dockerfile para Entrenamiento (Dockerfile.train)
# Usar una imagen base oficial de Python
FROM python:3.9-slim
# Establecer el directorio de trabajo dentro del contenedor
WORKDIR /app
# Copiar el archivo de dependencias primero (mejora el caching de capas)
COPY requirements.txt .
# Instalar las dependencias de Python
RUN pip install --no-cache-dir -r requirements.txt
# Copiar el código fuente de entrenamiento
COPY src/train.py .
# Comando por defecto: ejecutar el script de entrenamiento
CMD ["python", "train.py"]
2. Script de Entrenamiento (src/train.py)
import pandas as pd
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score
import joblib
import os
# Cargar datos
iris = load_iris()
X = pd.DataFrame(iris.data, columns=iris.feature_names)
y = iris.target
# Dividir en train/test
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# Entrenar modelo
print("Entrenando modelo RandomForest...")
model = RandomForestClassifier(n_estimators=100, random_state=42)
model.fit(X_train, y_train)
# Evaluar
y_pred = model.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
print(f"Precisión en el conjunto de prueba: {accuracy:.4f}")
# Guardar modelo
# Asegurarse de que el directorio existe (se montará como volumen o se creará)
model_dir = "/app/models"
os.makedirs(model_dir, exist_ok=True)
model_path = os.path.join(model_dir, "iris_rf_model.joblib")
joblib.dump(model, model_path)
print(f"Modelo guardado en: {model_path}")
3. Dockerfile para Servicio (Dockerfile.serve)
# Imagen base más ligera
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copiar el modelo pre-entrenado (construido en una etapa previa)
# NOTA: El modelo debe estar en el contexto de construcción. En la práctica,
# se copiaría desde el directorio local 'models/' tras ejecutar el entrenamiento.
COPY models/iris_rf_model.joblib ./model/
# Copiar el código de la API
COPY src/serve.py .
# Exponer el puerto que usará la aplicación
EXPOSE 5000
# Comando para ejecutar la aplicación con uvicorn (para FastAPI)
CMD ["uvicorn", "serve:app", "--host", "0.0.0.0", "--port", "5000"]
4. Script de Servicio API (src/serve.py)
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import joblib
import numpy as np
import os
# Cargar el modelo una vez al iniciar la aplicación
MODEL_PATH = "/app/model/iris_rf_model.joblib"
try:
model = joblib.load(MODEL_PATH)
print(f"Modelo cargado exitosamente desde {MODEL_PATH}")
except FileNotFoundError:
print(f"ERROR: No se encontró el modelo en {MODEL_PATH}. Asegúrate de que fue copiado durante el build.")
# En un entorno real, podrías querer que falle rápido.
model = None
# Definir la aplicación FastAPI
app = FastAPI(title="ML Model API", description="API para predicciones del modelo Iris")
# Definir el esquema de datos de entrada usando Pydantic
class IrisFeatures(BaseModel):
sepal_length: float
sepal_width: float
petal_length: float
petal_width: float
@app.get("/")
def read_root():
return {"message": "API de Modelo ML para Clasificación Iris"}
@app.post("/predict")
def predict(features: IrisFeatures):
if model is None:
raise HTTPException(status_code=500, detail="Modelo no cargado")
# Convertir los datos de entrada a un array numpy 2D
input_data = np.array([[features.sepal_length,
features.sepal_width,
features.petal_length,
features.petal_width]])
# Realizar la predicción
prediction = model.predict(input_data)
# Obtener probabilidades (si el modelo lo soporta)
probabilities = model.predict_proba(input_data)
# Mapear la predicción numérica al nombre de la clase
class_names = ["setosa", "versicolor", "virginica"]
predicted_class = class_names[prediction[0]]
return {
"predicted_class": predicted_class,
"class_id": int(prediction[0]),
"probabilities": {
class_names[i]: round(float(prob), 4) for i, prob in enumerate(probabilities[0])
}
}
@app.get("/health")
def health_check():
"""Endpoint de salud para verificaciones de readiness/liveness en Kubernetes."""
if model is not None:
return {"status": "healthy", "model_loaded": True}
else:
return {"status": "unhealthy", "model_loaded": False}, 503
5. Archivo de Requisitos (requirements.txt)
scikit-learn==1.3.0
pandas==2.0.3
joblib==1.3.2
fastapi==0.104.1
uvicorn[standard]==0.24.0
pydantic==2.5.0
numpy==1.24.3
Errores Comunes y Cómo Evitarlos
1. "ModuleNotFoundError" en tiempo de ejecución del contenedor: Esto ocurre cuando una dependencia no está listada en requirements.txt o hay un conflicto de versiones. Solución: Usa entornos virtuales durante el desarrollo y genera el requirements.txt con pip freeze > requirements.txt desde ese entorno. En el Dockerfile, ejecuta RUN pip install con la opción --no-cache-dir para evitar artefactos corruptos.
2. El contenedor de servicio no encuentra el modelo serializado: Es el error más frecuente. Ocurre porque la ruta en el código (/app/model/iris_rf_model.joblib) no coincide con la ubicación real dentro del contenedor. Solución: Verifica las instrucciones COPY en el Dockerfile.serve. Asegúrate de que el modelo exista en el contexto de construcción (tu directorio local models/) antes de ejecutar docker build. Usa comandos como docker run -it nombre-imagen bash para explorar el sistema de archivos interno.
3. Contenedores que consumen toda la memoria y son terminados (OOM Killer): Los procesos de entrenamiento, especialmente con grandes datasets o modelos complejos, pueden agotar la memoria. Solución: Establece límites de recursos explícitos al ejecutar los contenedores: docker run --memory="4g" --cpus="2" .... En Kubernetes, define resources.requests y resources.limits en los manifiestos de tus Pods/Jobs.
4. Imágenes Docker excesivamente grandes: Una imagen de 2GB es lenta para transferir y desplegar. Solución: Utiliza imágenes base ligeras (python:3.9-slim vs. python:3.9). Limpia la caché de apt y pip en la misma capa RUN. Considera el patrón de multi-stage builds, donde usas una imagen grande para compilar/entrenar y copias solo los artefactos necesarios (el modelo, el código) a una imagen final minimalista.
5. API de servicio bloqueante o lenta bajo carga: Usar el servidor de desarrollo de Flask o el comando por defecto de FastAPI (python serve.py) en producción no escala. Solución: Siempre sirve la aplicación con un servidor ASGI/WSGI de producción como Gunicorn con workers de Uvicorn (para FastAPI) o simplemente Uvicorn con múltiples workers. En el Dockerfile.serve, el comando CMD ["uvicorn", "...", "--workers", "4"] es un buen punto de partida para cargas moderadas.
Checklist de Dominio
Antes de considerar esta lección completa, asegúrate de poder verificar los siguientes puntos:
- Puedo explicar la diferencia fundamental en propósito y características entre un contenedor de entrenamiento y uno de servicio/predicción.
- He estructurado un proyecto ML con una separación clara de código, datos, modelos y archivos de configuración Docker.
- Puedo escribir un
Dockerfilefuncional que instale dependencias Python desde unrequirements.txty ejecute un script de entrenamiento, guardando el output en un volumen. - Puedo construir una API REST sencilla con FastAPI (o Flask) que cargue un modelo serializado y responda a solicitudes de predicción en el endpoint
/predict. - Comprendo cómo el contenedor de servicio obtiene el modelo entrenado: ya sea copiándolo durante el build (para inmutabilidad) o cargándolo desde un almacenamiento externo en runtime (para flexibilidad).
- Sé cómo ejecutar ambos contenedores localmente, mapeando puertos y volúmenes de manera efectiva para probar el flujo completo.
- Puedo listar al menos tres errores comunes de containerización ML y describir las estrategias para mitigarlos.
- Entiendo cómo este diseño de dos contenedores se mapea a los recursos de Kubernetes (Jobs para entrenamiento, Deployments/Services para la API).