Exponiendo modelos como APIs REST en contenedores

Lectura
20 min~11 min lectura

Introducción: Del Modelo aislado al Servicio en Producción

En el ecosistema del Machine Learning, el desarrollo de un modelo con métricas impresionantes en un Jupyter Notebook es solo el primer paso, y a menudo el más sencillo. El verdadero desafío comienza cuando debemos llevar ese modelo al mundo real, donde necesita interactuar con otros sistemas, procesar solicitudes en tiempo real y escalar bajo demanda. Aquí es donde la containerización y la arquitectura de APIs REST convergen para crear una solución robusta y reproducible.

Docker nos permite empaquetar no solo el modelo serializado (como un archivo .pkl o .h5), sino todo su entorno de ejecución: las bibliotecas de Python específicas, las dependencias del sistema, el código de inferencia y el servidor web que lo expondrá. Este "paquete" autónomo garantiza que el modelo se comporte exactamente igual en tu máquina de desarrollo, en el entorno de pruebas y en el servidor de producción. Exponer el modelo como una API REST es el estándar de facto para la integración, permitiendo que cualquier aplicación, ya sea un sitio web, un servicio móvil o otro microservicio, pueda consumir las predicciones enviando una simple solicitud HTTP.

Esta lección se enfoca en la transición fundamental: cómo tomar un modelo entrenado y encapsularlo dentro de un contenedor Docker que funcione como un servicio web accesible mediante endpoints REST. Abordaremos el diseño de la API, la construcción de la imagen, las consideraciones de eficiencia y los errores más frecuentes en este proceso.

Concepto Clave: La API REST como Interfaz Universal para Modelos

Imagina que tu modelo de Machine Learning es un chef experto en una cocina de restaurante (el contenedor). Los clientes (sistemas cliente) no pueden entrar a la cocina a darle instrucciones directamente. En su lugar, envían sus pedidos a través de un camarero (la API REST). El camarero recibe un pedido estructurado (una solicitud HTTP con datos JSON), se lo comunica al chef, espera a que el chef prepare el plato (la predicción), y luego lleva el resultado de vuelta al cliente. La cocina, el chef y sus herramientas están perfectamente aislados; el cliente solo necesita conocer el menú (la especificación de la API) y cómo hacer un pedido.

Una API REST (Representational State Transfer) opera sobre los principios fundamentales de la web. Utiliza verbos HTTP estándar: POST para enviar datos y solicitar una predicción, GET para verificar la salud del servicio, y quizás PUT o DELETE para gestionar el modelo en sí si tu API lo permite. Los datos se intercambian típicamente en formato JSON, que es ligero, legible y ampliamente compatible. En el contexto de Docker, este servicio web se ejecuta dentro del contenedor, escuchando en un puerto específico (ej., 5000 o 8080) que luego exponemos al mundo exterior.

La belleza de esta abstracción es la desacoplación. El equipo de Data Science puede actualizar el modelo o las bibliotecas dentro del contenedor sin que el equipo de desarrollo frontend tenga que cambiar una sola línea de código, siempre y cuando la "interfaz" (el formato de entrada y salida del endpoint /predict) permanezca constante. Este es un pilar esencial para los sistemas de MLOps.

Cómo funciona en la práctica: Anatomía de un Contenedor API

Construir un contenedor que sirva un modelo como API implica una serie de pasos bien definidos que combinan desarrollo de software y operaciones. Primero, necesitas un script de servidor. Este es típicamente un archivo Python que utiliza un framework web ligero como Flask o FastAPI. Este script tiene tres responsabilidades principales: cargar el modelo entrenado desde el sistema de archivos del contenedor al iniciarse, definir las rutas (endpoints) de la API, y contener la lógica para preprocesar la solicitud entrante, ejecutar la inferencia y postprocesar la respuesta.

Segundo, necesitas un Dockerfile. Este archivo de texto es la receta para construir tu imagen. Comienza desde una imagen base ligera de Python, copia tus archivos de trabajo (el script del servidor, el modelo serializado, un archivo de requisitos), instala las dependencias con pip, y finalmente especifica el comando que se ejecutará cuando el contenedor se inicie (por ejemplo, `python app.py`). El Dockerfile asegura que cada capa de tu entorno sea reproducible.

El flujo de trabajo práctico es: 1) Desarrollar y probar la API localmente en tu máquina. 2) Escribir el Dockerfile. 3) Construir la imagen con `docker build`. 4) Ejecutar un contenedor localmente a partir de esa imagen para verificar que funciona (`docker run -p 5000:5000 nombre-imagen`). 5) Subir la imagen a un registro como Docker Hub o Amazon ECR. 6) Desplegar esa imagen en cualquier entorno que ejecute Docker o un orquestador como Kubernetes. La magia reside en que los pasos 3 al 6 son idénticos independientemente de la complejidad de tu modelo.

Código en acción: Un Ejemplo Completo con Flask y Scikit-learn

