Skip to content

ADR 011 — Image de prod : multi-stage, Python 3.12-slim pinnĂ©, pas de distroless (2026-06-04)

Contexte

Issues #32 (Dockerfile multi-stage), #30 (CVE de l'image de base), #12 (Python 3.12).

Le Dockerfile d'origine était single-stage et accumulait plusieurs défauts :

  • FROM python:3.10-slim alors que requirements.txt Ă©tait dĂ©jĂ  compilĂ© pour Python 3.12 (et le venv local en 3.12). IncohĂ©rence de version, fragile.
  • Les dĂ©pendances de test embarquĂ©es en prod : requirements.txt contenait pytest, pytest-cov, coverage, httpx, wheel... installĂ©es dans l'image finale. Surface d'attaque et poids inutiles.
  • curl installĂ© uniquement pour un HEALTHCHECK Docker redondant : sous Kubernetes, les probes liveness/readiness (/healthz/live, /healthz/ready, #47) assurent dĂ©jĂ  la santĂ© du pod. Le HEALTHCHECK tapait / (pas un endpoint health).
  • Base par tag mutable (python:3.10-slim), incohĂ©rent avec la logique de promote par digest (#65, "l'image scannĂ©e == l'image dĂ©ployĂ©e").

DĂ©cision 1 — Multi-stage + split des requirements

Le Dockerfile passe en deux stages :

  • builder (python:3.12-slim) : installe les deps prod dans un venv isolĂ© (python -m venv /venv). Voir Amendement #67 ci-dessous.
  • runtime (python:3.12-slim) : ne reçoit que /venv (ni pip, ni outils de build, ni harnais de test).

Les dépendances sont scindées :

  • requirements.in / requirements.txt : runtime de prod uniquement.
  • requirements-dev.in / requirements-dev.txt : pytest, pytest-cov, httpx (contraints par -c requirements.txt pour aligner les versions partagĂ©es).

Le gain principal du multi-stage ici n'est pas la compilation (psycopg[binary] fournit des wheels précompilés, pas de toolchain C nécessaire) mais le fait de ne plus embarquer les deps de test dans l'image de prod.

Le job CI run-tests installe désormais requirements.txt et requirements-dev.txt.

Amendement #67 — 2026-06-09 : passage de --prefix à python -m venv

pip install --prefix=/install a été remplacé par python -m venv /venv dans le stage builder.

Cause racine : --prefix ne réinstalle pas les paquets déjà présents dans l'image de base du builder. packaging était préinstallé dans python:3.12-slim, donc skippé dans /install. Copié dans le runtime (base propre), packaging était absent. pip check levait une erreur masquée car le CMD n'invoque pas gunicorn.

Fix : Un venv est un environnement Python entiĂšrement isolĂ© — pip installe toutes les dĂ©pendances dĂ©clarĂ©es, sans tenir compte de la base image. Comportement dĂ©terministe quel que soit le contenu du builder.

# Stage builder
RUN python -m venv /venv && /venv/bin/pip install --no-cache-dir --upgrade pip wheel
RUN /venv/bin/pip install --no-cache-dir -r requirements.txt

# Stage runtime
COPY --from=builder /venv /venv
ENV PATH="/venv/bin:$PATH"

En bonus : gunicorn retiré des dépendances (inutile sous Kubernetes, scaling assuré par replicas/HPA).


DĂ©cision 2 — Base python:3.12-slim pinnĂ©e par digest, PAS distroless

Le choix a été tranché par mesure réelle (build + trivy image), pas par intuition. Les deux candidats comparés, multi-stage équivalent :

CritĂšre python:3.12-slim distroless/python3-debian12
Version Python 3.12 (conforme #12) 3.11.2 (figée, < #12)
Taille image 212 MB 161 MB
Trivy CI (--ignore-unfixed, CRIT/HIGH) 0 / 0 → pipeline vert 0 / 7 HIGH → pipeline rouge
Surface totale (fixed+unfixed) 2 CRIT + 7 HIGH 5 CRIT + 18 HIGH
Dont CVE dans l'interpréteur Python 0 3 HIGH (libpython3.11)
Patchable par nous oui (rebuild fréquent Docker) non sans rebuild upstream
Debug (shell) présent absent

Conclusion contre-intuitive : distroless n'est pas plus sĂ»r dans ce cas. L'image python:3.12-slim est rebuildĂ©e en continu (dĂ©sormais sur Debian 13 trixie, 0 CVE OS), donc ses seules CVE rĂ©siduelles sont unfixed et lĂ©gitimement masquĂ©es par --ignore-unfixed. Le distroless officiel est figĂ© sur un Python 3.11.2 ancien, dont des CVE dĂ©jĂ  corrigĂ©es en amont (dont 3 HIGH dans l'interprĂ©teur lui-mĂȘme) ne sont pas intĂ©grĂ©es : elles bloqueraient le pipeline et ne sont pas patchables cĂŽtĂ© projet (pas d'apt). La fraĂźcheur de l'image prime sur la minimalitĂ©.

La base est pinnée par digest (python:3.12-slim@sha256:090ba77e...) : build reproductible, cohérent avec le promote par digest (#65). Le tag est conservé pour la lisibilité.

Note : une 3e voie (Chainguard/Wolfi, Python à jour + minimal) existe mais dépend d'un registre tiers ; hors scope ici, à réévaluer si la minimalité redevient prioritaire.


DĂ©cision 3 — Retrait de curl et du HEALTHCHECK Docker

curl et la directive HEALTHCHECK sont retirés de l'image : sous Kubernetes, les probes assurent déjà liveness/readiness. Une dépendance réseau et une surface d'attaque en moins.

Conséquence assumée : les healthchecks des docker-compose-dev.yaml / docker-compose-prod.yaml reposaient sur curl. Ils passent à un check en Python stdlib (urllib) sur l'endpoint dédié /healthz/live (corrige au passage l'ancien check sur /).


Conséquences

  • Image plus lĂ©gĂšre et alignĂ©e sur Python 3.12 ; Trivy CI Ă  0 CRITICAL / 0 HIGH.
  • requirements.txt ne contient plus aucune dĂ©pendance de test.
  • ValidĂ© en local avant merge : build + trivy image (0/0), pytest (37 passed, 95%) sous 3.12, et dĂ©marrage end-to-end via docker-compose-dev (alembic upgrade head + PostgreSQL + /healthz/ready → 200).
  • La mĂ©canique de build CI (kaniko → Trivy → promote par digest) est inchangĂ©e : le multi-stage produit un nouveau digest, scannĂ© puis promu comme avant.
  • Pas de directive # syntax= dans le Dockerfile : kaniko (builder CI) ne gĂšre pas le frontend BuildKit.

Date : 2026-06-04 Sprint : 3 Issues : #32, #30, #12