Debian 13: Fugas de memoria en servicios — localízalas con la menor interrupción (caso #43)

¿Te fue útil?

La página suena a las 03:12. “Latencia del API en aumento, nodos con swap, un contenedor OOM-killed.” Inicias sesión y ves lo habitual: la memoria “usada” es alta, los directivos están despiertos y alguien ya propuso “simplemente añadir RAM”.

Las fugas de memoria son el peor tipo de incidente a cámara lenta: todo funciona… hasta que deja de hacerlo, y entonces falla en el peor momento. El truco en Debian 13 no es el heroísmo. Es recopilar evidencia sin convertir producción en un laboratorio, hacer cambios pequeños y reversibles, y siempre separar las fugas reales del comportamiento de memoria esperado.

Hechos interesantes y contexto (rápido y concreto)

  • Linux no “libera memoria” como esperan las personas. El kernel usa agresivamente RAM para page cache; MemAvailable es la métrica que importa más que MemFree.
  • cgroups han cambiado las reglas dos veces. cgroups v1 permitía configuración por controlador; v2 lo unificó, y systemd lo convirtió en el plano de control por defecto para servicios.
  • /proc es más antiguo que la mayoría de los runbooks de incidentes. Ha sido la interfaz canónica para estadísticas de procesos desde los primeros días de Linux, y sigue siendo ideal para visibilidad con baja interrupción.
  • “OOM killer” no es un único tipo de evento. Existe OOM a nivel sistema, OOM por cgroup y “morimos porque malloc falló” en espacio de usuario. En los dashboards parecen similares y en los logs son muy distintos.
  • El comportamiento de malloc es política, no física. El asignador de glibc usa arenas y puede no devolver memoria al OS de inmediato; esto frecuentemente parece una fuga cuando no lo es.
  • Overcommit es una característica. Linux puede prometer más memoria virtual de la que existe; es normal hasta que deja de serlo. El modo de fallo depende de la configuración de overcommit y de la carga.
  • eBPF popularizó “observar sin detener”. Puedes trazar asignaciones y fallos con mucha menos penalización de rendimiento que los enfoques antiguos basados en ptrace.
  • Java inventó nuevas categorías de “fuga”. Las fugas de heap son una cosa; el seguimiento de memoria nativa existe porque direct buffers, stacks de hilos y JNI pueden crecer silenciosamente.
  • Smaps es el héroe no reconocido. /proc/<pid>/smaps es detallado, relativamente costoso de leer a escala, y absolutamente decisivo cuando necesitas saber qué es la memoria realmente.

Qué significa realmente “fuga de memoria” en Linux

En producción, “fuga de memoria” se usa para cuatro problemas diferentes. Solo uno es una fuga clásica.
Si no nombras el problema correctamente, “arreglarás” la cosa equivocada y te sentirás productivo mientras la página sigue sonando.

1) Fuga verdadera: la memoria queda inaccesible y nunca se recupera

Esto es el libro de texto: las asignaciones continúan, los frees no alcanzan, y el conjunto vivo crece sin límite.
Verás una subida monótona en RSS o en el uso del heap que no se correlaciona con la carga.

2) Memoria retenida: accesible, pero retenida sin querer

Cachés sin política de expulsión, mapas sin límites indexados por ID de usuario, contextos de petición almacenados globalmente. Técnicamente no es “fuga” porque el programa aún puede alcanzarla, pero prácticamente el resultado es el mismo.

3) Comportamiento del asignador: memoria liberada internamente, no devuelta al OS

malloc de glibc, fragmentación, arenas por hilo y efectos de “marca máxima” pueden mantener el RSS alto incluso después de que pase la carga pico.
La aplicación puede estar sana. Tus gráficos la acusarán de todos modos.

4) Presión por page cache/Kernel: la RAM está usada, pero no por tu proceso

“Memoria usada” sube porque el kernel cachea páginas de archivos. Bajo presión debería bajar.
Si no lo hace, puedes tener congestión por páginas sucias, IO lento o reglas de reclaim de cgroup que vuelven el cache persistente.

El trabajo es averiguar en qué categoría estás con el menor radio de impacto. Depurar con pánico es caro.

Una idea parafraseada de Werner Vogels (CTO de Amazon): Todo falla eventualmente; construye sistemas y hábitos que hagan la falla soportable.

Guía rápida de diagnóstico (primero/segundo/tercero)

Primero: confirma que es un problema de proceso, no “Linux siendo Linux”

  • Mira MemAvailable, actividad de swap y fallos mayores. Si MemAvailable está sano y el swap está quieto, probablemente no tienes una fuga urgente.
  • Identifica los mayores consumidores de RSS y si el crecimiento es monótono.
  • Comprueba si la memoria está dominada por anon (heap) o por file (cache, mmaps).

