Le dernier article se terminait sur une promesse : « le nouveau site sort bientôt, mais on ne spoil pas la date ». La date, c’était le lundi 23 février 2026. Le premier tag de release est parti à 0 h 45, et en fin de journée on en avait posé dix, et quatorze sur la semaine.

Une migration se raconte souvent jusqu’à la bascule, comme si le travail s’arrêtait là. En réalité, c’est le premier jour où le code rencontre le vrai trafic, celui qu’aucun environnement de préproduction ne reproduit fidèlement. Pour nous, cette rencontre a pris la forme d’une courbe de mémoire qui montait, montait, puis tombait d’un coup quand Kubernetes tuait le pod.

Voici ce qui s’est passé pendant cette semaine, ce qu’on a corrigé, ce qu’on a dû défaire, et l’architecture de résilience qu’on a fini par construire dans les mois qui ont suivi.

Neuf storefronts, une seule bascule

Le site Zadig&Voltaire, ce sont neuf storefronts : France, Royaume-Uni, Allemagne, Espagne, Italie, Belgique, Suisse, reste du monde et États-Unis, quatre devises, cinq langues. L’ancien front Vue Storefront les servait tous. Nuxt devait les reprendre tous, le même jour.

On aurait pu basculer marché par marché. On a choisi de ne pas le faire : chaque storefront partage le même code, le même cluster et le même CDN, et maintenir deux fronts en parallèle pendant des semaines aurait doublé le support, le tracking et les tests. La bascule s’est donc faite au niveau du CDN, en une fois.

Le prérequis, c’était un modèle de release qu’on maîtrisait déjà. On a repris celui du backend Magento : la production ne se déploie que depuis un tag Git nommé AAAA-SS-NN (année, semaine ISO, numéro dans la semaine). Le tag déclenche le build de l’image de production, le déploiement reste un clic volontaire, et un smoke test k6 vérifie les parcours clés juste après.

Le chemin d'un changement jusqu'à la production, de gauche à droite : merge request, merge sur main, tag de release, déploiement, smoke test ; sous le pipeline, le nombre de tags par jour pendant la semaine de lancement Merge request sandbox + k6 Merge main préprod + k6 Tag de release AAAA-SS-NN Déploiement build + 1 clic Smoke prod k6, lecture seule Semaine de lancement : tags de release par jour 10 lun. 23/02 1 mar. 24/02 2 mer. 25/02 0 jeu. 26/02 1 ven. 27/02
Un tag de release par mise en production. Le lundi du lancement, on en a posé dix.

Ce modèle a une vertu qu’on mesure surtout un jour de lancement : chaque correctif est une release identifiable, qu’on peut relire, rejouer ou annuler. Dix releases dans la journée, c’est la boucle courte qui fonctionne.

Lundi : la mémoire qui monte

Les premières heures se passent bien. Puis les graphes de mémoire des pods SSR commencent à dessiner des dents de scie : la heap grimpe jusqu’à environ 1 Go, le pod est tué pour dépassement mémoire, un autre prend le relais et recommence. Les visiteurs voient quelques erreurs, les pods redémarrent en boucle.

On a d’abord donné de l’air au système, parce qu’on ne débogue pas sereinement un site qui tombe. Dans la journée, la heap Node passe de 1,5 à 2 Go, la mémoire des pods de 2 à 3 Go, et l’autoscaling de 6 à 15 pods vers 10 à 30. Ce n’est pas une correction, c’est un garrot. Il fallait trouver la fuite.

Le même jour, deux premières sources tombent :

  • L’état du paiement Adyen vivait au niveau du module. Trois variables let en haut du composable, pensées comme un singleton côté navigateur. Côté serveur, un module est chargé une fois pour toutes les requêtes : ces variables gardaient une référence vers l’instance de la dernière requête, et à travers elle vers tout son contexte. On les a déplacées sur nuxtApp, qui est propre à chaque requête côté serveur et persistant côté navigateur.
  • Plusieurs caches sans borne. Les réponses Apollo s’accumulaient dans le cache en mémoire à chaque rendu, les réponses du CMS (plus de 600 Ko par langue) n’étaient pas mises en cache côté serveur, et deux rate limiters grossissaient sans limite sous le trafic des bots. Politique no-cache pour Apollo en SSR, handler mis en cache pour le CMS, maps plafonnées à 10 000 entrées avec un balayage régulier.

La courbe se calme. Elle ne s’aplatit pas.

Mardi : la fuite qui ne touchait que les clients connectés

Pour aller plus loin, il fallait reproduire. On avait une suite k6 depuis la préparation du lancement ; on l’a transformée cette semaine-là en véritable outil d’enquête :

  • 100 comptes de test distincts, créés automatiquement s’ils manquent, avec des wishlists remplies ;
  • trois langues en parallèle (France, Italie, Espagne) avec leurs propres seuils ;
  • des parcours variés : navigation, pages de contenu, ajout au panier, checkout sans paiement, espace client ;
  • un scénario de robot qui demande des URL erronées, et un contournement du cache pour forcer le rendu SSR.

