El OOM killer y memcg: matar para seguir vivo
Cuándo se rinde el kernel y dispara el OOM killer, cómo puntúa a las víctimas con oom_badness, oom_score y oom_score_adj, por qué el oom_reaper recupera la memoria sin esperar a que el proceso muera, y cómo los cgroups v2 convierten una catástrofe global en una contenida por servicio.
Cuando el reclaim ya no libera lo suficiente, el reclaim directo se agota y cada reintento fracasa, al kernel le queda un solo recurso brutal: matar un proceso para que los demás vivan. El OOM killer elige a la víctima por su “maldad”, el oom_reaper le arranca la memoria sin esperar a que agonice, y los cgroups convierten lo que sería una catástrofe para toda la máquina en un incidente acotado a un servicio. Es el kernel admitiendo la derrota, pero haciéndolo con un único principio inquebrantable: progresar cueste lo que cueste.
- Entender cuándo se dispara el OOM killer y bajo qué restricciones.
- Leer
oom_badness,oom_score,oom_score_adjy la inmunidad -1000. - Comprender el
oom_reapery por qué el reaping es asíncrono. - Ver cómo memcg acota y aísla la presión con
memory.maxymemory.oom.group.
Cuándo se rinde el kernel
El OOM killer es el final del camino lento de asignación. Tras despertar a kswapd, hacer reclaim directo, compactar y reintentar varias veces sin conseguir memoria, __alloc_pages_may_oom llama a out_of_memory. Es un evento raro en máquinas con swap, porque antes suelen aparecer el thrashing o el reclaim directo; cuando llega, es que no queda nada que exprimir.
/* mm/oom_kill.c */
bool out_of_memory(struct oom_control *oc)
{
if (oom_killer_disabled)
return false;
select_bad_process(oc); /* rellena oc->chosen */
if (!oc->chosen) {
/* nadie a quien matar y sin memoria: pánico */
if (!is_sysrq_oom(oc) && !is_memcg_oom(oc))
panic("Out of memory and no killable processes\n");
return false;
}
oom_kill_process(oc, "Out of memory");
return true;
}
El oc->constraint distingue el alcance del incidente. CONSTRAINT_NONE es el OOM global: falta memoria en toda la máquina. CONSTRAINT_MEMCG es un OOM de cgroup: un grupo topó con su límite aunque el sistema tenga RAM de sobra. Existen además CONSTRAINT_CPUSET y CONSTRAINT_MEMORY_POLICY para cuando la escasez es de un subconjunto de nodos NUMA. La restricción decide sobre qué población se elige a la víctima.
Elegir a la víctima: oom_badness
El kernel no mata al azar ni al más viejo: puntúa a cada candidato con oom_badness y elimina al de mayor puntuación. La base del cálculo es sencilla y justa —cuánta memoria libera matarlo— y se corrige con un ajuste que el administrador controla:
/* mm/oom_kill.c */
long oom_badness(struct task_struct *p, unsigned long totalpages)
{
long points;
long adj;
adj = (long)p->signal->oom_score_adj;
if (adj == OOM_SCORE_ADJ_MIN) { /* -1000: intocable */
task_unlock(p);
return LONG_MIN;
}
/* base: rss + swap + tablas de páginas, en páginas */
points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) +
mm_pgtables_bytes(p->mm) / PAGE_SIZE;
task_unlock(p);
/* oom_score_adj desplaza en milésimas del total */
adj *= totalpages / 1000;
points += adj;
return points;
}
La puntuación mide, en esencia, la huella de memoria del proceso: rss residente, más lo que tiene en swap, más sus tablas de páginas. Sobre esa base, oom_score_adj —un entero de -1000 a 1000 en /proc/PID/oom_score_adj— la desplaza en unidades de milésimas de la RAM total. Un valor positivo ofrece el proceso como carne de cañón; uno negativo lo protege. El extremo -1000, OOM_SCORE_ADJ_MIN, devuelve LONG_MIN: inmunidad total, el proceso jamás será elegido. La puntuación efectiva resultante se lee en /proc/PID/oom_score.
# ¿quién moriría primero? mayor oom_score = primero en la lista
for p in /proc/[0-9]*; do
printf '%6s %5s %s\n' "$(cat $p/oom_score 2>/dev/null)" \
"$(basename $p)" "$(cat $p/comm 2>/dev/null)"
done | sort -rn | head
# proteger un proceso crítico de por vida:
echo -1000 > /proc/$(pidof sshd)/oom_score_adj
El oom_reaper: no esperes al cadáver
Matar es enviar SIGKILL, pero un SIGKILL no libera memoria de inmediato: la víctima debe despertar, salir y que su mm se desmonte. ¿Y si la víctima está atascada esperando justamente un lock que retiene el hilo que provocó el OOM? Entonces no muere, no libera nada, y el sistema se congela en un abrazo mortal. Para romper esa posibilidad existe el oom_reaper, un kthread que recupera la memoria de la víctima sin esperar a que muera:
/* mm/oom_kill.c — segar el mm de la víctima en paralelo a su muerte */
static bool __oom_reap_task_mm(struct mm_struct *mm)
{
struct vm_area_struct *vma;
VMA_ITERATOR(vmi, mm, 0);
for_each_vma(vmi, vma) {
if (vma->vm_flags & (VM_HUGETLB | VM_PFNMAP))
continue;
/* desmapear solo anónimas privadas: liberables al instante */
if (vma_is_anonymous(vma) || !(vma->vm_flags & VM_SHARED))
unmap_page_range(&tlb, vma, range.start,
range.end, NULL);
}
return true;
}
El segador desmapea las regiones anónimas privadas de la víctima —las que se liberan sin escribir a ningún sitio— aun antes de que el proceso salga, y marca el mm con MMF_OOM_SKIP cuando termina. Así el kernel obtiene memoria en milisegundos y garantiza el progreso aunque la víctima tarde o se quede colgada. Es la encarnación de una desconfianza sana: nunca fíes en que el condenado morirá a tiempo; arráncale lo que puedas tú mismo.
flowchart TD A[Asignacion falla tras reclaim y reintentos] --> B[Que restriccion aplica] B -->|Global| C[Recorre todos los procesos] B -->|memcg| D[Recorre solo el cgroup] C --> E[oom_badness puntua cada tarea] D --> E E --> F[Elige la mayor puntuacion] F --> G[SIGKILL a la victima] G --> H[oom_reaper recupera su mm sin esperar] H --> I[Memoria liberada y progreso garantizado]
memcg: de catástrofe global a incidente local
El controlador de memoria de cgroup v2 —memcg— transforma la naturaleza del OOM. En lugar de un evento que amenaza a toda la máquina, la presión se acota a un grupo. memory.max es el tope duro: al alcanzarlo, el grupo hace reclaim contra sí mismo y, si no cede, sufre un OOM de su propia población, sin tocar a los demás. memory.high frena antes, estrangulando al grupo con throttling en vez de matar. Y memory.min y memory.low protegen memoria por debajo de la cual el reclaim no expulsa.
# cgroup v2: acotar y aislar la presión de un servicio
echo 512M > /sys/fs/cgroup/carga.slice/memory.max # tope duro
echo 400M > /sys/fs/cgroup/carga.slice/memory.high # estrangula antes
echo 1 > /sys/fs/cgroup/carga.slice/memory.oom.group # matar el grupo entero
cat /sys/fs/cgroup/carga.slice/memory.events # oom, oom_kill, max...
memory.oom.group añade un matiz decisivo: cuando se activa, el OOM no mata a un proceso suelto sino al cgroup completo como una unidad, útil para cargas donde matar la mitad de los hilos deja un servicio corrupto. Y memory.events expone contadores —oom, oom_kill, max, high— que permiten a un supervisor reaccionar. Sobre esta base, gestores como systemd-oomd vigilan la PSI (nivel 25.3) y matan carga en espacio de usuario antes de que el kernel llegue a su OOM, con más contexto sobre qué es prescindible.
select_bad_process y oom_badness no cambian entre el OOM global y el de cgroup: lo único que cambia es la población sobre la que iteran y el totalpages que normaliza la puntuación. Esa reutilización es la elegancia del diseño: acotar la presión no exigió un subsistema nuevo, solo parametrizar el alcance del que ya existía. La misma maquinaria, restringida, se convierte en aislamiento.
Linux permite a los procesos reservar mucha más memoria virtual de la que existe físicamente: es el overcommit, y es una apuesta. La apuesta casi siempre se gana, porque casi ningún proceso toca de golpe toda la memoria que pidió; el kernel entrega páginas físicas solo cuando se escriben de verdad, y así una máquina de 32 GB sirve alegremente a procesos que “reservaron” 100. Pero una apuesta puede perderse, y el OOM killer es el mecanismo por el que la casa cobra cuando la realidad reclama lo que se prometió y no existe. Vista así, toda la gestión de memoria del kernel es un sistema de crédito: malloc es una promesa, la falta de página es el cobro diferido de esa promesa, el reclaim y el swap son refinanciación, y el OOM killer es la ejecución de la deuda cuando ya no hay refinanciación posible. Lo profundo es el principio que gobierna la ejecución: entre congelarse para siempre y matar a alguien, el kernel elige siempre matar, porque un sistema que no progresa está tan muerto como uno apagado, pero sin la dignidad de poder reiniciarse. El oom_reaper lleva ese principio al extremo —ni siquiera confía en que la víctima muera para recuperar su memoria— y memcg lo civiliza —convierte una ejecución que arruinaría a toda la máquina en una quiebra acotada a un solo deudor—. El overcommit es lo que hace a Linux eficiente; el OOM killer es lo que lo hace, pese a ello, capaz de sobrevivir a su propia ambición.
- Traza el camino de
__alloc_pages_may_oomaout_of_memoryy di qué reintentos lo preceden. - Calcula a mano la
oom_badnessde dos procesos conrssdistinto yoom_score_adjde 0 y 500. - Protege un proceso con
oom_score_adj = -1000y explica por quéoom_badnessdevuelveLONG_MIN. - Razona qué desastre evita el
oom_reaperdesmapeando elmmantes de que la víctima salga. - Crea un cgroup v2 con
memory.maxpequeño, provoca su OOM local y observamemory.events; luego activamemory.oom.groupy compara.