Segundo: decide si estás en un límite de cgroup/systemd

  • ¿El servicio está restringido por MemoryMax de systemd? Si sí, las fugas se mostrarán como cgroup OOM, no como OOM a nivel sistema.
  • Recopila memory.current, memory.events y RSS por proceso bajo el cgroup de la unidad.

Tercero: elige la fuente de evidencia de menor interrupción

  • /proc/<pid>/smaps_rollup para una vista rápida de PSS/RSS/Swap.
  • estadísticas de cgroup v2 para seguimiento a nivel de servicio y eventos OOM.
  • Perfiles nativos del lenguaje (pprof, JVM NMT, tracemalloc de Python) si puedes activarlos sin reinicio.
  • Muestreo con eBPF cuando no puedes tocar la app pero necesitas atribución.

Broma #1: Las fugas de memoria son como las provisiones de oficina: pequeñas al principio, luego de pronto todo desaparece y nadie admite nada.

Tareas prácticas: comandos, significado de la salida y decisiones (12+)

Estas son aptas para producción. La mayoría son solo lectura. Unas pocas cambian configuración, y están marcadas con la decisión que estás tomando.
Úsalas en orden; no saltes a las “herramientas molonas” a menos que hayas ganado el derecho con evidencia básica.

Task 1: Comprueba si el kernel está realmente bajo presión de memoria

cr0x@server:~$ grep -E 'Mem(Total|Free|Available)|Swap(Total|Free)' /proc/meminfo
MemTotal:       65843064 kB
MemFree:         1234560 kB
MemAvailable:   18432000 kB
SwapTotal:       4194300 kB
SwapFree:        4096000 kB

Qué significa: MemFree es bajo (normal), MemAvailable sigue siendo ~18 GB (bien), swap casi libre (bien).

Decisión: Si MemAvailable está sano y el swap no cae en tendencia, probablemente no tienes una fuga aguda. Pasa a la confirmación a nivel de proceso antes de despertar a todos.

Task 2: Busca swapping activo y churn de reclaim

cr0x@server:~$ vmstat 1 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 2  0      0 1234560  81234 21000000   0    0     5    12  820 1600 12  4 83  1  0
 1  0      0 1200000  80000 21100000   0    0     0     8  790 1550 11  3 85  1  0
 3  1      0 1100000  79000 21200000   0    0     0   120  900 1700 15  6 74  5  0
 5  2      0  900000  78000 21300000   0    0     0   800 1200 2200 18  8 60 14  0
 4  2      0  850000  77000 21350000   0    0     0   900 1180 2100 16  7 62 13  0

Qué significa: si/so son cero (sin swap-in/out), pero wa (IO wait) sube, y b (blocked) es no cero.

Decisión: Si el swapping está activo (si/so > 0 sostenido), estás en territorio de emergencia. Si no, el problema podría ser stalls inducidos por IO o un proceso que se hincha pero aún no obliga a swap.

Task 3: Identificar los mayores consumidores de RSS (triage rápido)

cr0x@server:~$ ps -eo pid,ppid,comm,%mem,rss --sort=-rss | head -n 10
  PID  PPID COMMAND         %MEM    RSS
 4123     1 api-service      18.4 12288000
 2877     1 search-worker    12.1  8050000
 1990     1 postgres          9.7  6450000
  911     1 nginx             1.2   780000

Qué significa: api-service es el principal consumidor residente.

Decisión: Elige al sospechoso principal y céntrate. Si varios procesos crecen a la vez, sospecha presión compartida de cache, crecimiento de tmpfs o un cambio de carga.

Task 4: Confirma crecimiento monótono (no confíes en una sola instantánea)

cr0x@server:~$ pid=4123; for i in 1 2 3 4 5; do date; awk '/VmRSS|VmSize/ {print}' /proc/$pid/status; sleep 30; done
Mon Dec 30 03:12:01 UTC 2025
VmSize:    18934264 kB
VmRSS:     12288000 kB
Mon Dec 30 03:12:31 UTC 2025
VmSize:    18959000 kB
VmRSS:     12340000 kB
Mon Dec 30 03:13:01 UTC 2025
VmSize:    19001000 kB
VmRSS:     12420000 kB
Mon Dec 30 03:13:31 UTC 2025
VmSize:    19042000 kB
VmRSS:     12510000 kB
Mon Dec 30 03:14:01 UTC 2025
VmSize:    19090000 kB
VmRSS:     12605000 kB

Qué significa: Tanto VmSize como VmRSS suben de forma sostenida. Eso es una fuga o un patrón de retención, no un pico puntual.

Decisión: Empieza a recopilar atribución (smaps_rollup, métricas de heap, estadísticas del asignador). También planifica una mitigación (reinicio, escalado) porque ya tienes un tiempo hasta el fallo.

Task 5: Determina anon vs file-backed (smaps_rollup)

