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
- Por qué contenedores para ML
- Conceptos mínimos: imagen, contenedor, capa, registry
- El Dockerfile del servicio CineClick, línea a línea
- .dockerignore: lo que NO entra en la imagen
- Decisión de diseño: ¿modelo en la imagen o desde el registry?
- Build, tag y run
- docker compose: API + MLflow en local
- 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), varianteslim(~150 MB frente a ~1 GB de la imagen completa). Nuncapython:latest: una imagen que cambia sola bajo tus pies es la antítesis de la reproducibilidad.COPY requirements.lockantes 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 enmain.pyreconstruye en segundos porque la capa (lenta) depip install -r requirements.locksigue 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 paquetecineclick_churn(el de verdad, no editable —-ees para desarrollo) sin re-resolver dependencias: ya están todas clavadas por el lock. Aquí viajanfeatures.pyy 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 8000es documentación (el mapeo real se hace endocker run -p), pero documentación que herramientas y humanos leen.HEALTHCHECKllama alGET /saludde 04-02 cada 30 s.--start-period=20sda margen al lifespan para descargar el modelo del registry antes de empezar a exigir salud. Usamosurllibde la biblioteca estándar para no instalarcurlen la imagen.CMDen forma exec (lista JSON): uvicorn recibe las señales del sistema directamente (undocker stophace 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:8000publica 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.internales como un contenedor ve a tu máquina anfitriona (donde corre el MLflow local del módulo 3).- Comprueba la salud:
docker psmostrará(healthy)pasado elstart-period, ycurl http://localhost:8000/versiondebe 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_starteddocker 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 delocalhost, que dentro de un contenedor significa "yo mismo". - El volumen
./mlflow_datahace persistente el registry local; sin él, cadadownborraría los modelos registrados. (Para que este MLflow contenga achurn-cineclickv1/v2 tendrás que apuntar tuMLFLOW_TRACKING_URIde entrenamiento aquí y re-registrar, o copiar tumlflow.dby artefactos amlflow_data/.) depends_onordena 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:latesto 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
ENVo copiando.env. Quedan grabadas en las capas para siempre (docker historylas 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 serviciosrunningy la API con estado(healthy)— el HEALTHCHECK contra/saludpasa.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
/predecirconCLI-04217debe 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.
Curso de MLOps
Módulo 1: Fundamentos de MLOps
- Qué es MLOps y por qué los modelos mueren en el notebook
- El ciclo de vida de un modelo de ML en producción
- Niveles de madurez MLOps y roles del equipo
- El proyecto del curso: del notebook a producción
Módulo 2: Del notebook al código reproducible
- Estructura de un proyecto de ML: del notebook al paquete
- Entornos reproducibles y gestión de dependencias
- Versionado de datos con DVC
- Pipelines de entrenamiento reproducibles
Módulo 3: Experimentos y registro de modelos
- Tracking de experimentos con MLflow
- Model registry: versionar y promocionar modelos
- Feature stores: cuándo y para qué
Módulo 4: Servir modelos en producción
- Patrones de despliegue: batch, online y streaming
- Un servicio de predicción con FastAPI
- Empaquetado con Docker
- Escalado y despliegue: Kubernetes y serverless
- Optimización de la inferencia: latencia y coste
Módulo 5: Automatización: CI/CD y orquestación
- CI para ML: tests de código, datos y modelos
- CD: automatizar el despliegue del modelo
- Orquestación de pipelines de ML
- Estrategias de release: shadow, canary y A/B
