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
- La distinció clau: dos pipelines de lliurament, no un
- CD del servei:
.github/workflows/cd-servei.yml - CD del model: moure
@championno és construir res - Gates automàtics vs. aprovació humana
- Gestió de secrets al CD
- Rollback a tots dos pipelines
- 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:
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=180sDesglossament per blocs
on: push: tags:— el patróv*.*.*encaixa ambv0.2.0però no amb tags de treball.GITHUB_REF_NAMEconté el nom del tag; li traiem lavper etiquetar la imatge com a0.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
pytestsón una assegurança barata contra desplegar una cosa que mai no s'ha validat. - Doble etiqueta: versió + SHA —
0.2.0és l'etiqueta "humana";${{ github.sha }}identifica el commit exacte de manera immutable. Si algun dia algú reetiqueta0.2.0per 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
Dockerfilede 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, aproduccio, 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
/salutrespon i que/versioretorna 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 enCrashLoopBackOff.
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:
- 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ó. - 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.
- 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.")- 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
/salutgaranteixen 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
- Verificar. L'endpoint
/versiodel mòdul 4 retorna la versió del model carregat; la comprovació és uncurli 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 | Sí | Criteri objectiu, cost d'error baix, volum alt |
| Bloquejar un challenger sota el llindar de mètriques | Sí | El criteri de negoci ja està codificat com a test |
| Construir i publicar la imatge en crear un tag | Sí | Procés mecànic i reproduïble; les mans només hi afegeixen errors |
| Desplegar a staging + smoke test | Sí | 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_PRODviu a l'environmentproduccio, 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-apial 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 --> KDos 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
maina 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.
/salutpot respondre OK amb la versió antiga si el rollout no ha arribat a reemplaçar els pods. Comprova sempre el contingut de/versiocontra 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 restartdesprés de moure l'àlies. L'àlies apunta a la v3 però els pods continuen servint la v2 carregada en memòria — i/versiot'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.
Curs de MLOps
Mòdul 1: Fonaments de MLOps
- Què és MLOps i per què els models moren al notebook
- El cicle de vida d'un model de ML en producció
- Nivells de maduresa MLOps i rols de l'equip
- El projecte del curs: del notebook a producció
Mòdul 2: Del notebook al codi reproduïble
- Estructura d'un projecte de ML: del notebook al paquet
- Entorns reproduïbles i gestió de dependències
- Versionat de dades amb DVC
- Pipelines d'entrenament reproduïbles
Mòdul 3: Experiments i registre de models
- Tracking d'experiments amb MLflow
- Model registry: versionar i promocionar models
- Feature stores: quan i per a què
Mòdul 4: Servir models en producció
- Patrons de desplegament: batch, online i streaming
- Un servei de predicció amb FastAPI
- Empaquetatge amb Docker
- Escalat i desplegament: Kubernetes i serverless
- Optimització de la inferència: latència i cost
Mòdul 5: Automatització: CI/CD i orquestració
- CI per a ML: tests de codi, dades i models
- CD: automatitzar el desplegament del model
- Orquestració de pipelines de ML
- Estratègies de release: shadow, canary i A/B
