Pourquoi une image Alpine multi-stage¶
Cette page explique pourquoi l'image Docker de Kirexo est bâtie sur Alpine Linux plutôt que sur Debian, et quels compromis ce choix engage. Pour l'inventaire factuel (stages, paquets, versions, chemins), voir la Référence du Dockerfile multi-stage. Pour le scan de vulnérabilités qui a motivé la bascule, voir Chaîne d'approvisionnement et signatures et la Référence de la pipeline GitLab CI.
Le bruit CVE intrinsèque de Debian¶
L'image amont dunglas/frankenphp:1-php8.5 héritait de Debian 13 (trixie) et embarquait par défaut deux ensembles de paquets que l'application PHP n'invoque jamais, mais que le scanner Trivy compte comme surface de vulnérabilité :
- la famille
perl*(perl-base,perl,perl-modules-5.40,libperl5.40, …) — présente parce que des scripts de maintenance Debian en dépendent, jamais exécutée par Kirexo ; linux-libc-dev— les headers de l'API du kernel Linux, qui remontent une vingtaine de CVE de kernel côté conteneur (faux positifs : le conteneur ne fait pas tourner de kernel).
Résultat : le scan de kirexo-prod sur base Debian remontait de l'ordre de 65 à 71 vulnérabilités Critical/High, dont l'essentiel était ce bruit intrinsèque (≈ 20 entrées Perl + ≈ 22 CVE linux-libc-dev) — des vulnérabilités réelles dans l'absolu, mais dans du code jamais chargé à l'exécution.
Alpine élimine ce bruit mécaniquement, pas par allowlist : une image Alpine de base compte environ 150 paquets contre plus de 400 côté Debian, et n'embarque ni la famille Perl, ni linux-libc-dev. Sur les scans réels post-migration, kirexo-prod tombe à 0 vulnérabilité Critical/High livrée (chiffres détaillés dans la référence pipeline). Le durcissement final vient du apk upgrade --no-cache du stage frankenphp_prod, qui tire les derniers correctifs Alpine sur les paquets résiduels.
Ce que la migration ne règle pas¶
Passer à Alpine n'est pas une baguette magique CVE, et il serait malhonnête de le présenter ainsi.
Les paquets réellement utilisés par l'application existent aussi sous Alpine, avec leurs propres vulnérabilités : curl / libcurl, libxml2, libssh2, libexpat, ncurses, openssl… La migration ne les fait pas disparaître. Ce qu'elle change, c'est :
- le volume de bruit : les CVE qui comptaient sans jamais être atteignables (Perl, headers kernel) disparaissent, ce qui rend un scan rouge de nouveau signifiant ;
- le timing du patch : les correctifs suivent désormais le cycle de sécurité Alpine plutôt que celui de la Debian Security Team — un rythme différent, pas nécessairement meilleur ou pire selon la CVE.
Autrement dit, la migration améliore le rapport signal/bruit du scan et réduit la surface, mais un résiduel Critical/High peut subsister côté builders (paquets non livrés en production) : il est traité par l'allowlist container_scanning documentée dans la référence pipeline.
Le compromis musl contre glibc¶
Alpine utilise la bibliothèque C musl au lieu de la glibc de Debian. C'est le vrai coût technique de la bascule, et il concentre les points d'attention.
Node : le binaire glibc ne tourne pas sous musl¶
Le tarball officiel node-v*-linux-x64.tar.xz de nodejs.org/dist est lié à la glibc et échoue au chargement sous musl. Kirexo récupère donc la variante node-v*-linux-x64-musl depuis unofficial-builds.nodejs.org (build maintenu par l'infrastructure Node), en conservant la même discipline de pin : version figée + SHA-256 vérifié avant extraction. C'est le seul binaire pour lequel la migration a changé de source d'approvisionnement (cf. binaires hors gestionnaire de paquets).
Locales, DNS et threads¶
Trois différences musl/glibc méritent d'être connues, aucune n'a d'impact pratique sur Kirexo :
- Locales : musl embarque un jeu minimal de locales. Un projet qui aurait besoin de locales étendues installerait
musl-locales— inutile ici. L'internationalisation applicative passe par l'extension PHPintl(ICU), qui n'est pas concernée par ce point : elle apporte ses propres données viaicu-libs. - Résolveur DNS : l'implémentation musl de
getaddrinfodiffère de celle de glibc (parsing de/etc/resolv.conf, ordre des requêtes). Dans le devcontainer, la résolution passe de toute façon pardnsmasq(cf. architecture du pare-feu) ; en production, la stack utilise des noms de services Docker résolus par le DNS interne. Aucun cas limite musl connu ici. - Taille de pile des threads : musl alloue par défaut une pile de thread bien plus petite que glibc (de l'ordre de ~128 KB contre ~8 MB). C'est un piège classique pour du code C multi-thread gourmand en pile, mais Symfony, Doctrine et le worker FrankenPHP fonctionnent sans y toucher.
Le compromis est donc très favorable : un seul point d'adaptation réel (le tarball Node musl), pour un gain net de surface d'attaque.
La nouvelle stratégie de pin Chromium¶
Chromium est le point le plus délicat de la migration, parce qu'il traîne un historique.
Avant (Debian, ticket #86). Le stage dev épinglait Chromium via snapshot.debian.org + apt-preferences, pour se protéger d'une version 150 qui crashait au démarrage en CI. Le pin figeait un snapshot daté du dépôt Debian.
Après (Alpine). Ce mécanisme n'existe plus. Chromium et son chromedriver viennent des dépôts Alpine — mais pas du community « rolling » de l'image de base : les mirrors Alpine ne conservent que la dernière version d'un paquet, donc un pin nu (chromium=<version>) casserait au premier refresh du mirror (exactement le piège du ticket #86). La parade retenue est d'ajouter explicitement une branche release figée comme dépôt :
apk add --no-cache \
--repository "https://dl-cdn.alpinelinux.org/alpine/v3.24/community" \
"chromium=150.0.7871.128-r0" \
"chromium-chromedriver=150.0.7871.128-r0"
Une branche v3.24 reste servie durablement et ne reçoit que des correctifs de sécurité dans la même major Chromium. C'est l'analogue Alpine du snapshot.debian.org : une source d'approvisionnement stable dans le temps.
Deux détails héritent d'arbitrages précis :
- Même branche que la base. On épingle sur
v3.24(la branche de l'image de base) plutôt que de cross-piner une branche Alpine antérieure. Un cross-pin de la version 149 (branchev3.23) sur une basev3.24échoue par incompatibilité d'ABI (so:libsimdutf.so.27 no such package— la basev3.24fournit unsimdutfau soname incompatible). Rester sur la branche de la base résout proprement toute la sous-arborescence de libs (simdutf, nss, libstdc++…). C'est pourquoi la version installée est 150.0.7871.128 (branchev3.24), et non une version antérieure. - Patch précise. Le ticket #86 avait vu
150.0.7871.46crasher (SIGTRAP) sur le runner ; on épingle150.0.7871.128, 82 patchs plus loin, où la régression headless est corrigée.
La finalité du smoke-test fail-fast reste inchangée : chromium --headless=new --no-sandbox --disable-dev-shm-usage --dump-dom about:blank force un vrai cycle lancement → rendu → sortie pendant le docker build. Un navigateur non démarrable casse le build ici, pas 55 tests E2E dix jours plus tard.
Voir aussi¶
- Référence du Dockerfile multi-stage — les faits : stages, paquets
apk, extensions PHP, binaires, renommages. - Choix d'isolation du Dev Container — pourquoi le stage dev embarque un pare-feu et l'outillage Claude Code.
- Chaîne d'approvisionnement et signatures — SBOM, signature keyless, scan de vulnérabilités.
- Référence de la pipeline GitLab CI — chiffres de réduction CVE relevés sur les scans réels.