Il y a une courbe que j'ai appris à reconnaître. Elle ne ressemble pas à un incident : pas de pic, pas d'alerte, pas de pod tué en pleine nuit. C'est un escalier. La mémoire d'un pod Next.js monte d'une marche, se stabilise, remonte d'une marche, et ne redescend jamais. Sur le site à fort trafic sur lequel je travaille, un pod web est passé d'environ 700 Mo à près de 2 Go en trois jours. Puis on déploie, les pods redémarrent, la courbe repart du bas, et tout le monde oublie.

Je serais sans doute passé à côté sans un projet précis : faire passer les photos des résultats de recherche par next/image. Aujourd'hui, ces photos sont servies telles quelles par le CDN, en 1920 px de large, dans des cartes qui en affichent à peine 400. Sur mobile, c'est du gâchis pur, et ça se paie en Core Web Vitals. Le composant next/image est fait exactement pour ça. Sauf que l'optimiseur d'images avait déjà fait grimper la mémoire de l'infra par le passé, et qu'on l'avait depuis soigneusement tenu à l'écart des photos d'annonces.

Avant de lui confier des dizaines de milliers de photos, je voulais comprendre ce qui se passe vraiment derrière /_next/image. Spoiler : ce n'est pas une fuite. Et la solution qui tient en deux lignes de Dockerfile ne règle que la moitié du problème.

Ce qui se passe derrière /_next/image

Tout ce qui suit concerne Next.js 15.5 en mode standalone, auto-hébergé dans Docker sur Kubernetes, avec Cloudflare devant. J'ai vérifié le comportement dans le code de Next (dist/server/image-optimizer.js) plutôt que dans des articles de blog : il a changé plusieurs fois d'une version à l'autre, et Next 16 modifie encore certaines valeurs par défaut.

Quand un composant next/image s'affiche, le navigateur ne demande pas l'image source mais une URL du type /_next/image?url=<source>&w=640&q=75. Voici le trajet de cette requête :

navigateur
  |  GET /_next/image?url=...&w=640&q=75   (Accept: image/webp, ...)
  v
Cloudflare : cf-cache-status: DYNAMIC, rien n'est mis en cache
  v
pod Next.js
  |- cache disque (.next/cache/images), clé = source + w + q + format
  |     HIT     -> réponse immédiate
  |     périmé  -> réponse en cache, revalidation de la source en arrière-plan
  '- MISS -> téléchargement de la source
            -> sharp : décodage, redimensionnement, encodage WebP
            -> écriture sur le disque, puis réponse
            Cache-Control: public, max-age=<durée>, must-revalidate

Trois détails de ce schéma comptent plus que les autres.

D'abord, Cloudflare ne cache rien. Par défaut, il décide de mettre en cache selon l'extension du fichier, pas selon le type MIME. Une URL /_next/image n'a pas d'extension : elle repart avec DYNAMIC, ce que la doc de Cloudflare traduit par "pas éligible au cache". Chaque requête atteint un pod.

Ensuite, chaque pod a son propre cache disque, et ce cache vit dans le conteneur. Un déploiement, un nouveau pod lancé par l'autoscaling : cache vide. Dans notre cas, les URL des assets du site contiennent en plus le hash du commit. Chaque mise en prod change donc toutes ces URL et fait tout réoptimiser.

Enfin, la durée de validité. Next prend le maximum entre images.minimumCacheTTL (60 secondes par défaut en 15.x) et le max-age annoncé par la source. Les photos et les assets servis par notre CDN annoncent un an, immutable : parfait. Les images envoyées dans le back-office n'annoncent rien. Elles sont donc considérées comme périmées au bout d'une minute, et Next retélécharge la source pour comparer son etag. Sur chaque pod. Et la doc le dit sans détour : il n'existe aucun mécanisme d'invalidation.

Sur une semaine, ça représentait plusieurs centaines de milliers de requêtes /_next/image, environ 3 % du trafic, pour quelques milliers d'images sources et près de 18 000 combinaisons source × largeur × qualité. Les plus demandées étaient des visuels fixes et déjà légers : badges des stores, QR code, bannières. Des images qui ne gagnaient presque rien à être réencodées.

Pourquoi "fuite" est le mauvais mot

