Les fuites de mémoire lentes dans Node.js sont généralement ennuyeuses de la pire façon possible.
Le service démarre correctement. Le débit semble normal. L'utilisation du CPU est acceptable. Puis la mémoire grimpe régulièrement pendant des heures jusqu'à ce que le conteneur soit redémarré et que le cycle recommence.
Ce modèle est un cadeau. Cela signifie que vous pouvez enquêter méthodiquement.
Commencez par prouver qu'il s'agit d'une fuite
Un graphique de mémoire montante à lui seul n'est pas suffisant.
Demandez :
- la mémoire baisse-t-elle après une diminution du trafic ?
- la croissance se trouve-t-elle dans
heapUsed, rss, ou les deux ?
- le taux de requêtes ou la taille de la charge utile a-t-il changé en même temps ?
Si la mémoire continue de grimper sans redescendre, commencez à chercher des références retenues.
Les snapshots de heap sont l'outil sérieux le plus rapide
Si la fuite se trouve dans des objets JavaScript, prenez un snapshot de heap :
import { writeHeapSnapshot } from "node:v8";
const path = writeHeapSnapshot();
console.log(`snapshot de heap : ${path}`);
Ouvrez-le dans DevTools et triez par taille retenue.
La phrase importante est taille retenue, pas taille superficielle. Vous cherchez l'objet qui maintient un sous-graphe anormalement grand en vie.
Un modèle de fuite très courant
Cette forme apparaît tout le temps dans des services réels :
app.use((req, _res, next) => {
db.on("queryCompleted", () => {
logger.info({ userId: req.user.id }, "requête terminée");
});
next();
});
Le problème n'est pas seulement le nombre d'écouteurs. C'est ce que l'écouteur capture.
Chaque requête ajoute un nouveau rappel à un émetteur de longue durée. Chaque rappel retient req. Si rien ne supprime l'écouteur, les anciens objets de requête restent accessibles et le ramasse-miettes ne peut pas les récupérer.
La solution est généralement la discipline de cycle de vie
Préférez l'un de ces modèles :
- enregistrez un seul écouteur de longue durée en dehors du chemin de requête
- utilisez
.once(...) si l'écouteur doit se supprimer automatiquement
- supprimez explicitement les écouteurs dans les chemins de nettoyage
Par exemple :
function onQueryCompleted(event) {
logger.info({ queryId: event.id }, "requête terminée");
}
db.on("queryCompleted", onQueryCompleted);
Cette version ne capture plus accidentellement des données spécifiques à la requête.
Que rechercher dans le snapshot
Les signes les plus utiles sont :
- de grands tableaux ou cartes suspendus à des objets globaux
- des fermetures conservant un état spécifique à la requête
- des caches sans éviction
- des listes d'écouteurs croissant avec le trafic
Vous ne cherchez pas seulement "gros". Vous cherchez "pourquoi cela est-il encore vivant ?"
Prévenir la prochaine fuite
Quelques habitudes permettent de repérer de nombreuses fuites Node tôt :
- éviter d'attacher des écouteurs à l'intérieur de chemins de requête préoccupants
- lier explicitement les caches
- préférer le streaming au buffering de grandes charges utiles
- tracer la mémoire parallèlement au débit
- prendre des snapshots avant que l'incident ne s'aggrave
Le débogage des fuites de mémoire semble mystérieux jusqu'à ce que vous voyiez la première clairement. Après cela, la plupart se réduisent à la durée de vie des objets.
Lectures complémentaires