cr0x@server:~$ pid=4123; cat /proc/$pid/smaps_rollup | egrep 'Rss:|Pss:|Private|Shared|Swap:'
Rss:               12631240 kB
Pss:               12590010 kB
Shared_Clean:         89240 kB
Shared_Dirty:          1024 kB
Private_Clean:       120000 kB
Private_Dirty:     12450000 kB
Swap:                   0 kB

Qué significa: Mayormente Private_Dirty anon. Eso suele ser heap, stacks o mmaps anónimos.

Decisión: Céntrate en las asignaciones de la aplicación y la retención. Si en cambio dominara la memoria file-backed, inspecciona mmaps, cachés y patrones de IO de archivos.

Task 6: Comprueba eventos OOM de cgroup bajo systemd (patrones por defecto en Debian 13)

cr0x@server:~$ systemctl status api-service.service --no-pager
● api-service.service - API Service
     Loaded: loaded (/etc/systemd/system/api-service.service; enabled; preset: enabled)
     Active: active (running) since Mon 2025-12-30 02:10:11 UTC; 1h 2min ago
   Main PID: 4123 (api-service)
      Tasks: 84 (limit: 12288)
     Memory: 12.4G (peak: 12.6G)
        CPU: 22min 9.843s
     CGroup: /system.slice/api-service.service
             └─4123 /usr/local/bin/api-service

Qué significa: systemd está registrando memoria actual y pico; esto ya es valioso para la forma de la fuga.

Decisión: Si ves “Memory: … (limit: …)” o mensajes recientes de OOM, pasa a las estadísticas del cgroup a continuación.

Task 7: Lee contadores de memoria de cgroup v2 y eventos OOM

cr0x@server:~$ cg=$(systemctl show -p ControlGroup --value api-service.service); echo $cg; cat /sys/fs/cgroup$cg/memory.current; cat /sys/fs/cgroup$cg/memory.events
/system.slice/api-service.service
13518778368
low 0
high 0
max 0
oom 0
oom_kill 0

Qué significa: El servicio está usando ~13.5 GB y no ha alcanzado aún el OOM de cgroup.

Decisión: Si oom_kill incrementa, no estás persiguiendo un “crash misterioso”: estás chocando contra una política conocida. Ajusta MemoryMax, corrige la fuga o escala.

Task 8: Detecta si la memoria está en tmpfs o logs desbocados (ofensores sigilosos)

cr0x@server:~$ df -h | egrep 'tmpfs|/run|/dev/shm'
tmpfs            32G  1.2G   31G   4% /run
tmpfs            32G   18G   14G  57% /dev/shm

Qué significa: /dev/shm es enorme. Eso es memoria, no disco.

Decisión: Si /dev/shm o /run se hinchan, inspecciona qué está escribiendo ahí (segmentos de memoria compartida, cachés tipo navegador, IPC). Esto no es una fuga de heap; reiniciar el servicio puede no ayudar si es otro proceso.

Task 9: Encuentra los principales consumidores dentro del cgroup del servicio (unidades multiproceso)

cr0x@server:~$ cg=$(systemctl show -p ControlGroup --value api-service.service); for p in $(cat /sys/fs/cgroup$cg/cgroup.procs | head -n 20); do awk -v p=$p '/VmRSS/ {print p, $2 "kB"}' /proc/$p/status 2>/dev/null; done | sort -k2 -n | tail
4123 12605000kB

Qué significa: Un proceso principal explica casi todo el RSS.

Decisión: Bien: la atribución es más simple. Si muchos procesos comparten el crecimiento, sospecha workers, patrón de fork o una explosión de arenas del asignador entre hilos.

Task 10: Comprueba la tasa de fallos de página para distinguir “tocar memoria nueva” vs “reutilizar”

cr0x@server:~$ pid=4123; awk '{print "minflt="$10, "majflt="$12}' /proc/$pid/stat
minflt=48392011 majflt=42

Qué significa: Los fallos menores son altos (normal para asignación), los fallos mayores bajos (aún no hay thrashing en disco).

Decisión: Si los fallos mayores suben rápido, ya estás en colapso de rendimiento. Prioriza mitigación (reinicio/escalado) antes de perfilar profundamente.

Task 11: Ver regiones mapeadas por tamaño (mmaps grandes destacan)

cr0x@server:~$ pid=4123; awk '{print $1, $2, $6}' /proc/$pid/maps | head
55fdb5a6b000-55fdb5b2e000 r--p /usr/local/bin/api-service
55fdb5b2e000-55fdb5e7e000 r-xp /usr/local/bin/api-service
55fdb5e7e000-55fdb5f24000 r--p /usr/local/bin/api-service
7f01b4000000-7f01b8000000 rw-p 
7f01b8000000-7f01bc000000 rw-p 

Qué significa: Grandes regiones anónimas (rw-p sin archivo) sugieren arenas de heap o asignaciones explícitas via mmap.

Decisión: Si ves mmaps grandes respaldados por archivos (p.ej., ficheros de datos), la “fuga” podría ser una estrategia de mapeo. Solución distinta.

