16 - Serveurs Node.js et mémoire
Ce que tu vas apprendre
- Comment configurer --max-old-space-size et pourquoi la valeur par défaut ne suffit pas toujours
- Les erreurs classiques qui font exploser la mémoire d'un serveur
- Dimensionner les pools de connexions sans gaspiller
- Mettre en place un monitoring mémoire et un redemarrage gracieux
Prerequisites
- Avoir une appli Node.js en production (ou en staging)
- Avoir lu le profiling Node.js pour les outils de diagnostic
512 Mo par défaut, et apres ?
V8 alloue par défaut environ 1.5 Go pour le old space sur les systèmes 64 bits. Ca parait beaucoup, mais un serveur qui traite des fichiers, manipule du JSON volumineux ou fait du SSR peut atteindre cette limite en quelques heures.
bashnode --max-old-space-size=2048 server.js
Cette option fixe la taille maximale du old space en Mo. Quelques repères :
- API REST legere : 256-512 Mo suffisent
- SSR React/Next.js : 1024-2048 Mo selon le trafic
- Traitement de fichiers : 2048-4096 Mo si tu charges des fichiers en mémoire (mais tu devrais utiliser des streams)
Le piège : augmenter la limite masque les fuites. Si ta mémoire monte lineairement, 4 Go au lieu de 1.5 Go te donne juste plus de temps avant le crash. Ca ne fixe rien.
Ne stocke pas les donnees de requête globalement
typescript// La fuite classique du serveur
const requestCache = new Map<string, unknown>();
app.post("/webhook", (req, res) => {
const id = req.body.id;
requestCache.set(id, req.body); // On ajoute
processWebhook(req.body);
res.sendStatus(200);
// On ne supprime jamais de requestCache
});
J'ai vu ce pattern sur trois projets différents. L'intention est bonne : deduplication ou idempotence. Mais sans nettoyage, le Map grossit indefiniment. En production avec 1000 requêtes par minute, ca fait 1.4 million d'entrees par jour.
typescript// Fix : TTL sur les entrees
const requestCache = new Map<string, { data: unknown; timestamp: number }>();
function cleanCache() {
const now = Date.now();
for (const [key, value] of requestCache) {
if (now - value.timestamp > 5 * 60 * 1000) {
requestCache.delete(key);
}
}
}
setInterval(cleanCache, 60_000);
Ou mieux, utilise un LRU cache avec une taille maximale.
Pools de connexions : le bon dimensionnement
typescriptimport { Pool } from "pg";
const pool = new Pool({
max: 20, // 20 connexions max
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
Chaque connexion PostgreSQL consomme environ 5 a 10 Mo de RAM cote serveur de base de donnees. 20 connexions = 100 a 200 Mo. Si tu as 4 instances Node.js avec chacune un pool de 20, ca fait 80 connexions et potentiellement 800 Mo cote PostgreSQL.
PgBouncer resout ca en mutualisant les connexions. 80 connexions applicatives passent par 20 connexions reelles a la base. Pour Redis, c'est moins critique parce que Redis est single-threaded et les connexions sont legeres, mais un pool de 10-20 reste raisonnable.
Mon conseil : commence avec max: 10, mesure les temps d'attente de connexion, et augmente si nécessaire. Trop de connexions gaspille de la mémoire sans gain de performance.
Caches bornes, pas des Map infinies
typescriptimport { LRUCache } from "lru-cache";
const cache = new LRUCache<string, Buffer>({
max: 500, // 500 entrees max
maxSize: 50 * 1024 * 1024, // 50 Mo max au total
sizeCalculation: (value) => value.byteLength,
ttl: 1000 * 60 * 10, // 10 minutes
});
Un Map sans limite de taille, c'est une fuite mémoire qui attend de se produire. Un LRU cache evince les entrees les moins recemment utilisees quand il atteint sa taille maximale. Tu contrôles la mémoire.
Sur paltemps.fr, je cache les réponses de l'API meteo avec un LRU de 200 entrees et un TTL de 5 minutes. Les donnees meteo changent lentement, mais je ne veux pas que le cache grossisse indefiniment si quelqu'un scanne toutes les villes de France.
Event emitters : la limite de listeners
typescriptimport { EventEmitter } from "events";
const emitter = new EventEmitter();
emitter.setMaxListeners(20); // Par defaut : 10
// Le warning que tu ne dois pas ignorer :
// MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
// 11 'data' listeners added to [EventEmitter].
Ce warning existe pour une raison. Si tu ajoutes des listeners sans les retirer, chaque listener retient une closure et tout ce qu'elle référencé. Le fix : retire tes listeners quand tu n'en as plus besoin, ou utilise once() pour les listeners a usage unique.
process.memoryUsage() : ton premier monitoring
typescriptfunction logMemory() {
const usage = process.memoryUsage();
console.log({
rss: `${Math.round(usage.rss / 1024 / 1024)} Mo`,
heapUsed: `${Math.round(usage.heapUsed / 1024 / 1024)} Mo`,
heapTotal: `${Math.round(usage.heapTotal / 1024 / 1024)} Mo`,
external: `${Math.round(usage.external / 1024 / 1024)} Mo`,
});
}
setInterval(logMemory, 30_000);
- rss : la mémoire totale du process vue par l'OS
- heapUsed : ce que V8 utilise vraiment dans le heap
- heapTotal : ce que V8 a reserve (peut etre plus que heapUsed)
- external : mémoire des objets C++ lies a des objets JS (Buffers par exemple)
Si heapUsed monte régulièrement sans jamais redescendre, tu as une fuite. Si rss est tres supérieur a heapTotal, tu as de la mémoire native (Buffers, addons C++) qui consomme.
Redemarrage gracieux sur seuil mémoire
typescriptconst MEMORY_THRESHOLD = 1.5 * 1024 * 1024 * 1024; // 1.5 Go
setInterval(() => {
const { heapUsed } = process.memoryUsage();
if (heapUsed > MEMORY_THRESHOLD) {
console.log("Seuil memoire atteint, arret gracieux...");
server.close(() => {
process.exit(0); // Le process manager (PM2, systemd) relancera
});
}
}, 60_000);
Ce n'est pas une solution. C'est un filet de sécurité. Ca évité le OOM kill brutal (exit code 137) en arretant proprement : on finit les requêtes en cours, on ferme les connexions, et on laisse le process manager relancer une instance fraiche.
Le vrai fix, c'est de trouver et corriger la fuite. Mais en attendant, un redemarrage gracieux a 1.5 Go c'est mieux qu'un kill a 2 Go avec des requêtes perdues.
L'état global qui s'accumule
Un dernier pattern vicieux : les modules qui accumulent de l'état entre les requêtes.
typescript// middleware/logger.ts
const logs: string[] = []; // Persiste entre toutes les requetes
export function logRequest(req: Request) {
logs.push(`${new Date().toISOString()} ${req.method} ${req.url}`);
// logs n'est jamais vide
}
En Node.js, les modules sont des singletons. Ce tableau logs vit pour toute la duree du process. Chaque requête l'allonge. En production, ca peut representer des dizaines de millions d'entrees.
Résumé
- --max-old-space-size definit la limite du heap V8, pas de la mémoire totale du process.
- Les Map et tableaux globaux sans limite de taille sont des fuites en attente.
- Les LRU caches avec max size et TTL controlent la mémoire.
- process.memoryUsage() est le monitoring minimal a mettre en place.
- Un redemarrage gracieux sur seuil protégé contre les OOM kills en production.
Precedent : Fuites mémoire en React Suivant : Mémoire et Docker