Nota

OOMKilled: análisis forense de pmap a cgroups memory.stat

El exit code 137 no deja ni stack trace ni última línea de log, mientras el dashboard jura que la memoria sobraba. Las herramientas que responden «adónde se fue la memoria» — mientras el pod sigue vivo.

Un contenedor se reinició con Reason: OOMKilled, exit code 137. El log se corta a mitad de frase: el SIGKILL viene del kernel y ningún handler puede atraparlo. El dashboard, mientras tanto, muestra un tranquilo 60% de memoria. Esta combinación es el escenario más común de reinicio «silencioso» en Kubernetes, y la reacción de «subir el límite y esperar» trata el síntoma exactamente hasta el siguiente pico de tráfico.

La cadena que conviene saberse de memoria

No es Kubernetes quien mata — el veredicto lo dicta el kernel OOM Killer. El límite de memoria de la pod spec se traduce en una cgroup; cuando el proceso intenta reservar por encima, el kernel elige una víctima y envía SIGKILL. El contenedor sale con código 137 (128 + 9), el kubelet lo marca OOMKilled y, con restartPolicy: Always, reinicia solo el contenedor, no el pod: la IP se conserva, los sidecars siguen vivos, los volúmenes permanecen montados. Repetido en bucle, da CrashLoopBackOff con backoff exponencial hasta el tope de cinco minutos.

Un contraste útil: por exceder CPU Kubernetes no mata — estrangula vía cgroup bandwidth. El exceso de CPU degrada la latencia; el de memoria corta el proceso sin aviso. Son dos failure modes distintos, y mezclarlos en una sola alerta es un error.

Por qué el dashboard «no vio nada»

El caso clásico: los pods mueren con regularidad y el gráfico de memoria nunca sube del 65%. La causa raíz son picos de 200–500 ms: suficientes para perforar el hard limit, demasiado cortos para un scrape interval de 15–30 segundos. El suavizado remata el cuadro: avg() sobre una ventana larga muestra 320 Mi donde el pico llegó a 600.

La tercera trampa es la métrica equivocada. El kernel decide el OOM por el working set, así que el ancla es container_memory_working_set_bytes, no el heap de la aplicación. Detección de picos:

max_over_time(container_memory_working_set_bytes[30s])
 # correlacionar con:
kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}

Los picos no son bugs: parsear un JSON grande, pausas de GC, una ráfaga de requests concurrentes. Combinados con límites recortados al milímetro, son el disparador.

Cirugía en un pod vivo

Mientras el pod vive — entre reinicios — el análisis forense pasa por un ephemeral container con shared process namespace:

kubectl debug -it <pod> --image=nicolaka/netshoot --target=<container>

--target abre los procesos del contenedor principal: cat /proc/1/status | grep VmRSS muestra el footprint actual, ls /proc/1/fd | wc -l caza una fuga de descriptores. Después, diff de dos snapshots de pmap -x 1 con cinco minutos de separación: qué segmentos crecieron. Si el diff no muestra crecimiento evidente, el memleak de eBPF (BCC) imprime los stack traces de todas las reservas sin liberar. Todo sin reinicio ni rebuild de imagen, y funciona también en distroless.

Ground truth — cgroups memory.stat

Las métricas de cadvisor engañan con las páginas compartidas: libc y libssl, compartidas entre contenedores, RSS las cuenta completas para cada uno. La imagen honesta la da la propia cgroup:

cat /sys/fs/cgroup/memory.stat   # cgroup v2
 # anon  — heap y stacks: la memoria «real» del contenedor
 # file  — page cache: el kernel lo libera antes de matar a nadie
 # shmem — compartida entre procesos

El riesgo OOM real es memory.current − file − shmem — solo el heap. PSS (lo que reporta smem) reparte las páginas compartidas a partes iguales entre procesos — una medida honesta del footprint por contenedor. Y una falsa alarma frecuente: pgmajfault son major page faults, síntoma de un cuello de botella de I/O, no una señal OOM; las alertas construidas sobre él cazan lo que no es.

Arreglar, no rezar

Se reduce a cuatro movimientos. Límites derivados del working set p99: limit = p99 × 1.5, con historial de OOM — × 2.0; baseline objetivo del 50% del límite en vez del 70%. En el alerting, max_over_time sobre ventanas cortas en vez de avg(). Pruebas de carga con bursts realistas, no con tráfico sintético plano. Y desde Kubernetes 1.36 hay una señal temprana: kubelet_psi_memory_full (PSI, GA) — la fracción de tiempo en que todos los threads esperan memoria; eso es un candidato a OOM antes del primer SIGKILL.

OOMKilled deja de ser un misterio cuando la investigación va por capas: kubectl describe — qué pasó; PromQL con la resolución correcta — cuándo; pmap y memory.stat — adónde se fue la memoria; right-sizing — para que no se repita.

© 2026 axyi.ru · CC BY 4.0