Me

Composer Vendors partagé : rester sous la limite d'inodes en hébergement mutualisé

:: Aucun commentaire

shared-composer-vendors est un unique fichier PHP qui remplace composer install dans un script de déploiement. Il garde une seule copie de chaque version de package dans un dossier partagé, à côté de vos projets, et transforme le vendor/ de chaque projet en quelques centaines de liens qui pointent vers ce dossier. Dix sites Laravel sur la même version du framework cessent d'en stocker dix copies, et le compte d'hébergement qu'ils partagent cesse de manquer de fichiers.

Cet article s'adresse aux développeurs PHP qui hébergent plusieurs projets Composer sur un même compte, en hébergement mutualisé surtout. Si chacun de vos sites tourne sur son propre serveur, vous n'en avez pas besoin.

L'histoire derrière l'outil

J'ai écrit la première version quand j'étais CTO d'une société de développement qui livre des projets clients à un rythme mensuel. Les projets étaient en Laravel, hébergés côte à côte sur un même compte. Ce qui a manqué en premier sur ce compte, ce n'était ni l'espace disque ni la mémoire. C'étaient les inodes.

Un inode, c'est l'entrée qu'un système de fichiers Linux tient pour chaque fichier et chaque dossier. Les offres d'hébergement mutualisé plafonnent le nombre qu'un compte peut en avoir, et ce plafond figure sur la page de l'offre, à côté de l'espace disque, là où presque personne ne le lit.

C'est Composer qui y mène un projet PHP. Le vendor/ d'un projet Laravel compte entre 13 000 et 20 000 fichiers et dossiers, pour environ 150 Mo, et c'est de loin la plus grosse partie du projet. Dix projets qui utilisent à peu près les mêmes packages consomment plus de 150 000 inodes et 1,5 Go en copies quasi identiques : le même laravel/framework, les mêmes composants Symfony, le même Carbon, une fois par site.

Atteindre le plafond ne ralentit rien. L'hébergeur refuse simplement les nouveaux fichiers : un envoi échoue, un cache ne peut plus être écrit, et le composer install suivant s'arrête en plein milieu. La solution habituelle, c'est l'offre supérieure, et avec un nouveau projet par mois, on pouvait presque prévoir la date à laquelle nous y passerions.

La première version de cet outil, un script bash doublé d'un équivalent PowerShell, a réduit de 40 % l'utilisation d'inodes du compte et repoussé ce changement d'offre de plusieurs années. La version que je publie est une réécriture en un seul fichier PHP, qui fonctionne de la même façon sous Linux et sous Windows. Avant de la publier, j'ai monté un projet Laravel dont le seul rôle est de la mettre en défaut : un package patché à l'installation, un plugin Composer qui modifie ses propres fichiers, un package installé depuis git, un autre depuis un dossier local, et mPDF qui écrit des fichiers temporaires dans son propre dossier.

Une copie par version, un lien par projet

L'idée tient en une image :

# Sans l'outil : chaque projet a sa propre copie
site-a/vendor/laravel/framework/     # 1 600 fichiers
site-b/vendor/laravel/framework/     # 1 600 fichiers

# Avec l'outil : chaque projet a un lien, et les fichiers n'existent qu'une fois
site-a/vendor/laravel/framework  ->  shared-vendors/vendors/laravel/framework_13.33.0/
site-b/vendor/laravel/framework  ->  shared-vendors/vendors/laravel/framework_13.33.0/
Arborescence

Un lien est une toute petite entrée qui pointe vers un dossier situé ailleurs, et PHP lit à travers lui comme si les fichiers étaient vraiment là. Sous Linux, c'est un lien symbolique. Sous Windows, c'est une jonction, qui ne demande aucun droit administrateur. Chaque copie partagée est rangée sous sa version : un projet resté sur une version plus ancienne pointe vers celle-là, et deux versions d'un même package cohabitent aussi longtemps qu'un projet utilise chacune d'elles.

Le vendor/ de chaque projet se réduit à quelques centaines de liens, et un nouveau site qui repose sur des packages déjà présents dans le dossier partagé ne coûte presque aucun inode.

Installer shared-composer-vendors

Il nécessite PHP 8.0 ou plus et Composer 2, avec le cache de téléchargement de Composer activé, ce qui est le réglage par défaut. Clonez-le dans le dossier au-dessus de vos projets : le dossier partagé y est créé au premier lancement.

cd ~
git clone https://github.com/mehdismekouar/shared-composer-vendors.git shared-vendors
Shell

Ensuite, dans le script de déploiement de chaque projet, remplacez composer install par un appel à setup.php :

cd ~/domains/site-a
git pull

# Avant : composer install --no-dev
php ~/shared-vendors/setup.php

php artisan migrate --force
Script de déploiement

