jobsphp

Pipeline CI/CD PHP : le secret de Thomas pour diviser le temps de build

Ingénierie Web. Pipeline CI/CD PHP : le secret de Thomas pour diviser le temps de build

Le « secret de Thomas » circule dans les équipes PHP avec la persistance d’un vendor/ commité en 2014: quelqu’un aurait trouvé le réglage magique pour diviser le temps de build Docker.

Pipeline CI/CD PHP: le secret de Thomas pour diviser le temps de build

Mauvaise nouvelle pour les amateurs de formules miracles: aucun benchmark sérieux ne permet d’attribuer cet exploit à un Thomas identifiable. Bonne nouvelle, beaucoup plus utile: on sait précisément où nos pipelines perdent leur temps — et comment arrêter de le gaspiller.

Dans une CI PHP conteneurisée, le build lent n’est presque jamais un problème de CPU pur. C’est un problème de répétition. On retélécharge les mêmes paquets Composer. On invalide des couches Docker stables en copiant le projet entier trop tôt. On envoie node_modules, var/cache, .git et parfois un sympathique dump SQL de 800 Mo au builder distant. Puis on s’étonne que le déploiement traîne entre deux corrections de typo. Classique. Très legacy dans l’âme.

L’optimisation d’un pipeline CI/CD PHP avec Docker pour réduire le temps de build repose donc sur une idée simple, mais exigeante sous le capot: séparer ce qui change souvent de ce qui change rarement, puis permettre à chaque niveau de cache de faire son travail. Docker, Composer, GitLab CI/CD: aucun n’est magique isolément. Ensemble, en revanche, ils deviennent franchement nerveux.

Pourquoi Docker reconstruit-il tout dès qu’on touche à presque rien?

Docker construit une image par couches. Chaque instruction du Dockerfile produit une couche réutilisable à condition que l’instruction et les fichiers dont elle dépendent n’aient pas changé. Dès qu’une couche est invalidée, Docker doit reconstruire les instructions suivantes. C’est cette mécanique qui rend un COPY.. placé trop tôt si coûteux.

Prenons un Dockerfile PHP très courant:

FROM php:8.3-fpm
WORKDIR /app
COPY..
RUN composer install --no-dev --prefer-dist --no-interaction
CMD ["php-fpm"]

À chaque modification dans src/, templates/, un fichier de test ou une config locale oubliée dans le contexte, la commande COPY.. change. La couche suivante aussi. Résultat: Composer repart télécharger et installer les dépendances, alors que composer.lock n’a pas bougé d’un octet. On vient de transformer une petite modification métier en chantier de déménagement.

Le découpage utile consiste à copier d’abord les manifestes de dépendances, installer les dépendances, puis copier le code applicatif.

FROM php:8.3-fpm
WORKDIR /app
COPY composer.json composer.lock./
RUN composer install --no-dev --prefer-dist --no-interaction
COPY..
CMD ["php-fpm"]

Cette version ne résout pas tout, mais elle rétablit l’ordre logique des choses. Le code métier peut évoluer vingt fois dans la journée sans forcer Composer à repartir de zéro, tant que composer.json et composer.lock restent identiques.

Élément modifiéDockerfile mal ordonnéDockerfile découpé proprement
Un contrôleur PHP dans src/Réinstallation des dépendancesRéutilisation de la couche Composer
Un fichier TwigRéinstallation des dépendancesRéutilisation de la couche Composer
composer.lockRéinstallation nécessaireRéinstallation nécessaire, et c’est normal
Une règle dans .dockerignorePeut invalider le contextePeut invalider le contexte, à surveiller
Une extension PHP systèmeReconstruction de l’image concernéeReconstruction de l’image concernée

Le point qui pique un peu: la plupart des équipes ont déjà entendu cette règle. Mais dans les vrais dépôts, le Dockerfile a souvent accumulé les compromis. Un ajout de script, une copie nécessaire pour un build front, un vieux COPY.. gardé « parce que ça marche », et voilà la hiérarchie des couches cassée.

Un cache utile n’est pas celui qu’on active dans un fichier YAML: c’est celui qu’on laisse vivant en évitant de l’invalider pour une virgule dans un contrôleur.

