Ce qu'un smoke test de build n'attrape pas
Une image Docker qui se construit sans erreur peut tomber au premier appel : les bibliothèques que WeasyPrint charge à l'exécution ne sont pas des paquets pip. Un smoke test dans le Dockerfile déplace la panne du démarrage vers la construction. Reste à savoir ce qu'il prouve réellement — et ce qu'il ne peut pas prouver, mesures à l'appui.
Le code cité ici vient d’une petite API Python qui génère des factures PDF/A‑3
avec XML Factur‑X embarqué : FastAPI, WeasyPrint pour la mise en page,
factur-x pour l’assemblage final. Rien de ce qui suit n’est propre à ce
domaine — le problème se pose à l’identique pour n’importe quelle image dont
le code Python appelle des bibliothèques système.
Le décalage entre « ça construit » et « ça marche »
WeasyPrint ne fait pas sa mise en page tout seul : il délègue le texte à Pango
et HarfBuzz, deux bibliothèques C qu’il charge à l’exécution par cffi. Le
chargement se fait dans weasyprint/text/ffi.py, par une fonction maison :
def _dlopen(ffi, *names, allow_fail=False):
"""Try various names for the same library, for different platforms."""
...
for name in names:
with suppress(OSError):
return ffi.dlopen(name, flags)
if allow_fail:
return
# Print error message and re-raise the exception.
...
Ces appels ont lieu au niveau module, donc au premier import weasyprint. Pas
à l’installation.
La conséquence est désagréable : pip install weasyprint réussit parfaitement
sur une image où Pango n’existe pas. docker build sort en 0, l’image est
publiée, le registre est content. La panne arrive au démarrage du conteneur,
c’est-à-dire en production. Le pipeline est vert et le service est mort.
Déplacer la panne vers la construction
La parade tient en deux instructions à la fin du Dockerfile :
COPY docker/smoke_test.py ./smoke_test.py
RUN python3 smoke_test.py && rm smoke_test.py
Si le script sort en erreur, docker build sort en erreur, et l’image n’existe
jamais. On échange une panne de production contre un build rouge. C’est tout
l’intérêt, et c’est un bon échange.
Un import ne prouve presque rien
La version paresseuse du script serait import weasyprint. Elle attrape
effectivement l’absence de Pango ou du cœur de HarfBuzz, puisque ces
dlopen-là sont en allow_fail=False et lèvent à l’import.
Elle n’attrape rien d’autre. Elle ne dit pas si Pillow sait décoder le logo, si Pango sait composer un tableau, si l’écriture du PDF aboutit, ni si l’assemblage PDF/A‑3 avec pièce jointe XML fonctionne. Le script appelle donc le point d’entrée réel du projet, avec une charge utile complète :
from app.generation import generer_facture
from app.models import FactureRequest
pdf_bytes = generer_facture(FactureRequest.model_validate(payload))
assert pdf_bytes.startswith(b"%PDF"), "le PDF généré ne commence pas par l'en-tête %PDF"
C’est ce qui a permis d’alléger l’image en confiance. Deux paquets ont été
retirés de l’apt-get install, sur lecture du code source installé plutôt que
sur un pari :
libgdk-pixbuf-2.0-0:grep -ri gdksur le paquetweasyprintinstallé (version 69.0) ne renvoie plus rien. Depuis que le moteur PDF est bâti surpydyf, les images sont décodées par Pillow, une dépendance pip pure.shared-mime-info: ni WeasyPrint nifactur-xn’interrogent la base/usr/share/mime. Les deux passent parmimetypes.guess_type()de la bibliothèque standard, qui embarque sa propre table (facturx/facturx.py, ligne 971).
Le smoke test est le filet sous ces deux retraits : si la lecture était fausse, la construction tombe.
Angle mort n° 1 : la bibliothèque optionnelle qui ne lève rien
HarfBuzz est chargé deux fois par WeasyPrint, et pas de la même façon :
harfbuzz = _dlopen(
ffi, 'libharfbuzz-0', 'harfbuzz', 'harfbuzz-0.0',
'libharfbuzz.so.0', 'libharfbuzz.0.dylib', 'libharfbuzz-0.dll')
harfbuzz_subset = _dlopen(
ffi, 'libharfbuzz-subset-0', 'harfbuzz-subset', 'harfbuzz-subset-0.0',
'libharfbuzz-subset.so.0', 'libharfbuzz-subset.0.dylib', 'libharfbuzz-subset-0.dll',
allow_fail=True)
Le cœur est obligatoire. Le sous-ensembleur de police est optionnel : en cas
d’échec, _dlopen retourne None sans un mot. Ce que devient ce None se lit
dans weasyprint/pdf/fonts.py :
def subset(self, to_unicode, hinting):
"""Remove unused glyphs and tables from font."""
if not to_unicode:
return
if harfbuzz_subset and harfbuzz.hb_version_atleast(4, 1, 0):
# 4.1.0 is required for hb_set_add_sorted_array.
self._harfbuzz_subset(to_unicode, hinting)
else:
self._fonttools_subset(to_unicode, hinting)
Sans libharfbuzz-subset, WeasyPrint bascule sur un sous-ensembleur en pur
Python (fontTools). Aucune exception, aucun journal — _fonttools_subset ne
consigne un avertissement que si fontTools lui-même échoue sur un
TTLibError. Le PDF produit est parfaitement valide, simplement plus lent à
produire.
Autrement dit : aucune assertion portant sur le document de sortie ne peut détecter cette absence. Le test peut vérifier l’en-tête, la taille, la structure, les métadonnées — le résultat est bon dans les deux cas.
Le détail que je n’avais pas vu avant de relire cette condition : la bascule a
deux déclencheurs. harfbuzz_subset and harfbuzz.hb_version_atleast(4, 1, 0)
part aussi dans le repli silencieux quand HarfBuzz est présent mais antérieur
à 4.1.0. Un contrôle qui se contenterait de vérifier la présence du paquet
laisserait donc passer le second cas.
La seule parade est de ne pas interroger le résultat, mais l’objet chargé, en recopiant la condition réelle plutôt qu’en la paraphrasant :
if not (harfbuzz_subset and harfbuzz.hb_version_atleast(4, 1, 0)):
raison = (
"libharfbuzz-subset introuvable"
if not harfbuzz_subset
else "HarfBuzz antérieur à 4.1.0"
)
print(f"AVERTISSEMENT : {raison} — repli sur fontTools. Pas un échec de build.")
Un avertissement, pas une erreur : le repli fonctionne. Faire échouer une construction sur une configuration qui marche est un défaut, pas une rigueur.
Angle mort n° 2 : l’assertion qui ne mesure pas ce qu’elle annonce
Celui-ci m’a coûté une mesure, et c’est le plus instructif. Le script portait cette seconde assertion :
assert len(pdf_bytes) > 2000, "PDF anormalement petit : le logo n'a probablement pas été embarqué"
L’intention est lisible : vérifier que le logo est bien passé par Pillow, donc
que le retrait de libgdk-pixbuf-2.0-0 était sans conséquence. Le message
l’affirme explicitement.
En générant la même facture avec puis sans logo :
avec logo : 83 122 octets
sans logo : 81 895 octets
delta : 1 227 octets
Le logo pèse 1 227 octets, soit moins que le seuil censé le détecter, sur
une base déjà quarante fois supérieure à ce seuil — l’essentiel du poids vient
de la police embarquée et du XML Factur‑X. Un PDF sans logo mesure 81 895
octets et franchit > 2000 sans effort.
Cette assertion ne pouvait donc pas échouer pour le motif qu’elle affichait. Elle testait « un PDF a été produit », ce que la ligne précédente vérifiait déjà mieux. Elle rassurait sans rien couvrir.
La règle qui s’en dégage : le seuil d’une assertion doit être calibré sur le delta mesuré de ce qu’elle prétend détecter. Un seuil choisi au jugé produit une assertion décorative, et une assertion décorative est pire que pas d’assertion — elle occupe la place et éteint la question.
Quand le delta est trop faible pour un seuil, il faut un marqueur structurel
plutôt qu’une mesure. Ici, le PDF sans image ne contient nulle part la chaîne
/Subtype /Image, qui n’est pas compressée dans un flux :
assert b"/Subtype /Image" in pdf_bytes, (
"aucun objet image dans le PDF : le logo n'a pas été embarqué"
)
Le réflexe voisin — comparer une empreinte du document à une valeur de référence — aurait été un mauvais choix : deux générations de la même charge utile donnent la même longueur mais des octets différents, le PDF portant un identifiant de document. L’assertion aurait échoué une fois sur deux.
Reste l’étape que l’on saute presque toujours : vérifier que la nouvelle
assertion échoue quand elle doit échouer. En remplaçant le logo par None et
en rejouant le script, l’AssertionError tombe ; avec l’ancienne version, le
script passait. Une assertion qu’on n’a jamais vue échouer est une
hypothèse, pas un test.
Angle mort n° 3 : « supprimé » n’est pas « absent »
Reprenons les deux instructions du début :
COPY docker/smoke_test.py ./smoke_test.py
RUN python3 smoke_test.py && rm smoke_test.py
Le rm garantit que le script n’est pas dans le système de fichiers du
conteneur en exécution. Il ne garantit pas qu’il est absent de l’image :
COPY et RUN sont deux instructions, donc deux couches, et supprimer un
fichier dans une couche n’efface pas ses octets de la précédente — cela y
inscrit un marqueur d’effacement. Le fichier reste extractible d’un
docker save.
Sans conséquence pour un script de test. Le même raisonnement appliqué à une clé privée ou à un jeton, en revanche, produit un secret que l’on croit effacé et qui voyage avec l’image. La formulation « supprimé dans le Dockerfile » mérite d’être remplacée par « absent du système de fichiers final, présent dans l’historique des couches », qui est ce qui se passe vraiment.
Sans BuildKit, on ne peut pas faire mieux à moindres frais : un montage
--mount=type=bind éviterait la couche, mais…
…mais le Dockerfile n’a pas droit à BuildKit
Cette image est déployée par flyctl, dont le constructeur par défaut n’est
pas BuildKit. C’est écrit dans l’aide de la commande (flyctl v0.4.81) :
--local-only Perform builds locally using the local docker daemon.
The default is --remote-only.
--buildkit Deploy using buildkit-based remote builder
--remote-only est le défaut et --buildkit un drapeau à demander
explicitement. Le Dockerfile évite donc toute syntaxe que seul BuildKit
comprend : pas de directive # syntax=, pas de heredoc RUN <<EOF, pas de
--mount. Un fichier .py ordinaire, copié puis exécuté par un RUN
ordinaire, fonctionne sur les deux constructeurs. Une instruction BuildKit
aurait cassé le premier déploiement, sur le seul chemin réellement emprunté.
C’est le genre de contrainte qu’on découvre au mauvais moment si on écrit le Dockerfile en supposant l’outillage local.
La limite qui reste, et qu’un smoke test ne lèvera pas
Docker n’est pas installé sur la machine où ce code est écrit. Le script a donc été validé hors conteneur, dans le venv de développement : mêmes imports, même appel, même charge utile. Cela prouve que le Python est correct. Cela ne prouve pas que la liste de paquets Debian est suffisante — c’est précisément l’hypothèse à tester, et elle ne le sera qu’à la première construction réelle.
Il faut le dire dans cet ordre, parce que l’inverse est une illusion
confortable : un test de build ne rend pas l’hypothèse vraie, il avance
seulement le moment où elle sera démentie. Ici, du premier appel d’un
utilisateur au deploy. C’est un gain réel, et c’est le seul.
Il y a aussi un écart connu et non couvert : les tests passent en local sous Python 3.14, l’image de production est bâtie sur 3.12.
Ce qui se généralise
Cinq points, dans l’ordre où ils m’ont servi :
- Un test de build doit exercer le chemin d’appel réel du projet. Un
importne valide que le chargement des bibliothèques, ce qui est la portion la plus facile du problème. - Une dépendance optionnelle — chargée en
allow_fail, entry: import … except ImportError— est invisible à toute assertion portant sur la sortie. Il faut interroger l’objet chargé, et recopier la condition du code amont plutôt que la paraphraser : elle peut avoir plusieurs déclencheurs. - Un seuil doit être calibré sur un delta mesuré. À défaut de delta exploitable, chercher un marqueur structurel dans la sortie.
- Faire échouer l’assertion volontairement avant de lui faire confiance.
- Écrire dans le test lui-même ce qu’il ne couvre pas. Un angle mort documenté reste un angle mort, mais il cesse d’être un piège pour la personne suivante — souvent soi-même, six mois plus tard.
Le dernier point est le seul qui ne coûte rien. C’est aussi celui qu’on saute le plus volontiers, parce qu’il oblige à écrire noir sur blanc que le test qu’on vient d’écrire est incomplet.