Comme composer install --no-dev, il laisse de côté les packages de développement, sauf si vous passez --dev. Quelques options couvrent le reste :

  • --dry montre ce qui se passerait sans rien modifier, ni dans le projet ni dans le dossier partagé.

  • --wait SECONDES fixe combien de temps un déploiement attend son tour, puisqu'un seul projet se déploie à la fois. Par défaut, dix minutes.

  • --composer "php ~/composer.phar" indique comment lancer Composer sur un serveur où composer seul ne fonctionne pas.

  • Tout ce qui suit -- est transmis à composer install, par exemple -- --optimize-autoloader.

Il se termine avec le code 0 quand le site est déployé, et avec le code 1 quand le déploiement a échoué et que rien n'a été modifié ou que tout a été remis en place. La suite de votre script de déploiement peut le vérifier comme n'importe quelle autre commande.

Ce qui se passe pendant un déploiement

Un déploiement passe par cinq étapes, et le site reste en ligne pendant chacune d'elles.

Il prépare le nouveau vendor/ à côté de celui en service. composer install s'exécute dans un dossier appelé vendor.next, pendant que le site continue d'utiliser le vendor/ actuel.

Il échange les deux en les renommant. Le vendor/ en service devient vendor.old et vendor.next devient vendor/. Un renommage est instantané : aucune requête ne voit jamais un vendor/ à moitié installé, et si quelque chose a échoué avant ce point, rien n'a changé.

Il lance vos scripts Composer, dont le package:discover de Laravel, aux mêmes moments qu'une installation normale. Si l'un d'eux échoue, le vendor/ précédent est remis en place.

Il partage les packages, un par un. Chaque package est remplacé par son lien en deux renommages de plus, et ne manque donc jamais plus d'un instant.

Il fait le ménage. Le vendor/ précédent est supprimé et un résumé s'affiche.

Quels packages sont partagés, et comment il le sait

La règle est stricte, et c'est voulu : un package n'est partagé que s'il est exactement ce que Composer a téléchargé.

Composer garde dans son dossier de cache le zip de chaque package qu'il télécharge, et un zip stocke déjà une somme de contrôle pour chaque fichier qu'il contient, dans un index placé à la fin de l'archive. L'outil lit donc cet index, calcule la même somme de contrôle pour les fichiers installés et compare les deux listes, sans rien décompresser. Si elles correspondent, le package est lié à la copie partagée, ou devient la copie partagée si ce projet est le premier sur cette version.

Un package peut différer de son téléchargement pour de bonnes raisons : un patch appliqué par composer-patches, un plugin Composer qui écrit sa propre configuration, un de vos scripts qui modifie un fichier après l'installation. Ceux-là restent dans le projet, avec la raison affichée sur leur ligne, jusqu'à ce qu'un deuxième projet fasse exactement la même modification. La version modifiée est alors rangée sous un nom à elle, et les deux projets pointent vers elle.

Si l'outil attend ce deuxième projet, c'est voulu. Avec un seul projet, partager une copie modifiée ne fait rien gagner. Et certaines modifications changent à chaque déploiement, un fichier généré qui contient une date par exemple : les stocker une à une ajouterait une copie complète du package au dossier partagé à chaque déploiement.

Deux sortes de packages ne sont jamais partagées : ceux installés depuis un dépôt git ou un dossier local, dont les fichiers peuvent changer sans que leur version change, et ceux que vous listez vous-même dans composer.json. Les caractères génériques sont acceptés :

"extra": {
    "non-shared-vendors": ["some/package", "some-vendor/*"]
}
composer.json

Les packages qui n'aiment pas être un lien

C'est là qu'est passé l'essentiel du travail. Quand PHP calcule __DIR__ et __FILE__, il suit les liens : un fichier atteint à travers un lien connaît donc sa vraie adresse, dans le dossier partagé, et non son adresse dans votre projet. La plupart des packages ne s'en aperçoivent jamais. Deux sortes, si.

Les outils qui cherchent l'autoloader en remontant les dossiers. Les outils en ligne de commande de vendor/bin, php-parse ou var-dump-server par exemple, cherchent vendor/autoload.php un nombre fixe de dossiers au-dessus d'eux. Compté depuis le dossier partagé, trois dossiers plus haut n'est pas le bon endroit, et l'outil échoue.

Les packages qui écrivent dans leur propre dossier. mPDF écrit des fichiers temporaires dans son propre dossier tmp/ pendant que votre site tourne. Le dossier partagé est en lecture seule, donc ces écritures échoueraient, et s'il ne l'était pas, tous les projets écriraient dans le même dossier.

Pour ces packages, le projet reçoit un vrai dossier au lieu d'un simple lien. Les fichiers qui regardent autour d'eux sont placés à l'adresse du projet sous forme de liens physiques (hard links) : un deuxième nom pour le même fichier, qui ne coûte ni espace ni inode supplémentaire. Les dossiers dans lesquels le package écrit sont de vrais dossiers, accessibles en écriture et propres au projet. Tout le reste pointe toujours vers la copie partagée. Pour mPDF, cela donne ceci :

