Déployer les modules PrestaShop sur Kubernetes sans passer par le back-office

Par Maxime Bénard

13 min de lecture

Couverture d’article Mintfull : « Des modules déployés sans un clic », le manifeste tools/modules/modules.txt avec ses lignes install, enable et uninstall

Le code du module est dans l’image, l’image tourne en production, et la fonctionnalité n’existe pas. Personne n’a cliqué sur « Installer » en prod, ou seulement en recette. Ou la version monte dans l’image mais pas dans ps_module, et les scripts d’upgrade ne tournent jamais. Aucune erreur : chaque pod répond 200, seule la base dit la vérité.

Notre réponse : un modules.txt versionné, un runner PHP idempotent en initContainer, qui reconstruit le cache Symfony quand il a changé quelque chose. Le montage sur un projet PrestaShop 8.1.1 / PHP 8.0, et deux pièges vérifiés dans le core : une commande console qui répond « succès » quand elle échoue (notre runner s’y est fait prendre), et une option --skip-overrides absente là où on l’attendait.

Pourquoi un module livré peut rester absent : trois états qui divergent sans erreur

L’état d’un module vit à trois endroits :

  • sur disque, dans modules/ de l’image Docker ;
  • en base, dans ps_module (installé, version, actif) et ps_module_shop (activé par boutique) ;
  • dans le cache de chaque pod, dans var/cache, où Symfony compile services et routes. Ici, var/cache est un emptyDir en mémoire : chaque pod a le sien.

Rien ne les aligne tant que l’installation reste un geste de back-office. Un pod qui a compilé son cache avant l’installation ignore les services et routes du module, son voisin démarré après les connaît : les pages back-office du module répondent sur un pod sur deux.

Ce piège manquait à notre liste des erreurs classiques d’une migration PrestaShop sur Kubernetes.

Le manifeste modules.txt : un état attendu, cinq verbes, et pourquoi reset est refusé

tools/modules/modules.txt est relu en merge request comme du code, une action par ligne (noms neutralisés) :

text
# tools/modules/modules.txt
# COMMANDES AUTORISEES : install|upgrade|enable|disable|uninstall
# "install x" veut dire "x doit être installé et à la version présente sur le disque"
install   mon_module_paiement
install   mon_module_catalogue
enable    ps_facetedsearch
disable   mon_module_legacy
uninstall ancien_module_promo

C’est un état attendu : une version qui monte dans l’image est appliquée sans ligne upgrade x, qui reste un synonyme accepté. L’amorce compte vingt lignes, une par module développé pour la boutique. Commande inconnue et reset sont refusés.

bin/console prestashop:module accepte reset, mais voyez ModuleManager::reset() en 8.1.1 comme en 8.1.7 (src/Core/Module/ModuleManager.php, à partir de la ligne 298) :

php
public function reset(string $name, bool $keepData = false): bool
{
    // ... contrôle des droits, assertIsInstalled(), hook actionBeforeResetModule
    $module = $this->moduleRepository->getModule($name);

    if ($keepData && method_exists($module, 'reset')) {
        $reset = $module->onReset();
        $this->dispatch(ModuleManagementEvent::RESET, $module);
    } else {
        $reset = $this->uninstall($name) && $this->install($name);
    }

    return $reset;
}

La console appelle $this->moduleManager->{$action}($moduleName) sans second argument (ModuleCommand::executeGenericModuleAction()) : $keepData vaut false, et un reset en ligne de commande est un uninstall suivi d’un install. Une purge, rejouée à chaque démarrage de pod. Ce qui détruit des données reste un geste humain.

Le runner apply.php : régler chaque ligne en SQL, ne lancer la console que sur l’écart

tools/modules/apply.php compare chaque ligne à la base et ne lance la console que sur un écart : un démarrage sans rien à faire ne boote aucun kernel Symfony. Extraits réels, raccourcis, commentaires traduits.

CLI uniquement. Le fichier peut se trouver sous la racine web, et Apache exécute toute URI en .php. D’où cette garde, doublée d’un .htaccess sur le dossier (manifeste et README ne se protègent pas seuls) :

php
if (PHP_SAPI !== 'cli') {
    http_response_code(403);
    exit('CLI only.');
}

L’écart de version se lit sans instancier le module : version du fichier principal par expression régulière, contre la colonne version de ps_module.