A continuación, presentamos un ejemplo funcional y completo de una API REST para un modelo de clasificación. Utilizaremos Flask por su simplicidad, Scikit-learn para el modelo, y Gunicorn como servidor WSGI para un entorno más robusto que el servidor de desarrollo integrado de Flask.

Estructura del Proyecto


ml-api-container/
├── Dockerfile
├── requirements.txt
├── app.py
├── train_model.py  # Script para generar el modelo de ejemplo
└── model/
    └── iris_classifier.pkl

1. Script de Entrenamiento (train_model.py)

Este script genera y guarda un modelo simple para usar en nuestra API. Normalmente, el entrenamiento sería un pipeline separado, pero lo incluimos aquí para completitud.


# train_model.py
import pickle
from sklearn.datasets import load_iris
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split

# Cargar datos y entrenar modelo
iris = load_iris()
X_train, X_test, y_train, y_test = train_test_split(iris.data, iris.target, test_size=0.2, random_state=42)

model = RandomForestClassifier(n_estimators=10, random_state=42)
model.fit(X_train, y_train)

# Guardar modelo
with open('model/iris_classifier.pkl', 'wb') as f:
    pickle.dump(model, f)

print(f"Modelo entrenado y guardado. Precisión en test: {model.score(X_test, y_test):.2f}")

2. Aplicación Flask Principal (app.py)

Este es el corazón de nuestro servicio API. Define los endpoints y la lógica de inferencia.


# app.py
import pickle
import numpy as np
from flask import Flask, request, jsonify

app = Flask(__name__)

# Cargar el modelo al iniciar la aplicación
MODEL_PATH = 'model/iris_classifier.pkl'
with open(MODEL_PATH, 'rb') as f:
    model = pickle.load(f)

# Mapeo de clases a nombres
CLASS_NAMES = ['setosa', 'versicolor', 'virginica']

@app.route('/health', methods=['GET'])
def health_check():
    """Endpoint para verificar que el servicio está vivo."""
    return jsonify({'status': 'healthy', 'model_loaded': True})

@app.route('/predict', methods=['POST'])
def predict():
    """
    Endpoint principal para predicciones.
    Espera un JSON con una clave 'features' que sea una lista de listas (cada lista con 4 números).
    """
    data = request.get_json()

    # Validación básica de entrada
    if not data or 'features' not in data:
        return jsonify({'error': 'Se requiere un campo "features" en el JSON'}), 400

    try:
        features = np.array(data['features'])
        # Validar forma: esperamos (n_samples, 4)
        if features.ndim != 2 or features.shape[1] != 4:
            return jsonify({'error': 'El campo "features" debe ser un array 2D con 4 columnas'}), 400

        # Realizar predicción
        predictions = model.predict(features)
        probabilities = model.predict_proba(features).tolist()

        # Formatear respuesta
        results = []
        for i, (pred, probs) in enumerate(zip(predictions, probabilities)):
            results.append({
                'sample_index': i,
                'predicted_class': int(pred),
                'predicted_class_name': CLASS_NAMES[pred],
                'probabilities': {CLASS_NAMES[j]: round(prob, 4) for j, prob in enumerate(probs)}
            })

        return jsonify({'predictions': results})

    except Exception as e:
        # Capturar cualquier error en la predicción o procesamiento
        return jsonify({'error': f'Error durante la predicción: {str(e)}'}), 500

if __name__ == '__main__':
    # Nota: Usar Gunicorn en producción. Este modo solo es para desarrollo.
    app.run(host='0.0.0.0', port=5000, debug=False)

3. Archivo de Dependencias (requirements.txt)


Flask==2.3.3
scikit-learn==1.3.0
numpy==1.24.3
gunicorn==21.2.0

4. Dockerfile

La receta para construir nuestra imagen contenedora.


# Usar una imagen base oficial de Python slim
FROM python:3.9-slim

# Establecer el directorio de trabajo dentro del contenedor
WORKDIR /app

# Copiar el archivo de requisitos e instalar dependencias primero (para aprovechar la cache de Docker)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copiar el resto de los archivos de la aplicación
COPY . .

# Crear directorio para el modelo si no existe
RUN mkdir -p model

# (Opcional) Ejecutar el script de entrenamiento para generar el modelo dentro de la imagen
# RUN python train_model.py

# Exponer el puerto en el que la aplicación escuchará
EXPOSE 5000

# Comando para ejecutar la aplicación usando Gunicorn (recomendado para producción)
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]

# Alternativa para desarrollo (sin Gunicorn):
# CMD ["python", "app.py"]

5. Construir, Ejecutar y Probar

Desde el directorio `ml-api-container`, ejecuta:


# Construir la imagen Docker
docker build -t ml-model-api:latest .

# Ejecutar el contenedor, mapeando el puerto 5000 del host al 5000 del contenedor
docker run -d -p 5000:5000 --name iris-api ml-model-api:latest