Task 12: Captura un snapshot con pmap (solo lectura, útil para diffs de tendencia)

cr0x@server:~$ pid=4123; sudo pmap -x $pid | tail -n 5
---------------- ------- ------- ------- -------
total kB         18990000 12631240 12591000       0

Qué significa: Confirma totales; pmap es un buen artefacto “antes/después” para tickets.

Decisión: Guarda esta salida durante la línea temporal del incidente. Si la memoria baja tras un cambio de configuración o un cambio de carga, tienes pistas de causalidad.

Task 13: Inspecciona journald en busca de evidencia de OOM y kills por cgroup

cr0x@server:~$ sudo journalctl -k --since "1 hour ago" | egrep -i 'oom|out of memory|killed process|memory cgroup' | tail -n 20
Dec 30 02:55:10 server kernel: Memory cgroup out of memory: Killed process 2877 (search-worker) total-vm:9132000kB, anon-rss:7800000kB, file-rss:12000kB, shmem-rss:0kB
Dec 30 02:55:10 server kernel: oom_reaper: reaped process 2877 (search-worker), now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB

Qué significa: El kernel mató un proceso por out of memory del cgroup. Eso no es un segfault; es una aplicación de política.

Decisión: Decide si el límite está mal (demasiado bajo para el pico) o la carga está mal (fuga/retención). A menudo son ambas cosas.

Task 14: Ver la perspectiva de systemd sobre límites y contabilidad de memoria (y arreglar lo que falte)

cr0x@server:~$ systemctl show api-service.service -p MemoryAccounting -p MemoryMax -p MemoryHigh -p OOMPolicy
MemoryAccounting=yes
MemoryMax=infinity
MemoryHigh=infinity
OOMPolicy=continue

Qué significa: La contabilidad está activada, pero no hay tope. OOMPolicy=continue significa que systemd no detendrá la unidad por OOM por sí mismo.

Decisión: Si ejecutas hosts multi-tenant, establece MemoryHigh/MemoryMax para proteger a los vecinos. Si es un nodo dedicado, puede que prefieras sin tope y confiar en autoscaling + alertas.

Task 15: Poner un cap “tripwire” temporal (con cuidado) para forzar un fallo anticipado con mejor evidencia

cr0x@server:~$ sudo systemctl set-property api-service.service MemoryHigh=14G MemoryMax=16G
cr0x@server:~$ systemctl show api-service.service -p MemoryHigh -p MemoryMax
MemoryHigh=15032385536
MemoryMax=17179869184

Qué significa: Has aplicado un límite en caliente (systemd escribe en el cgroup). MemoryHigh desencadena presión de reclaim; MemoryMax es el tope duro.

Decisión: Haz esto solo si puedes tolerar que el servicio sea matado antes. Es un trade-off: mejor contención y señales más claras vs impacto a usuarios. En hosts compartidos, suele ser la opción responsable.

Task 16: Si es Java, habilita Native Memory Tracking (baja interrupción si está planeado)

cr0x@server:~$ jcmd 4123 VM.native_memory summary
4123:
Native Memory Tracking:

Total: reserved=13540MB, committed=12620MB
-                 Java Heap (reserved=8192MB, committed=8192MB)
-                     Class (reserved=512MB, committed=480MB)
-                    Thread (reserved=1024MB, committed=920MB)
-                      Code (reserved=256MB, committed=220MB)
-                        GC (reserved=1200MB, committed=1100MB)
-                  Internal (reserved=2300MB, committed=1700MB)

Qué significa: El proceso no es solo “heap”. Los hilos y asignaciones internas pueden dominar.

Decisión: Si el heap está estable pero lo nativo/interno crece, céntrate en buffers off-heap, JNI, creación de hilos o fragmentación del asignador—no en tunear GC.

Task 17: Si es Go, toma un snapshot pprof del heap (disrupción mínima si existe el endpoint)

cr0x@server:~$ curl -sS localhost:6060/debug/pprof/heap?seconds=30 | head
\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff...

Qué significa: Eso es un perfil pprof gzipado. No es legible en la terminal.

Decisión: Guárdalo y analízalo fuera de la caja. Si no puedes exponer pprof de forma segura, no abras huecos en prod durante un incidente: usa port forwarding por SSH o enlace solo a localhost.

Task 18: Si es Python, usa tracemalloc (mejor cuando se habilita temprano)

cr0x@server:~$ python3 -c 'import tracemalloc; tracemalloc.start(); a=[b"x"*1024 for _ in range(10000)]; print(tracemalloc.get_traced_memory())'
(10312960, 10312960)

Qué significa: tracemalloc informa asignaciones actuales y pico (bytes).

Decisión: En servicios reales, habilitas tracemalloc al arranque o mediante un flag. Si no está activado, no pretendas que puedes reconstruir el historial de asignación de objetos Python a partir del RSS solo.

