Depuración de problemas de memoria en sistemas de producción
Técnicas para diagnosticar fugas de memoria, asignación excesiva y muertes por OOM en producción: heap snapshots, profiling y patrones de fuga.

El uso de memoria del servidor sube constantemente durante horas. En algún momento, el contenedor alcanza su límite de memoria y Kubernetes lo mata con OOMKilled. El pod se reinicia, la memoria empieza baja y la subida vuelve a comenzar. Este patrón de sierra es la señal de una fuga de memoria.
Solucionar problemas de memoria en producción requiere técnicas distintas a las del debugging en desarrollo. No puedes conectar un debugger a un proceso de producción a la ligera. Necesitas herramientas de bajo overhead que capturen suficiente información para identificar la fuga sin impactar el tráfico de usuarios.
Reconociendo los patrones
Los problemas de memoria se manifiestan en tres patrones distintos, cada uno con causas diferentes:
Gradual leak (sawtooth):
Memory ▲
│ /| /| /|
│ / | / | / | ← OOMKill + restart
│ / | / | / |
│ / | / | / |
└─────┴─────┴─────── Time
Sudden spike:
Memory ▲
│ ┌─┐
│ │ │
│─────────┘ └─────── ← One request/event causes massive allocation
└─────────────────── Time
Slow growth (no release):
Memory ▲
│ ┌────
│ ┌────┘
│ ┌────┘
│────┘ ← Caches, pools, or closures growing without bound
└─────────────────── Time
# Monitor memory patterns in Kubernetes
kubectl top pods -w --sort-by=memory
# Check for OOMKill events
kubectl get events --field-selector reason=OOMKilling
# Get memory details for a specific pod
kubectl describe pod api-server-7d5f8b6c4-abc12 | grep -A5 "Last State"Heap snapshots en Node.js
Los heap snapshots capturan todo el estado de la memoria. Comparar dos snapshots tomados minutos aparte revela los objetos que crecieron — los probables candidatos a fuga.
// Expose a debug endpoint for heap snapshots (behind auth)
import v8 from 'v8';
import fs from 'fs';
import path from 'path';
// Only expose in controlled environments
function registerDebugEndpoints(app: Express) {
app.post('/debug/heap-snapshot', authMiddleware, async (req, res) => {
const snapshotPath = path.join(
'/tmp',
`heap-${Date.now()}.heapsnapshot`
);
const snapshotStream = v8.writeHeapSnapshot(snapshotPath);
res.json({
path: snapshotStream,
timestamp: new Date().toISOString(),
heapUsed: process.memoryUsage().heapUsed,
});
});
app.get('/debug/memory', authMiddleware, (req, res) => {
const mem = process.memoryUsage();
res.json({
rss: `${(mem.rss / 1024 / 1024).toFixed(1)}MB`,
heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(1)}MB`,
heapUsed: `${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB`,
external: `${(mem.external / 1024 / 1024).toFixed(1)}MB`,
arrayBuffers: `${(mem.arrayBuffers / 1024 / 1024).toFixed(1)}MB`,
});
});
}# Workflow: take two snapshots 5 minutes apart
curl -X POST https://internal-api/debug/heap-snapshot # snapshot 1
sleep 300
curl -X POST https://internal-api/debug/heap-snapshot # snapshot 2
# Download snapshots and open in Chrome DevTools:
# 1. Open chrome://inspect → Memory tab
# 2. Load both snapshots
# 3. Select "Comparison" view between snapshot 1 and 2
# 4. Sort by "Size Delta" — largest growth = likely leakPatrones comunes de fuga
La mayoría de las fugas de memoria en Node.js caen en unas pocas categorías recurrentes.
// ❌ Leak Pattern 1: Event listeners accumulating
class DataProcessor {
constructor(private emitter: EventEmitter) {}
process(data: unknown) {
// Each call adds a NEW listener — they never get removed
this.emitter.on('update', () => {
this.handleUpdate(data); // Closure captures 'data'
});
}
}
// ✅ Fixed: remove listeners when done
class DataProcessor {
private handler: (() => void) | null = null;
process(data: unknown) {
// Remove previous listener before adding new one
if (this.handler) {
this.emitter.off('update', this.handler);
}
this.handler = () => this.handleUpdate(data);
this.emitter.on('update', this.handler);
}
destroy() {
if (this.handler) {
this.emitter.off('update', this.handler);
this.handler = null;
}
}
}// ❌ Leak Pattern 2: Unbounded cache
const cache = new Map<string, UserData>();
async function getUser(id: string): Promise<UserData> {
if (cache.has(id)) return cache.get(id)!;
const user = await db.users.findById(id);
cache.set(id, user); // Map grows forever
return user;
}
// ✅ Fixed: bounded cache with LRU eviction
import { LRUCache } from 'lru-cache';
const cache = new LRUCache<string, UserData>({
max: 1000, // Maximum entries
ttl: 1000 * 60 * 5, // 5-minute TTL
});
async function getUser(id: string): Promise<UserData> {
const cached = cache.get(id);
if (cached) return cached;
const user = await db.users.findById(id);
cache.set(id, user);
return user;
}// ❌ Leak Pattern 3: Closures holding references
function createHandler(largeDataset: Buffer) {
// The returned function captures the entire largeDataset
// Even if the handler only needs a small piece
return (req: Request) => {
const id = req.params.id;
return largeDataset.slice(0, 100);
};
// largeDataset (possibly MB) stays in memory as long as
// the handler exists
}
// ✅ Fixed: extract only what you need before the closure
function createHandler(largeDataset: Buffer) {
const header = Buffer.from(largeDataset.slice(0, 100));
// largeDataset can now be garbage collected
return (req: Request) => {
return header;
};
}Monitoreo continuo de memoria
No esperes a los OOMKills para descubrir fugas. Monitorea las métricas de memoria continuamente y alerta sobre tasas de crecimiento anormales.
// Expose Prometheus-compatible memory metrics
import { collectDefaultMetrics, register, Gauge } from 'prom-client';
collectDefaultMetrics({ prefix: 'app_' });
const heapGauge = new Gauge({
name: 'app_heap_used_bytes',
help: 'Process heap used in bytes',
});
const rssGauge = new Gauge({
name: 'app_rss_bytes',
help: 'Process resident set size in bytes',
});
// Update metrics every 15 seconds
setInterval(() => {
const mem = process.memoryUsage();
heapGauge.set(mem.heapUsed);
rssGauge.set(mem.rss);
}, 15000);
// Metrics endpoint
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});# Prometheus alert rule: detect memory leak pattern
groups:
- name: memory
rules:
- alert: MemoryLeakDetected
expr: |
deriv(app_heap_used_bytes[1h]) > 1048576
for: 30m
labels:
severity: warning
annotations:
summary: "Possible memory leak in {{ $labels.pod }}"
description: "Heap memory growing at >1MB/hour for 30 minutes"La función deriv() en PromQL calcula la tasa de cambio. Si el heap crece consistentemente a más de 1MB por hora durante 30 minutos, algo se está acumulando.
Profiling sin impacto en producción
Los heap snapshots son costosos — pausan el proceso por segundos. Para profiling en producción, usa allocation sampling, que tiene un overhead mínimo.
// Allocation sampling — low overhead, production-safe
import { Session } from 'inspector';
async function captureAllocationProfile(durationMs: number): Promise<Buffer> {
const session = new Session();
session.connect();
return new Promise((resolve) => {
session.post('HeapProfiler.startSampling', {
samplingInterval: 32768, // Sample every 32KB allocated
});
setTimeout(() => {
session.post('HeapProfiler.stopSampling', (err, result) => {
session.disconnect();
const profile = JSON.stringify(result?.profile);
resolve(Buffer.from(profile));
});
}, durationMs);
});
}
// Capture a 30-second allocation profile
app.post('/debug/allocation-profile', authMiddleware, async (req, res) => {
const profile = await captureAllocationProfile(30000);
res.setHeader('Content-Disposition',
`attachment; filename="alloc-${Date.now()}.heapprofile"`);
res.send(profile);
});# For non-Node.js systems: use system-level tools
# Linux memory profiling
valgrind --tool=massif ./your-binary
# JVM heap dump
jmap -dump:format=b,file=heap.bin <pid>
# Go pprof
go tool pprof http://localhost:6060/debug/pprof/heapConfiguración de memoria en contenedores
Los límites de contenedor mal configurados causan OOMKills incluso sin fugas. El proceso cree que tiene más memoria de la que el contenedor permite.
# ❌ Node.js default heap limit (1.7GB) exceeds container limit (512MB)
spec:
containers:
- name: api
image: myregistry/api:latest
resources:
limits:
memory: "512Mi"
# Node.js will try to use 1.7GB → OOMKilled
# ✅ Match Node.js heap limit to container limit
spec:
containers:
- name: api
image: myregistry/api:latest
resources:
requests:
memory: "256Mi"
limits:
memory: "512Mi"
env:
- name: NODE_OPTIONS
value: "--max-old-space-size=384"
# 384MB heap + ~128MB overhead = within 512MB container limit# Rule of thumb for Node.js:
# --max-old-space-size = container_limit * 0.75
# The remaining 25% covers:
# - V8 overhead (new space, code space)
# - Native memory (buffers, C++ objects)
# - OS page cachePuntos clave
- Reconoce el patrón primero — la fuga gradual, el pico repentino y el crecimiento lento tienen diferentes causas raíz y diferentes soluciones
- Compara heap snapshots — dos snapshots separados minutos revelan qué está creciendo
- Acota cada caché — usa LRU con un tamaño máximo, nunca un Map sin límites
- Elimina los event listeners explícitamente — los listeners acumulados son la fuga más común en Node.js
- Monitorea la tasa de crecimiento de memoria, no el valor absoluto —
deriv()en Prometheus detecta fugas antes de que causen OOMKills - Iguala el límite del heap de Node.js al límite del contenedor — configura
--max-old-space-sizeal 75% del límite de memoria del contenedor