La première hypothèse qui vient, c'est une fuite dans le code JavaScript : un cache applicatif qui grossit, un listener jamais retiré. Mauvaise piste, et l'issue vercel/next.js#44685 l'illustre bien. Ouverte en janvier 2023 sous le titre "Another Memory Leak in next/image", elle est toujours ouverte et confirmée par l'équipe de Next. En la lisant, un détail saute aux yeux : la "fuite" apparaît sur des images Docker basées sur la glibc, pas sur Alpine.

Le problème se situe sous JavaScript, dans l'allocateur mémoire : la partie de la libc qui réserve et libère la mémoire demandée par le programme. sharp est une bibliothèque native, construite sur libvips, écrite en C. Pour chaque image, elle alloue de gros tampons : l'image décodée, l'image redimensionnée, le résultat encodé. Et elle le fait depuis plusieurs threads.

Pour que les threads ne se bloquent pas les uns les autres, l'allocateur de la glibc crée des zones mémoire séparées, les arènes, en nombre proportionnel aux cœurs disponibles. Quand sharp libère ses tampons, la mémoire libre reste éparpillée dans ces arènes, et la glibc la rend très mal au système. Le RSS du process monte par paliers et ne redescend presque pas. Rien n'est perdu au sens d'une fuite : la mémoire est libre, mais elle n'est pas rendue.

Je n'invente rien : la documentation de sharp qualifie l'allocateur par défaut de la glibc de "unsuitable" pour les process longs, multithreadés, qui font beaucoup de petites allocations, et recommande jemalloc. Les systèmes basés sur musl, comme Alpine, ne sont pas concernés.