Mini-historia 1: la asunción equivocada (la trampa “RSS equivale a fuga”)

Una compañía SaaS de tamaño medio operaba una flota Debian con una API multi-tenant. Cada lunes por la mañana, un nodo ascendía en memoria hasta golpear swap, y la latencia se volvía geométrica. El on-call hizo lo que hacen los on-call: encontró el proceso con mayor RSS y abrió un ticket de “fuga de memoria” contra el equipo de API.

El equipo de API respondió con el repertorio habitual: “funciona en staging”, unos gráficos de heap y la sincera convicción de que su garbage collector era un inocente espectador. Operaciones seguía señalando el RSS. El debate se volvió religioso: “Linux está cacheando” vs “tu servicio está fugando”.

La asunción equivocada era simple: que un RSS ascendente significa datos vivos en aumento. No siempre. El servicio usaba una librería que mapeaba en memoria grandes datasets de solo lectura para consultas rápidas. El lunes, un job por lotes rotó nuevos datasets y el servicio mapeó los nuevos antes de desmapear los viejos. Durante un tiempo, coexistieron ambos conjuntos. RSS saltó. No era una fuga; era un patrón de despliegue.

La solución no fue un profiler. Fue coordinar la rotación de datasets y forzar una estrategia de mapeo que intercambiara el puntero cuando el nuevo mapa estuviera listo, y luego desmapear inmediatamente la región antigua. Tras eso, el RSS siguió subiendo los lunes—solo menos y por ventanas más cortas. También cambiaron las alertas de “RSS > umbral” a “tendencia de MemAvailable hacia abajo + fallos mayores + latencia.”

A nadie le encanta la moraleja porque es aburrida: mide lo correcto antes de culpar. Pero sale más barato que celebrar un juicio semanal.

Mini-historia 2: la optimización que salió mal (perillas del asignador vs tráfico real)

Otra organización, otro trimestre, otra “iniciativa de eficiencia de costes”. Ejecutaban un servicio en C++ sobre Debian y querían reducir uso de memoria. Un ingeniero bien intencionado leyó sobre arenas de glibc y decidió reducir el footprint estableciendo MALLOC_ARENA_MAX=2 en toda la flota.

El cambio funcionó en preprod. El RSS bajó bajo carga sintética. Los gráficos quedaron fantásticos. Entonces llegó producción: los patrones de tráfico tenían más conexiones de larga duración, más picos de concurrencia por petición y, crucialmente, diferentes tiempos de vida de objetos. La latencia empezó a rizarse. La CPU subió. La memoria no se volvió estable; se volvió contendida.

Con pocas arenas, los hilos competían por locks del asignador. Algunas solicitudes se enlentecieron, las colas crecieron y el servicio retenía memoria por más tiempo porque estaba ocupado siendo lento. El on-call vio RSS creciente y volvió a culpar a una fuga. Estaban midiendo un efecto, no la causa.

El rollback arregló la latencia. La memoria subió de nuevo—pero ahora se comportaba de forma predecible. Finalmente migraron ese servicio a jemalloc con profiling activado en staging y un despliegue controlado en producción. La lección no fue “nunca tunear malloc”. Fue “las perillas del asignador son específicas de la carga y pueden convertir problemas de memoria en problemas de rendimiento”.

Broma #2: Afinar malloc en producción es como reorganizar la despensa durante una cena—técnicamente posible, socialmente cuestionable.

Mini-historia 3: la práctica aburrida pero correcta que salvó el día (presupuestos, límites y runbooks rehechos)

Un equipo de servicios financieros ejecutaba nodos Debian 13 para procesamiento en background. El servicio no era glamuroso: un consumidor de colas que transformaba documentos y guardaba resultados. Tenía además un historial de picos de memoria ocasionales por librerías de parsing de terceros.

Hicieron dos cosas aburridas. Primero, usaron MemoryAccounting de systemd con MemoryHigh ajustado a un valor que forzaba presión de reclaim antes de que el host estuviera en peligro. Segundo, construyeron un job semanal de “ensayo de fuga” en staging: incrementar carga, verificar que la memoria se estabiliza y capturar snapshots de smaps_rollup en intervalos fijos.

Una noche, una actualización de una librería de un proveedor cambió comportamiento y empezó a retener buffers gigantes. La memoria empezó a subir. El servicio no tumbó todo el nodo porque el límite del cgroup lo contuvo. El nodo se mantuvo sano; solo ese grupo de workers se vio afectado.

El on-call no tuvo que adivinar. Las alertas incluían memory.current y memory.events. El runbook ya decía: si memory.current sube y domina private dirty, reinicia la unidad, fija la versión del paquete y abre un incidente para la causa raíz. Lo siguieron, volvieron a dormir y arreglaron la fuga correctamente al día siguiente.

No pasó nada heroico. Ese era el punto. La práctica “aburrida” convirtió un incidente potencial de flota en un tropiezo de un solo servicio.