Le résultat a été immédiat et surprenant : les visiteurs anonymes ne fuyaient pas. Seules les requêtes authentifiées faisaient grossir la heap.

La cause tenait en une ligne. Le plugin qui injecte le jeton client dans Apollo lisait le cookie à l’intérieur du hook apollo:auth, qui se déclenche pour chaque requête GraphQL. Or useCookie n’est pas une simple lecture : il crée un Ref et enregistre deux hooks qui capturent nuxtApp. Avec cinq à dix requêtes par page, chaque rendu authentifié laissait derrière lui des dizaines d’objets réactifs accrochés au contexte de la requête.

export default defineNuxtPlugin((nuxtApp) => {
  // Lu une seule fois par requête, dans le contexte du plugin
  const apolloToken = useCookie<string | null>('apollo.token')

  nuxtApp.hook('apollo:auth', ({ token }) => {
    token.value = apolloToken.value || null
  })
})

Avant le correctif, la ligne useCookie était à l’intérieur du hook. C’est le genre de bug qu’aucune revue ne repère, parce que le code est correct d’un point de vue fonctionnel. Seul un test de charge authentifié le fait apparaître.

Mercredi : comprendre pourquoi Nuxt ne libère rien

Restait une croissance lente, sur tous les types de trafic. Un snapshot de heap pris sur un pod après 743 requêtes pesait 785 Mo : environ 1,16 Mo retenu par requête, dominé par les objets de réactivité de Vue.

L’explication est structurelle. En SSR, Nuxt crée une application Vue complète par requête, la rend en HTML, puis l’abandonne au garbage collector. Ça fonctionne tant que rien d’extérieur à la requête ne pointe vers elle. Mais plusieurs bibliothèques gardent une référence au niveau du module, pensée pour le navigateur où il n’y a qu’une seule application :

Quatre variables de module retiennent les objets d'une requête SSR terminée ; le plugin de nettoyage coupe ces références à la fin de chaque rendu Requête N, censée disparaître Variables de module, jamais libérées nuxtApp de la requête app Vue, router, contexte Pinia et ses stores panier, utilisateur, wishlist Client Apollo cache, requêtes en cours Overlays Nuxt UI objets réactifs par rendu Effect scopes, vue-i18n watchers, computed État du paiement Adyen let checkoutInstance retient Pinia active activePinia retient Clients Apollo fournis currentApolloClients retient Composable partagé useOverlay() accumule Plugin ssr-cleanup, sur app:rendered et app:error arrêt des scopes, disposePinia, reset Apollo, vidage des singletons, unmount
Une requête SSR terminée reste en mémoire tant qu'une variable de module pointe vers elle.
  • activePinia garde la dernière instance Pinia, donc ses stores, donc l’application Vue, donc le router.
  • currentApolloClients garde le dernier client Apollo et son cache normalisé.
  • useOverlay de Nuxt UI est un composable partagé : son tableau d’overlays vit au niveau du module et reçoit une entrée réactive à chaque rendu, sans jamais être vidé, puisqu’en SSR les composants ne sont jamais démontés.
  • Les effect scopes de Nuxt, de Pinia et de vue-i18n ne sont jamais arrêtés.

La réponse a été un plugin serveur exécuté en dernier, qui démonte proprement tout ce que la requête a créé : arrêt des scopes, disposePinia, remise à zéro des clients Apollo, vidage des singletons, puis vueApp.unmount() pour déclencher les nettoyages que les bibliothèques ont branchés sur le démontage.

Un détail nous a coûté une journée : le nettoyage doit aussi tourner sur app:error. Quand le rendu échoue, Nuxt déclenche app:error mais jamais app:rendered. Sans ce second hook, chaque page en erreur, et les robots en génèrent beaucoup, fuyait l’intégralité de son contexte.

La semaine suivante : défaire ce qui allait trop loin

Dans l’élan, on a aussi poussé des optimisations plus agressives. La plus tentante : la configuration des storefronts (385 Ko) vivait dans app.config, que Nuxt clone en profondeur à chaque accès pendant le rendu. On l’avait remplacée par un objet gelé partagé entre les requêtes, et on avait neutralisé un module Apollo inutilisé par un alias vers un module vide.

Sur le papier, c’était autant d’allocations en moins. En pratique, ces contournements introduisaient des régressions subtiles, parce qu’ils sortaient du cycle de vie prévu par le framework. Le 9 mars, on a tout annulé, puis réintroduit uniquement ce qui tenait.

Ce qui est resté, ce sont les corrections qui suivent le framework : le cookie lu au bon endroit, l’état déplacé sur nuxtApp, les composants client-only gardés côté client, les caches bornés et le plugin de nettoyage branché sur les hooks officiels. Ce qui est parti, ce sont les raccourcis qui le contournaient.

Juillet : quand la mémoire n’est plus une fuite mais une vague

