Une montée de version Magento a mauvaise réputation, et elle la mérite souvent. On lit les notes de version, on relit ses surcharges, on teste, on livre, et le lendemain un détail casse quelque part, loin de tout ce qu’on a touché.

Cet été, nous devions monter Adobe Commerce de 2.4.7-p10 à 2.4.9. La 2.4.9 exige PHP 8.5 et MySQL 8.4, et elle remplace son moteur de cache. Autrement dit, tout le socle bougeait en même temps : le langage, la base de données, le cache, le serveur d’application, et une partie des modules tiers dont Magento impose désormais la version.

Nous avons fait un choix simple : ne jamais changer deux couches à la fois. Entre fin juin et début septembre, chaque brique est partie seule en production, avec son propre retour arrière, sauf le dernier pas : la 2.4.9 impose PHP 8.5 et Adyen 10, partis donc ensemble. La 2.4.9 est arrivée en dernier, le 7 septembre, sur un socle déjà renouvelé.

Ce qu’on devait monter

Couche Avant Après En production
Base de données MySQL 8.0 MySQL 8.4 29 juillet
Cache et sessions Redis OSS 7.0 Valkey 8.1 3 août
Cache applicatif fichier local + Redis Redis seul 27 juillet
Plateforme Adobe Commerce 2.4.7-p10 2.4.9 7 septembre
Langage PHP 8.2 PHP 8.5 7 septembre
Paiement module Adyen 9 Adyen 10 7 septembre

Le paiement n’était pas dans le plan de départ. La version 9 du module Adyen ne démarre pas avec la bibliothèque de logs imposée par la 2.4.9 : monter Magento obligeait à monter Adyen d’une version majeure. C’est typique de ce genre de chantier, et c’est pour ça qu’on a voulu dégager tout le reste avant.

L’ordre des opérations

Ordre des opérations, de fin juin à fin septembre 2026 : MySQL 8.4 testé en préproduction, élagage du code, cache 100 % Redis, bascule blue/green de MySQL 8.4 en production, Valkey 8.1, Magento 2.4.9 en préproduction puis en production, enfin PHP 8.5 et Rector. MySQL 8.4 test 30 juin Élagage 21 juillet Cache Redis 27 juillet MySQL 8.4 prod 29 juillet Valkey 8.1 3 août 2.4.9 préprod 19 août 2.4.9 prod 7 septembre Rector PHP 8.5 30 septembre
Une couche à la fois, de la donnée vers l'application. Chaque étape partait seule et pouvait revenir en arrière seule, sauf la dernière : la 2.4.9 impose PHP 8.5 et Adyen 10.

L’idée est de partir du bas. La donnée d’abord, puis le cache, puis l’application. Chaque étape doit pouvoir être livrée, observée quelques jours, et annulée sans toucher aux autres.

Élaguer avant de monter

La première étape n’a rien monté du tout : elle a supprimé. Un module de changement de groupe client programmé que plus personne n’utilisait, un ancien flux d’export, les restes d’un thème front que le passage en headless avait rendu inutile. 3 452 lignes en moins, en production le 21 juillet. Chaque ligne supprimée est une ligne qu’on n’aura pas à porter, à relire ni à tester.

Un seul cache, dans Redis

Magento proposait un cache à deux étages : une copie locale en fichiers sur chaque serveur, synchronisée avec Redis. Sur Kubernetes, avec des pods qui vont et viennent, cet étage local apportait surtout de la complexité et des incohérences possibles entre pods. Le 27 juillet, nous l’avons retiré : le cache par défaut vit uniquement dans Redis. La configuration a perdu une trentaine de lignes, et la 2.4.9 nous attendait avec un moteur de cache neuf, autant lui donner la configuration la plus simple possible.

MySQL 8.4 par blue/green

Pour la base, nous avons utilisé les déploiements blue/green de RDS. AWS crée une copie de la base de production dans la nouvelle version, la tient à jour par réplication, et bascule le trafic d’un coup quand on le décide.

La préparation a pris plus de temps que la bascule. Il a fallu reconstruire un groupe de paramètres MySQL 8.4 à partir des valeurs réellement déployées, et pas de celles qu’on croyait déployées. Passer le stockage en gp3 avant la bascule, pour ne pas mélanger deux changements. Répéter le tout sur l’environnement de test dès le 30 juin.

Le 29 juillet, tôt le matin, la bascule a pris environ 70 secondes. Un détail pour qui gère son infrastructure en Terraform : une bascule blue/green crée de nouvelles instances avec de nouveaux identifiants internes. L’état Terraform pointait encore sur les anciennes. Nous l’avons réconcilié à la main (retrait puis import de l’instance principale et du réplica) jusqu’à obtenir un plan vide face à la production.

Après une bascule de base, nous contrôlons désormais l’état des vues d’indexation de Magento. Elles suivent les tables de changement par un numéro de version, et une base qui change sous elles peut les laisser en avance sur ce qu’il reste à traiter : l’index ne se met plus à jour, sans aucune erreur.

Valkey sans que les applications le sachent

