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

  1. Per què contenidors per a ML
  2. Conceptes mínims: imatge, contenidor, capa, registry
  3. El Dockerfile del servei CineClick, línia a línia
  4. .dockerignore: el que NO entra a la imatge
  5. Decisió de disseny: model a la imatge o des del registry?
  6. Build, tag i run
  7. docker compose: API + MLflow en local
  8. 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), variant slim (~150 MB davant ~1 GB de la imatge completa). Mai python:latest: una imatge que canvia sola sota els teus peus és l'antítesi de la reproduïbilitat.
  • COPY requirements.lock abans 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 a main.py reconstrueix en segons perquè la capa (lenta) del pip install -r requirements.lock continua 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 paquet cineclick_churn (el de debò, no editable — -e és per a desenvolupament) sense re-resoldre dependències: ja estan totes clavades pel lock. Aquí viatgen features.py i 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 a docker run -p), però documentació que eines i humans llegeixen.
  • HEALTHCHECK crida el GET /salut de 04-02 cada 30 s. --start-period=20s dona marge al lifespan per descarregar el model del registry abans de començar a exigir salut. Fem servir urllib de la biblioteca estàndard per no instal·lar curl a la imatge.
  • CMD en forma exec (llista JSON): uvicorn rep els senyals del sistema directament (un docker stop fa 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:8000 publica 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 ps mostrarà (healthy) passat l'start-period, i curl http://localhost:8000/versio ha 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_started
docker 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 de localhost, que dins d'un contenidor vol dir "jo mateix".
  • El volum ./mlflow_data fa persistent el registry local; sense ell, cada down esborraria els models registrats. (Perquè aquest MLflow contingui churn-cineclick v1/v2 hauràs d'apuntar el teu MLFLOW_TRACKING_URI d'entrenament aquí i re-registrar, o copiar el teu mlflow.db i els artefactes a mlflow_data/.)
  • depends_on ordena 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 amb docker 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:latest o 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 ENV o copiant .env. Queden gravades a les capes per sempre (docker history les 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 serveis running i l'API amb estat (healthy) — el HEALTHCHECK contra /salut passa.
  • 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 /predir amb CLI-04217 ha 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.

© Copyright 2026. Tots els drets reservats