Técnicas de baja interrupción que realmente funcionan

Empieza con artefactos que puedes recolectar sin cambiar el proceso

Tus herramientas más seguras son: /proc, systemd y contadores de cgroup. No necesitan reinicio. No adjuntan debuggers. No introducen el Heisenbug donde tu profiler “arregla” el timing y oculta la fuga.

  • /proc/<pid>/smaps_rollup te dice si la memoria es Private_Dirty (tipo heap) versus file-backed.
  • /sys/fs/cgroup/…/memory.current te dice si toda la unidad crece, incluso si tiene varios PIDs.
  • journalctl -k te dice si estás viendo kills por cgroup OOM, OOM global, o algo totalmente distinto.

Prefiere muestreo sobre trazado cuando estés bajo carga

El trazado completo de asignaciones puede ser caro y distorsionar el comportamiento. Los profilers por muestreo y snapshots periódicos son el estándar en producción.
Si necesitas sitios de asignación precisos, hazlo brevemente y con un plan de rollback.

Contén el daño con límites de cgroup y reinicios limpios

“Menor interrupción” no significa “nunca reiniciar”. Significa reiniciar de forma intencional: drenar tráfico, rotar una instancia, verificar comportamiento en meseta y continuar.
Un reinicio controlado que evita tormentas de swap a nivel nodo suele ser el acto más amable que puedes hacer por tus usuarios.

Busca correlación temporal con cambios de carga

Las fugas a menudo se correlacionan con un tipo de petición específico, un cron job, un deploy o un cambio en la forma de los datos.
Si la memoria crece solo cuando una cola particular está activa, acabas de acotar la búsqueda más que cualquier herramienta genérica.

systemd y cgroups v2: úsalos, no los combatas

Debian 13 apuesta por systemd y cgroups v2. No es una declaración ideológica; es la realidad que depuras.
Si tratas el host como “un montón de procesos” e ignoras los cgroups, te perderás el límite real de aplicación.

Por qué importa pensar a nivel de cgroup

Un servicio puede ser matado mientras el host aún tiene memoria libre porque golpeó su límite de cgroup. Al contrario, un host puede estar en problemas incluso si el servicio parece bien porque otro cgroup está acaparando memoria.
La telemetría a nivel servicio evita discutir qué proceso “se ve grande” y responde: qué unidad es responsable de la presión.

Usa MemoryHigh antes que MemoryMax

MemoryHigh introduce presión de reclaim; MemoryMax es un límite duro que mata. En la práctica, MemoryHigh es una advertencia temprana más suave que además compra tiempo.
Si solo pones MemoryMax, tu primera señal podría ser un kill. Eso es como descubrir que el indicador de combustible está roto cuando el motor se detiene.

Tener una OOMPolicy explícita para la unidad

Si systemd mata algo por OOM, ¿qué quieres que haga? ¿Reiniciar? ¿Detener? ¿Continuar?
Decide según el comportamiento del servicio: APIs sin estado pueden reiniciarse; workers con estado pueden necesitar drenado y lógica de reintento cuidadosa.

cr0x@server:~$ sudo systemctl edit api-service.service
cr0x@server:~$ sudo cat /etc/systemd/system/api-service.service.d/override.conf
[Service]
MemoryAccounting=yes
MemoryHigh=14G
MemoryMax=16G
OOMPolicy=restart
Restart=always
RestartSec=5

Qué significa: Has definido la frontera de contención y la recuperación automatizada.

Decisión: Si los reinicios son seguros y tu fuga es lenta, esto convierte “muerte del nodo a las 3am” en “reciclado controlado de instancias”, comprando tiempo para el trabajo de causa raíz.

No confundas “memoria usada” con “memoria imputada”

los cgroups imputan memoria de forma distinta para anónimos y file cache, y el comportamiento exacto depende de versiones de kernel y ajustes.
El punto no es memorizar cada detalle; es seguir tendencias y saber qué contadores estás usando.

Caza de fugas por lenguaje (Java, Go, Python, C/C++)

Java: separa el mundo en heap vs nativo

Si RSS crece pero el uso de heap está plano, deja de culpar al GC. Estás en memoria nativa: direct buffers, stacks de hilos, JNI, code cache o fragmentación del asignador.
NMT (Native Memory Tracking) es tu amigo, pero es mejor habilitarlo intencionalmente (flags de arranque) para baja sobrecarga.

Enfoque de baja interrupción: recopila resúmenes NMT periódicos, más logs de GC o eventos JFR si ya están habilitados. Si necesitas un heap dump, prefiere una ventana controlada y asegúrate de tener espacio en disco y margen de IO; los heap dumps pueden ser disruptivos.

Go: trata los perfiles de heap como evidencia, no como opinión