Le 3 août, ElastiCache est passé de Redis OSS 7.0 à Valkey 8.1, par un nouveau groupe de réplication. La partie délicate était ailleurs : nos noms DNS internes contenaient la version du moteur. Nous les avons renommés en deux temps. D’abord, les anciens et les nouveaux noms pointaient vers la même cible. Ensuite, les nouveaux groupes ont pris les noms définitifs, neutres. Aucune application n’a eu à connaître le nom du moteur, ni à redémarrer au bon moment.

La montée elle-même

Le 19 août, la 2.4.9 est arrivée en préproduction. Le commit de montée touche 5 666 fichiers : l’essentiel vient du cœur de Magento, déployé dans le dépôt, mais 205 fichiers de nos propres modules ont dû être adaptés.

La migration de la base était trop lourde pour un déploiement ordinaire. La 2.4.9 passe les tables en utf8mb4 : 328 reconstructions de tables, pour 78 Go de données. Impossible de faire ça pendant que le site vend. Nous l’avons jouée à l’avance sur une copie de la base, puis basculé le trafic dessus en 66 secondes.

La recette en préproduction a duré deux semaines et demie, jusqu’au 4 septembre. Elle a trouvé dix défauts. La plupart ne ressemblaient pas à ce qu’on attendait.

Le serveur d’application : quand l’état survit à la requête

Notre API GraphQL tourne sur le serveur d’application de Magento. C’est le changement le plus profond pour un développeur PHP, et il mérite qu’on s’y arrête.

À gauche, PHP-FPM : chaque requête démarre un processus neuf, qui jette toute sa mémoire à la fin. À droite, le serveur d'application : un worker démarre une fois et enchaîne les requêtes ; un objet rempli pendant la requête A est encore là pendant la requête B si sa méthode de remise à zéro ne le vide pas. PHP-FPM un processus neuf par requête Requête A Requête B Requête C Lent, mais rien ne survit à la requête. Serveur d'application un worker, des milliers de requêtes Démarrage A B C _resetState() État de la requête A relu par B Rapide, si chaque objet se vide entre deux requêtes.
Le même objet PHP vit le temps d'une requête avec FPM, et le temps d'un worker avec le serveur d'application.

Avec PHP-FPM, chaque requête démarre Magento, la traite, puis le processus jette toute sa mémoire. C’est lent, mais c’est sûr : rien ne survit d’une requête à l’autre. Le serveur d’application garde des workers vivants qui enchaînent les requêtes. Démarrer Magento une seule fois fait gagner beaucoup de temps, mais chaque objet partagé doit maintenant savoir se vider entre deux requêtes. Magento prévoit pour ça une interface, ResetAfterRequestInterface, et sa méthode _resetState(). Dans notre propre code, 28 classes l’implémentent.

La 2.4.9 nous a montré les deux façons de rater ce contrat.

Ne pas vider assez. En recette, l’ajout au panier échouait par moments, sans motif reproductible. Le modèle client du cœur se remettait à zéro, sauf deux propriétés : le cache des adresses et la collection d’adresses. Un worker gardait l’état du client précédent pour la requête suivante. Le correctif tient en deux lignes :

// Magento\Customer\Model\Customer::_resetState()
         $this->_origData = null;
         $this->storedData = [];
         $this->_data = [];
+        $this->storedAddress = [];
+        $this->_addressesCollection = null;

Vider trop. Un module tiers de promotions vidait dans _resetState() une liste de validateurs que le conteneur d’injection ne lui fournit qu’une fois, à la construction de l’objet. Après la première requête, le worker tournait sans validateurs. La correction a consisté à supprimer cette remise à zéro, par un patch Composer sur le module.

La règle qu’on en tire : une propriété remplie pendant une requête doit être vidée, une propriété injectée à la construction ne doit jamais l’être. Les deux erreurs ne produisent aucune exception, seulement des comportements qui changent d’une requête à l’autre selon le worker qui répond.

Les échecs silencieux

Les défauts les plus coûteux à trouver ont un point commun : ils ne disent rien.

Une déclaration de cache ignorée

Le nouveau moteur de cache de la 2.4.9 ne reconnaissait plus notre manière de déclarer Redis, par nom de classe. Pas d’erreur : il retombait sur un cache fichier, propre à chaque serveur. En recette, l’ordre des produits d’une catégorie était perdu une fois sur deux, selon le pod qui répondait. La correction est une ligne de configuration :

