El servicio de predicción funciona en tu máquina, y esa frase debería ponerte en guardia: tu máquina tiene Python 3.11.9, las dependencias exactas del requirements.lock, el paquete instalado en modo editable y las variables de entorno bien puestas. El servidor de producción no tiene nada de eso, y reproducirlo a mano es frágil e inauditable. Docker resuelve el problema de raíz: congela sistema operativo + Python + librerías + código en una imagen inmutable que se ejecuta idéntica en tu portátil, en el servidor de staging y en el clúster de producción. En esta lección escribiremos el Dockerfile real del servicio de CineClick — multi-stage, no-root, explicado línea a línea —, tomaremos una decisión de diseño importante (¿el modelo dentro de la imagen o descargado del registry al arrancar?), construiremos y etiquetaremos la imagen, y levantaremos API + MLflow juntos con docker compose.

Contenido

  1. Por qué contenedores para ML
  2. Conceptos mínimos: imagen, contenedor, capa, registry
  3. El Dockerfile del servicio CineClick, línea a línea
  4. .dockerignore: lo que NO entra en la imagen
  5. Decisión de diseño: ¿modelo en la imagen o desde el registry?
  6. Build, tag y run
  7. docker compose: API + MLflow en local
  8. Buenas prácticas de imágenes para ML

Por qué contenedores para ML

Un modelo de ML es especialmente sensible al entorno: la predicción de un RandomForest serializado con scikit-learn 1.5.2 puede fallar (o, peor, cambiar silenciosamente) si se deserializa con otra versión. En el módulo 2 congelamos las dependencias con pip-tools; el contenedor da el paso final congelando también lo que el lock no cubre:

  • La versión exacta de Python (3.11.9, no "el 3.11 que hubiera en el servidor").
  • Las librerías de sistema (glibc, libgomp que usa scikit-learn para paralelizar, zonas horarias, certificados).
  • El propio sistema operativo base y su configuración.
  • El código y su instalación: dentro de la imagen el paquete está instalado de una forma concreta e inmutable.

El resultado es que la unidad de despliegue deja de ser "un código + unas instrucciones de instalación" y pasa a ser un artefacto binario verificable: la imagen cineclick/churn-api:0.1.0 que pasó las pruebas es, byte a byte, la misma que llegará a producción. "Funciona en mi máquina" queda definitivamente resuelto porque tu máquina viaja con el servicio.

Conceptos mínimos: imagen, contenedor, capa, registry

Asumimos las nociones básicas de Docker del prerrequisito del curso; fijemos solo el vocabulario que usaremos:

Concepto Qué es Analogía
Imagen Plantilla inmutable con SO + dependencias + código. Se construye una vez. La clase
Contenedor Proceso en ejecución creado a partir de una imagen. Puede haber N del mismo. La instancia
Capa Cada instrucción del Dockerfile crea una capa cacheable. Si no cambia, no se reconstruye. Commits apilados
Registry de imágenes Almacén remoto de imágenes versionadas (Docker Hub, GHCR, ECR...). push/pull. El "GitHub" de imágenes

Atención a una colisión de nombres que confunde a todo el mundo: el registry de imágenes (donde vive cineclick/churn-api:0.1.0) no tiene nada que ver con el model registry de MLflow (donde vive churn-cineclick v2). Son dos almacenes de artefactos distintos, con ciclos de vida distintos: la imagen cambia cuando cambia el código o el entorno; la versión del modelo cambia cuando cambia el entrenamiento. Mantener esa separación es precisamente la decisión de diseño que discutiremos en el apartado 5.

El Dockerfile del servicio CineClick, línea a línea

El Dockerfile vive en la raíz del repo cineclick-churn. Usamos multi-stage: una etapa builder con las herramientas de compilación instala las dependencias, y una etapa runtime mínima se queda solo con el resultado.

# ---------- Etapa 1: builder ----------
FROM python:3.11.9-slim AS builder

WORKDIR /app

# Primero SOLO los ficheros de dependencias: si no cambian, esta capa se cachea
COPY requirements.lock .
RUN pip install --no-cache-dir --prefix=/install -r requirements.lock

# Ahora el código del paquete, que cambia más a menudo (capa aparte)
COPY pyproject.toml .
COPY src/ src/
RUN pip install --no-cache-dir --prefix=/install --no-deps .

# ---------- Etapa 2: runtime ----------
FROM python:3.11.9-slim AS runtime