Comment BuildKit change-t-il la donne pour Composer?

Même avec un Dockerfile bien rangé, il reste un cas irritant: composer.lock change. Et il doit changer, évidemment. Mise à jour de Symfony, correctif de sécurité, ajout d’un client HTTP, refacto vers une lib interne… Lorsque la couche Composer est reconstruite, on ne veut pas forcément re-télécharger l’intégralité des archives depuis le réseau.

C’est là que Docker BuildKit apporte un levier très propre: le montage de cache pendant l’exécution de Composer.

RUN --mount=type=cache,target=/tmp/cache \
composer install --no-dev --prefer-dist --no-interaction

L’idée est très différente du cache de couche Docker. La couche peut être invalidée parce que le lockfile a changé; le cache BuildKit, lui, garde les artefacts téléchargés par Composer. Composer ne récupère alors que les paquets nouveaux ou modifiés. C’est plus fin, plus réaliste, et bien moins fragile qu’un grand vendor/ récupéré à l’aveugle.

Il faut bien distinguer les trois niveaux qui se mélangent souvent dans les discussions — surtout quand quelqu’un prononce « cache » avec l’assurance d’avoir résolu l’univers:

  • Le cache de couches Docker réutilise une instruction déjà exécutée si ses entrées sont intactes. C’est très rapide, mais sensible à l’ordre du Dockerfile.
  • Le cache de téléchargement Composer via BuildKit évite de récupérer à nouveau les archives déjà connues lorsque composer install doit réellement tourner.
  • Le cache de CI persiste ou restaure des données entre jobs et entre pipelines, selon la plateforme et la configuration du runner.

Les trois peuvent cohabiter. Les confondre, c’est produire des pipelines qui semblent rapides le mardi, puis redeviennent des escargots sous caféine le mercredi parce qu’un runner différent a pris le job.

Un détail à ne pas vendre comme un game changer du build: --optimize-autoloader. Cette option génère notamment une classmap pour les classes connues et vise d’abord les performances d’exécution en production. Elle a toute sa place dans une image de production, oui. Mais ce n’est pas une baguette magique pour réduire le temps de construction de l’image. On ne lui demandera pas de réparer une stratégie de cache bancale.

Pourquoi composer.lock est-il le vrai contrat du pipeline?

Un pipeline CI/CD PHP rapide mais incapable de reproduire le même environnement est un pipeline rapide vers des ennuis. Le duo composer.json + composer.lock doit être traité comme une frontière nette: le premier exprime les contraintes; le second verrouille les versions réellement résolues.

Avec un composer.lock présent, composer install s’appuie sur les versions exactes qu’il contient. Deux exécutions de la même commande produisent donc, en dehors notamment des horodatages, un répertoire vendor identique. Cette stabilité est fondamentale pour les tests PHPunit, l’analyse statique, les images de production et les débogages qui arrivent toujours au pire moment.

La tentation de lancer composer update dans la CI mérite le musée des mauvaises idées. update résout de nouvelles versions selon les contraintes déclarées; il peut modifier le lockfile et introduire un comportement différent de celui validé localement. Dans un job de livraison, on veut installer ce qui a été validé, pas improviser une mise à jour de dépendances entre deux étapes de déploiement.

Le bon partage des rôles ressemble à ceci:

1. Un développeur met à jour une dépendance de façon volontaire, localement ou dans une branche dédiée.

2. Il committe le nouveau composer.lock avec le changement applicatif associé.

3. La CI exécute composer install de manière non interactive et déterministe.

4. Les tests, l’analyse statique et les contrôles de sécurité s’exécutent sur ce graphe précis de dépendances.

5. L’image livrée repose sur le même verrouillage que celui qui a été testé.

Cette discipline a un autre effet très concret sur l’optimisation d’image Docker PHP: le lockfile devient la clé naturelle de l’invalidation. Il change? Composer doit travailler. Il ne change pas? Notre pipeline n’a aucune bonne raison de rejouer cette étape coûteuse.

Et GitLab CI/CD: cache ou artefact, on choisit quoi?

Dans GitLab CI/CD, la confusion entre cache et artifacts est une source de lenteur presque aussi fiable qu’un test end-to-end sans isolation. Pourtant, les deux objets n’ont pas le même métier.