'frontend' => [
    'default' => [
-       'backend' => '\\Magento\\Framework\\Cache\\Backend\\Redis',
+       'backend' => 'redis',

Une connexion Redis partagée par fork

Nos indexeurs forkent des processus pour paralléliser l’envoi du catalogue vers Elasticsearch. Après pcntl_fork(), chaque enfant hérite des connexions ouvertes par le parent, socket compris.

À gauche, avant le correctif : l'indexeur forke trois processus enfants qui héritent de la même connexion Redis, et leurs échanges se mélangent sur un seul socket. À droite, après le correctif : chaque enfant détecte qu'il a changé de processus et ouvre sa propre connexion. Avant le fork copie aussi la connexion Indexeur (parent) Enfant 1 Enfant 2 Enfant 3 1 socket Redis partagé Les échanges se croisent sur un socket. Après la connexion est rattachée à son pid Indexeur (parent) Enfant 1 Enfant 2 Enfant 3 Socket Socket Socket Chaque enfant ouvre sa connexion.
Une connexion héritée par fork n'appartient plus à personne. Le correctif la rattache au processus qui l'a ouverte.

Plusieurs processus qui parlent sur le même socket Redis mélangent leurs échanges : un processus lit la réponse destinée à un autre. Résultat, des documents perdus dans l’index, sans erreur visible. Nous avons patché le fournisseur de connexions du framework pour rattacher son pool au processus qui l’a ouvert :

$currentPid = getmypid();
if ($this->connectionPoolPid !== null && $this->connectionPoolPid !== $currentPid) {
    // keep the parent's sockets referenced: closing them here would close them for the parent too
    $this->inheritedConnections[] = $this->connectionPool;
    $this->connectionPool = [];
}
$this->connectionPoolPid = $currentPid;

Le même patch suffixe l’identifiant des connexions persistantes par le pid, pour qu’un enfant ne puisse pas se rattacher au socket persistant de son parent.

Un cache qui ne libère jamais

Dernier exemple : les index de tags du nouveau cache n’expiraient jamais. La mémoire Redis montait doucement, sans alerte possible tant qu’elle restait sous le seuil. Un correctif publié en amont ajoutait une durée de vie à ces index ; nous l’avons appliqué avant la mise en production.

Le bilan : 26 défauts

La 2.4.9 a produit 26 défauts au total. Quinze ont été arrêtés avant la production : dix pendant la recette, deux en préproduction juste après, et trois changements de contrat de l’API neutralisés à l’avance, pour que le site et nos autres applications ne voient aucune différence. Onze sont arrivés en production, et ont été corrigés dans les jours et semaines qui ont suivi.

En les classant, on retrouve cinq mécanismes :

  1. Le socle change sous le code : cache, serveur d’application, indexation, encodage de la base. C’est la première source de défauts, et aucun fichier de notre code ne la signale.
  2. Le cœur ajoute un comportement qui s’active sur nos données, sans qu’une ligne de notre code change.
  3. Les modules tiers montent avec Magento et changent leurs formats.
  4. Nos copies et contournements deviennent faux : un template recopié qui utilisait une bibliothèque retirée, le contournement d’un défaut que le cœur a corrigé depuis.
  5. Les échecs silencieux retardent la détection : un repli sans erreur, une ligne effacée sans log.

La leçon est inconfortable. La méthode classique, relire nos surcharges du cœur, ne couvre que la quatrième famille. La plupart des casses viennent de ce qui bouge autour de notre code.

Ce qu’on fait différemment pour la prochaine fois

Nous avons transformé ce bilan en procédure.

  • Comparer ce que la nouvelle version change hors de notre code : pour chaque module du cœur utilisé, les déclarations de plugins, d’observers et de tâches planifiées entre les deux versions, et les deux composer.lock. C’est ce qui aurait montré la majorité des casses de la 2.4.9.
  • Relire nos accroches au cœur : nos surcharges, nos 115 patchs Composer, et chaque désactivation par nom. Un plugin désactivé par son nom dont le nom a changé revient silencieusement.
  • Mesurer la migration de la base sur une copie, et la jouer à l’avance quand elle dépasse la fenêtre de déploiement.
  • Contrôler des invariants à J0, J+1 et J+3 : des règles qui doivent toujours être vraies (stock, commandes, indexation, grilles du back-office, cache, codes de retour de l’API), comparées aux jours précédents. Une alerte sur une erreur ne suffit pas quand l’erreur n’existe pas.

PHP 8.5, pour finir

Le 30 septembre, dernière étape : Rector 2 avec les règles PHP 8.5 sur nos modules, et leurs métadonnées Composer alignées sur la nouvelle version. Une étape volontairement séparée : quand la plateforme tourne déjà en 8.5, moderniser le code devient un changement de style, plus un risque.

Ce qu’on a appris

  • Une couche à la fois. Six livraisons séparées en production, dont une seule regroupait plusieurs couches parce que la 2.4.9 l’imposait : six retours arrière simples. Le jour de la 2.4.9, le socle était déjà stable depuis un mois.
  • Supprimer avant de monter. Le code le moins cher à migrer est celui qui n’existe plus.
  • Le serveur d’application change la nature de PHP. L’état vit le temps d’un worker, plus le temps d’une requête. Chaque _resetState() doit vider ce que la requête a rempli, et rien d’autre.
  • Les défauts les plus chers sont muets. Un repli sans erreur coûte plus qu’une exception. Nous cherchons maintenant les invariants, plus seulement les erreurs.
  • La base se migre à côté. Blue/green pour la version de MySQL, migration jouée sur une copie pour le schéma : dans les deux cas, environ une minute de bascule.

La prochaine montée commencera par la même question : qu’est-ce qui bouge autour de notre code, et comment le verrons-nous si ça casse sans rien dire ?