# Usuario sin privilegios: si comprometen el proceso, no son root
RUN groupadd --gid 1000 cineclick && \
    useradd --uid 1000 --gid 1000 --create-home cineclick

# Solo lo instalado en el builder; ni pip-tools, ni caches, ni código suelto
COPY --from=builder /install /usr/local

USER cineclick
WORKDIR /home/cineclick

# Documenta el puerto del servicio (uvicorn escuchará aquí)
EXPOSE 8000

# El healthcheck de 04-02 puesto a trabajar: Docker marcará el contenedor
# unhealthy si /salud no responde 200
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/salud')"

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

Explicación línea a línea:

  • FROM python:3.11.9-slim: la versión exacta del proyecto (fijada en el módulo 2), variante slim (~150 MB frente a ~1 GB de la imagen completa). Nunca python:latest: una imagen que cambia sola bajo tus pies es la antítesis de la reproducibilidad.
  • COPY requirements.lock antes que el código: el orden explota la caché de capas. Las dependencias cambian poco; el código, cada día. Con este orden, un cambio en main.py reconstruye en segundos porque la capa (lenta) de pip install -r requirements.lock sigue cacheada.
  • --prefix=/install: instala todo en un directorio limpio que luego copiamos entero a la etapa runtime. Es el truco central del multi-stage.
  • pip install --no-deps .: instala el paquete cineclick_churn (el de verdad, no editable — -e es para desarrollo) sin re-resolver dependencias: ya están todas clavadas por el lock. Aquí viajan features.py y la API juntos, como decidimos en 04-02.
  • COPY --from=builder /install /usr/local: la etapa runtime recibe solo el resultado. Las herramientas de build, la caché de pip y los ficheros intermedios se quedan en la etapa builder, que se descarta. Resultado: imagen final de unos 380 MB (frente a >1 GB sin multi-stage), y menos superficie de ataque.
  • USER cineclick: el proceso corre como usuario sin privilegios. Es de las prácticas de seguridad más baratas que existen y muchos clústeres directamente rechazan contenedores root.
  • EXPOSE 8000 es documentación (el mapeo real se hace en docker run -p), pero documentación que herramientas y humanos leen.
  • HEALTHCHECK llama al GET /salud de 04-02 cada 30 s. --start-period=20s da margen al lifespan para descargar el modelo del registry antes de empezar a exigir salud. Usamos urllib de la biblioteca estándar para no instalar curl en la imagen.
  • CMD en forma exec (lista JSON): uvicorn recibe las señales del sistema directamente (un docker stop hace apagado limpio, no un kill a los 10 segundos).
  • Fíjate en lo que no hay: ninguna copia del modelo y ningún secreto. Lo primero es la decisión del apartado 5; lo segundo, una regla absoluta (apartado 8).

.dockerignore: lo que NO entra en la imagen

COPY src/ src/ copia lo que le digas, pero el contexto de build que Docker empaqueta y envía al daemon es, por defecto, el directorio entero. Sin .dockerignore, el build de CineClick arrastraría gigabytes de datos y experimentos:

# .dockerignore
.git/
.venv/
data/            # los datos los gestiona DVC, no la imagen
models/          # artefactos locales de entrenamiento
mlruns/          # experimentos MLflow locales
.dvc/cache/
tests/
notebooks/
__pycache__/
*.pyc
.env             # ¡secretos locales JAMÁS en el contexto de build!

Los tres primeros bloques no son solo peso: data/ y mlruns/ pueden contener información que no debe viajar dentro de una imagen que se sube a un registry compartido. .env directamente sería una fuga de credenciales. Regla mental: la imagen contiene el servicio, no el proyecto.

Decisión de diseño: ¿modelo en la imagen o desde el registry?

Hay dos formas de que el contenedor tenga el modelo, y la elección condiciona todo el flujo de despliegue:

Opción A — "Hornear" el modelo en la imagen: durante el build, un RUN descarga el modelo y lo copia dentro (COPY modelo/ /app/modelo/). La imagen es autosuficiente.

Opción B — Descargar del registry al arrancar: la imagen solo lleva código; el lifespan de 04-02 descarga models:/churn-cineclick@champion usando MLFLOW_TRACKING_URI como variable de entorno.

