jobsphp

Docker pour PHP : la méthode de Sarah pour accélérer sa CI/CD

Ingénierie Web. Docker pour PHP : la méthode de Sarah pour accélérer sa CI/CD

Un docker build qui s'étire sur huit minutes. Chaque push. Chaque merge request. Sur un dépôt PHP de taille moyenne, le simple fait de corriger une virgule dans un contrôleur peut relancer…

Docker pour PHP: la méthode de Sarah pour accélérer sa CI/CD

Un docker build qui s'étire sur huit minutes. Chaque push. Chaque merge request. Sur un dépôt PHP de taille moyenne, le simple fait de corriger une virgule dans un contrôleur peut relancer l'intégralité du composer install — téléchargement, résolution, extraction — comme si rien n'avait jamais été construit. Le problème ne vient pas de Docker. Il vient du Dockerfile.

L'optimisation des temps de build PHP s'appuie sur trois leviers concrets — l'ordre des instructions, les builds multi-étapes et le cache BuildKit — plus un fichier .dockerignore trop souvent négligé. Combinées, ces techniques produisent des gains qui peuvent atteindre plusieurs fois le temps initial — sans changer une seule ligne de code applicatif.

L'architecture du cache: pourquoi l'ordre des instructions change tout

Docker construit son image couche par couche. Chaque instruction du Dockerfile — COPY, RUN, ADD — génère une couche identifiée par un hash. Si le hash d'une couche n'a pas changé depuis le build précédent, Docker réutilise la version en cache. Mais ce mécanisme est strictement séquentiel: une couche est invalidée dès qu'une couche précédente change, et toutes les couches suivantes sont recalculées dans la foulée. Le hash d'une couche dépend à la fois du contenu de l'instruction et de l'état de toutes les couches qui la précèdent.

C'est là que la plupart des Dockerfiles PHP échouent. Le pattern classique — copier l'intégralité du code source puis lancer composer install — place l'installation des dépendances après le COPY. Toute modification du code applicatif, même un simple changement de commentaire, invalide la couche de copie, et donc relance l'intégralité de composer install. Sur un projet avec un nombre important de paquets, c'est un temps significatif perdu à chaque build, pour un résultat strictement identique au précédent.

La bonne séquence exploite la granularité du cache de couches. Elle consiste à copier d'abord les seuls manifestes Composer, à exécuter l'installation des dépendances, puis à copier le reste du code source, et enfin à régénérer l'autoloader. Concrètement, on enchaîne COPY composer.json composer.lock./, puis RUN composer install --no-scripts --no-autoloader, puis COPY.., et enfin RUN composer dump-autoload --optimize.

Avec cette séquence, la couche d'installation des dépendances n'est invalidée que lorsque composer.json ou composer.lock change — c'est-à-dire lors d'un ajout, d'une suppression ou d'une mise à jour de paquet. Une modification du code applicatif, en revanche, traverse cette couche en quelques secondes au lieu de relancer toute la chaîne.

Une régénération d'autoloader prend typiquement une à deux secondes. Une installation complète des dépendances peut prendre entre trente secondes et plusieurs minutes selon le nombre de paquets. L'ordre des instructions dans le Dockerfile, c'est le choix entre ces deux durées.

Les flags --no-scripts et --no-autoloader méritent un mot d'explication. Le premier évite l'exécution des hooks post-install — les scripts de découverte de packages Laravel, par exemple — lors de cette étape intermédiaire, où certains fichiers source ne sont pas encore présents. Le second saute la génération de l'autoloader, puisqu'on la régénérera à l'étape suivante, après la copie complète du code. Combinés, ces deux flags accélèrent la couche de dépendances et préviennent des erreurs en cascade lors du build. À noter que --no-scripts est aussi un garde-fou en CI: un script post-install qui tenterait d'accéder à la base de données ou d'exécuter une commande artisan échouerait sans message clair pendant un build Docker, parce que l'application n'est pas encore complète.

Builds multi-étapes: isoler le build de la production