Le cache sert à réutiliser des dépendances téléchargées: par exemple le répertoire de cache Composer configuré dans le projet. Il est opportuniste. GitLab le dit sans détour: sa disponibilité n’est pas garantie. Elle dépend du type d’exécuteur, de la persistance du runner, de la distribution des jobs entre plusieurs runners et de l’infrastructure de stockage.

Les artifacts, eux, transmettent des résultats intermédiaires d’un job à un autre: rapport de couverture, fichier de build, archive frontend, résultat généré par une phase donnée. Ils sont un contrat entre étapes du pipeline. Par défaut, GitLab les fait expirer après 30 jours, sauf configuration différente. On ne les utilise pas comme un fourre-tout pour transporter un vendor/ entre tous les jobs parce que « ça évite de réinstaller ». Ça fonctionne parfois. Puis ça gonfle les transferts, brouille les dépendances et finit par coûter plus que prévu.

Pour Composer, une configuration de cache peut s’appuyer sur les fichiers qui déterminent réellement les dépendances, notamment composer.lock. GitLab permet de dériver une clé à partir du contenu de fichiers: c’est exactement le bon niveau de granularité.

cache:
key:
files:
- composer.lock
paths:
-.composer-cache/

Le chemin réel dépend de la manière dont Composer est configuré dans l’image ou le job. L’essentiel est de ramener son cache dans le répertoire du projet, puisque les chemins de cache et d’artifacts GitLab sont relatifs à ce répertoire. Pointer vers un emplacement externe ne donnera pas le résultat attendu.

GitLab autorise jusqu’à quatre caches par job et jusqu’à cinq clés de repli par entrée. C’est largement assez pour un pipeline PHP sérieux: un cache Composer, éventuellement un cache npm ou pnpm si une chaîne front existe, et des caches ciblés pour des outils comme PHPStan ou Rector quand leur configuration le justifie. Pas besoin de mettre toute la maison dans le congélateur.

Le cache partagé est-il toujours une bonne idée?

Non. Et c’est ici que la vitesse rejoint la sécurité — sujet moins hype, mais nettement plus important le jour où ça brûle.

GitLab sépare par défaut les caches des branches protégées et non protégées. Cette séparation évite qu’une branche moins contrôlée alimente un cache ensuite consommé par un pipeline de livraison sensible. La désactiver pour gagner quelques secondes est une économie du genre « on enlève les freins, la voiture sera plus légère ».

Dans une organisation avec plusieurs runners, il faut aussi accepter une vérité peu glamour: un cache local sur un runner n’aidera pas un job exécuté ailleurs. Un cache distribué ou un stockage partagé adapté devient alors nécessaire pour obtenir des résultats cohérents. Sinon, on mesure surtout la chance de tomber sur la bonne machine.

Le cache accélère un pipeline; il ne doit jamais devenir une source de confiance implicite entre une branche ouverte et une branche de production.

Que faut-il sortir du contexte de build?

Le contexte Docker, c’est l’ensemble des fichiers envoyés au builder. Pour un contexte issu du système de fichiers, les sous-répertoires sont inclus récursivement. Autrement dit: si notre dépôt contient des fichiers inutiles au build, Docker peut les embarquer dans le voyage avant même d’exécuter la première instruction.

Sur une machine locale, ce gaspillage peut passer inaperçu. Avec un builder distant ou une CI chargée, chaque mégaoctet inutile devient une petite facture de latence. Et parfois une grosse facture, parce que le dossier oublié est node_modules/, vendor/, var/log/ ou une archive de sauvegarde sortie de nulle part. Oui, on l’a tous vu.

Un .dockerignore sobre fait souvent plus pour la réduction du temps de déploiement Docker qu’une micro-optimisation ésotérique du Dockerfile.

.git
.gitignore
.env
.env.local
vendor/
node_modules/
var/cache/
var/log/
tests/
phpunit.xml
README.md
docker-compose.yml

Cette liste n’est pas à copier religieusement: elle dépend de l’image construite. Si l’image exécute les tests, exclure tests/ serait absurde. Si le build produit des assets front, exclure les sources nécessaires l’est tout autant. Mais la question à poser est brutale et saine: « Ce fichier doit-il vraiment entrer dans l’image ou seulement dans le dépôt? »