Criterio A: modelo en la imagen B: desde el registry al arrancar
Autosuficiencia Total: corre sin registry ni red Necesita el registry accesible en cada arranque
Arranque Rápido (modelo ya dentro) Más lento (descarga; segundos para nuestro RF)
Nuevo modelo ⇒ Rebuild + push + redeploy de la imagen Mover el alias + reiniciar contenedores; misma imagen
Trazabilidad La etiqueta de imagen fija código Y modelo juntos /version dice qué modelo cargó cada arranque
Gobierno del modelo Se decide en el build (pipeline de imágenes) Se decide en el registry (flujo de 03-02: alias, revisión humana)
Riesgo característico Proliferan imágenes por combinación código×modelo Registry caído ⇒ arranques nuevos fallan (ej. 3 de 04-02)
Encaja cuando... Edge/air-gapped, sin registry en runtime, arranque crítico Hay registry gobernado y el modelo cambia más que el código

CineClick elige la opción B, y el razonamiento importa más que la elección: en el módulo 3 construimos un flujo de gobierno del modelo — versiones, aliases, promoción con revisión humana, rollback moviendo @champion —. Hornear el modelo en la imagen vaciaría ese flujo: cada promoción exigiría reconstruir y redistribuir una imagen, y el rollback dejaría de ser "mover un alias" para ser "redesplegar la imagen anterior". Con la opción B, imagen y modelo evolucionan por separado: cineclick/churn-api:0.1.0 es la misma tanto si el campeón es la v2 como si mañana es la v3, y el registry sigue siendo el único punto de gobierno. El precio — dependencia del registry en el arranque — se mitiga con réplicas ya arrancadas (04-04) y es un riesgo aceptado y documentado.

La opción A queda anotada como alternativa válida para contextos concretos (un despliegue en el set-top-box de un partner, un entorno sin salida a red): si algún día hace falta, es un stage adicional en el Dockerfile, no un rediseño.

Build, tag y run

# Construir y etiquetar: nombre/servicio:versión-semántica
docker build -t cineclick/churn-api:0.1.0 .

# También conviene una etiqueta flotante para "lo último estable en local"
docker tag cineclick/churn-api:0.1.0 cineclick/churn-api:latest

El tag semántico 0.1.0 (coincide con app.version del servicio y con la versión del paquete en pyproject.toml) es el identificador que citarán los manifiestos de Kubernetes en 04-04 y el CD en 05-02. latest es cómodo en local pero nunca se despliega en producción: no sabrías qué está corriendo.

Ejecutar el contenedor:

docker run --rm -p 8000:8000 \
  -e MLFLOW_TRACKING_URI=http://host.docker.internal:5000 \
  -e UMBRAL_ABANDONO=0.5 \
  --name churn-api \
  cineclick/churn-api:0.1.0
  • -p 8000:8000 publica el puerto del contenedor en tu máquina.
  • -e MLFLOW_TRACKING_URI=... es la variable de la opción B: el mismo contenedor apunta a un MLflow u otro según el entorno, sin tocar la imagen. host.docker.internal es como un contenedor ve a tu máquina anfitriona (donde corre el MLflow local del módulo 3).
  • Comprueba la salud: docker ps mostrará (healthy) pasado el start-period, y curl http://localhost:8000/version debe responder "version_modelo": "2" — la prueba de que el contenedor descargó al campeón del registry.

docker compose: API + MLflow en local

Arrancar dos terminales a mano cada vez es fricción. docker compose declara el conjunto:

# compose.yaml
services:
  mlflow:
    image: ghcr.io/mlflow/mlflow:v2.18.0
    command: >
      mlflow server
      --backend-store-uri sqlite:///mlflow/mlflow.db
      --artifacts-destination /mlflow/artefactos
      --host 0.0.0.0 --port 5000
    ports:
      - "5000:5000"
    volumes:
      - ./mlflow_data:/mlflow    # persistencia: sqlite y artefactos sobreviven al contenedor

  api:
    image: cineclick/churn-api:0.1.0
    ports:
      - "8000:8000"
    environment:
      MLFLOW_TRACKING_URI: http://mlflow:5000   # DNS interno de compose: el servicio se llama 'mlflow'
      UMBRAL_ABANDONO: "0.5"
    depends_on:
      mlflow:
        condition: service_started
docker compose up -d       # levanta ambos
docker compose logs -f api # ver el lifespan cargando el modelo
docker compose down        # apagar todo