Les multi-stage builds, introduits dans Docker 17.05 (mai 2017), permettent de définir plusieurs images dans un seul Dockerfile. Seule la dernière est conservée comme image finale; les précédentes servent d'environnements de construction temporaires et sont écartées du livrable.

Pour un projet PHP, le principe se décline en deux étapes. Une étape dite « builder », basée sur une image complète incluant PHP, Composer et éventuellement Node.js pour la compilation du front, se charge d'installer l'ensemble des dépendances et de préparer l'autoloader. Une étape dite « production » utilise une image finale minimale — typiquement php:8.3-fpm-alpine — dans laquelle on ne copie que le code source et le dossier vendor/ issu du builder.

La séquence du builder, dans les grandes lignes: FROM composer:2.8 AS builder, puis positionnement du répertoire de travail, copie des manifestes, exécution de composer install --no-dev --no-scripts --no-autoloader, copie du code source complet, puis exécution de composer dump-autoload --optimize. L'étape de production se résume à un FROM php:8.3-fpm-alpine AS production suivi d'un COPY --from=builder /app /var/www/html.

Trois bénéfices directs en découlent. La taille de l'image finale est nettement réduite, puisqu'on ne conserve ni Composer ni les éventuels outils de build front-end, ce qui allège le registre et accélère les pulls. La surface d'attaque diminue d'autant: les binaires de développement absents de l'image de production ne peuvent pas être exploités par un attaquant qui parviendrait à exécuter du code dans le conteneur. Enfin, la reproductibilité est garantie, puisque le builder est éphémère et chaque build repart d'un état propre — sans dépendance cachée issue d'un cache local.

Le choix de l'image de base mérite une mention. Les variantes Alpine (php:8.3-fpm-alpine) produisent des images bien plus légères, mais leur libc musl occasionne parfois des incompatibilités avec certaines extensions PHP natives. Les variantes Debian (php:8.3-fpm, php:8.3-cli) restent la valeur sûre pour les projets qui manipulent des bibliothèques système; elles sont plus lourdes, mais leur compatibilité est totale. Pour un service qui n'héberge que du code applicatif PHP standard, Alpine reste le meilleur compromis. Pour un worker qui interagit avec du binaire compilé ou des extensions C exigeantes, Debian évite les nuits blanches. Dans les deux cas, il est crucial de figer la version exacte — y compris la version mineure — pour garantir des builds reproductibles au fil des mois.

BuildKit: persister le cache de téléchargement entre les builds

BuildKit a été introduit dans Docker 18.09 (novembre 2018) comme backend de build alternatif, activé via la variable d'environnement DOCKER_BUILDKIT=1. Il est progressivement devenu le builder par défaut au fil des versions suivantes. Son apport principal pour PHP tient aux montages de cache, accessibles via la directive --mount=type=cache.

Même avec un ordre de couches optimal, un changement dans composer.lock invalide la couche composer install. Tous les paquets qui figuraient dans le lockfile doivent alors être re-résolus, et une grande partie d'entre eux est retéléchargée depuis Packagist. BuildKit résout ce problème en séparant le cache de téléchargement du cache de couches.

Le mécanisme s'active ainsi, au niveau de l'instruction RUN: RUN --mount=type=cache,target=/root/.composer/cache composer install --no-dev --no-scripts --no-autoloader. Le dossier /root/.composer/cache est monté comme un volume persistant, géré par BuildKit en dehors du système de couches Docker. Lorsque composer.lock change, la couche RUN est bien invalidée et réexécutée — mais Composer retrouve dans son cache local les archives déjà téléchargées lors des builds précédents. Le volume de retéléchargements depuis Packagist s'en trouve réduit, parfois de manière très significative selon la nature du changement; reste que Composer peut avoir besoin de vérifier des métadonnées même pour des paquets dont la version n'a pas bougé, et qu'un changement global du lockfile invalide davantage de caches qu'un simple ajout isolé.