El runtime de Go te da pprof y métricas de runtime sorprendentemente buenas. El riesgo es la exposición: un endpoint de debug accesible desde la red equivocada es un incidente por sí mismo.
Mantén pprof ligado a localhost y haz túneles cuando sea necesario.

Busca: heap en uso creciente, aumento del número de objetos o conteos de goroutines en aumento (otro tipo de fuga).

Python: las fugas también pueden ser “nativas”

Los servicios Python pueden fugar en objetos puros de Python (rastreables con tracemalloc), pero también en extensiones nativas que asignan fuera del seguimiento de objetos de Python.
Si tracemalloc parece bien y el RSS sube igual, sospecha librerías nativas, buffers y patrones de mmap.

C/C++: decide si necesitas telemetría del asignador o herramientas a nivel de código

En C/C++ una fuga real es común, pero también lo es “no devolver memoria al OS”.
Si puedes permitírtelo, reemplazar malloc de glibc por jemalloc en un rollout controlado puede aportar profiling y a menudo un comportamiento RSS más estable. Pero no trates el reemplazo del asignador como una panacea.

La vía menos disruptiva: captura smaps_rollup, snapshots de pmap y, si hace falta, muestreo corto con eBPF de stacks de asignación en lugar de trazado siempre activo.

Errores comunes: síntoma → causa raíz → solución

“La memoria usada está al 95%, estamos fugando”

Causa raíz: el page cache está haciendo su trabajo; el sistema aún tiene MemAvailable sano.

Solución: alerta sobre tendencia de MemAvailable, swap in/out, fallos mayores y latencia. No despiertes humanos por Linux usando RAM.

“RSS crece, por lo tanto fuga de heap”

Causa raíz: mmaps file-backed, fragmentación del asignador o crecimiento de memoria nativa (direct buffers en Java, extensiones en Python).

Solución: usa smaps_rollup para separar private dirty vs file-backed; usa herramientas del lenguaje (JVM NMT, pprof) para confirmar.

“El servicio murió, debe ser un segfault”

Causa raíz: kill por cgroup OOM (a menudo silencioso a nivel de app) o OOM a nivel sistema.

Solución: journalctl -k para líneas de OOM; revisa memory.events en el cgroup del servicio; decide una estrategia de MemoryMax/OOMPolicy.

“Lo arreglaremos añadiendo swap”

Causa raíz: el swap enmascara fugas y las convierte en incidentes de latencia.

Solución: mantén swap moderado, monitoriza la actividad de swap y trata swap-in/out sostenido como P1. Usa contención y reinicios, no swap infinito.

“Activamos profiling fuerte y la fuga desapareció”

Causa raíz: efecto del observador; cambios de timing; patrones de asignación distintos.

Solución: prefiere muestreo; recopila múltiples ventanas cortas; correlaciona con la carga; reproduce en staging con tráfico similar a producción.

“Pusimos MemoryMax y ahora se reinicia aleatoriamente”

Causa raíz: límite demasiado bajo para picos legítimos, o ausencia de MemoryHigh por lo que recibes muerte súbita.

Solución: establece MemoryHigh por debajo de MemoryMax, mide picos y ajusta. Usa hooks de apagado graceful y autoscaling si es posible.

“El límite del contenedor está bien; el host igual OOMeó”

Causa raíz: otros cgroups sin límites; presión del kernel; file cache y páginas sucias; o contabilidad mal configurada.

Solución: inspecciona memory.current en los cgroups de más alto nivel; asegúrate de que la contabilidad esté activada; establece límites sensatos para vecinos ruidosos.

Listas de verificación / plan paso a paso

Checklist A: Confirmación en 10 minutos (sin reinicios, sin nuevos agentes)

  1. Revisa /proc/meminfo: ¿está MemAvailable cayendo con el tiempo?
  2. Ejecuta vmstat 1: ¿está activo el swap (si/so), está subiendo IO wait?
  3. Identifica el proceso con mayor RSS vía ps --sort=-rss.
  4. Confirma crecimiento con lecturas repetidas de /proc/<pid>/status (VmRSS).
  5. Usa /proc/<pid>/smaps_rollup: private dirty vs file-backed.
  6. Revisa la memoria de la unidad con systemctl status y memory.current del cgroup.
  7. Busca en logs del kernel eventos OOM y víctimas.

Checklist B: Contención sin drama (cuando la fuga es real)

  1. Estima tiempo hasta el fallo: tasa de crecimiento actual del RSS vs margen disponible.
  2. Decide la frontera de contención: MemoryHigh/MemoryMax por servicio o escalado del nodo.
  3. Fija MemoryHigh primero, luego MemoryMax si es necesario. Prefiere cambios iterativos pequeños.
  4. Asegura que OOMPolicy y comportamiento de Restart coincidan con el tipo de servicio.
  5. Planifica una ventana de reinicio rolling; drena tráfico si aplica.
  6. Captura artefactos “antes del reinicio”: smaps_rollup, totales pmap, memory.current, memory.events.
  7. Después del reinicio: verifica que la memoria se estabiliza con la misma carga.

