El servei de predicció funciona a la teva màquina, i aquesta frase t'hauria de posar en guàrdia: la teva màquina té Python 3.11.9, les dependències exactes del requirements.lock, el paquet instal·lat en mode editable i les variables d'entorn ben posades. El servidor de producció no té res de tot això, i reproduir-ho a mà és fràgil i inauditable. Docker resol el problema d'arrel: congela sistema operatiu + Python + llibreries + codi en una imatge immutable que s'executa idèntica al teu portàtil, al servidor de staging i al clúster de producció. En aquesta lliçó escriurem el Dockerfile real del servei de CineClick — multi-stage, no-root, explicat línia a línia —, prendrem una decisió de disseny important (el model dins la imatge o descarregat del registry en arrencar?), construirem i etiquetarem la imatge, i aixecarem API + MLflow junts amb docker compose.
Contingut
- Per què contenidors per a ML
- Conceptes mínims: imatge, contenidor, capa, registry
- El Dockerfile del servei CineClick, línia a línia
- .dockerignore: el que NO entra a la imatge
- Decisió de disseny: model a la imatge o des del registry?
- Build, tag i run
- docker compose: API + MLflow en local
- Bones pràctiques d'imatges per a ML
Per què contenidors per a ML
Un model de ML és especialment sensible a l'entorn: la predicció d'un RandomForest serialitzat amb scikit-learn 1.5.2 pot fallar (o, pitjor, canviar silenciosament) si es deserialitza amb una altra versió. Al mòdul 2 vam congelar les dependències amb pip-tools; el contenidor fa el pas final congelant també el que el lock no cobreix:
- La versió exacta de Python (3.11.9, no "el 3.11 que hi hagués al servidor").
- Les llibreries de sistema (glibc, la libgomp que scikit-learn fa servir per paral·lelitzar, zones horàries, certificats).
- El mateix sistema operatiu base i la seva configuració.
- El codi i la seva instal·lació: dins de la imatge el paquet està instal·lat d'una manera concreta i immutable.
El resultat és que la unitat de desplegament deixa de ser "un codi + unes instruccions d'instal·lació" i passa a ser un artefacte binari verificable: la imatge cineclick/churn-api:0.1.0 que va passar les proves és, byte a byte, la mateixa que arribarà a producció. El "funciona a la meva màquina" queda definitivament resolt perquè la teva màquina viatja amb el servei.
Conceptes mínims: imatge, contenidor, capa, registry
Donem per fetes les nocions bàsiques de Docker del prerequisit del curs; fixem només el vocabulari que farem servir:
| Concepte | Què és | Analogia |
|---|---|---|
| Imatge | Plantilla immutable amb SO + dependències + codi. Es construeix una vegada. | La classe |
| Contenidor | Procés en execució creat a partir d'una imatge. N'hi pot haver N del mateix. | La instància |
| Capa | Cada instrucció del Dockerfile crea una capa cachejable. Si no canvia, no es reconstrueix. | Commits apilats |
| Registry d'imatges | Magatzem remot d'imatges versionades (Docker Hub, GHCR, ECR...). push/pull. |
El "GitHub" de les imatges |
Atenció a una col·lisió de noms que confon tothom: el registry d'imatges (on viu cineclick/churn-api:0.1.0) no té res a veure amb el model registry de MLflow (on viu churn-cineclick v2). Són dos magatzems d'artefactes diferents, amb cicles de vida diferents: la imatge canvia quan canvia el codi o l'entorn; la versió del model canvia quan canvia l'entrenament. Mantenir aquesta separació és precisament la decisió de disseny que discutirem a l'apartat 5.
El Dockerfile del servei CineClick, línia a línia
El Dockerfile viu a l'arrel del repo cineclick-churn. Fem servir multi-stage: una etapa builder amb les eines de compilació instal·la les dependències, i una etapa runtime mínima es queda només amb el resultat.
# ---------- Etapa 1: builder ----------
FROM python:3.11.9-slim AS builder
WORKDIR /app
# Primer NOMÉS els fitxers de dependències: si no canvien, aquesta capa es cacheja
COPY requirements.lock .
RUN pip install --no-cache-dir --prefix=/install -r requirements.lock
# Ara el codi del paquet, que canvia més sovint (capa a part)
COPY pyproject.toml .
COPY src/ src/
RUN pip install --no-cache-dir --prefix=/install --no-deps .
# ---------- Etapa 2: runtime ----------
FROM python:3.11.9-slim AS runtime
# Usuari sense privilegis: si comprometen el procés, no són root
RUN groupadd --gid 1000 cineclick && \
useradd --uid 1000 --gid 1000 --create-home cineclick
# Només el que s'ha instal·lat al builder; ni pip-tools, ni caches, ni codi solt
COPY --from=builder /install /usr/local
USER cineclick
WORKDIR /home/cineclick
# Documenta el port del servei (uvicorn hi escoltarà)
EXPOSE 8000
# El healthcheck de 04-02 posat a treballar: Docker marcarà el contenidor
# unhealthy si /salut no respon 200
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/salut')"
CMD ["uvicorn", "cineclick_churn.api.main:app", "--host", "0.0.0.0", "--port", "8000"]Explicació línia a línia:
FROM python:3.11.9-slim: la versió exacta del projecte (fixada al mòdul 2), variantslim(~150 MB davant ~1 GB de la imatge completa). Maipython:latest: una imatge que canvia sola sota els teus peus és l'antítesi de la reproduïbilitat.COPY requirements.lockabans que el codi: l'ordre explota la memòria cau de capes. Les dependències canvien poc; el codi, cada dia. Amb aquest ordre, un canvi amain.pyreconstrueix en segons perquè la capa (lenta) delpip install -r requirements.lockcontinua cachejada.--prefix=/install: ho instal·la tot en un directori net que després copiem sencer a l'etapa runtime. És el truc central del multi-stage.pip install --no-deps .: instal·la el paquetcineclick_churn(el de debò, no editable —-eés per a desenvolupament) sense re-resoldre dependències: ja estan totes clavades pel lock. Aquí viatgenfeatures.pyi l'API junts, com vam decidir a 04-02.COPY --from=builder /install /usr/local: l'etapa runtime rep només el resultat. Les eines de build, la memòria cau de pip i els fitxers intermedis es queden a l'etapa builder, que es descarta. Resultat: imatge final d'uns 380 MB (davant >1 GB sense multi-stage), i menys superfície d'atac.USER cineclick: el procés corre com a usuari sense privilegis. És de les pràctiques de seguretat més barates que existeixen i molts clústers directament rebutgen contenidors root.EXPOSE 8000és documentació (el mapatge real es fa adocker run -p), però documentació que eines i humans llegeixen.HEALTHCHECKcrida elGET /salutde 04-02 cada 30 s.--start-period=20sdona marge al lifespan per descarregar el model del registry abans de començar a exigir salut. Fem servirurllibde la biblioteca estàndard per no instal·larcurla la imatge.CMDen forma exec (llista JSON): uvicorn rep els senyals del sistema directament (undocker stopfa una aturada neta, no un kill al cap de 10 segons).- Fixa't en el que no hi ha: cap còpia del model i cap secret. El primer és la decisió de l'apartat 5; el segon, una regla absoluta (apartat 8).
.dockerignore: el que NO entra a la imatge
COPY src/ src/ copia el que li diguis, però el context de build que Docker empaqueta i envia al daemon és, per defecte, el directori sencer. Sense .dockerignore, el build de CineClick arrossegaria gigabytes de dades i experiments:
# .dockerignore .git/ .venv/ data/ # les dades les gestiona DVC, no la imatge models/ # artefactes locals d'entrenament mlruns/ # experiments MLflow locals .dvc/cache/ tests/ notebooks/ __pycache__/ *.pyc .env # secrets locals MAI al context de build!
Els tres primers blocs no són només pes: data/ i mlruns/ poden contenir informació que no ha de viatjar dins d'una imatge que es puja a un registry compartit. .env directament seria una fuga de credencials. Regla mental: la imatge conté el servei, no el projecte.
Decisió de disseny: model a la imatge o des del registry?
Hi ha dues maneres que el contenidor tingui el model, i l'elecció condiciona tot el flux de desplegament:
Opció A — "Enfornar" el model a la imatge: durant el build, un RUN descarrega el model i el copia a dins (COPY model/ /app/model/). La imatge és autosuficient.
Opció B — Descarregar del registry en arrencar: la imatge només porta codi; el lifespan de 04-02 descarrega models:/churn-cineclick@champion fent servir MLFLOW_TRACKING_URI com a variable d'entorn.
| Criteri | A: model a la imatge | B: des del registry en arrencar |
|---|---|---|
| Autosuficiència | Total: corre sense registry ni xarxa | Necessita el registry accessible a cada arrencada |
| Arrencada | Ràpida (model ja a dins) | Més lenta (descàrrega; segons per al nostre RF) |
| Model nou ⇒ | Rebuild + push + redeploy de la imatge | Moure l'àlies + reiniciar contenidors; mateixa imatge |
| Traçabilitat | L'etiqueta d'imatge fixa codi I model junts | /versio diu quin model va carregar cada arrencada |
| Govern del model | Es decideix al build (pipeline d'imatges) | Es decideix al registry (flux de 03-02: àlies, revisió humana) |
| Risc característic | Proliferen imatges per combinació codi×model | Registry caigut ⇒ arrencades noves fallen (ex. 3 de 04-02) |
| Encaixa quan... | Edge/air-gapped, sense registry en runtime, arrencada crítica | Hi ha registry governat i el model canvia més que el codi |
CineClick tria l'opció B, i el raonament importa més que l'elecció: al mòdul 3 vam construir un flux de govern del model — versions, àlies, promoció amb revisió humana, rollback movent @champion —. Enfornar el model a la imatge buidaria aquest flux: cada promoció exigiria reconstruir i redistribuir una imatge, i el rollback deixaria de ser "moure un àlies" per ser "redesplegar la imatge anterior". Amb l'opció B, imatge i model evolucionen per separat: cineclick/churn-api:0.1.0 és la mateixa tant si el campió és la v2 com si demà és la v3, i el registry continua sent l'únic punt de govern. El preu — dependència del registry a l'arrencada — es mitiga amb rèpliques ja arrencades (04-04) i és un risc acceptat i documentat.
L'opció A queda anotada com a alternativa vàlida per a contextos concrets (un desplegament al set-top-box d'un partner, un entorn sense sortida a la xarxa): si algun dia cal, és un stage addicional al Dockerfile, no un redisseny.
Build, tag i run
# Construir i etiquetar: nom/servei:versió-semàntica docker build -t cineclick/churn-api:0.1.0 . # També convé una etiqueta flotant per a "l'últim estable en local" docker tag cineclick/churn-api:0.1.0 cineclick/churn-api:latest
El tag semàntic 0.1.0 (coincideix amb l'app.version del servei i amb la versió del paquet a pyproject.toml) és l'identificador que citaran els manifestos de Kubernetes a 04-04 i el CD a 05-02. latest és còmode en local però mai no es desplega a producció: no sabries què està corrent.
Executar el contenidor:
docker run --rm -p 8000:8000 \ -e MLFLOW_TRACKING_URI=http://host.docker.internal:5000 \ -e LLINDAR_ABANDONAMENT=0.5 \ --name churn-api \ cineclick/churn-api:0.1.0
-p 8000:8000publica el port del contenidor a la teva màquina.-e MLFLOW_TRACKING_URI=...és la variable de l'opció B: el mateix contenidor apunta a un MLflow o a un altre segons l'entorn, sense tocar la imatge.host.docker.internalés com un contenidor veu la teva màquina amfitriona (on corre el MLflow local del mòdul 3).- Comprova la salut:
docker psmostrarà(healthy)passat l'start-period, icurl http://localhost:8000/versioha de respondre"versio_model": "2"— la prova que el contenidor va descarregar el campió del registry.
docker compose: API + MLflow en local
Arrencar dos terminals a mà cada vegada és fricció. docker compose declara el conjunt:
# compose.yaml
services:
mlflow:
image: ghcr.io/mlflow/mlflow:v2.18.0
command: >
mlflow server
--backend-store-uri sqlite:///mlflow/mlflow.db
--artifacts-destination /mlflow/artefactes
--host 0.0.0.0 --port 5000
ports:
- "5000:5000"
volumes:
- ./mlflow_data:/mlflow # persistència: sqlite i artefactes sobreviuen al contenidor
api:
image: cineclick/churn-api:0.1.0
ports:
- "8000:8000"
environment:
MLFLOW_TRACKING_URI: http://mlflow:5000 # DNS intern de compose: el servei es diu 'mlflow'
LLINDAR_ABANDONAMENT: "0.5"
depends_on:
mlflow:
condition: service_starteddocker compose up -d # aixeca tots dos docker compose logs -f api # veure el lifespan carregant el model docker compose down # apagar-ho tot
Detalls que mereixen atenció:
- Dins de la xarxa de compose, l'API arriba a MLflow pel seu nom de servei (
http://mlflow:5000) — res delocalhost, que dins d'un contenidor vol dir "jo mateix". - El volum
./mlflow_datafa persistent el registry local; sense ell, cadadownesborraria els models registrats. (Perquè aquest MLflow continguichurn-cineclickv1/v2 hauràs d'apuntar el teuMLFLOW_TRACKING_URId'entrenament aquí i re-registrar, o copiar el teumlflow.dbi els artefactes amlflow_data/.) depends_onordena l'arrencada però no espera que MLflow estigui llest: si l'API arrenca abans que MLflow respongui, fallarà (fallar ràpid, 04-02) i n'hi haurà prou ambdocker compose restart api. En producció aquest ball el gestionen les probes de Kubernetes (04-04).
Aquest compose és l'entorn de desenvolupament integrat de l'equip: en Marc clona el repo, executa docker compose up i té el mateix sistema que la Laura, sense instal·lar ni Python.
Bones pràctiques d'imatges per a ML
| Pràctica | Per què | A CineClick |
|---|---|---|
Base slim + multi-stage |
Menys MB = pulls més ràpids, menys superfície d'atac | 380 MB davant >1 GB |
| Pin total: base amb versió exacta, deps per lock | Build reproduïble avui i d'aquí a un any | python:3.11.9-slim + requirements.lock |
| Capa de dependències abans que la de codi | Rebuilds de segons en el dia a dia | COPY requirements.lock primer |
| Usuari no-root | Contenció si el procés es veu compromès | USER cineclick |
| Zero secrets a la imatge | Qualsevol amb accés al registry d'imatges els llegiria (docker history delata els ENV) |
Credencials només per variables/secrets en runtime |
| Ni dades ni experiments a dins | Pes, i possible fuga d'informació | .dockerignore: data/, mlruns/, .env |
Tag semàntic, mai desplegar latest |
Saber què corre i poder tornar enrere | cineclick/churn-api:0.1.0 |
| HEALTHCHECK contra un endpoint real | Que "viu" signifiqui "capaç de predir" | GET /salut verifica el model en memòria |
| CMD en forma exec | Aturada neta davant els senyals | CMD ["uvicorn", ...] |
| Una imatge per a tots els entorns | El que s'ha provat és el que es desplega; l'entorn entra per variables | MLFLOW_TRACKING_URI per entorn |
Errors Comuns i Consells
- Error:
FROM python:latesto dependències sense fixar. El build d'avui i el del mes que ve produirien serveis diferents amb el mateix Dockerfile. Tot clavat: base, lock, versió del paquet. - Error: copiar tot el projecte (
COPY . .) sense.dockerignore. Context de build gegant, dades i secrets dins de la imatge, i qualsevol canvi a qualsevol fitxer invalida la memòria cau. Copia només el que el servei necessita, en capes ordenades per freqüència de canvi. - Error: confondre els dos registries. "Puja el model al registry" és ambigu en una conversa de MLOps. Imatge → registry d'imatges (
docker push); model → model registry de MLflow. Cicles de vida separats, expressament. - Error: ficar credencials amb
ENVo copiant.env. Queden gravades a les capes per sempre (docker historyles mostra). Els secrets entren en runtime:-e, fitxers muntats o els Secrets de Kubernetes (04-04). - Error: provar la imatge amb el teu venv activat i creure que has provat la imatge. Prova contra el contenidor (
docker run+curl), que és el que anirà a producció; el teu venv ja no hi pinta res. - Consell: mira la mida (
docker images) després de cada canvi del Dockerfile. Un salt inesperat de centenars de MB sol delatar dades colades o una capa mal ordenada. - Consell: mantén compose com a entorn canònic de desenvolupament. "Clona i
docker compose up" és l'onboarding que li hauria agradat tenir a la Laura el seu primer dia.
Exercicis
Exercici 1
Sense mirar la taula: un company proposa "ficar el model a la imatge, així no depenem de MLflow en arrencar". Escriu la resposta raonada per a CineClick (què es guanya, què es perd, per què l'equip va decidir el contrari) i descriu UN escenari en què li donaries la raó.
Exercici 2
El build triga 4 minuts cada vegada que la Laura canvia una línia de main.py. Revisant el seu Dockerfile hi veus:
FROM python:3.11.9-slim WORKDIR /app COPY . . RUN pip install --no-cache-dir -r requirements.lock && pip install --no-deps . CMD ["uvicorn", "cineclick_churn.api.main:app", "--host", "0.0.0.0", "--port", "8000"]
Identifica els problemes (n'hi ha almenys tres) i reescriu-lo.
Exercici 3
Amb el compose aixecat, executa la seqüència de verificació completa del contenidor i anota què comprova cada pas: docker compose ps, curl http://localhost:8000/salut, curl http://localhost:8000/versio, i una predicció amb el client CLI-04217 de la lliçó anterior. Què caldria mirar si /versio retornés "versio_model": "1"?
Solucions
Solució 1
Resposta al company: enfornar el model dona autosuficiència (corre sense registry, arrencada més ràpida) però trenca el govern construït al mòdul 3 — cada promoció de model passaria a exigir rebuild + push + redeploy d'imatge, i el rollback deixaria de ser "moure @champion" (segons, reversible, auditat al registry) per ser un redesplegament. A més acoblaria dos cicles de vida que a CineClick canvien a ritmes diferents: el model es reentrenarà molt més sovint que el codi del servei. El risc de l'opció triada (registry caigut ⇒ arrencades noves fallen) està mitigat per les rèpliques ja en marxa i acceptat per escrit. Escenari on el company tindria raó: un desplegament edge o air-gapped — per exemple, servir el model en un dispositiu d'un partner sense connectivitat amb la infraestructura de CineClick — on no hi ha registry accessible en runtime; allà la imatge autosuficient és l'única opció viable.
Solució 2
Problemes: (1) COPY . . al principi — qualsevol canvi de codi invalida la capa següent, que reinstal·la TOTES les dependències: aquests són els 4 minuts; a més, sense .dockerignore visible, arrossega data/, mlruns/ i possibles secrets. (2) No hi ha multi-stage: la imatge final carrega amb la memòria cau de pip i fitxers de projecte innecessaris. (3) Corre com a root, sense HEALTHCHECK i sense EXPOSE. Reescriptura: exactament el Dockerfile de la lliçó — COPY requirements.lock + pip install com a primera capa (cachejada mentre el lock no canviï), codi després, multi-stage amb --prefix=/install, USER cineclick, EXPOSE 8000 i HEALTHCHECK contra /salut. Amb la memòria cau ben ordenada, el canvi d'una línia a main.py reconstrueix en segons.
Solució 3
docker compose ps: tots dos serveisrunningi l'API amb estat(healthy)— el HEALTHCHECK contra/salutpassa.curl /salut→{"estat": "ok", ...}: el procés és viu i té el model en memòria (recorda: el nostre healthcheck no menteix).curl /versio→{"servei": "0.1.0", "model_uri": "models:/churn-cineclick@champion", "versio_model": "2"}: la imatge 0.1.0 corrent amb el campió v2 — traçabilitat completa de quin codi i quin model serveixen.- El POST a
/predirambCLI-04217ha de retornar la mateixa probabilitat (0.8412) que a 04-02: mateix model, mateixes features, ara dins del contenidor — la reproduïbilitat de punta a punta.
Si /versio digués "1", el contenidor hauria carregat la versió equivocada: el primer és mirar a quin MLflow apunta (docker compose exec api env | grep MLFLOW) — el més probable és que el MLFLOW_TRACKING_URI assenyali un registry (per exemple, l'sqlite buit del volum acabat de crear) on l'àlies @champion apunta a una altra versió o només s'hi va re-registrar la v1. L'àlies viu al registry, no a la imatge: es corregeix allà i es reinicia l'API.
Conclusió
El servei de CineClick ja és un artefacte portàtil: cineclick/churn-api:0.1.0, una imatge multi-stage de ~380 MB que corre com a usuari sense privilegis, declara el seu healthcheck, no conté ni dades ni secrets ni model — perquè el model es governa on toca, al registry de MLflow, i entra al contenidor a l'arrencada via MLFLOW_TRACKING_URI. Amb docker compose, qualsevol membre de l'equip aixeca API + MLflow amb una comanda, i el que corre al seu portàtil és byte a byte el que correrà a producció. Però un contenidor solitari no és producció: si el procés mor a les 3 de la matinada, ningú no el reinicia; si el trànsit es triplica en una nit d'estrenes, ningú no afegeix rèpliques; si despleges una versió trencada, ningú no la retira. Aquest "algú" és un orquestrador, i la lliçó següent el presenta: Kubernetes amb el mínim que un ML engineer necessita — Deployments, Services, probes apuntant al nostre /salut, autoescalat — i l'alternativa serverless per a quan mantenir un clúster no compensa.
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