php
// Version déclarée dans le fichier principal du module, c'est-à-dire celle de l'image
preg_match('/\$this->version\s*=\s*[\'"]([^\'"]+)/', file_get_contents($file), $matches);

// install et upgrade expriment la même intention :
// installé, et à la version livrée par l'image
if ($state === null) {
    $actions[] = 'install';
} elseif (version_compare((string) $state['version'], $onDisk, '<')) {
    $actions[] = 'upgrade';
}

Pas de Module::needUpgrade() : sans script dans upgrade/, il écrit lui-même la nouvelle version dans ps_module (via Module::upgradeModuleVersion()) et renvoie false. Dans un --dry-run, il modifierait la base en silence. Et install x ne redescend jamais une version.

enable, disable et uninstall lisent la colonne active ou la présence de la ligne.

Une ligne invalide est refusée et le run continue : reset, commande inconnue, ligne mal formée, module absent de modules/. Bilan final :

text
# format du bilan (apply.php), les chiffres varient selon le run
<n> appliquée(s), <n> déjà à jour, <n> en échec, <n> refusée(s)

--dry-run liste l’écart sans rien toucher ni prendre le verrou, en local via une cible Makefile et sur la preprod avant merge.

Multiboutique. En CLI, Module::enable() n’écrit ps_module_shop que pour la boutique par défaut, alors que les hooks sont posés partout. Après chaque install, upgrade ou enable, le runner complète les boutiques actives manquantes via enable() en Shop::CONTEXT_ALL. Détail dans notre article sur Module::enable() en multiboutique.

Critère : un second passage sur base alignée annonce « 0 appliquée(s), n déjà à jour » en environ une seconde, car il est rejoué à chaque démarrage de pod (rolling update, scale-up, éviction). Vérifié sur un bootstrap PrestaShop simulé ; la durée réelle reste à mesurer.

Deux pods, une base : le verrou MySQL GET_LOCK()

Deux Deployments de deux replicas, front et back-office, partagent la base. Sans coordination, deux pods installent le même module. Au mieux, le second tombe sur « This module has already been installed. » dans Module::install() (ModuleManager::install(), lui, bascule alors sur un upgrade). Au pire, les deux passent ce contrôle avant que l’un n’écrive : le second bute sur l’index unique de ps_module.name et s’arrête sur « Technical error: PrestaShop could not install this module. », code d’installation du module peut-être à moitié exécuté.

La base est la ressource partagée, c’est elle qui arbitre :

php
// Deux pods qui démarrent ensemble ne doivent pas appliquer la liste deux fois.
if (!$dryRun && !$db->getValue('SELECT GET_LOCK("ps_module_manager", 600)')) {
    echo 'Verrou non obtenu après 600 s, un autre pod applique la liste, on le laisse finir.', PHP_EOL;
    exit(0);
}

try {
    // ... boucle sur le manifeste
} finally {
    if (!$dryRun) {
        $db->getValue('SELECT RELEASE_LOCK("ps_module_manager")');
    }
}

Le pod qui obtient le verrou après attente ne trouve plus rien à faire : l’idempotence suffit. Au-delà de 600 secondes, il sort en 0. Lié à la session MySQL, le verrou est libéré si le pod meurt.

Déployer les modules PrestaShop en initContainer plutôt qu’en Job Kubernetes

Un Job Kubernetes, classique pour les migrations de schéma, tourne pendant que les pods compilent leur cache. Un pod qui compile avant la fin du Job ignore les services et routes du module (s’il en déclare, voir comment un module PrestaShop embarque ses propres services Symfony). Avec un emptyDir par pod, les replicas divergent derrière le même Service.

Un initContainer passe avant le conteneur principal, même image, même var/cache, ordre garanti :

yaml
# Extrait simplifié et anonymisé du Deployment
initContainers:
  - name: apply-modules
    image: registry.example/boutique      # tag posé par l'overlay, comme pour le conteneur principal
    workingDir: /app
    command:
      - /bin/sh
      - -ec
      - |
        if [ ! -f tools/modules/apply.php ]; then
          echo "tools/modules/apply.php absent de cette image, rien à appliquer"
          exit 0
        fi
        # L'image sait-elle démarrer ? Si le kernel prod ne compile pas,
        # on sort en erreur AVANT d'avoir touché à la base.
        php bin/console --env=prod about > /dev/null
        php -d memory_limit=512M tools/modules/apply.php
    volumeMounts:
      - name: prestashop-cache             # emptyDir partagé avec le conteneur principal
        mountPath: /app/var/cache
        subPath: cache

