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:router ne l'aurait pas vu

bin/console debug:router reconstruit sa liste en relisant les fichiers de routes : RouterDebugCommand appelle getRouteCollection(), qui charge les ressources via routing.loader (Symfony 4.4, la version embarquée par PrestaShop 8.2). La commande ne passe jamais par UrlGenerator.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.meta

Avant 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.php

Le 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-debug

cache: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/cache EFS. Ç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.