Il faut également surveiller un piège plus discret: des fichiers qui changent fréquemment et se retrouvent dans un COPY précédent l’installation Composer. Un rapport, un fichier de version généré par le pipeline, une config de développement, et la mécanique de cache saute sans bruit. Le Dockerfile n’est pas seulement une recette d’image; c’est une déclaration de dépendances entre fichiers et étapes.

Comment mesurer sans inventer un “x2” de conférence?

Dire qu’un cache « divise par deux » un build sans mesure avant/après, c’est exactement le type de promesse qui fait bien dans un post LinkedIn et mal dans un incident post-déploiement. Le gain dépend de la taille du contexte, du nombre de dépendances, de la fréquence des changements de lockfile, du réseau, de la persistance des runners, du taux réel de réutilisation et du poids des extensions PHP compilées.

On mesure donc avant de célébrer. Pas avec une cathédrale d’observabilité pour un projet de trois routes, mais avec quelques repères propres:

  • le temps d’envoi du contexte Docker;
  • le temps de résolution et d’installation Composer;
  • le nombre de couches récupérées depuis le cache;
  • la durée des jobs de tests, notamment si l’automatisation des tests PHPunit dépend d’une image fraîchement construite;
  • la fréquence des caches manqués selon les branches et les runners;
  • le poids final de l’image, qui ne dit pas tout sur la durée du build mais révèle souvent les débris embarqués.

Le plus intéressant est de comparer plusieurs commits ordinaires: une modification dans src/, une modification de composer.lock, une modification de Dockerfile, un pipeline exécuté sur un runner différent. C’est là qu’on voit si le système est robuste ou juste chanceux.

Un pipeline bien optimisé n’est pas celui qui affiche un temps spectaculaire une fois. C’est celui dont le comportement reste prévisible quand l’équipe travaille vraiment: plusieurs branches, des dépendances mises à jour, des correctifs urgents, des runners qui tournent, et cette refacto qu’on avait juré de faire « dès qu’on aura le temps ».

Le secret n’était pas Thomas, c’était l’ordre

La recette n’a rien de mystique: un contexte Docker maigre, un Dockerfile qui copie les manifestes avant le code, un cache BuildKit pour les téléchargements Composer, un composer.lock considéré comme un contrat, et une séparation nette entre cache de dépendances et artifacts de pipeline.

On peut gagner beaucoup de temps ainsi. Combien exactement? Cela appartient à vos mesures, pas à une légende de couloir. Mais on récupère surtout autre chose: des builds plus déterministes, des déploiements moins nerveux et une CI qui accompagne le rythme de l’équipe au lieu de le freiner.

Et si votre pipeline reconstruit encore Composer après chaque changement dans un contrôleur, pas besoin de chercher Thomas. Ouvrez le Dockerfile. Le coupable est probablement déjà là, tranquillement installé entre deux COPY.

Questions fréquentes

Pourquoi mon build Docker reconstruit-il toutes les dépendances à chaque modification ?
C'est généralement dû à une instruction COPY placée trop tôt dans le Dockerfile, qui invalide la couche de cache dès qu'un fichier du projet est modifié.
Quelle est la différence entre le cache de couches Docker et le cache BuildKit ?
Le cache de couches réutilise une instruction entière si ses entrées n'ont pas changé, tandis que le cache BuildKit permet de conserver les archives téléchargées par Composer même si le fichier composer.lock a été modifié.
Dois-je utiliser composer update dans mon pipeline CI/CD ?
Non, il faut utiliser composer install pour garantir une installation déterministe basée sur le fichier composer.lock déjà validé.
Quelle est la différence entre cache et artifacts dans GitLab CI/CD ?
Le cache sert à accélérer les jobs en stockant des dépendances opportunistes, tandis que les artifacts servent à transmettre des résultats de build entre les différentes étapes du pipeline.
L'option --optimize-autoloader réduit-elle le temps de construction de l'image ?
Non, cette option améliore les performances d'exécution en production, mais elle n'est pas conçue pour réduire le temps de construction de l'image Docker.