Deux conséquences pratiques. D'abord, --max-old-space-size ne sert à rien : cette option limite le heap de V8, et la mémoire en cause est hors du tas. Ensuite, les garde-fous existants ne suffisent pas. sharp détecte la glibc et descend à 1 thread libvips par image quand jemalloc n'est pas chargé (Next, de son côté, divise cette concurrence par deux en production dès qu'elle dépasse 1). Plusieurs images restent traitées en parallèle, et la courbe montait quand même.

Un point d'honnêteté, avant d'aller plus loin : en production, je n'ai pas pu prouver que toute la hausse venait de sharp. Les métriques internes de Node (taille du tas, RSS) ne remontent pas dans notre monitoring. J'avais un mécanisme documenté, des symptômes cohérents avec lui et une issue publique qui décrit la même chose. C'est une hypothèse solide, pas un diagnostic. Pour passer de l'une à l'autre, il fallait un essai.

Un problème, même sans OOM

Avant l'essai, je me suis posé une question bête : si aucun pod ne plante, est-ce vraiment un problème ? Nos pods demandent 2 Go et ont une limite à 4 Go. Ils ne se font pas tuer pour dépassement de mémoire.

Sauf que l'autoscaling (le HPA de Kubernetes) calcule l'utilisation mémoire par rapport à ce que le pod demande, pas par rapport à sa limite. Avec un seuil à 80 %, il se déclenche vers 1,6 Go. Une mémoire qui monte sans jamais redescendre finit donc par ajouter des pods qui ne servent à rien, et qui vont monter à leur tour. Le rythme des déploiements masque le phénomène en redémarrant tout avant qu'il se voie. Un symptôme masqué par la fréquence des mises en prod reste un symptôme.

L'essai : deux pods, la même charge

Plutôt que de tester jemalloc directement en production ><, j'ai monté un essai jetable dans notre environnement de démo et un pod témoin.

  • Le pod jemalloc charge l'allocateur via LD_PRELOAD (les deux lignes un peu plus bas), avec un cache disque borné à 1 Go.
  • Le pod témoin a le même Dockerfile que la prod : glibc, cache disque non borné. Il a été redémarré juste avant l'essai pour partir d'un cache vide.
  • Même nœud de 16 cœurs, un réplica chacun, 768 Mi demandés, aucune limite CPU ni mémoire, pas de Cloudflare devant.

Un script envoyait les mêmes requêtes aux deux pods, 6 en parallèle. Phase 1 : 10 000 vraies photos d'annonces, en 4 largeurs (384, 640, 750 et 828 px) et en qualité 75, soit environ 40 000 variantes, plus que ce que la prod voit en une semaine. Phase 2 : les mêmes photos en qualités 50 et 90, soit 80 000 variantes jamais vues, donc 80 000 passages par sharp. Je relevais toutes les minutes le working_set de chaque pod, la mémoire que regarde l'autoscaling.

Le working_set, en Mo, à quelques moments clés :

  • au repos : 171 pour le pod jemalloc, 130 pour le témoin ;
  • à 20 minutes, fin de la phase 1 : 357 contre 583 ;
  • à 52 minutes, en pleine phase 2 : 790 contre 1 295 ;
  • à 70 minutes : 739 contre 5 063.

Le pod jemalloc s'est stabilisé entre 700 et 800 Mo pendant toute la phase 2, avec un maximum à 814 Mo. Entre les deux phases, il était même redescendu de 712 à 357 Mo. Le témoin, lui, a monté par paliers (1,4 Go, puis 3,3 Go, puis 5 Go) sans jamais redescendre. Exactement l'escalier de la prod, en accéléré. Côté service, rien à signaler : 100 % de réponses 200 des deux côtés, aucun redémarrage, et un p95 de 195 ms pour jemalloc contre 155 ms pour le témoin.

Au passage, une photo servie par next/image pesait de 41 à 99 Ko en WebP, contre 326 à 646 Ko pour le JPEG d'origine. C'est tout l'intérêt du projet.

Lire l'essai sans se raconter d'histoires

Ces chiffres sont flatteurs, et c'est justement pour ça qu'il faut regarder les biais.

Le premier joue contre jemalloc. Avec son cache borné à 1 Go, le pod jemalloc évinçait des variantes et les recalculait en continu : 137 552 requêtes, toutes en MISS. Le témoin, avec son cache illimité, est repassé en HIT une fois ses 80 000 variantes calculées (155 121 HIT). Le pod jemalloc a donc fait tourner sharp beaucoup plus souvent, et il finit pourtant avec environ 7 fois moins de mémoire.

Le deuxième, je ne l'ai vu qu'en relisant le code de sharp pour écrire cet article. La descente à 1 thread par image ne s'applique que si sharp ne détecte pas jemalloc, et il le vérifie à l'exécution, ce qui fonctionne avec LD_PRELOAD. Le pod jemalloc tournait donc avec beaucoup plus de threads libvips par image que le témoin : avec la division par deux de Next, sans doute 8 contre 1 sur ce nœud. Les deux pods ne faisaient pas exactement le même travail. Ça explique probablement une partie du CPU consommé, qui a dépassé 4 cœurs sur le pod jemalloc. Et ça renforce le résultat sur la mémoire plus que ça ne l'affaiblit : plus de threads, c'est précisément ce qui fait souffrir la glibc.

Restent les limites, tout simplement. C'est une seule expérience, avec un seul pod par démo, sur un nœud partagé, sans cache CDN, avec une charge synthétique, et rien sur le rendu visuel ni sur le LCP. Ce que l'essai montre : sous cette charge, l'allocateur fait la différence entre un pod qui plafonne et un pod qui ne s'arrête plus. Ce qu'il ne montre pas : que toute la hausse observée en prod vient de là. La mesure qui tranchera, c'est la même courbe une à deux semaines avant et après jemalloc en production. Elle n'est pas encore faite.

Les deux lignes, et leurs petites lignes

Sur une image Debian (ici node:24-slim), le correctif tient en deux instructions, juste avant le CMD :

RUN apt-get update && apt-get install -y --no-install-recommends libjemalloc2 \
    && rm -rf /var/lib/apt/lists/*
ENV LD_PRELOAD=libjemalloc.so.2

LD_PRELOAD demande au chargeur dynamique de charger jemalloc avant les autres bibliothèques : les malloc et les free de tout le process passent alors par jemalloc. Ce n'est pas une idée exotique. C'est exactement ce que propose la PR vercel/next.js#95821 pour l'exemple Docker officiel, encore ouverte au moment où j'écris. Dans la mesure publiée par son auteur, après une charge d'optimisation d'images et 30 secondes de repos, la mémoire du conteneur a pris 318 Mo avec la glibc contre 37 Mo avec jemalloc, pour 7 Mo de plus au démarrage.

Maintenant, les petites lignes. LD_PRELOAD s'applique à tous les process du conteneur : aucun souci quand il n'y a que node server.js, mais à surveiller s'il y a un script d'entrée ou des outils lancés à côté. Le nom du paquet et celui de la bibliothèque dépendent de la distribution. Et sur Alpine, ne faites rien de tout ça : musl n'a pas le problème. On aurait d'ailleurs pu passer sur Alpine. Je ne l'ai pas retenu : changer de libc pour toute l'application, c'est changer bien plus que l'allocateur, pour régler un problème qui tient en deux lignes.

Pour vérifier que jemalloc est réellement chargé dans un pod (ici, Node est le process 1 du conteneur) :

kubectl exec <pod> -- grep -m1 jemalloc /proc/1/maps

Ce que jemalloc ne règle pas

Une fois la courbe aplatie, la tentation est de s'arrêter là. Ce serait confondre "le pod tient" et "le système est sain". jemalloc rend la mémoire, mais chaque pod continue de recalculer les mêmes images, chaque déploiement continue de vider les caches, et le CPU continue d'encoder du WebP. Avec les photos de recherche, on passe d'une trentaine de visuels fixes à des dizaines de milliers de photos de 1920 px : chacun de ces coûts est multiplié.

Un cache partagé devant les pods

Le levier principal, c'est une règle de cache Cloudflare sur /_next/image*. Les photos et les assets annoncent déjà un an de validité : si Cloudflare ne les garde pas, c'est seulement parce que l'URL n'a pas d'extension. Avec la règle, une image ne serait optimisée qu'une fois pour tous les pods, et ce cache survivrait aux déploiements, puisque les URL des photos ne contiennent pas le hash du commit.

Mais une règle copiée-collée ne suffit pas, pour trois raisons.

  • Le format. Next choisit WebP ou le format d'origine selon l'en-tête Accept, et répond avec Vary: Accept. Par défaut, Cloudflare ignore Vary, sauf Accept-Encoding : un navigateur qui ne lit pas le WebP pourrait recevoir la version WebP mise en cache pour un autre visiteur. C'est marginal aujourd'hui, mais c'est un choix à assumer, ou à traiter avec "Vary for Images", réservé aux offres payantes.
  • La clé. La query string fait partie de l'URL : chaque combinaison url × w × q est une entrée de cache à part, sur Cloudflare comme sur les pods. Moins il y a de variantes, mieux le cache est réutilisé.
  • L'invalidation. Une image modifiée sans changement d'URL reste servie jusqu'à l'expiration. Nos photos ont une empreinte dans l'URL et le CDN les déclare immutable, ce qui laisse penser qu'une photo remplacée change d'URL. Je ne l'ai pas encore confirmé avec l'équipe qui gère les photos. D'ici là, c'est une hypothèse, et elle conditionne la durée de cache.

Côté Next, minimumCacheTTL passerait de 60 secondes à plusieurs jours. Seules les images du back-office, sans cache-control, sont concernées : elles cesseraient d'être retéléchargées toutes les minutes sur chaque pod.

Moins de variantes

Sans sizes, le navigateur choisit parmi toutes les largeurs de deviceSizes et imageSizes, de 16 à 3840 px par défaut. Un sizes qui décrit la vraie mise en page, et des listes réduites aux largeurs utiles, concentrent les visiteurs sur peu de variantes.

La liste qualities mérite aussi un ménage : la nôtre contenait 25, 50, 75, 90 et 100, alors que le code n'utilisait que 75 et 100. Attention au piège : en 15.5, une qualité absente de la liste fait planter le rendu du composant, et l'optimiseur répond 400 si on l'appelle directement. On ne retire une valeur que lorsque plus personne ne l'utilise.

On garde aussi le WebP seul : l'AVIF compresse mieux, mais coûte nettement plus de CPU à l'encodage. Et les visuels fixes déjà légers passent en unoptimized : ils faisaient l'essentiel du volume sans rien y gagner.

Borner le disque

images.maximumDiskCacheSize n'existe que depuis Next 15.5.14. Sans valeur, Next réserve 50 % de l'espace disque libre mesuré au démarrage. Sur Kubernetes, sans volume dédié ni limite ephemeral-storage, ce disque est celui du nœud, partagé avec les autres applications. Un seul pod peut en remplir la moitié, jusqu'à ce que le nœud passe en DiskPressure et expulse des pods.

Il faut donc une borne côté Next et la même côté Kubernetes : une limite ephemeral-storage, ou un emptyDir avec sizeLimit monté sur .next/cache. Et la valeur ne se choisit pas seule. Trop petite, Next évince et relance sharp : le pod jemalloc de l'essai en est la démonstration. Trop grande, on reprend le risque sur le disque. Avec Cloudflare devant, ce cache n'est qu'un second niveau, et 1 à 2 Go suffisent probablement. Sans Cloudflare, c'est une autre histoire. Petite précision si vous utilisez déjà un cache handler personnalisé : d'après la doc de Next 15, maximumDiskCacheSize est alors ignoré.

Il reste une piste que je n'ai pas mesurée. Linux garde en RAM les fichiers lus et écrits récemment, et une partie de ce page cache est comptée dans le working_set du conteneur. Un gros cache disque très sollicité pourrait donc gonfler la mémoire affichée. Je la garde comme hypothèse, à vérifier si la mémoire reste haute après jemalloc.

Isoler les images du rendu des pages

Dernier point, plus structurel : le même process Node rend les pages et optimise les images. Les tâches de sharp attendent un thread du pool de libuv, 4 threads par défaut, le même pool qui sert aux résolutions DNS, à la compression gzip et aux lectures disque. Une vague d'images à optimiser peut donc retarder le rendu d'une page de recherche.

La piste envisagée : un second Deployment avec la même image Docker, son propre HPA basé sur le CPU, et une règle d'ingress qui lui envoie /_next/image. C'est plus d'infra à maintenir et au moins deux pods de plus, mais une saturation des images ne ralentirait plus que les images.

À vérifier sur votre propre infra

Si vous servez next/image en auto-hébergé, voici ce que je regarderais, dans cet ordre.

# 1. Quelle libc dans l'image ? glibc : concerné, musl : non
docker run --rm --entrypoint ldd <votre-image> --version

# 2. Le cache partagé fonctionne-t-il ? Lancez la commande deux fois :
#    la seconde devrait renvoyer cf-cache-status: HIT
curl -sI -H 'Accept: image/webp' \
  'https://votre-site.example/_next/image?url=%2Fimage.jpg&w=640&q=75' \
  | grep -iE 'cf-cache-status|x-nextjs-cache|cache-control|vary'

# 3. Quelle taille fait le cache disque d'un pod ? (adaptez au WORKDIR)
kubectl exec <pod> -- du -sh /app/.next/cache/images

Pour mesurer l'effet de jemalloc chez vous, reproduisez la logique de l'essai plutôt que ses chiffres. Il faut deux pods identiques à l'allocateur près, partis d'un cache vide, et la même liste d'URL /_next/image rejouée en parallèle, avec une phase de variantes jamais vues pour forcer sharp à travailler. Relevez le working_set toutes les minutes, la part de MISS dans x-nextjs-cache, le p95 et le CPU. Et donnez aux deux pods le même cache disque et la même concurrence sharp. Je ne l'ai pas fait, et ça m'a coûté deux paragraphes de précautions.

La checklist courte que j'en retire :

  • la libc de l'image : avec la glibc, jemalloc, ou au minimum une mesure ;
  • cf-cache-status sur /_next/image : DYNAMIC signifie que chaque requête atteint vos pods ;
  • le cache-control des images sources : sans max-age, la validité tombe à minimumCacheTTL ;
  • le nombre de valeurs distinctes de w et q par image dans vos logs ;
  • maximumDiskCacheSize et ephemeral-storage, déclarés ensemble ;
  • le seuil mémoire du HPA, comparé à la courbe réelle d'un pod sur plusieurs jours sans déploiement.

Ce que j'en retiens

next/image est simple à utiliser et trompeusement simple à exploiter. Derrière le composant, il y a une petite chaîne de traitement d'images : un cache par pod, une durée de validité héritée de la source, un encodeur natif multithread et un allocateur qui n'a pas été pensé pour lui. Sur Vercel, l'optimisation est un service de la plateforme. En auto-hébergé, cette chaîne est à vous, avec tous ses réglages par défaut.

jemalloc, ce sont deux lignes que je mettrais dans n'importe quelle image Debian qui fait tourner sharp : c'est l'étape la moins chère et la mieux démontrée. Mais si je m'étais arrêté là, j'aurais eu un pod qui tient et un système qui recalcule toujours la même photo, sur chaque pod, après chaque déploiement. La vraie question n'est pas "combien de mémoire prend sharp", mais "combien de fois on lui demande de refaire le même travail".

Pour les photos de recherche, le plan avance par étapes, chacune mesurable : jemalloc en production avec deux semaines de courbe avant et après, la règle de cache CDN, le ménage dans les variantes, des pods dédiés, puis un pilote sur notre plus petit site avant le plus gros. Avec un critère de repli : si le cache CDN ne prend pas, on demandera au CDN des photos de produire une taille intermédiaire plutôt que d'ajouter des pods. Rien de tout ça n'est encore en production au moment où j'écris. Je reviendrai avec la vraie courbe, qu'elle me donne raison ou non.

Sources