Un git pull banal, une correction qui aggrave tout, et une admin qui ne répond plus
Also available in English.
Un git pull sur le serveur, quelques routes ajoutées à un module PrestaShop. Le genre de déploiement qui ne devrait pas laisser de trace.
Lâadmin sâest pourtant mis Ă planter. Deux erreurs et deux corrections plus tard, câĂ©tait tout le back-office qui ne rĂ©pondait plus.
Je raconte lâincident dans lâordre oĂč je lâai vĂ©cu, fausses pistes comprises.
Un peu de contexte. PrestaShop 8 est hybride, mais pas symĂ©triquement : seul le back-office dĂ©marre un noyau Symfony complet Ă chaque requĂȘte.
Son point dâentrĂ©e, admin-dev/index.php, instancie AppKernel et appelle $kernel->handle($request), le cycle HTTP complet de Symfony, routing et conteneur de services compris, avec un repli vers le dispatcher legacy uniquement si aucune route ne matche (NotFoundHttpException).
Le front, lui, reste entiĂšrement sur lâaiguillage hĂ©ritĂ© de PrestaShop 1.6 : son index.php se limite Ă un Dispatcher::getInstance()->dispatch(), sans jamais invoquer le noyau Symfony ni son routeur compilĂ©.
Sur lâinstallation concernĂ©e, le dĂ©ploiement se fait par un simple git pull cĂŽtĂ© serveur : pas de pipeline CI qui reconstruit les assets ou les caches. Et var/cache/ est montĂ© sur un volume partagĂ©, accessible depuis les instances applicatives et depuis le bastion SSH.
Routes introuvables aprÚs déploiement
AprĂšs avoir ajoutĂ© de nouvelles routes admin (config/routes.yml) Ă un module et dĂ©ployĂ© par git pull, lâadmin sâest mis Ă planter sur :
Unable to generate a URL for the named route "app_my_new_route" as such route does not exist.
Symfony compile les routes en deux fichiers au nom fixe, indĂ©pendant de lâenvironnement applicatif : UrlGenerator.php et UrlMatcher.php (accompagnĂ©s de leurs .meta), stockĂ©s dans var/cache/{env}/.
Ils sont gĂ©nĂ©rĂ©s une fois, puis jamais revĂ©rifiĂ©s. En mode prod (debug=false), ConfigCache considĂšre le cache comme valide dĂšs lors que le fichier existe. Il ne vĂ©rifie plus sa fraĂźcheur par rapport aux ressources qui lâont produit.
Un git pull qui ajoute des routes ne les invalide donc jamais tout seul.
Pourquoi
debug:routerne l'aurait pas vu
bin/console debug:routerreconstruit sa liste en relisant les fichiers de routes :RouterDebugCommandappellegetRouteCollection(), qui charge les ressources viarouting.loader(Symfony 4.4, la version embarquĂ©e par PrestaShop 8.2). La commande ne passe jamais parUrlGenerator.php/UrlMatcher.php, les fichiers que le routing HTTP utilise rĂ©ellement.Elle aurait donc affichĂ© la nouvelle route pendant que le site plantait. Seule une vraie requĂȘte HTTP (ou un appel explicite Ă
$router->generate(), ce que fait le rendu dâun menu ou dâun lien Twig) exerce ce cache.
Solution ciblĂ©e : supprimer uniquement ces quatre fichiers force Symfony Ă les rĂ©gĂ©nĂ©rer proprement Ă la prochaine requĂȘte, sans toucher au reste du cache (Smarty, Doctrine, Twig, conteneur DI) :
rm -f var/cache/prod/UrlGenerator.php var/cache/prod/UrlGenerator.php.meta \
var/cache/prod/UrlMatcher.php var/cache/prod/UrlMatcher.php.metaAvant de lâappliquer en production, jâai validĂ© la mĂ©thode : reproduire la suppression dans un environnement de dev/staging, confirmer quâune commande console ne rĂ©gĂ©nĂšre pas ces fichiers, puis vĂ©rifier quâune vraie requĂȘte HTTP (la page de login admin, par exemple) les rĂ©gĂ©nĂšre automatiquement.
Jâai comparĂ© les timestamps des fichiers avant et aprĂšs, et vĂ©rifiĂ© par un grep sur le contenu rĂ©gĂ©nĂ©rĂ© que la nouvelle route y figurait bien.
Le contrĂŽleur nâest plus appelable
Une fois les routes corrigĂ©es, nouvelle erreur sur les mĂȘmes pages :
The controller for URI "/admin/my-new-page" is not callable: Controller "App\Controller\MyController"
has required constructor arguments and does not exist in the container. Did you forget to define
the controller as a service?
Le routing et le conteneur de services (DI) sont mis en cache séparément.
Le contrĂŽleur Ă©tait bien dĂ©clarĂ© dans config/services.yml, mais le conteneur compilĂ© existant datait dâavant lâajout de ce service. Corriger le cache de routing ne suffisait pas.
Info
La structure rĂ©elle du cache de conteneur Symfony, utile Ă connaĂźtre si on ne lâa jamais inspectĂ©e directement :
var/cache/prod/ âââ appAppKernelProdContainer.php # ~750 octets : un simple stub qui pointe vers... âââ appAppKernelProdContainer.php.lock âââ appAppKernelProdContainer.php.meta âââ appAppKernelProdContainer.preload.php âââ ContainerA1b2C3d/ # ... ce dossier : le vrai code compilĂ©, un fichier âââ ... # PHP par service (chargement paresseux) âââ getMyControllerService.phpLe nom du dossier
Container<hash>change à chaque recompilation, un signal utile pour savoir, en observant simplement le filesystem, si un rebuild a eu lieu récemment.
Ă ce niveau, jâaurais pu utiliser le bouton « Vider le cache » du back-office, mais il nettoie tout : Symfony, Smarty, XML, mĂ©dias, index de classes, Doctrine. Smarty et le cache mĂ©dias sont nĂ©cessaires au front et je ne voulais pas y toucher.
Le problĂšme diagnostiquĂ© concerne le routing et le conteneur de services Symfony, deux caches uniquement consommĂ©s par le back-office. Cibler Ă la main les seuls fichiers en cause, plutĂŽt que dĂ©clencher un vidage global, Ă©tait ma façon de limiter le rayon dâimpact : ne pas casser cĂŽtĂ© front ce qui fonctionnait trĂšs bien.
Jâai attendu que lâĂ©quipe soit rĂ©duite (pause repas) pour ne pas les impacter et jâai supprimĂ© le stub, ses fichiers compagnons, et le dossier, pour forcer une recompilation complĂšte du conteneur Ă la requĂȘte suivante. Ăa a semblĂ© ĂȘtre la suite logique de lâĂ©tape prĂ©cĂ©dente, mĂȘme logique, cache diffĂ©rent.
Et lĂ , câest le drameâŠ
AprĂšs la suppression du cache de conteneur, le back-office est parti en 504 gĂ©nĂ©ralisĂ©. Pas seulement la page concernĂ©e par le nouveau module : tout lâadmin, pour tous les utilisateurs.
Le front, lui, rĂ©pondait normalement. Comme on lâa vu, il ne passe jamais par le noyau Symfony, donc jamais par les caches en cause.
Au dĂ©but, sans accĂšs aux logs applicatifs ni au process de lâhĂŽte, jâai lu du code. Deux hypothĂšses en sont sorties.
HypothĂšse 1, dans le noyau Symfony. Le noyau a un garde-fou contre les compilations en double : Kernel::initializeContainer() tente un verrou exclusif non bloquant sur <container>.lock, puis, sâil Ă©choue, repasse en mode bloquant et revĂ©rifie entre-temps si le conteneur a Ă©tĂ© reconstruit (source).
Pour cela il faut que flock() garantisse rĂ©ellement lâexclusion mutuelle. Câest vrai en local, mais sur NFS, la garantie dĂ©pend du protocole et du montage (man flock).
Lâinfra ne tourne quâĂ une instance, mais un redĂ©ploiement en fait briĂšvement coexister deux, le temps du drainage. Si flock() accordait un succĂšs aux deux sans quâelles se voient, chacune compilerait de son cĂŽtĂ© sur le mĂȘme dossier. CâĂ©tait mon hypothĂšse de dĂ©part.
HypothĂšse 2, dans le code de PrestaShop. Le bouton « Vider le cache » du back-office (dĂ©tail du code plus bas) lance sa reconstruction dans un register_shutdown_function, exĂ©cutĂ© Ă lâintĂ©rieur du worker HTTP qui a servi le clic.
Si ce worker est tuĂ© par un timeout en plein cache:warmup, lâOS libĂšre le flock() quâil dĂ©tenait sans jamais passer par le bloc finally censĂ© le faire proprement. Dâautres requĂȘtes en attente pourraient alors reprendre en croyant le conteneur prĂȘt.
Les deux hypothĂšses sont cohĂ©rentes avec ce que dit le code. Reste Ă savoir si la production est dâaccord.
Ce que lâaccĂšs Ă lâinstance a montrĂ©
Une fois lâaccĂšs obtenu, ça a Ă©tĂ© plus simple. Aucun php-fpm dans le ps, seulement des apache2 -D FOREGROUND :
$ ps -eo pid,ppid,pmem,pcpu,etime,cmd --sort=-pmem | head -5
PID PPID %MEM %CPU ELAPSED CMD
1307323 1 3.4 1.3 31:23 apache2 -D FOREGROUND
1303492 1 2.9 1.5 01:45:00 apache2 -D FOREGROUND
Le runtime réel est Apache + mod_php, donc le MPM prefork : un process OS entier par connexion, jamais un pool de workers légers.
Ăa invalide lâhypothĂšse 2 : sans PHP-FPM, pas de request_terminate_timeout PHP-FPM Ă dĂ©passer.
Pas de timeout Apache non plus : apache2.conf fixe Timeout Ă 7200 secondes, et le php.ini de mod_php aligne max_execution_time sur la mĂȘme valeur. Deux heures de budget, pas trente secondes. Aucune de ces limites nâexplique quâun worker ait Ă©tĂ© tuĂ© en quelques minutes.
Les timestamps du cache de conteneur sont plus parlants :
$ ls -la var/cache/prod/
-rw-r--r--. 1 www-data www-data 261168 11:25 UrlGenerator.php
-rw-r--r--. 1 www-data www-data 280129 11:25 UrlMatcher.php
-rw-r--r--. 1 www-data www-data 19898 11:46 annotations.map
-rw-r--r--. 1 www-data www-data 755 11:50 appAppKernelProdContainer.php
-rw-r--r--. 1 www-data www-data 0 11:36 appAppKernelProdContainer.php.lock
-rw-r--r--. 1 www-data www-data 537095 11:50 appAppKernelProdContainer.php.meta
-rw-r--r--. 1 www-data www-data 219847 11:50 appAppKernelProdContainer.preload.php
Heures de la console, en GMT. UrlGenerator.php et UrlMatcher.php datent de 11:25, ma premiĂšre correction. Jâai ensuite laissĂ© passer une dizaine de minutes, le temps que lâĂ©quipe prenne sa pause, avant de supprimer le conteneur. Le .lock apparaĂźt Ă 11:36 et le conteneur est réécrit Ă 11:50 : la reconstruction a durĂ© 14 minutes.
Le fichier .lock actif pendant cette fenĂȘtre est le lock natif du noyau Symfony, celui de Kernel::initializeContainer() (appAppKernelProdContainer.php.lock, posĂ© Ă 11:36, au dĂ©but de la fenĂȘtre).
Pourquoi 14 minutes pour une opération censée durer quelques secondes ?
var/cache est monté en NFSv4.1 via un proxy local sur AWS EFS. Sur EFS, chaque opération de métadonnées (créer un fichier, vérifier une existence, écrire un répertoire) coûte plusieurs millisecondes.
Le cache:warmup gĂ©nĂšre et Ă©crit une grande quantitĂ© de petits fichiers : classes de proxy Doctrine, routing, annotations, classes de services compilĂ©es, etc. Sur un filesystem local, lâopĂ©ration prend quelques secondes. Sur EFS, elle peut prendre un quart dâheure.
Le mécanisme réel
Pendant ces 14 minutes, une seule chose se passe : un process compile correctement mais aussi lentement que son stockage le lui impose. Sous un verrou qui tient bon du début à la fin.
Pendant ce temps, toute autre requĂȘte qui trouve le conteneur manquant tente dâacquĂ©rir ce verrou et bloque.
Câest voulu : ça Ă©vite que dix requĂȘtes compilent le mĂȘme conteneur en parallĂšle. Mais pendant toute lâattente, la requĂȘte ne fait rien dâautre que dormir en occupant son process.
En MPM prefork, chaque requĂȘte bloquĂ©e immobilise un process Apache entier, pas un thread lĂ©ger. Sâil y a assez de trafic concurrent pendant la fenĂȘtre, le pool de workers sâĂ©puise. Plus aucun process disponible. Le site devient injoignable pour tout le monde.
Note
Comme prĂ©vu, il y avait peu de trafic Ă ce moment-lĂ , ça nâa pas atteint ce stade et seule lâadmin a Ă©tĂ© impactĂ©, les 504 venant trĂšs probablement du load balancer AWS, qui coupe la connexion quand le serveur ne rĂ©pond pas dans son dĂ©lai dâinactivitĂ©.
Donc pas de verrou mal libĂ©rĂ©, ni de compilations qui se marchent dessus. Le .lock posĂ© Ă 11:36 nâa Ă©tĂ© pris que par un seul compilateur : il nây a eu quâune seule compilation mais elle a durĂ© 14 minutes. Deux reconstructions concurrentes auraient laissĂ© deux traces, ou un autre profil de verrou.
Une rĂ©serve, tout de mĂȘme : les logs applicatifs (CloudWatch) nâĂ©taient pas accessibles avec les identifiants disponibles, et le fichier de log applicatif local Ă©tait vide.
Je nâai donc pas de preuve directe reliant cette fenĂȘtre de reconstruction prĂ©cise Ă lâincident racontĂ© plus haut. Seulement une explication cohĂ©rente avec tous les indices filesystem disponibles, obtenue en creusant lâinfrastructure rĂ©elle plutĂŽt quâen restant sur des hypothĂšses lues dans le code.
flock() a bien travaillĂ© mais câest la durĂ©e de ce quâil protĂ©geait qui a mis lâadmin Ă genoux. Le pool prefork nâa pas saturĂ© mais son modĂšle, un process OS entier par requĂȘte bloquĂ©e, aurait transformĂ© cette lenteur en panne gĂ©nĂ©rale sous trafic normal.
Ăa Ă©limine lâhypothĂšse 1, au moins au regard des indices disponibles. Pas besoin de faire appel Ă une dĂ©faillance du verrou. Lâexplication la plus simple suffisait.
Conclusion
Un dĂ©ploiement par git pull sans Ă©tape de cache-warming explicite reproduira ce genre dâerreur au prochain dĂ©ploiement qui touche le routing ou le conteneur.
Le correctif tient en une ligne, exĂ©cutĂ©e en CLI aprĂšs le git pull, pendant que le site continue de servir depuis lâancien cache :
php bin/console cache:clear --env=prod --no-debugcache:clear (avec warmup) construit le nouveau cache avant de supprimer lâancien : le site reste servi par lâancien conteneur pendant toute la durĂ©e de la recompilation. Sur EFS, cette durĂ©e se compte en minutes, ici un quart dâheure, mais elle est invisible pour les utilisateurs.
La CLI, exĂ©cutĂ©e par un opĂ©rateur, sort la compilation du chemin des requĂȘtes qui, sinon, se bloquent dessus une par une.
Ici le verrou nâest pas le problĂšme. Ce qui aurait pu mettre le site Ă genoux, câest la combinaison dâun stockage rĂ©seau lent pour ce genre dâĂ©criture massive de petits fichiers et dâun serveur qui paie chaque requĂȘte en attente avec un process OS complet.
Sur une architecture prefork, une compilation qui prend quatorze minutes au lieu de quelques secondes nâest pas juste lente. Elle affame le pool de workers pendant tout ce temps.
Un verrou qui fonctionne garantit quâun seul process dĂ©tient une ressource Ă un instant donnĂ©. Il ne garantit ni que cette ressource se libĂšre vite, ni ce que coĂ»te lâattente de ceux qui patientent derriĂšre.
Trois pistes, par coût croissant
DĂ©dier un worker, hors trafic, au warmup aprĂšs chaque dĂ©ploiement. Câest le cache:clear de la conclusion, automatisĂ© plutĂŽt que laissĂ© Ă la mĂ©moire de lâopĂ©rateur.
Sortir var/cache du montage réseau. Le cache est reconstructible, propre à chaque instance, et rien ne justifie de le poser sur un filesystem réseau lent à écrire pour ce type de charge.
Note
On a vu plus haut que deux instances peuvent exister en mĂȘme temps lors dâun redĂ©ploiement. Pendant ce chevauchement, les deux instances partagent le mĂȘme
var/cacheEFS. Ăa nâa pas jouĂ© ici, mais câest une fenĂȘtre oĂč une recompilation concurrente serait thĂ©oriquement possible. Si lâinfra doit un jour passer Ă plusieurs instances permanentes, câest le premier point Ă revoir.
Passer Ă un runtime qui ne pin pas un process OS entier par requĂȘte en attente. FrankenPHP en mode worker, ou Swoole. PHP-FPM reste un process par requĂȘte active, juste dĂ©couplĂ© du serveur web.
Aucune des trois nâest propre Ă cet incident. Chacune supprime une des conditions qui lâont rendu possible.
Annexe 1 : Le risque du bouton « Vider le cache »
LâhypothĂšse 2 ne sâest pas vĂ©rifiĂ©e ici, faute de PHP-FPM.
Le risque reste rĂ©el sur tout dĂ©ploiement qui tourne effectivement sous PHP-FPM avec un request_terminate_timeout serrĂ©. Ăa vaut la peine de le documenter, mĂȘme si ce nâest pas ce qui sâest produit cette fois.
La route du bouton pointe vers PerformanceController::clearCacheAction() :
public function clearCacheAction()
{
$this->get('prestashop.core.cache.clearer.cache_clearer_chain')->clear();
$this->addFlash('success', $this->trans('All caches cleared successfully', 'Admin.Advparameters.Notification'));
return $this->redirectToRoute('admin_performance');
}Ce service enchaßne six clearers. Celui qui nous intéresse est SymfonyCacheClearer.
Sa mĂ©thode clear() pose dâabord un verrou applicatif ($kernel->locksCacheClear(), un flock(LOCK_EX | LOCK_NB) sur un fichier dĂ©diĂ©, distinct du verrou gĂ©nĂ©rique de compilation vu plus haut), puis enregistre une register_shutdown_function qui fait le vrai travail :
public function clear()
{
global $kernel;
if (!$kernel || false === $kernel->locksCacheClear()) {
return; // déjà en cours ailleurs
}
register_shutdown_function(function () use ($kernel) {
try {
foreach (['prod', 'dev'] as $environment) {
$application = new Application($kernel);
$application->setAutoExit(false);
$application->doRun(new ArrayInput([
'command' => 'cache:clear',
'--no-warmup' => true,
'--env' => $environment,
]), new NullOutput());
}
$application = new Application($kernel);
$application->setAutoExit(false);
$application->doRun(new ArrayInput([
'command' => 'cache:warmup',
'--no-optional-warmers' => true,
'--env' => 'prod',
'--no-debug' => true,
]), new NullOutput());
} finally {
Hook::exec('actionClearSf2Cache');
$kernel->unlocksCacheClear();
}
});
}Ce register_shutdown_function sâexĂ©cute dans le worker de la requĂȘte HTTP dâorigine elle-mĂȘme, pas dans un sous-processus dĂ©tachĂ©.
Sur un dĂ©ploiement PHP-FPM avec un request_terminate_timeout court, un worker tuĂ© en plein cache:warmup libĂšre son flock() au niveau OS sans jamais passer par ce bloc finally. « Verrou libĂ©rĂ© » nâest alors plus synonyme de « travail terminĂ© ».
Les requĂȘtes qui patientaient dans AppKernel::waitUntilCacheClearIsOver() reprennent alors en croyant le conteneur prĂȘt, au mot prĂšs du commentaire du code source : « the container has been rebuilt and is good to go ».
Ce bouton pose un second problĂšme, indĂ©pendant de tout ce qui prĂ©cĂšde : son rayon dâaction ne correspond pas Ă celui dâun bug confinĂ© au routing et au conteneur, puisquâil vide aussi Smarty et les mĂ©dias, dont le front dĂ©pend.
Annexe 2 : la dérive du cache Twig
Pendant cet incident, il fallait aussi supprimer un template prĂ©cis dans le cache Twig. En le cherchant je suis tombĂ© sur un dossier de 9 Go, plus de 80 000 fichiers gĂ©nĂ©rĂ©s par le back-office de PrestaShop 8 lui-mĂȘme. Cette dĂ©couverte fait lâobjet dâun article sĂ©parĂ© : 9 Go de cache Twig : un template compilĂ© par page dans le back-office PrestaShop 8.