vendor/mpdf/mpdf/           # un vrai dossier
├── data/, ttfonts/         # liens vers la copie partagée : l'essentiel du package
├── src/Config/...          # vrais dossiers, fichiers en liens physiques : ils décident où est tmp/
└── tmp/                    # un vrai dossier, accessible en écriture, propre à ce projet
Arborescence

Le package fonctionne normalement, pour une poignée de fichiers en plus, en général entre 3 et 20. L'outil repère ce qui en a besoin en lisant une fois le code de chaque package, quand une version entre pour la première fois dans le dossier partagé : un chemin __DIR__ qui remonte au-dessus du package, un file_put_contents ou un fopen en écriture visant son propre dossier. Quand il trouve une écriture dont la cible n'est connue qu'à l'exécution, il ne peut pas la placer : il vous avertit, et la solution consiste soit à faire pointer le dossier de cache de ce package vers le storage/ de votre projet, soit à le lister dans non-shared-vendors. Dans mon projet de test, sur 90 packages, mPDF est le seul qui ait besoin d'un dossier accessible en écriture à lui.

Pourquoi le dossier partagé est en lecture seule

J'ai envisagé de le rendre accessible en écriture, ce qui aurait réglé sans effort le cas de mPDF et consorts. Ce qui a tranché, c'est qu'un dossier partagé accessible en écriture transforme chaque erreur en erreur sur tous les sites à la fois : un package supprimé à travers un lien par quelqu'un qui fait le ménage dans un projet, une modification rapide faite sur le serveur pour déboguer un site, un malware entré par l'un d'eux.

En lecture seule, un projet ne peut pas modifier ce qu'exécutent les autres, et les quelques packages qui ont besoin d'écrire reçoivent un dossier à eux. Une nouvelle version est aussi copiée sous un nom temporaire et renommée seulement une fois complète : un déploiement interrompu en cours de route ne laisse jamais derrière lui un package à moitié copié auquel le projet suivant se lierait.

Lire la sortie

L'outil affiche une ligne par package, en anglais, et cette ligne dit pourquoi quand un package n'est pas partagé :

[4/5] Linking packages to the shared folder
  [LINK]  laravel/framework v13.33.0
  [NEW]   filament/filament v3.3.43
  [LINK]  mpdf/mpdf v8.3.1 - has its own writable folder in this project: tmp
  [LOCAL] psr/log 3.0.2 - patched by composer-patches (changed: src/LoggerInterface.php); it will be shared as soon as another project makes the exact same change
  [LOCAL] ramsey/collection 2.1.1 - installed from a git repository, so its files can change without a new version
Sortie

[LINK] signifie que le package était déjà dans le dossier partagé, [NEW] que ce déploiement l'y a ajouté, et [LOCAL] que le projet garde sa propre copie. Un package qui n'a pas pu être lié s'affiche en [FAIL], en rouge, et garde lui aussi sa propre copie, mais le déploiement compte quand même comme réussi : le site fonctionne, et les migrations qui suivent dans votre script de déploiement doivent encore s'exécuter.

Un résumé clôt l'exécution. Sur mon projet de test, une application Laravel 13 avec 90 packages de production, un redéploiement en lie 85 et partage 7 247 fichiers au lieu de les copier. Les cinq autres gardent leur propre copie, chacun pour la raison affichée sur sa ligne.

Les limites à connaître

  • Pendant environ deux minutes après un déploiement qui change la version d'un package, certaines requêtes peuvent encore lire les fichiers de l'ancienne version. PHP retient pendant 120 secondes où mène chaque chemin, c'est son cache realpath, et l'ancienne version est toujours dans le dossier partagé. Si votre hébergeur vous permet de redémarrer PHP, faites-le après le déploiement.

  • Les anciennes versions ne sont jamais supprimées. Quand plus aucun projet n'utilise une version, elle reste dans le dossier partagé jusqu'à ce que vous la supprimiez à la main, et le README montre comment vérifier d'abord que plus rien ne pointe vers elle.

  • Le cache de téléchargement de Composer doit rester sur le disque d'un déploiement à l'autre, puisque c'est à lui que les packages sont comparés. Un package dont le zip a disparu du cache n'est partagé que s'il correspond à la copie déjà présente dans le dossier partagé.

  • Chaque déploiement installe dans un dossier neuf : Composer ne voit donc que des installations, et les scripts liés aux événements de mise à jour ou de désinstallation d'un package ne s'exécutent jamais. Placez ce travail dans post-package-install.

  • Windows fonctionne, avec des jonctions au lieu des liens symboliques et des copies au lieu des liens physiques, mais il n'a été testé que sur une machine de développement, pas encore sur un serveur Windows en production. Un piège au passage : dans Git Bash, un rm -rf sur un package lié avec une barre oblique finale, celle qu'ajoute la complétion par Tab, supprime la copie partagée pour tous les projets.

Où le trouver

L'outil est sur GitHub, sous licence MIT.

Si vous faites tourner plusieurs sites Laravel sur un même compte d'hébergement et voulez que leurs déploiements soient mis en place pour durer, c'est exactement ce que je propose.

Qu’en pensez-vous ?
Aucun fichier choisi