Checklist C: Trabajo de causa raíz (cuando los usuarios están a salvo)

  1. Elige la herramienta correcta: pprof/JFR/NMT/tracemalloc/profiling del asignador.
  2. Correlaciona la fuga con la carga: endpoints, tipos de job, tamaños de payload.
  3. Reproduce en staging con concurrencia y datos similares a producción.
  4. Arregla retenciones: añade eviction, límites, timeouts y backpressure.
  5. Añade dashboards: memory.current, proporción private dirty, eventos OOM, contadores de reinicio.
  6. Añade una prueba de regresión: ejecuta la carga sospechada lo suficiente como para mostrar la pendiente.

Preguntas frecuentes

1) ¿Cómo distingo “fuga” de “el asignador no devuelve memoria”?

Busca crecimiento monótono en memoria private dirty (smaps_rollup) correlacionado con conteos de objetos o métricas de heap. Si los datos vivos a nivel de app bajan pero el RSS se mantiene alto, sospecha comportamiento del asignador/fragmentación.

2) ¿Por qué RSS sigue subiendo aun cuando la carga está plana?

Fuga verdadera, retención en caché, jobs en background o una tarea lenta “cada hora”. Confirma con lecturas repetidas de VmRSS y correlaciona con logs de peticiones/jobs. Tráfico plano no significa forma de carga plana.

3) ¿Cuál es el archivo /proc más útil para esto?

/proc/<pid>/smaps_rollup. Es compacto suficiente para obtenerlo durante incidentes y te da señales de desglosado decisivas.

4) ¿Debo poner MemoryMax en cada servicio de systemd?

En hosts compartidos: sí, casi siempre. En nodos dedicados: tal vez. Los límites previenen que vecinos ruidosos tumben la máquina, pero también introducen fallos duros que debes diseñar para manejar.

5) ¿Cómo sé si el OOM killer mató mi servicio?

Revisa journalctl -k por líneas “Killed process” y revisa los contadores memory.events del cgroup del servicio (oom/oom_kill). No adivines.

6) ¿Añadir swap es una mitigación válida?

Es una muleta a corto plazo, no una solución. El swap puede prevenir un OOM inmediato pero a menudo convierte fallos en picos de latencia y tormentas de IO. Úsalo con moderación y monitorízalo agresivamente.

7) ¿Puedo depurar fugas sin reinicios?

A veces. /proc y estadísticas de cgroup no requieren reinicios. El muestreo con eBPF a menudo solo requiere privilegios, no reinicios. Las herramientas a nivel de lenguaje varían: Go y JVM pueden adjuntarse a menudo; tracemalloc de Python es mejor habilitarlo al inicio.

8) ¿Cuál es la forma menos disruptiva de recoger evidencia “antes/después”?

Toma snapshots periódicos: smaps_rollup, totales pmap, memory.current y memory.events. Almacénalos con marcas temporales. Eso te da una pendiente y un registro de cambios sin instrumentación pesada.

9) ¿Por qué systemd muestra “Memory: 12G” pero ps muestra un RSS distinto?

systemd reporta uso de memoria del cgroup completo (incluyendo hijos y cache imputada al cgroup). ps muestra RSS por proceso. Responden a preguntas diferentes; usa ambos.

10) ¿Cuándo debo dejar de depurar y simplemente revertir?

Si la fuga empezó tras un deploy y tienes un rollback seguro, hazlo pronto. La causa raíz puede esperar. A los usuarios no les importa si encontraste el sitio perfecto de asignación a las 04:40.

Conclusión: siguientes acciones que puedes tomar hoy

Las fugas de memoria en servicios no se solucionan con intuiciones. En Debian 13, la ruta de menor interrupción es consistente: confirma presión con MemAvailable y swap, identifica la unidad creciente vía cgroups, clasifica la memoria con smaps_rollup y solo entonces elige herramientas más profundas.

Pasos siguientes que dan rendimiento inmediato:

  • Añade dashboards por servicio para memory.current, memory.events y contadores de reinicio.
  • Configura MemoryHigh (y a veces MemoryMax) para servicios ruidosos, más una OOMPolicy explícita.
  • Actualiza alertas para centrarse en tendencia de MemAvailable, actividad de swap, fallos mayores y latencia, no en “memoria usada” en bruto.
  • Haz hábito de capturar snapshots de smaps_rollup durante incidentes para que puedas dejar de discutir y empezar a arreglar.

No puedes prevenir cada fuga. Puedes evitar que la mayoría de incidentes por fugas se conviertan en desastres a nivel de host. Esa es la verdadera victoria.

← Anterior
Cifrado ZFS: seguridad sólida sin matar el rendimiento
Siguiente →
Correo electrónico: renovación de certificado TLS — evitar que SMTP/IMAP falle el día de la renovación

Deja un comentario