about compile le kernel prod et sert de test de démarrage. Mais PrestaShop 8.1 ne vide pas le cache Symfony à l’installation d’un module : si une ligne a été appliquée, le runner termine par bin/console --env=prod cache:clear --no-optional-warmers et Media::clearCache() pour les assets concaténés du thème. Sinon, rien. Un cache:clear en échec est journalisé, non fatal.

Deux corrections. Sans --env=prod, bin/console part en dev si SYMFONY_ENV est absent : installation via un kernel de debug jamais servi, et var/cache/dev chauffé pour rien. Et la console enfant reçoit explicitement le memory_limit du runner, sinon elle reprend celui du php.ini CLI.

La garde sur apply.php évite un plantage en boucle sur une image antérieure au runner (retour arrière).

prestashop:module renvoie 0 même quand l’installation échoue

Dans src/PrestaShopBundle/Command/ModuleCommand.php, quand le ModuleManager renvoie false, executeGenericModuleAction() affiche « Cannot %action% module %module%. %error_details% » sans rien renvoyer, et execute() termine par return 0;. Seule une action inconnue renvoie explicitement 1 ; une exception du ModuleManager (droits insuffisants, module non installé pour un upgrade ou un uninstall) fait sortir en erreur. Même comportement sur les tags 8.1.1, 8.1.7, 9.0.0, 9.1.5 et 9.2.0-rc.1.

bash
php bin/console prestashop:module install mon_module
echo $?   # 0, même si le ModuleManager a renvoyé false

Un install raté passe donc inaperçu sous set -e. Notre runner, en première version, s’y fie aussi :

php
exec($command, $output, $code);

return ['ok' => $code === 0, 'output' => trim(implode(PHP_EOL, $output))];

Un install refusé y compte comme « appliqué ». Correctif : relire ps_module après l’appel. Bug muet au sens strict : la commande écrit « error » dans sa sortie et « succès » à l’orchestrateur.

--skip-overrides : absent de PrestaShop 8.1, apparu en 9.1.2

En 8.1.1, Module::install() appelle enable(), qui appelle installOverrides() dès que getOverrides() n’est pas vide : les classes du dossier override/ du module sont copiées dans /override, à l’exécution, dans le système de fichiers du pod.

Ici, /override est dans l’image, pas un volume : l’override n’existe que dans le conteneur qui a installé (en initContainer, pas celui qui sert), invisible pour l’autre replica, perdu au redéploiement, impossible en lecture seule. Installé en base, sans override durable.

L’option prévue pour ce cas, cherchée tag par tag :

bash
for v in 8.1.1 8.1.7 8.2.8 9.0.3 9.1.1 9.1.2 9.2.0-rc.1; do
  printf "%s: " "$v"
  curl -s "https://raw.githubusercontent.com/PrestaShop/PrestaShop/$v/src/PrestaShopBundle/Command/ModuleCommand.php" \
    | grep -c "skip-overrides"
done
# 8.1.1: 0 / 8.1.7: 0 / 8.2.8: 0 / 9.0.3: 0 / 9.1.1: 0 / 9.1.2: 2 / 9.2.0-rc.1: 2

--skip-overrides apparaît en 9.1.2. Elle met PS_DISABLE_MODULE_OVERRIDES à 1 le temps de l’action, et Module.php teste cette clé avant de toucher à /override. Test nouveau aussi : classes/module/Module.php ignore la clé en 8.1.1, 8.1.7 et 9.1.1. Repère : « PrestaShop 9.1.2 et plus », à noter si vous anticipez le passage à PrestaShop 9.

En 8.1.1, sans toucher au core, on renomme le dossier override/ du module le temps de l’appel console : Module::getOverrides() ne trouve plus rien. Extrait raccourci :

php
if (is_dir($overrideDir)) {
    // Sans le renommage, PrestaShop fusionnerait l'override dans le /override
    // d'un conteneur qui ne servira jamais : on refuse la ligne plutôt.
    if (!is_writable($moduleDir) || is_dir($parkedDir)) {
        return ['ok' => false, 'output' => 'override/ impossible à mettre de côté, action abandonnée'];
    }
    $parked = rename($overrideDir, $parkedDir);
}

try {
    // ... appel console
} finally {
    if ($parked && !rename($parkedDir, $overrideDir)) {
        logFailure('override/ laissé de côté, le conteneur est à jeter');
    }
}