Avec les fuites corrigées, la mémoire des pods est devenue stable en régime normal. Restaient les pics. Le 3 juillet, un pic de trafic soudain a saturé le rendu SSR. Un pod est tombé pour dépassement mémoire, et les cinq autres ont été tués par Kubernetes en moins de vingt secondes. Pendant deux minutes, de vrais clients ont reçu des erreurs.

Le diagnostic était gênant parce qu’il venait de nous : la sonde de liveness échouait dès que la heap dépassait un seuil. Sous une charge qui touche tous les pods en même temps, cela revient à demander à Kubernetes de redémarrer toute la flotte d’un coup, au moment précis où on en a le plus besoin.

On a remis les lignes de défense dans le bon ordre : délester d’abord, sortir un pod du trafic ensuite, ne le redémarrer qu’en dernier recours.

Trois paliers, de gauche à droite à mesure que la pression monte sur un pod : délester d'abord, sortir du load balancer ensuite, redémarrer seulement après plusieurs minutes au-dessus d'un seuil élevé Rendu normal pages rendues en SSR 1. Délester 503 + Retry-After 2. Sortir du pool readiness /healthz 3. Redémarrer liveness /livez Pression croissante sur le pod, puis pression qui dure
Le délestage se déclenche au-delà d'un seuil de heap, d'un nombre de rendus simultanés ou d'un retard de la boucle d'événements.

La liveness est devenue aveugle à la pression passagère. La nouvelle route /livez répond 200 tant que le process vit. Une seule exception : une heap bloquée au-dessus d’un seuil élevé pendant plusieurs minutes consécutives, signe d’une vraie fuite que le garbage collector ne rattrapera jamais. Les pics mesurés durent une à deux minutes : un redémarrage synchronisé de la flotte devient impossible.

La readiness, elle, reste sensible. Au-delà d’un seuil de heap, le pod sort du load balancer le temps que le GC fasse son travail, puis revient. Personne n’est tué.

Et avant tout cela, un middleware de délestage. Au-delà d’un seuil de heap, d’un nombre de rendus simultanés ou d’un retard de la boucle d’événements, les nouvelles demandes de page reçoivent immédiatement un 503 avec un Retry-After. Les routes d’API (panier, paiement, compte) et les sondes de santé ne sont jamais délestées : seul le rendu HTML l’est, et le navigateur sait réessayer.

if (heapPressure || eventLoopSaturated || inFlightRenders >= MAX_CONCURRENT_RENDERS) {
  // Réponse brute : createError rendrait error.vue en SSR, soit un rendu complet par requête délestée
  const res = event.node.res
  res.statusCode = 503
  res.setHeader('content-type', 'text/plain')
  res.setHeader('retry-after', '5')
  res.setHeader('cache-control', 'no-store')
  res.end('Server overloaded')
  return
}

Le commentaire de ce bloc résume un piège qu’on a évité de justesse : renvoyer une erreur Nuxt classique aurait déclenché le rendu de la page d’erreur, soit environ 240 Ko de travail par requête délestée. Un délestage qui fait du rendu ne déleste rien.

Le déclencheur sur la boucle d’événements est arrivé en août, après une série de rafales courtes où le plafond de rendus simultanés n’était jamais atteint alors que les sondes, trop nerveuses, vidaient déjà le pool. On a mesuré ce qui saturait vraiment (le retard de la boucle d’événements, via monitorEventLoopDelay) et on a assoupli les sondes pour qu’elles n’agissent qu’après une saturation durable. Lors du test de validation en préproduction, avec une charge supérieure à l’incident réel, le pool n’est jamais descendu sous deux pods et aucun n’a redémarré.

Entre-temps, la flotte elle-même a changé de forme : trois pods à la demande en socle fixe, et entre 4 et 28 pods sur instances Spot pilotés par l’autoscaling. Un socle stable pour la disponibilité, l’élasticité au meilleur coût pour les pics.

Ce qu’on a appris

  • Le jour du lancement est le premier test réel du projet. Prévoyez l’équipe, les outils d’observation et le modèle de release pour corriger dix fois dans la journée.
  • Testez en charge avec des utilisateurs connectés. Notre fuite la plus sévère ne touchait que les requêtes authentifiées. Une suite k6 uniquement anonyme ne l’aurait jamais montrée.
  • En SSR, une variable de module est une variable partagée entre tous vos clients. Tout ce qui ressemble à un singleton côté navigateur doit être relu avec cette question en tête.
  • Suivez le framework, même pour optimiser. Nos correctifs durables sont ceux qui passent par les hooks et le cycle de vie prévus. Les contournements astucieux ont été annulés deux semaines plus tard.
  • Une sonde de liveness ne doit jamais réagir à un état récupérable. Sinon, une surcharge globale devient un redémarrage global.
  • Délester tôt, avec une réponse qui ne coûte rien. Un 503 brut avec Retry-After vaut mieux qu’une page d’erreur rendue, et bien mieux qu’un pod qui tombe.

La suite de l’histoire, ce sont les fonctionnalités qu’on a pu livrer sur ce socle, en commençant par le checkout mobile et les Core Web Vitals.