Le cache de couches élimine les réinstallations inutiles. Le cache BuildKit élimine les retéléchargements inutiles. Les deux mécanismes sont complémentaires et s'additionnent.

Quelques précisions utiles sur les paramètres du montage. L'option id permet de nommer explicitement le cache pour le retrouver entre plusieurs étapes du même build ou entre builds partageant le même contexte. L'option sharing=locked empêche les builds concurrents d'écrire simultanément dans le même volume — utile sur des runners CI mutualisés. L'option ro monte le cache en lecture seule, ce qui peut servir pour figer l'état lors d'une étape spécifique. En pratique, sur la plupart des projets PHP, le montage par défaut avec target=/root/.composer/cache suffit; les raffinements ci-dessus ne deviennent nécessaires qu'à l'échelle de pipelines à fort parallélisme.

Pour activer BuildKit, deux options coexistent. La première consiste à définir la variable d'environnement DOCKER_BUILDKIT=1 dans la configuration du runner CI ou au début du job. La seconde passe par docker buildx build, qui s'appuie sur BuildKit et constitue l'interface recommandée pour les builds complexes. Dans une pipeline GitLab CI, on déclare DOCKER_BUILDKIT: 1 au niveau des variables du job; dans GitHub Actions, on peut s'appuyer sur docker buildx build directement, ou configurer l'action Docker officielle pour activer BuildKit.

Le .dockerignore: le détail qui coûte plusieurs secondes à chaque build

Avant même que Docker ne commence à exécuter les instructions du Dockerfile, il transfère le contexte de build — l'ensemble des fichiers du répertoire courant — vers le démon Docker. Sans fichier .dockerignore, ce transfert inclut .git/, vendor/, node_modules/, les logs, les fichiers de cache IDE, les dumps de base de données locaux. Sur des projets avec un historique Git volumineux, ce transfert peut représenter plusieurs secondes — parfois plus d'une dizaine — intégralement perdues avant même le début du build proprement dit.

Les ordres de grandeur pour un projet PHP de taille moyenne parlent d'eux-mêmes: un .git/ de plusieurs centaines de mégaoctets, un dossier vendor/ du même ordre, des node_modules/ qui peuvent dépasser le gigaoctet. Un .dockerignore correctement rédigé ramène ce transfert à un délai négligeable, souvent inférieur à la seconde.

Le fichier .dockerignore minimal pour un projet PHP couvre au minimum les entrées suivantes, listées sans hiérarchie particulière: .git, vendor, node_modules, .env, .env.*, docker-compose*.yml, Makefile, *.md, tests, .phpunit.cache, .php-cs-fixer.cache, ainsi que les dossiers storage/logs, storage/framework/cache, storage/framework/sessions et storage/framework/views lorsqu'ils ne sont pas nécessaires à la construction de l'image. Les règles s'appliquent comme des motifs filepath.Match de Go, ce qui permet d'utiliser des jokers (*, ?, **) et des négations (!pattern) pour affiner; l'ordre de lecture est de haut en bas, et la dernière règle qui s'applique l'emporte. Pour les projets avec un historique de fixtures ou de dumps SQL importants, ajouter *.sql, *.dump et docker/db/ est souvent rentable: ces fichiers n'ont rien à faire dans une image applicative.

Attention au piège classique: si vendor/ n'est pas exclu du contexte de build, le COPY.. de l'étape finale écrase le dossier vendor/ installé par Composer dans le builder. Les dépendances locales — potentiellement obsolètes, partielles ou corrompues — remplacent celles correctement installées. Le bug est silencieux: le build réussit, l'image démarre, mais les versions des paquets ne correspondent plus au composer.lock du commit. Ce type d'anomalie est un cauchemar à diagnostiquer en production, parce qu'elle ne se manifeste que par des comportements applicatifs dégradés, sans message d'erreur explicite au démarrage du conteneur.

Composer 2.0: les hacks d'optimisation sont morts