# Probar el endpoint de salud
curl http://localhost:5000/health

# Probar el endpoint de predicción con datos de ejemplo
curl -X POST http://localhost:5000/predict \
  -H "Content-Type: application/json" \
  -d '{"features": [[5.1, 3.5, 1.4, 0.2], [6.7, 3.1, 4.4, 1.4]]}'

Errores comunes y cómo evitarlos

Al dockerizar APIs de ML, varios obstáculos pueden aparecer. Identificarlos temprano ahorra horas de depuración.

1. Contenedor que se cierra inmediatamente después de iniciar: Esto casi siempre se debe a que el proceso principal (tu script Python) termina, o encuentra un error de importación y crashea. Solución: Asegúrate de que tu aplicación Flask/FastAPI esté configurada para ejecutarse en el host `0.0.0.0` (no `localhost`). Verifica que todas las dependencias estén listadas en `requirements.txt` y que el `CMD` en el Dockerfile sea correcto. Usa `docker logs <nombre_contenedor>` para ver los mensajes de error.

2. El modelo no se encuentra dentro del contenedor: El Dockerfile copia archivos, pero la ruta de acceso al modelo en `app.py` puede ser incorrecta. Solución: Usa rutas absolutas o relativas consistentes. Si copias el modelo a `/app/model/` en el Dockerfile, asegúrate de que tu código lo busque allí. Considera usar variables de entorno para configurar la ruta del modelo, lo que hace tu contenedor más configurable.

3. API lenta o que agota la memoria: Cargar un modelo grande (como un modelo de deep learning) en cada solicitud, o no liberar recursos, puede causar esto. Solución: Carga el modelo una sola vez al iniciar la aplicación (como en nuestro ejemplo), no dentro de la función del endpoint. Para modelos muy grandes, considera servicios de inferencia especializados (como TensorFlow Serving o TorchServe) que están optimizados para este fin. Además, limita el tamaño de las solicitudes entrantes en tu framework web.

4. Problemas de serialización JSON: Los objetos NumPy o tipos de datos personalizados no son serializables a JSON por defecto. Si tu modelo devuelve estos, Flask lanzará un error. Solución: Convierte explícitamente los resultados a tipos nativos de Python (listas, diccionarios, strings, números) antes de devolverlos con `jsonify()`. Usa métodos como `.tolist()` en arrays de NumPy.

5. Falta de gestión de versiones del modelo: Si actualizas el modelo `iris_classifier.pkl` y reconstruyes la imagen, ¿cómo sabes qué versión está desplegada? Solución: Implementa un endpoint `/metadata` que devuelva la versión del modelo, la fecha de entrenamiento o el hash del commit usado. Etiqueta tus imágenes Docker con versiones semánticas (ej., `:v1.2.0`) y nunca uses únicamente la etiqueta `:latest` en producción.

Tip Crítico: Nunca expongas directamente el puerto de tu aplicación Flask en desarrollo (`debug=True`) en un entorno de producción. Usa un servidor WSGI de producción como Gunicorn (como en nuestro Dockerfile) o uWSGI detrás de un proxy inverso como Nginx. Esto mejora significativamente el rendimiento, la seguridad y la concurrencia.

Checklist de dominio

Al completar esta lección, debes ser capaz de verificar los siguientes puntos. Si puedes marcar la mayoría, has asimilado los conceptos clave.

  • Puedo explicar la analogía del chef/camarero para describir la relación entre un modelo contenedorizado y una API REST.
  • He escrito y probado un script sencillo en Flask o FastAPI que carga un modelo y expone al menos un endpoint `/predict`.
  • He construido una imagen Docker a partir de un Dockerfile que incluye mi código de API, el modelo y las dependencias.
  • He ejecutado un contenedor a partir de mi imagen y he interactuado con la API usando `curl` o una herramienta como Postman.
  • Puedo identificar y resolver al menos tres de los "errores comunes" listados anteriormente (ej., modelo no encontrado, error de JSON).
  • Comprendo la importancia de cargar el modelo una sola vez al inicio del servicio, no por cada solicitud.
  • Sé la diferencia entre el servidor de desarrollo de Flask y un servidor WSGI de producción como Gunicorn, y cuándo usar cada uno.
  • Puedo describir el flujo de trabajo completo: desde el código en mi máquina hasta una imagen desplegada en un registro.
De lección a portfolio

Convertí esta lección en una habilidad visible para entrevistas.

Guardá el curso, completá los ejercicios y conectá esta habilidad con una ruta de empleo, data, IA, programación o marketing.

Newsletter Cursalo

Recibí rutas y cursos nuevos

Sumate para recibir recursos orientados a empleo y portfolio.

  • Rutas de empleo
  • Cursos prácticos
  • Portfolio y entrevistas

Sin spam. También podés entrar con tu cuenta para guardar progreso. Iniciá sesión