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

  1. La distinción clave: dos pipelines de entrega, no uno
  2. CD del servicio: .github/workflows/cd-servicio.yml
  3. CD del modelo: mover @champion no es construir nada
  4. Gates automáticos vs. aprobación humana
  5. Gestión de secretos en CD
  6. Rollback en ambos pipelines
  7. 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:

git tag v0.2.0
git push origin v0.2.0   # esto, y solo esto, dispara el despliegue

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=180s

Desglose por bloques

  • on: push: tags: — el patrón v*.*.* casa con v0.2.0 pero no con tags de trabajo. GITHUB_REF_NAME contiene el nombre del tag; le quitamos la v para etiquetar la imagen como 0.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 pytest son un seguro barato contra desplegar algo que jamás se validó.
  • Doble etiqueta: versión + SHA0.2.0 es la etiqueta "humana"; ${{ github.sha }} identifica el commit exacto de forma inmutable. Si algún día alguien re-etiqueta 0.2.0 por 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 Dockerfile de 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, en produccion, 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 /salud responde y que /version devuelve 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 en CrashLoopBackOff.

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:

  1. 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.
  2. 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.
  3. 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.")
  1. 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 /salud garantizan 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
  1. Verificar. El endpoint /version del módulo 4 devuelve la versión del modelo cargado; la comprobación es un curl y 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 Criterio objetivo, coste de error bajo, volumen alto
Bloquear un challenger bajo umbral de métricas El criterio de negocio ya está codificado como test
Construir y publicar la imagen al crear un tag Proceso mecánico y reproducible; las manos solo añaden errores
Desplegar a staging + smoke test 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_PROD vive en el environment produccion, 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-api en 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 --> K

Dos 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 main en 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. /salud puede responder OK con la versión antigua si el rollout no llegó a reemplazar los pods. Comprueba siempre el contenido de /version contra 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 restart tras mover el alias. El alias apunta a v3 pero los pods siguen sirviendo v2 cargada en memoria — y /version te 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.

© Copyright 2026. Todos los derechos reservados