La CI de la lección anterior termina en un tick verde, pero el tick verde no atiende peticiones: alguien sigue construyendo la imagen en su portátil, subiéndola al registry y tecleando kubectl apply con cuidado. La entrega continua (CD) automatiza ese último tramo — del código validado al clúster — y en ML tiene una particularidad que conviene entender antes de escribir una sola línea de YAML: en CineClick no hay una cosa que desplegar, hay dos, y viajan por caminos distintos. El servicio (código e imagen Docker) cambia cuando Marc toca el API; el modelo (artefacto del registry de MLflow) cambia cuando Laura entrena un challenger mejor. Esta lección construye ambos pipelines: el del servicio con un workflow de GitHub Actions disparado por tags de versión, y el del modelo como un flujo de promoción de alias con humano en el bucle.
Contenido
- La distinción clave: dos pipelines de entrega, no uno
- CD del servicio:
.github/workflows/cd-servicio.yml - CD del modelo: mover
@championno es construir nada - Gates automáticos vs. aprobación humana
- Gestión de secretos en CD
- Rollback en ambos pipelines
- Los dos pipelines, juntos, en un diagrama
La distinción clave: dos pipelines de entrega, no uno
Recuerda la decisión del módulo 4 (lección 04-03): la imagen cineclick/churn-api:0.1.0 no contiene el modelo. Al arrancar, el contenedor descarga models:/churn-cineclick@champion del registry usando MLFLOW_TRACKING_URI. Aquella decisión, que entonces parecía un detalle de empaquetado, es la que ahora hace posible desacoplar las dos entregas:
| Pipeline del SERVICIO | Pipeline del MODELO | |
|---|---|---|
| Qué se entrega | Código: API, features, dependencias → una imagen Docker | Un artefacto: la versión del modelo en el registry |
| Quién lo dispara | Un tag de versión en Git (v0.2.0) |
Un challenger que supera la validación |
| Herramienta | GitHub Actions | MLflow registry + un script + una decisión humana |
| Qué cambia en el clúster | La imagen de los pods | Nada en los manifiestos: los pods recargan el modelo |
| Frecuencia típica | Cada pocas semanas (features nuevas del API) | Cuando hay un modelo mejor (a su propio ritmo) |
| Ejemplo | Añadir el endpoint /predecir-lote fue servicio |
Pasar de v1 (recall 0.43) a v2 (recall 0.68) fue modelo |
¿Por qué importa tanto no mezclarlos? Porque acoplan ritmos que no tienen nada que ver. Si el modelo viviera dentro de la imagen, cada modelo nuevo obligaría a construir, escanear, publicar y desplegar una imagen — y cada release del API "arrastraría" el modelo del momento, dificultando responder a la pregunta más básica de operación: ¿qué cambió, el código o el modelo? Desacoplados, cada pipeline tiene su propio disparador, su propia validación y — crucial — su propio rollback (los dos niveles que ya separamos en 04-04).
CD del servicio: .github/workflows/cd-servicio.yml
El disparador: tags de versión
La CI corre en cada push; el despliegue no debe. La convención que adoptamos: desplegar solo cuando se crea un tag semántico (v0.2.0). Crear un tag es un acto deliberado — "esto es una release" — que además deja el historial de despliegues escrito en Git:
El workflow completo
# .github/workflows/cd-servicio.yml
name: CD servicio
on:
push:
tags: ["v*.*.*"]
env:
IMAGEN: ghcr.io/cineclick/churn-api
jobs:
construir-y-publicar:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # necesario para subir a GHCR
outputs:
version: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Tests rapidos (ultima red de seguridad)
run: |
pip install -r requirements.lock && pip install -e .
pytest tests -m "not modelo" -q
- name: Calcular etiquetas
id: meta
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Login en el registry de imagenes
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build multi-stage y push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.IMAGEN }}:${{ steps.meta.outputs.version }}
${{ env.IMAGEN }}:${{ github.sha }}
desplegar-staging:
needs: construir-y-publicar
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- uses: azure/setup-kubectl@v4
- name: Configurar acceso al cluster
run: echo "${{ secrets.KUBECONFIG_STAGING }}" | base64 -d > kubeconfig
- name: Actualizar imagen en staging
env: { KUBECONFIG: ./kubeconfig }
run: |
kubectl set image deployment/churn-api \
churn-api=${{ env.IMAGEN }}:${{ needs.construir-y-publicar.outputs.version }} \
-n cineclick-staging
kubectl rollout status deployment/churn-api -n cineclick-staging --timeout=180s
- name: Smoke test contra staging
run: |
URL=https://staging.churn.cineclick.internal
curl --fail --max-time 5 "$URL/salud"
VERSION=$(curl --fail --max-time 5 "$URL/version" | python -c \
"import sys, json; print(json.load(sys.stdin)['version_servicio'])")
test "$VERSION" = "${{ needs.construir-y-publicar.outputs.version }}"
desplegar-produccion:
needs: [construir-y-publicar, desplegar-staging]
runs-on: ubuntu-latest
environment: produccion # <- exige aprobacion manual
steps:
- uses: actions/checkout@v4
- uses: azure/setup-kubectl@v4
- name: Configurar acceso al cluster
run: echo "${{ secrets.KUBECONFIG_PROD }}" | base64 -d > kubeconfig
- name: Actualizar imagen en produccion
env: { KUBECONFIG: ./kubeconfig }
run: |
kubectl set image deployment/churn-api \
churn-api=${{ env.IMAGEN }}:${{ needs.construir-y-publicar.outputs.version }} \
-n cineclick
kubectl rollout status deployment/churn-api -n cineclick --timeout=180sDesglose por bloques
on: push: tags:— el patrónv*.*.*casa conv0.2.0pero no con tags de trabajo.GITHUB_REF_NAMEcontiene el nombre del tag; le quitamos lavpara etiquetar la imagen como0.2.0.- Tests otra vez, ¿no era eso CI? — sí, y aun así se repiten los rápidos: un tag puede crearse sobre un commit que nunca pasó por una PR. Treinta segundos de
pytestson un seguro barato contra desplegar algo que jamás se validó. - Doble etiqueta: versión + SHA —
0.2.0es la etiqueta "humana";${{ github.sha }}identifica el commit exacto de forma inmutable. Si algún día alguien re-etiqueta0.2.0por error (no debería, pero pasa), el SHA no miente. Desplegar por SHA es la opción paranoica-correcta; mostramos ambas. - Build multi-stage — el mismo
Dockerfilede dos etapas del módulo 4 (builder con pip-tools, runtime de ~380 MB). CD no cambia cómo se construye, cambia quién: un runner efímero y auditado en vez del portátil de turno. environment: staging/environment: produccion— los environments de GitHub agrupan secretos por entorno y, enproduccion, llevan configurados required reviewers: el job se queda en pausa hasta que una persona autorizada (Laura o el lead de plataforma) pulsa "Approve". Es aprobación manual dentro del pipeline, con quién-y-cuándo registrado.- Smoke test — antes de pedirle a nadie que apruebe, el pipeline comprueba en staging que
/saludresponde y que/versiondevuelve exactamente la versión recién desplegada. Un smoke test no valida calidad; valida que eso que hemos desplegado es lo que creemos y arranca. Sin él, la aprobación humana aprobaría a ciegas. kubectl rollout status --timeout— convierte "he aplicado el cambio" en "el cambio está sano o el job falla". Sin esta línea, el pipeline puede quedar verde con los pods enCrashLoopBackOff.
Una limitación deliberada que hay que dejar dicha: tras la aprobación, producción recibe la versión nueva de golpe, en las 3-10 réplicas a la vez (rolling update estándar). Para el código del API suele ser aceptable; para un modelo nuevo es más arriesgado de lo que parece, porque las métricas offline no garantizan el comportamiento con tráfico real. La lección 05-04 sustituye este "todo de golpe" por shadow, canary y A/B.
CD del modelo: mover @champion no es construir nada
Aquí está el momento de reencuadre mental de la lección: desplegar un modelo nuevo no construye ninguna imagen. El "despliegue" es una operación sobre metadatos del registry — mover el alias @champion a otra versión — seguida de una recarga del servicio. El flujo completo:
flowchart LR
A[Challenger v3<br/>registrado en MLflow] --> B{Gates automaticos<br/>tests de 05-01}
B -- falla --> X[Se queda en @challenger<br/>o se descarta]
B -- pasa --> C[Validacion en subgrupos<br/>informe para revision]
C --> D{Aprobacion humana<br/>documentada}
D -- no --> X
D -- si --> E[Mover alias @champion -> v3]
E --> F[kubectl rollout restart]
F --> G[Verificar /version]Paso a paso:
- Challenger validado. El candidato (digamos, la v3 que Laura registró como
@challenger) ya pasó los tests de modelo de 05-01: humo, invariancia, direccionalidad y umbrales (recall ≥ 0.60, precision ≥ 0.50). Además, antes de proponer promoción se evalúa en subgrupos relevantes: ¿mantiene el recall en clientes con menos de 6 meses de antigüedad? ¿Y por plan? Un modelo puede mejorar la media global empeorando justo el segmento que más importa a retención. - Aprobación humana documentada. Coherente con la política del módulo 3: la promoción SIEMPRE pasa por una persona. En la práctica: una PR o issue de promoción con el informe de métricas (global y por subgrupos), la comparación contra el champion actual, y la aprobación explícita de Laura como responsable del modelo. La decisión queda escrita y enlazable — dentro de un año alguien preguntará por qué se promocionó la v3, y habrá respuesta.
- Mover el alias. La operación en sí es mínima, y por eso conviene hacerla con un script versionado en vez de con clics en la UI:
# scripts/promocionar_champion.py
"""Promociona una version del modelo a @champion.
Uso: python scripts/promocionar_champion.py 3
Requiere MLFLOW_TRACKING_URI en el entorno."""
import sys
from mlflow import MlflowClient
MODELO = "churn-cineclick"
version_nueva = sys.argv[1]
cliente = MlflowClient()
actual = cliente.get_model_version_by_alias(MODELO, "champion")
print(f"Champion actual: v{actual.version} -> nuevo champion: v{version_nueva}")
# set_registered_model_alias reasigna el alias de forma atomica:
# el alias solo puede apuntar a una version a la vez.
cliente.set_registered_model_alias(MODELO, "champion", version_nueva)
print("Alias movido. Recuerda: rollout restart + verificar /version.")- Recargar el servicio. Los pods cargaron el modelo al arrancar, así que siguen sirviendo el antiguo hasta que se reinician. La recarga estándar es un reinicio progresivo — sin caída, porque las probes de
/saludgarantizan que cada pod nuevo no recibe tráfico hasta tener el modelo cargado:
kubectl rollout restart deployment/churn-api -n cineclick kubectl rollout status deployment/churn-api -n cineclick
- Verificar. El endpoint
/versiondel módulo 4 devuelve la versión del modelo cargado; la comprobación es uncurly un ojo:
curl https://churn.cineclick.internal/version
# {"version_servicio": "0.2.0", "version_modelo": "3", "alias": "champion"}¿Se podría automatizar este flujo entero en otro workflow de Actions? Los pasos 3-5, sí (y equipos maduros lo hacen, disparado por la aprobación de la PR de promoción). Lo que no se automatiza es el paso 2 — y no por nostalgia, como justifica la siguiente sección.
Gates automáticos vs. aprobación humana
| Decisión | ¿Automática? | Por qué |
|---|---|---|
| Bloquear un merge si fallan tests o linter | Sí | Criterio objetivo, coste de error bajo, volumen alto |
| Bloquear un challenger bajo umbral de métricas | Sí | El criterio de negocio ya está codificado como test |
| Construir y publicar la imagen al crear un tag | Sí | Proceso mecánico y reproducible; las manos solo añaden errores |
| Desplegar a staging + smoke test | Sí | Staging existe para eso: fallar barato |
| Pasar de staging a producción (servicio) | No — required reviewer | Momento de contexto humano: ¿es buen momento? ¿hay campaña activa? |
Promocionar un modelo a @champion |
No — revisión documentada | Las métricas agregadas no capturan todo: subgrupos, plausibilidad, impacto en la campaña de retención |
| Rollback de emergencia | Semiautomática | El comando está preparado y ensayado; el disparo es humano (hasta 05-04, donde el canary lo automatiza con métricas de guardia) |
El patrón general: se automatiza la ejecución, no necesariamente la decisión. Los gates automáticos filtran (nada por debajo del listón llega a una persona); la persona decide sobre lo que los tests no ven. En CineClick esto es además coherencia con lo pactado en el módulo 3: el criterio "precision ≥ 0.50 si recall > 0.60" es condición necesaria, no suficiente, para promocionar.
Gestión de secretos en CD
El pipeline de CD toca credenciales sensibles: acceso al clúster (KUBECONFIG_STAGING, KUBECONFIG_PROD), al registry de imágenes, al remote de DVC y al servidor de MLflow. Reglas no negociables:
- Nunca en el repo: ni en el YAML, ni en
configs/, ni "temporalmente" en un commit. El historial de Git no olvida. - Nunca en la imagen: la imagen del módulo 4 ya se construyó sin secretos; el modelo se descarga en runtime con credenciales inyectadas por Kubernetes (el Secret
churn-api-secretos). CD no cambia esto — el runner usa sus secretos para desplegar, y el pod usa los suyos para funcionar. Son credenciales distintas con permisos distintos (el pod no puede desplegar; el runner no atiende predicciones). - GitHub Secrets por environment:
KUBECONFIG_PRODvive en el environmentproduccion, así que solo los jobs que pasaron la aprobación pueden leerlo. Un job de una rama cualquiera no puede ni verlo. - Mínimo privilegio: el kubeconfig del pipeline apunta a una ServiceAccount que solo puede tocar el deployment
churn-apien su namespace, no el clúster entero. - Rotación: si un secreto pudo filtrarse (un log que lo imprimió, un portátil perdido), se rota y punto. GitHub enmascara secretos en los logs, pero no detecta transformaciones (base64, por ejemplo).
Rollback en ambos pipelines
Los dos niveles de rollback que separamos en 04-04 ahora se corresponden exactamente con los dos pipelines — y esa simetría es la razón por la que insistimos en desacoplarlos:
| Rollback del SERVICIO | Rollback del MODELO | |
|---|---|---|
| Síntoma típico | 500s, latencia disparada, pods que no arrancan tras una release | Predicciones "raras": tasa de positivos anómala, quejas de retención |
| Comando | kubectl rollout undo deployment/churn-api (o re-desplegar el tag anterior: kubectl set image ...:0.1.0) |
python scripts/promocionar_champion.py 2 (alias de vuelta) + kubectl rollout restart |
| Qué NO toca | El modelo: el pod nuevo carga el mismo @champion |
La imagen: el código no cambia en absoluto |
| Tiempo | 1-2 minutos (lo que tarde el rolling update) | 2-3 minutos (mover alias + reinicio progresivo) |
| Verificación | /salud + /version (versión del servicio) |
/version (versión del modelo) |
Dos consejos operativos: primero, el rollback se ensaya — un rollback que nunca se ha ejecutado es una hipótesis, no un plan; en CineClick se prueba en staging tras cada release. Segundo, gracias a la doble etiqueta versión+SHA del workflow, siempre existe una imagen anterior exacta a la que volver; nunca se sobreescriben tags de imagen ya publicados.
Los dos pipelines, juntos, en un diagrama
flowchart TB
subgraph PS["Pipeline del SERVICIO (GitHub Actions)"]
T[git tag v0.2.0] --> TE[Tests rapidos]
TE --> B[Build multi-stage]
B --> P[Push ghcr.io/cineclick/churn-api<br/>:0.2.0 y :sha]
P --> S[Deploy staging]
S --> SM[Smoke test /salud /version]
SM --> AP{Aprobacion<br/>required reviewer}
AP --> PR[Deploy produccion]
end
subgraph PM["Pipeline del MODELO (MLflow + humano)"]
C[Challenger v3] --> G[Gates: tests de modelo 05-01<br/>+ subgrupos]
G --> H{Aprobacion humana<br/>documentada}
H --> AL[Mover @champion -> v3]
AL --> RR[kubectl rollout restart]
RR --> V[Verificar /version]
end
PR --> K[(Cluster Kubernetes<br/>churn-api, 3-10 replicas)]
V --> KDos caminos, dos disparadores, dos rollbacks — un solo clúster donde confluyen. La imagen dice cómo se predice; el alias dice con qué se predice.
Errores Comunes y Consejos
- Hornear el modelo dentro de la imagen "para simplificar". Acabas de fusionar los dos pipelines: cada modelo nuevo exige release de imagen, y cada release de imagen congela un modelo. Perderás además el rollback barato del alias. La descarga en arranque del módulo 4 existe para esto.
- Desplegar desde
mainen cada merge, sin tags. Funciona hasta que un merge inocente (un README) redespliega producción un viernes. El tag hace la release intencional y deja inventario de versiones en Git. - Smoke test que solo comprueba el código HTTP 200.
/saludpuede responder OK con la versión antigua si el rollout no llegó a reemplazar los pods. Comprueba siempre el contenido de/versioncontra la versión esperada — es la diferencia entre "algo responde" y "responde lo que desplegué". - Aprobación manual sin información. Un botón "Approve" sin el informe de métricas delante es teatro de compliance. El gate humano vale lo que valga el contexto que se le presenta: enlaza el informe de subgrupos en la PR de promoción.
- Olvidar el
rollout restarttras mover el alias. El alias apunta a v3 pero los pods siguen sirviendo v2 cargada en memoria — y/versionte lo habría contado. Es el error más común de todo el flujo del modelo; por eso el script lo recuerda al terminar. - Compartir credenciales entre el pipeline y el pod. Si el token del pod puede desplegar, un contenedor comprometido puede redesplegar el clúster. Credenciales distintas, permisos mínimos.
- Consejo: escribe el procedimiento de promoción del modelo (pasos 1-5) en el repo, en
docs/promocion-modelo.md, y que la PR de promoción use una plantilla con checklist. Cuando algo es a la vez infrecuente y crítico, la memoria humana es el peor almacén posible.
Ejercicios
Ejercicio 1
Marc pregunta: "Si el modelo se descarga del registry al arrancar, ¿por qué no hacemos que el pod compruebe cada 5 minutos si @champion cambió y lo recargue en caliente, sin rollout restart?". Da un argumento a favor y dos en contra de la recarga automática en caliente, pensando en operación (pistas: auditoría, réplicas, memoria).
Ejercicio 2
Se acaba de desplegar el tag v0.3.0 y a los diez minutos la tasa de errores 500 se dispara. /version muestra version_servicio: 0.3.0, version_modelo: 2. El último cambio de modelo fue hace tres semanas. ¿Qué rollback ejecutas, con qué comando exacto, y qué comprobación haces después? ¿Por qué descartas el otro rollback?
Ejercicio 3
Escribe el fragmento del job desplegar-produccion que faltaría para hacer un smoke test también en producción tras el rollout (URL https://churn.cineclick.internal), comprobando /salud y que version_servicio coincide con la versión desplegada. ¿Qué debería pasar si el smoke test falla en producción?
Soluciones
Ejercicio 1
A favor: la promoción del modelo se completaría sin tocar Kubernetes — menos pasos, menos oportunidades de olvidar el restart. En contra: (1) auditoría y control: el cambio de modelo dejaría de ser un evento explícito y verificable (un rollout con su historial) para convertirse en un proceso que ocurre "en algún momento de los próximos 5 minutos" en cada pod, difícil de correlacionar con incidencias; (2) incoherencia entre réplicas: durante la ventana de recarga, unas réplicas servirían v2 y otras v3 según su temporizador, y dos peticiones idénticas consecutivas podrían recibir predicciones distintas sin que ningún despliegue lo explique; además, la recarga en caliente duplica temporalmente el modelo en memoria (el viejo y el nuevo coexisten durante el swap), lo que puede empujar al pod contra su límite de memoria justo en el peor momento. El rollout restart progresivo da lo mismo con garantías: pods frescos, probes que verifican la carga y un evento auditable.
Ejercicio 2
Rollback del servicio: el síntoma llegó con la release v0.3.0 y el modelo no ha cambiado en tres semanas (lo confirma version_modelo: 2). Comando:
kubectl rollout undo deployment/churn-api -n cineclick kubectl rollout status deployment/churn-api -n cineclick
Comprobación posterior: curl .../version debe mostrar version_servicio: 0.2.0 (la anterior) y la tasa de 500 debe volver a la normalidad. Se descarta el rollback del modelo porque no hay ningún indicio de que el modelo sea el problema — mover @champion no arreglaría un bug de código y añadiría una segunda variable al incidente, complicando el diagnóstico. Regla práctica: revierte la última cosa que cambió, y solo una cosa a la vez.
Ejercicio 3
- name: Smoke test contra produccion
run: |
URL=https://churn.cineclick.internal
curl --fail --max-time 5 "$URL/salud"
VERSION=$(curl --fail --max-time 5 "$URL/version" | python -c \
"import sys, json; print(json.load(sys.stdin)['version_servicio'])")
test "$VERSION" = "${{ needs.construir-y-publicar.outputs.version }}"Si falla, el job queda en rojo y el equipo debe tratarlo como incidente: el despliegue ya ocurrió (esto es verificación post-despliegue, no un gate previo), así que la acción es el rollback del servicio del ejercicio 2. Algunos equipos añaden el kubectl rollout undo como paso automático con if: failure() — es una opción razonable aquí porque el smoke test es objetivo; la exploraremos con más matiz en 05-04, donde el rollback automático se basa en métricas de tráfico real y no solo en un endpoint.
Conclusión
CineClick ya entrega sin manos, y lo hace por dos vías bien separadas. El servicio viaja por cd-servicio.yml: un tag v*.*.* dispara tests, build multi-stage, push a ghcr.io/cineclick/churn-api con doble etiqueta (versión y SHA), despliegue a staging, smoke test contra /salud y /version, aprobación de un required reviewer en el environment produccion, y rollout a producción. El modelo viaja por el registry: gates automáticos (los tests de 05-01 más validación por subgrupos), aprobación humana documentada — la política del módulo 3, intacta —, scripts/promocionar_champion.py para mover el alias, rollout restart y verificación en /version. Cada vía con su rollback: rollout undo para la imagen, alias de vuelta para el modelo. Los secretos, en GitHub Secrets por environment y en el Secret del clúster, jamás en el repo ni en la imagen. Quedan dos cabos sueltos, ambos anunciados: el despliegue a producción sigue siendo "todo de golpe" tras staging — 05-04 lo convertirá en un proceso gradual que se gana la confianza con tráfico real — y hay un proceso entero que no encaja en ningún workflow de GitHub Actions: el scoring batch de los lunes a las 06:00, que no lo dispara ningún commit sino el calendario. Para eso hace falta otra pieza — un orquestador — y es exactamente la siguiente lección.
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