Le runner liste en fin de run les modules concernés (« Overrides non appliqués (à baker dans /override du dépôt si absents) »), reportés à la main dans le /override du dépôt : seule copie qui survit à un redéploiement.

Un module en échec ne doit pas empêcher le pod de démarrer

Un initContainer en erreur bloque le pod : un module raté couperait la boutique. Le runner sort donc toujours en 0, l’échec est journalisé, la ligne suivante s’applique.

Seul le about en amont peut échouer : si le kernel ne compile pas, les anciens pods servent (maxUnavailable: 0) et la base ne prend pas d’avance sur un code mort.

Hors de ce test : Apache qui tombe après démarrage (vhost, probe, OOM) et le retour à une image plus ancienne. La base est alors en avance, et uninstall (les hooks partent) ou un upgrade destructif de schéma font mal. D’où la règle : un uninstall se fait en deux déploiements, d’abord le code qui n’a plus besoin du module, ensuite la ligne uninstall.

L’erreur part dans kubectl logs <pod> -c apply-modules, et dans ps_log via PrestaShopLogger::addLog() en sévérité 3, préfixée [module manager] et tronquée à 200 caractères, lisible sans accès au cluster. Aucune alerte : un déploiement peut passer au vert avec un module non appliqué.

Comment vérifier si vos modules PrestaShop ont divergé entre disque, base et pods

Trois contrôles sans outillage particulier (préfixe ps_ à adapter). L’état en base, boutique par boutique :

sql
SELECT m.name, m.version, m.active, GROUP_CONCAT(ms.id_shop) AS shops
FROM ps_module m
LEFT JOIN ps_module_shop ms ON ms.id_module = m.id_module
GROUP BY m.id_module, m.name, m.version, m.active
ORDER BY m.name;

La version déclarée sur disque :

bash
grep -H "this->version" modules/*/*.php

L’âge du cache de chaque pod (chemin relatif au répertoire de travail du conteneur, à ajuster) :

bash
for p in $(kubectl get pods -l app=boutique -o name); do
  echo "$p $(kubectl exec "$p" -c apache -- stat -c %y var/cache/prod)"
done

Module sur disque sans ligne en base, version disque supérieure à la base, boutique absente de ps_module_shop, pod au cache plus ancien que le dernier install : vous êtes concerné.

Le réflexe à retenir Quand un module est « livré mais absent », ne relisez pas son code : comparez trois états. Le dossier dans modules/ de l’image, la ligne dans ps_module et ps_module_shop, et l’âge du cache du pod qui répond. Et ne vous fiez jamais au code de sortie de prestashop:module : vérifiez l’état en base après chaque action.

Ce qu’on validerait avant de réutiliser le pattern

Prérequis : manifeste relu en merge request, modules dans l’image, destructions manuelles. Le runner a été vérifié contre un bootstrap PrestaShop simulé : écart de version résolu en upgrade, module absent en install, module à jour laissé tel quel, override/ invisible pendant l’appel puis restauré, échec journalisé sans interrompre le run ni changer le code de sortie, pod perdant du verrou reparti sans rien appliquer, et quatre lignes piégées (reset x, commande inconnue, ligne mal formée, module absent du disque) refusées. Le --dry-run sur la vraie base est fait. Restent un passage qui applique chaque type d’action, un second qui annonce 0 action, puis un déploiement en preprod contrôlé par kubectl logs et dans le back-office.

Le premier --dry-run preprod bloquait tout merge : toute ligne annoncée en install parmi les vingt aurait désigné un module absent de la base, à confirmer ou retirer. Il n’a annoncé aucune installation inattendue. Ordre de merge : dépôt applicatif, puis, image construite et repérée par l’outil GitOps, le Deployment de l’initContainer. L’inverse est sans danger grâce à la garde, mais inutile.

Si vos modules s’installent encore à la main sur une boutique à plusieurs replicas, c’est typiquement le genre de chantier sur lequel on intervient.

Partager cet article
Articles

Des articles pour faire pousser vos idées

D'autres retours d'expérience et méthodes à mettre en pratique sur votre site.

Bon, on en discute ?

Demandez votre diagnostic de performance gratuit : un échange de 30 minutes, vos Core Web Vitals relevés sur mobile et une synthèse écrite de ce qui mérite d’être corrigé en premier.

Discuter de mes besoins