Detalles que merecen atención:

  • Dentro de la red de compose, la API alcanza a MLflow por su nombre de servicio (http://mlflow:5000) — nada de localhost, que dentro de un contenedor significa "yo mismo".
  • El volumen ./mlflow_data hace persistente el registry local; sin él, cada down borraría los modelos registrados. (Para que este MLflow contenga a churn-cineclick v1/v2 tendrás que apuntar tu MLFLOW_TRACKING_URI de entrenamiento aquí y re-registrar, o copiar tu mlflow.db y artefactos a mlflow_data/.)
  • depends_on ordena el arranque pero no espera a que MLflow esté listo: si la API arranca antes de que MLflow responda, fallará (fallar rápido, 04-02) y bastará docker compose restart api. En producción este baile lo gestionan las probes de Kubernetes (04-04).

Este compose es el entorno de desarrollo integrado del equipo: Marc clona el repo, ejecuta docker compose up y tiene el mismo sistema que Laura, sin instalar ni Python.

Buenas prácticas de imágenes para ML

Práctica Por qué En CineClick
Base slim + multi-stage Menos MB = pulls más rápidos, menos superficie de ataque 380 MB frente a >1 GB
Pin total: base con versión exacta, deps por lock Build reproducible hoy y dentro de un año python:3.11.9-slim + requirements.lock
Capa de dependencias antes que la de código Rebuilds de segundos en el día a día COPY requirements.lock primero
Usuario no-root Contención si el proceso se ve comprometido USER cineclick
Cero secretos en la imagen Cualquiera con acceso al registry de imágenes los leería (docker history delata los ENV) Credenciales solo por variables/secretos en runtime
Ni datos ni experimentos dentro Peso, y posible fuga de información .dockerignore: data/, mlruns/, .env
Tag semántico, jamás desplegar latest Saber qué corre y poder volver atrás cineclick/churn-api:0.1.0
HEALTHCHECK contra un endpoint real Que "vivo" signifique "capaz de predecir" GET /salud verifica modelo en memoria
CMD en forma exec Apagado limpio ante señales CMD ["uvicorn", ...]
Una imagen para todos los entornos Lo probado es lo desplegado; el entorno entra por variables MLFLOW_TRACKING_URI por entorno

Errores Comunes y Consejos

  • Error: FROM python:latest o dependencias sin fijar. El build de hoy y el del mes que viene producirían servicios distintos con el mismo Dockerfile. Todo clavado: base, lock, versión del paquete.
  • Error: copiar todo el proyecto (COPY . .) sin .dockerignore. Contexto de build gigante, datos y secretos dentro de la imagen, y cualquier cambio en cualquier fichero invalida la caché. Copia solo lo que el servicio necesita, en capas ordenadas por frecuencia de cambio.
  • Error: confundir los dos registries. "Sube el modelo al registry" es ambiguo en una conversación de MLOps. Imagen → registry de imágenes (docker push); modelo → model registry de MLflow. Ciclos de vida separados, adrede.
  • Error: meter credenciales con ENV o copiando .env. Quedan grabadas en las capas para siempre (docker history las muestra). Los secretos entran en runtime: -e, ficheros montados o los Secrets de Kubernetes (04-04).
  • Error: probar la imagen con tu venv activado y creer que probaste la imagen. Prueba contra el contenedor (docker run + curl), que es lo que irá a producción; tu venv ya no pinta nada.
  • Consejo: mira el tamaño (docker images) tras cada cambio del Dockerfile. Un salto inesperado de cientos de MB suele delatar datos colados o una capa mal ordenada.
  • Consejo: mantén compose como entorno canónico de desarrollo. "Clona y docker compose up" es el onboarding que le hubiera gustado tener a Laura en su primer día.

Ejercicios

Ejercicio 1

Sin mirar la tabla: un compañero propone "meter el modelo en la imagen, así no dependemos de MLflow al arrancar". Escribe la respuesta razonada para CineClick (qué se gana, qué se pierde, por qué el equipo decidió lo contrario) y describe UN escenario en el que le darías la razón.

Ejercicio 2

El build tarda 4 minutos cada vez que Laura cambia una línea de main.py. Revisando su Dockerfile ves:

FROM python:3.11.9-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.lock && pip install --no-deps .
CMD ["uvicorn", "cineclick_churn.api.main:app", "--host", "0.0.0.0", "--port", "8000"]

Identifica los problemas (hay al menos tres) y reescríbelo.

Ejercicio 3

Con el compose levantado, ejecuta la secuencia de verificación completa del contenedor y anota qué comprueba cada paso: docker compose ps, curl http://localhost:8000/salud, curl http://localhost:8000/version, y una predicción con el cliente CLI-04217 de la lección anterior. ¿Qué habría que mirar si /version devolviera "version_modelo": "1"?

Soluciones

Solución 1

Respuesta al compañero: hornear el modelo da autosuficiencia (corre sin registry, arranque más rápido) pero rompe el gobierno construido en el módulo 3 — cada promoción de modelo pasaría a exigir rebuild + push + redeploy de imagen, y el rollback dejaría de ser "mover @champion" (segundos, reversible, auditado en el registry) para ser un redespliegue. Además acoplaría dos ciclos de vida que en CineClick cambian a ritmos distintos: el modelo se reentrenará mucho más a menudo que el código del servicio. El riesgo de la opción elegida (registry caído ⇒ arranques nuevos fallan) está mitigado por las réplicas ya en marcha y aceptado por escrito. Escenario donde el compañero tendría razón: un despliegue edge o air-gapped — por ejemplo, servir el modelo en un dispositivo de partner sin conectividad con la infraestructura de CineClick — donde no existe registry alcanzable en runtime; ahí la imagen autosuficiente es la única opción viable.

Solución 2

Problemas: (1) COPY . . al principio — cualquier cambio de código invalida la capa siguiente, que reinstala TODAS las dependencias: esos son los 4 minutos; además, sin .dockerignore visible, arrastra data/, mlruns/ y posibles secretos. (2) No hay multi-stage: la imagen final carga con caché de pip y ficheros de proyecto innecesarios. (3) Corre como root, sin HEALTHCHECK y sin EXPOSE. Reescritura: exactamente el Dockerfile de la lección — COPY requirements.lock + pip install como primera capa (cacheada mientras el lock no cambie), código después, multi-stage con --prefix=/install, USER cineclick, EXPOSE 8000 y HEALTHCHECK contra /salud. Con la caché bien ordenada, el cambio de una línea en main.py reconstruye en segundos.

Solución 3

  • docker compose ps: ambos servicios running y la API con estado (healthy) — el HEALTHCHECK contra /salud pasa.
  • curl /salud{"estado": "ok", ...}: el proceso está vivo y tiene modelo en memoria (recuerda: nuestro healthcheck no miente).
  • curl /version{"servicio": "0.1.0", "modelo_uri": "models:/churn-cineclick@champion", "version_modelo": "2"}: la imagen 0.1.0 corriendo con el campeón v2 — trazabilidad completa de qué código y qué modelo sirven.
  • El POST a /predecir con CLI-04217 debe devolver la misma probabilidad (0.8412) que en 04-02: mismo modelo, mismas features, ahora dentro del contenedor — la reproducibilidad de punta a punta.

Si /version dijera "1", el contenedor habría cargado la versión equivocada: lo primero es mirar a qué MLflow apunta (docker compose exec api env | grep MLFLOW) — lo más probable es que el MLFLOW_TRACKING_URI señale a un registry (por ejemplo, el sqlite vacío del volumen recién creado) donde el alias @champion apunta a otra versión o se re-registró solo la v1. El alias vive en el registry, no en la imagen: se corrige allí y se reinicia la API.

Conclusión

El servicio de CineClick ya es un artefacto portátil: cineclick/churn-api:0.1.0, una imagen multi-stage de ~380 MB que corre como usuario sin privilegios, declara su healthcheck, no contiene ni datos ni secretos ni modelo — porque el modelo se gobierna donde debe, en el registry de MLflow, y entra al contenedor en el arranque vía MLFLOW_TRACKING_URI. Con docker compose, cualquier miembro del equipo levanta API + MLflow con un comando, y lo que corre en su portátil es byte a byte lo que correrá en producción. Pero un contenedor solitario no es producción: si el proceso muere a las 3 de la madrugada, nadie lo reinicia; si el tráfico se triplica en una noche de estrenos, nadie añade réplicas; si despliegas una versión rota, nadie la retira. Ese "alguien" es un orquestador, y la siguiente lección lo presenta: Kubernetes con lo mínimo que un ML engineer necesita — Deployments, Services, probes apuntando a nuestro /salud, autoescalado — y la alternativa serverless para cuando mantener un clúster no compensa.

© Copyright 2026. Todos los derechos reservados