Avant octobre 2020, accélérer Composer dans Docker impliquait quasi systématiquement d'installer le plugin hirak/prestissimo, qui ajoutait le téléchargement parallèle des paquets — une fonctionnalité que Composer 1.x ne proposait pas nativement. Ce plugin était ajouté dans les Dockerfiles de production comme une étape préliminaire, avec une ligne du type RUN composer global require hirak/prestissimo.

Composer 2.0, sorti en octobre 2020, a intégré nativement le téléchargement parallèle, une résolution de dépendances optimisée et un protocole de téléchargement plus efficace sur le réseau. Le plugin hirak/prestissimo est devenu non seulement inutile, mais également incompatible avec Composer 2 — son installation provoque une erreur explicite lors du composer install qui empêche le build d'aboutir.

D'autres hacks courants ont également perdu leur pertinence, et il est utile de savoir distinguer ce qui reste valide de ce qui est obsolète:

TechniqueStatutRaison
hirak/prestissimoObsolèteTéléchargement parallèle natif dans Composer 2.0
composer install --prefer-distInutile (par défaut)Composer 2 utilise --prefer-dist par défaut
composer install --no-dev en buildToujours valideRéduit la taille des dépendances en excluant PHPUnit, PHPStan, etc.
composer dump-autoload --optimizeToujours valideGénère un classmap au lieu d'un PSR-4, accélère l'autoloading en production
composer install --no-scriptsToujours valideÉvite les hooks post-install dans le contexte de build Docker

Un Dockerfile PHP d'aujourd'hui ne devrait contenir aucune référence à prestissimo. Si c'est encore le cas dans un projet, c'est du code mort à supprimer — et potentiellement la source d'erreurs silencieuses lors d'un upgrade de Composer. La migration vers Composer 2.x est par ailleurs une étape préalable recommandée avant de tirer pleinement parti des optimisations décrites dans cet article: le téléchargement parallèle natif, la résolution plus rapide et le protocole réseau optimisé de Composer 2 s'additionnent aux gains apportés par le cache BuildKit, qui persiste le dossier de cache de Composer entre les builds sans dépendre d'une API spécifique.

Verdict: un Dockerfile PHP optimisé en pratique

Le Dockerfile final, pour un projet PHP standard, tient en moins de quinze lignes actives. Il combine les trois leviers — ordre des couches, multi-stage et BuildKit — plus un .dockerignore complet. Pas de plugin exotique. Pas d'astuce fragile. Des mécanismes officiels de Docker et de Composer, utilisés correctement.

Les pipelines CI/CD qui implémentent ces techniques constatent une réduction significative du temps de build, variable selon la taille du projet et la fréquence des changements de dépendances. Sur des déploiements continus à haute fréquence, l'économie se mesure en dizaines d'heures machine par semaine — et en dizaines de minutes par jour et par développeur qui ne sont plus passées à attendre une barre de progression.

En production, c'est à utiliser. Sans réserve.

Questions fréquentes

Pourquoi mon Dockerfile relance-t-il l'installation de Composer à chaque modification de code ?
Cela arrive si vous copiez l'intégralité du code source avant de lancer composer install. Docker invalide alors la couche de dépendances dès qu'un fichier source est modifié.
Quels sont les avantages des builds multi-étapes pour PHP ?
Ils permettent d'utiliser des outils de build lourds dans une étape temporaire tout en produisant une image finale minimale, plus légère et plus sécurisée pour la production.
Comment activer le cache de téléchargement de Composer avec BuildKit ?
Il faut utiliser la directive --mount=type=cache,target=/root/.composer/cache dans l'instruction RUN de votre commande composer install.
Pourquoi est-il risqué d'oublier le dossier vendor dans le .dockerignore ?
Si le dossier vendor local n'est pas exclu, il sera copié dans l'image et écrasera les dépendances correctement installées par Composer, créant des incohérences silencieuses.
Faut-il encore utiliser le plugin hirak/prestissimo avec Composer 2 ?
Non, ce plugin est devenu inutile et même incompatible avec Composer 2.0, qui intègre nativement le téléchargement parallèle des paquets.