La CI de la lliçó anterior acaba en un tic verd, però el tic verd no atén peticions: algú continua construint la imatge al seu portàtil, pujant-la al registry i teclejant kubectl apply amb compte. El lliurament continu (CD) automatitza aquest últim tram — del codi validat al clúster — i en ML té una particularitat que convé entendre abans d'escriure ni una sola línia de YAML: a CineClick no hi ha una cosa a desplegar, n'hi ha dues, i viatgen per camins diferents. El servei (codi i imatge Docker) canvia quan en Marc toca l'API; el model (artefacte del registry de MLflow) canvia quan la Laura entrena un challenger millor. Aquesta lliçó construeix tots dos pipelines: el del servei amb un workflow de GitHub Actions disparat per tags de versió, i el del model com un flux de promoció d'àlies amb un humà al bucle.

Contingut

  1. La distinció clau: dos pipelines de lliurament, no un
  2. CD del servei: .github/workflows/cd-servei.yml
  3. CD del model: moure @champion no és construir res
  4. Gates automàtics vs. aprovació humana
  5. Gestió de secrets al CD
  6. Rollback a tots dos pipelines
  7. Els dos pipelines, junts, en un diagrama

La distinció clau: dos pipelines de lliurament, no un

Recorda la decisió del mòdul 4 (lliçó 04-03): la imatge cineclick/churn-api:0.1.0 no conté el model. En arrencar, el contenidor descarrega models:/churn-cineclick@champion del registry fent servir MLFLOW_TRACKING_URI. Aquella decisió, que aleshores semblava un detall d'empaquetatge, és la que ara fa possible desacoblar els dos lliuraments:

Pipeline del SERVEI Pipeline del MODEL
Què es lliura Codi: API, features, dependències → una imatge Docker Un artefacte: la versió del model al registry
Qui el dispara Un tag de versió a Git (v0.2.0) Un challenger que supera la validació
Eina GitHub Actions MLflow registry + un script + una decisió humana
Què canvia al clúster La imatge dels pods Res als manifestos: els pods recarreguen el model
Freqüència típica Cada poques setmanes (features noves de l'API) Quan hi ha un model millor (al seu propi ritme)
Exemple Afegir l'endpoint /predir-lot va ser servei Passar de la v1 (recall 0.43) a la v2 (recall 0.68) va ser model

Per què importa tant no barrejar-los? Perquè acoblen ritmes que no tenen res a veure. Si el model visqués dins de la imatge, cada model nou obligaria a construir, escanejar, publicar i desplegar una imatge — i cada release de l'API "arrossegaria" el model del moment, dificultant respondre la pregunta més bàsica d'operació: què ha canviat, el codi o el model? Desacoblats, cada pipeline té el seu propi disparador, la seva pròpia validació i — crucial — el seu propi rollback (els dos nivells que ja vam separar a 04-04).

CD del servei: .github/workflows/cd-servei.yml

El disparador: tags de versió

La CI corre a cada push; el desplegament no ha de fer-ho. La convenció que adoptem: desplegar només quan es crea un tag semàntic (v0.2.0). Crear un tag és un acte deliberat — "això és una release" — que a més deixa l'historial de desplegaments escrit a Git:

git tag v0.2.0
git push origin v0.2.0   # això, i només això, dispara el desplegament

El workflow complet

# .github/workflows/cd-servei.yml
name: CD servei

on:
  push:
    tags: ["v*.*.*"]

env:
  IMATGE: ghcr.io/cineclick/churn-api

jobs:
  construir-i-publicar:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write          # necessari per pujar a GHCR
    outputs:
      version: ${{ steps.meta.outputs.version }}
    steps:
      - uses: actions/checkout@v4

      - name: Tests ràpids (última xarxa de seguretat)
        run: |
          pip install -r requirements.lock && pip install -e .
          pytest tests -m "not model" -q

      - name: Calcular etiquetes
        id: meta
        run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"

      - name: Login al registry d'imatges
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build multi-stage i push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ${{ env.IMATGE }}:${{ steps.meta.outputs.version }}
            ${{ env.IMATGE }}:${{ github.sha }}

  desplegar-staging:
    needs: construir-i-publicar
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - uses: azure/setup-kubectl@v4

      - name: Configurar l'accés al clúster
        run: echo "${{ secrets.KUBECONFIG_STAGING }}" | base64 -d > kubeconfig

      - name: Actualitzar la imatge a staging
        env: { KUBECONFIG: ./kubeconfig }
        run: |
          kubectl set image deployment/churn-api \
            churn-api=${{ env.IMATGE }}:${{ needs.construir-i-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/salut"
          VERSION=$(curl --fail --max-time 5 "$URL/versio" | python -c \
            "import sys, json; print(json.load(sys.stdin)['versio_servei'])")
          test "$VERSION" = "${{ needs.construir-i-publicar.outputs.version }}"

  desplegar-produccio:
    needs: [construir-i-publicar, desplegar-staging]
    runs-on: ubuntu-latest
    environment: produccio        # <- exigeix aprovació manual
    steps:
      - uses: actions/checkout@v4
      - uses: azure/setup-kubectl@v4
      - name: Configurar l'accés al clúster
        run: echo "${{ secrets.KUBECONFIG_PROD }}" | base64 -d > kubeconfig
      - name: Actualitzar la imatge a producció
        env: { KUBECONFIG: ./kubeconfig }
        run: |
          kubectl set image deployment/churn-api \
            churn-api=${{ env.IMATGE }}:${{ needs.construir-i-publicar.outputs.version }} \
            -n cineclick
          kubectl rollout status deployment/churn-api -n cineclick --timeout=180s

Desglossament per blocs

  • on: push: tags: — el patró v*.*.* encaixa amb v0.2.0 però no amb tags de treball. GITHUB_REF_NAME conté el nom del tag; li traiem la v per etiquetar la imatge com a 0.2.0.
  • Tests un altre cop, no era això la CI? — sí, i tot i així es repeteixen els ràpids: un tag es pot crear sobre un commit que mai no ha passat per cap PR. Trenta segons de pytest són una assegurança barata contra desplegar una cosa que mai no s'ha validat.
  • Doble etiqueta: versió + SHA0.2.0 és l'etiqueta "humana"; ${{ github.sha }} identifica el commit exacte de manera immutable. Si algun dia algú reetiqueta 0.2.0 per error (no hauria de passar, però passa), el SHA no menteix. Desplegar per SHA és l'opció paranoica-correcta; en mostrem totes dues.
  • Build multi-stage — el mateix Dockerfile de dues etapes del mòdul 4 (builder amb pip-tools, runtime d'uns 380 MB). El CD no canvia com es construeix, canvia qui: un runner efímer i auditat en lloc del portàtil de torn.
  • environment: staging / environment: produccio — els environments de GitHub agrupen secrets per entorn i, a produccio, porten configurats required reviewers: el job es queda en pausa fins que una persona autoritzada (la Laura o el lead de plataforma) prem "Approve". És aprovació manual dins del pipeline, amb el qui-i-quan registrat.
  • Smoke test — abans de demanar a ningú que aprovi, el pipeline comprova a staging que /salut respon i que /versio retorna exactament la versió acabada de desplegar. Un smoke test no valida qualitat; valida que allò que hem desplegat és el que creiem i arrenca. Sense ell, l'aprovació humana aprovaria a cegues.
  • kubectl rollout status --timeout — converteix "he aplicat el canvi" en "el canvi està sa o el job falla". Sense aquesta línia, el pipeline pot quedar verd amb els pods en CrashLoopBackOff.

Una limitació deliberada que cal deixar dita: després de l'aprovació, producció rep la versió nova de cop, a les 3-10 rèpliques alhora (rolling update estàndard). Per al codi de l'API sol ser acceptable; per a un model nou és més arriscat del que sembla, perquè les mètriques offline no garanteixen el comportament amb trànsit real. La lliçó 05-04 substitueix aquest "tot de cop" per shadow, canary i A/B.

CD del model: moure @champion no és construir res

Aquí arriba el moment de reenquadrament mental de la lliçó: desplegar un model nou no construeix cap imatge. El "desplegament" és una operació sobre metadades del registry — moure l'àlies @champion a una altra versió — seguida d'una recàrrega del servei. El flux complet:

flowchart LR
    A[Challenger v3<br/>registrat a MLflow] --> B{Gates automàtics<br/>tests de 05-01}
    B -- falla --> X[Es queda a @challenger<br/>o es descarta]
    B -- passa --> C[Validació en subgrups<br/>informe per a revisió]
    C --> D{Aprovació humana<br/>documentada}
    D -- no --> X
    D -- sí --> E[Moure àlies @champion -> v3]
    E --> F[kubectl rollout restart]
    F --> G[Verificar /versio]

Pas a pas:

  1. Challenger validat. El candidat (posem que la v3 que la Laura va registrar com a @challenger) ja ha passat els tests de model de 05-01: fum, invariància, direccionalitat i llindars (recall ≥ 0.60, precision ≥ 0.50). A més, abans de proposar la promoció s'avalua en subgrups rellevants: manté el recall en clients amb menys de 6 mesos d'antiguitat? I per pla? Un model pot millorar la mitjana global empitjorant justament el segment que més importa a retenció.
  2. Aprovació humana documentada. Coherent amb la política del mòdul 3: la promoció SEMPRE passa per una persona. A la pràctica: una PR o issue de promoció amb l'informe de mètriques (global i per subgrups), la comparació contra el champion actual, i l'aprovació explícita de la Laura com a responsable del model. La decisió queda escrita i enllaçable — d'aquí a un any algú preguntarà per què es va promocionar la v3, i hi haurà resposta.
  3. Moure l'àlies. L'operació en si és mínima, i per això convé fer-la amb un script versionat en lloc de amb clics a la UI:
# scripts/promocionar_champion.py
"""Promociona una versió del model a @champion.
Ús: python scripts/promocionar_champion.py 3
Requereix MLFLOW_TRACKING_URI a l'entorn."""
import sys

from mlflow import MlflowClient

MODEL = "churn-cineclick"

versio_nova = sys.argv[1]
client = MlflowClient()

actual = client.get_model_version_by_alias(MODEL, "champion")
print(f"Champion actual: v{actual.version} -> nou champion: v{versio_nova}")

# set_registered_model_alias reassigna l'àlies de manera atòmica:
# l'àlies només pot apuntar a una versió alhora.
client.set_registered_model_alias(MODEL, "champion", versio_nova)
print("Àlies mogut. Recorda: rollout restart + verificar /versio.")
  1. Recarregar el servei. Els pods van carregar el model en arrencar, així que continuen servint l'antic fins que es reinicien. La recàrrega estàndard és un reinici progressiu — sense caiguda, perquè les probes de /salut garanteixen que cap pod nou no rep trànsit fins a tenir el model carregat:
kubectl rollout restart deployment/churn-api -n cineclick
kubectl rollout status deployment/churn-api -n cineclick
  1. Verificar. L'endpoint /versio del mòdul 4 retorna la versió del model carregat; la comprovació és un curl i un cop d'ull:
curl https://churn.cineclick.internal/versio
# {"versio_servei": "0.2.0", "versio_model": "3", "alias": "champion"}

Es podria automatitzar aquest flux sencer en un altre workflow d'Actions? Els passos 3-5, sí (i els equips madurs ho fan, disparat per l'aprovació de la PR de promoció). El que no s'automatitza és el pas 2 — i no per nostàlgia, com justifica la secció següent.

Gates automàtics vs. aprovació humana

Decisió Automàtica? Per què
Bloquejar un merge si fallen tests o linter Criteri objectiu, cost d'error baix, volum alt
Bloquejar un challenger sota el llindar de mètriques El criteri de negoci ja està codificat com a test
Construir i publicar la imatge en crear un tag Procés mecànic i reproduïble; les mans només hi afegeixen errors
Desplegar a staging + smoke test Staging existeix per a això: fallar barat
Passar de staging a producció (servei) No — required reviewer Moment de context humà: és bon moment? Hi ha campanya activa?
Promocionar un model a @champion No — revisió documentada Les mètriques agregades no ho capturen tot: subgrups, plausibilitat, impacte a la campanya de retenció
Rollback d'emergència Semiautomàtica L'ordre està preparada i assajada; el tret és humà (fins a 05-04, on el canary l'automatitza amb mètriques de guàrdia)

El patró general: s'automatitza l'execució, no necessàriament la decisió. Els gates automàtics filtren (res per sota del llistó no arriba a una persona); la persona decideix sobre allò que els tests no veuen. A CineClick això és a més coherència amb el que es va pactar al mòdul 3: el criteri "precision ≥ 0.50 si recall > 0.60" és condició necessària, no suficient, per promocionar.

Gestió de secrets al CD

El pipeline de CD toca credencials sensibles: accés al clúster (KUBECONFIG_STAGING, KUBECONFIG_PROD), al registry d'imatges, al remote de DVC i al servidor de MLflow. Regles no negociables:

  • Mai al repo: ni al YAML, ni a configs/, ni "temporalment" en un commit. L'historial de Git no oblida.
  • Mai a la imatge: la imatge del mòdul 4 ja es va construir sense secrets; el model es descarrega en runtime amb credencials injectades per Kubernetes (el Secret churn-api-secrets). El CD no canvia això — el runner fa servir els seus secrets per desplegar, i el pod els seus per funcionar. Són credencials diferents amb permisos diferents (el pod no pot desplegar; el runner no atén prediccions).
  • GitHub Secrets per environment: KUBECONFIG_PROD viu a l'environment produccio, així que només els jobs que han passat l'aprovació el poden llegir. Un job d'una branca qualsevol no el pot ni veure.
  • Mínim privilegi: el kubeconfig del pipeline apunta a una ServiceAccount que només pot tocar el deployment churn-api al seu namespace, no el clúster sencer.
  • Rotació: si un secret ha pogut filtrar-se (un log que l'ha imprès, un portàtil perdut), es rota i punt. GitHub emmascara els secrets als logs, però no detecta transformacions (base64, per exemple).

Rollback a tots dos pipelines

Els dos nivells de rollback que vam separar a 04-04 ara es corresponen exactament amb els dos pipelines — i aquesta simetria és la raó per la qual insistim a desacoblar-los:

Rollback del SERVEI Rollback del MODEL
Símptoma típic 500s, latència disparada, pods que no arrenquen després d'una release Prediccions "rares": taxa de positius anòmala, queixes de retenció
Ordre kubectl rollout undo deployment/churn-api (o redesplegar el tag anterior: kubectl set image ...:0.1.0) python scripts/promocionar_champion.py 2 (àlies de tornada) + kubectl rollout restart
Què NO toca El model: el pod nou carrega el mateix @champion La imatge: el codi no canvia en absolut
Temps 1-2 minuts (el que trigui el rolling update) 2-3 minuts (moure l'àlies + reinici progressiu)
Verificació /salut + /versio (versió del servei) /versio (versió del model)

Dos consells operatius: primer, el rollback s'assaja — un rollback que mai no s'ha executat és una hipòtesi, no un pla; a CineClick es prova a staging després de cada release. Segon, gràcies a la doble etiqueta versió+SHA del workflow, sempre existeix una imatge anterior exacta a la qual tornar; mai no se sobreescriuen tags d'imatge ja publicats.

Els dos pipelines, junts, en un diagrama

flowchart TB
    subgraph PS["Pipeline del SERVEI (GitHub Actions)"]
        T[git tag v0.2.0] --> TE[Tests ràpids]
        TE --> B[Build multi-stage]
        B --> P[Push ghcr.io/cineclick/churn-api<br/>:0.2.0 i :sha]
        P --> S[Deploy staging]
        S --> SM[Smoke test /salut /versio]
        SM --> AP{Aprovació<br/>required reviewer}
        AP --> PR[Deploy producció]
    end

    subgraph PM["Pipeline del MODEL (MLflow + humà)"]
        C[Challenger v3] --> G[Gates: tests de model 05-01<br/>+ subgrups]
        G --> H{Aprovació humana<br/>documentada}
        H --> AL[Moure @champion -> v3]
        AL --> RR[kubectl rollout restart]
        RR --> V[Verificar /versio]
    end

    PR --> K[(Clúster Kubernetes<br/>churn-api, 3-10 rèpliques)]
    V --> K

Dos camins, dos disparadors, dos rollbacks — un sol clúster on conflueixen. La imatge diu com es prediu; l'àlies diu amb què es prediu.

Errors Comuns i Consells

  • Enfornar el model dins de la imatge "per simplificar". Acabes de fusionar els dos pipelines: cada model nou exigeix una release d'imatge, i cada release d'imatge congela un model. Perdràs a més el rollback barat de l'àlies. La descàrrega en arrencar del mòdul 4 existeix per a això.
  • Desplegar des de main a cada merge, sense tags. Funciona fins que un merge innocent (un README) redesplega producció un divendres. El tag fa la release intencional i deixa un inventari de versions a Git.
  • Smoke test que només comprova el codi HTTP 200. /salut pot respondre OK amb la versió antiga si el rollout no ha arribat a reemplaçar els pods. Comprova sempre el contingut de /versio contra la versió esperada — és la diferència entre "alguna cosa respon" i "respon el que he desplegat".
  • Aprovació manual sense informació. Un botó "Approve" sense l'informe de mètriques al davant és teatre de compliance. El gate humà val el que valgui el context que se li presenta: enllaça l'informe de subgrups a la PR de promoció.
  • Oblidar el rollout restart després de moure l'àlies. L'àlies apunta a la v3 però els pods continuen servint la v2 carregada en memòria — i /versio t'ho hauria explicat. És l'error més comú de tot el flux del model; per això l'script ho recorda en acabar.
  • Compartir credencials entre el pipeline i el pod. Si el token del pod pot desplegar, un contenidor compromès pot redesplegar el clúster. Credencials diferents, permisos mínims.
  • Consell: escriu el procediment de promoció del model (passos 1-5) al repo, a docs/promocio-model.md, i que la PR de promoció faci servir una plantilla amb checklist. Quan una cosa és alhora infreqüent i crítica, la memòria humana és el pitjor magatzem possible.

Exercicis

Exercici 1

En Marc pregunta: "Si el model es descarrega del registry en arrencar, per què no fem que el pod comprovi cada 5 minuts si @champion ha canviat i el recarregui en calent, sense rollout restart?". Dona un argument a favor i dos en contra de la recàrrega automàtica en calent, pensant en operació (pistes: auditoria, rèpliques, memòria).

Exercici 2

S'acaba de desplegar el tag v0.3.0 i al cap de deu minuts la taxa d'errors 500 es dispara. /versio mostra versio_servei: 0.3.0, versio_model: 2. L'últim canvi de model va ser fa tres setmanes. Quin rollback executes, amb quina ordre exacta, i quina comprovació fas després? Per què descartes l'altre rollback?

Exercici 3

Escriu el fragment del job desplegar-produccio que faltaria per fer un smoke test també a producció després del rollout (URL https://churn.cineclick.internal), comprovant /salut i que versio_servei coincideix amb la versió desplegada. Què hauria de passar si el smoke test falla a producció?

Solucions

Exercici 1

A favor: la promoció del model es completaria sense tocar Kubernetes — menys passos, menys oportunitats d'oblidar el restart. En contra: (1) auditoria i control: el canvi de model deixaria de ser un esdeveniment explícit i verificable (un rollout amb el seu historial) per convertir-se en un procés que passa "en algun moment dels propers 5 minuts" a cada pod, difícil de correlacionar amb incidències; (2) incoherència entre rèpliques: durant la finestra de recàrrega, unes rèpliques servirien la v2 i altres la v3 segons el seu temporitzador, i dues peticions idèntiques consecutives podrien rebre prediccions diferents sense que cap desplegament ho expliqui; a més, la recàrrega en calent duplica temporalment el model en memòria (el vell i el nou coexisteixen durant l'intercanvi), cosa que pot empènyer el pod contra el seu límit de memòria just en el pitjor moment. El rollout restart progressiu dona el mateix amb garanties: pods frescos, probes que verifiquen la càrrega i un esdeveniment auditable.

Exercici 2

Rollback del servei: el símptoma va arribar amb la release v0.3.0 i el model no ha canviat en tres setmanes (ho confirma versio_model: 2). Ordre:

kubectl rollout undo deployment/churn-api -n cineclick
kubectl rollout status deployment/churn-api -n cineclick

Comprovació posterior: curl .../versio ha de mostrar versio_servei: 0.2.0 (l'anterior) i la taxa de 500 ha de tornar a la normalitat. Es descarta el rollback del model perquè no hi ha cap indici que el model sigui el problema — moure @champion no arreglaria un bug de codi i afegiria una segona variable a l'incident, complicant el diagnòstic. Regla pràctica: reverteix l'última cosa que ha canviat, i només una cosa a la vegada.

Exercici 3

      - name: Smoke test contra producció
        run: |
          URL=https://churn.cineclick.internal
          curl --fail --max-time 5 "$URL/salut"
          VERSION=$(curl --fail --max-time 5 "$URL/versio" | python -c \
            "import sys, json; print(json.load(sys.stdin)['versio_servei'])")
          test "$VERSION" = "${{ needs.construir-i-publicar.outputs.version }}"

Si falla, el job queda en vermell i l'equip ho ha de tractar com un incident: el desplegament ja ha passat (això és verificació post-desplegament, no un gate previ), així que l'acció és el rollback del servei de l'exercici 2. Alguns equips afegeixen el kubectl rollout undo com a pas automàtic amb if: failure() — és una opció raonable aquí perquè el smoke test és objectiu; l'explorarem amb més matís a 05-04, on el rollback automàtic es basa en mètriques de trànsit real i no només en un endpoint.

Conclusió

CineClick ja lliura sense mans, i ho fa per dues vies ben separades. El servei viatja per cd-servei.yml: un tag v*.*.* dispara tests, build multi-stage, push a ghcr.io/cineclick/churn-api amb doble etiqueta (versió i SHA), desplegament a staging, smoke test contra /salut i /versio, aprovació d'un required reviewer a l'environment produccio, i rollout a producció. El model viatja pel registry: gates automàtics (els tests de 05-01 més la validació per subgrups), aprovació humana documentada — la política del mòdul 3, intacta —, scripts/promocionar_champion.py per moure l'àlies, rollout restart i verificació a /versio. Cada via amb el seu rollback: rollout undo per a la imatge, àlies de tornada per al model. Els secrets, a GitHub Secrets per environment i al Secret del clúster, mai al repo ni a la imatge. Queden dos caps per lligar, tots dos anunciats: el desplegament a producció continua sent "tot de cop" després de staging — 05-04 el convertirà en un procés gradual que es guanya la confiança amb trànsit real — i hi ha un procés sencer que no encaixa en cap workflow de GitHub Actions: el scoring batch dels dilluns a les 06:00, que no el dispara cap commit sinó el calendari. Per a això cal una altra peça — un orquestrador — i és exactament la lliçó següent.

© Copyright 2026. Tots els drets reservats