OOM de Docker en contenedores: los límites de memoria que evitan fallos silenciosos

¿Te fue útil?

Tu servicio “se reinicia aleatoriamente”. No hay stack trace, ni excepción clara, ni mensaje de despedida. Un minuto atiende tráfico;
al siguiente está reincorporado con un PID nuevo y los mismos problemas sin resolver.

Nueve de cada diez veces, el culpable es la memoria: un contenedor alcanzó su límite, el kernel intervino y el proceso fue eliminado con
el entusiasmo de un portero que saca a alguien que “solo quiere hablar”.

Qué significa realmente “OOM” en Docker (y por qué parece un fallo)

“OOM” no es una función de Docker. Es una decisión del kernel de Linux: Out Of Memory. Cuando el sistema (o un cgroup de memoria) no puede satisfacer una
asignación, el kernel intenta recuperar memoria. Si eso falla, mata algo para sobrevivir.

Docker solo proporciona un escenario conveniente para que esto ocurra, porque los contenedores suelen colocarse en cgroups de memoria con límites
explícitos. Una vez que estableces un límite, has creado un pequeño universo donde “sin memoria” puede ocurrir aunque el host tenga mucha RAM libre.
Ese es el objetivo: límites de fallo previsibles en lugar de un servicio desbocado que se come el nodo.

La matiz clave: hay dos escenarios amplios de OOM que parecen similares desde afuera.

  • OOM de cgroup (límite del contenedor alcanzado): el contenedor llega a su límite de memoria, y el kernel mata uno o más procesos en ese
    cgroup. El host puede estar perfectamente sano.
  • OOM del sistema (OOM del nodo): todo el host se queda sin memoria. Entonces el kernel mata procesos a nivel del sistema, incluyendo
    dockerd, containerd y víctimas inocentes. De aquí viene esa sensación de “todo se fue al garete a la vez”.

En ambos casos, el proceso suele terminar con SIGKILL. SIGKILL significa sin limpieza, sin “vaciar logs”, sin apagado ordenado. Tu app
no “falla” tanto como es borrada de la existencia.

Una cita útil para mantener en tu canal de incidentes, atribuida a Werner Vogels (idea parafraseada): Todo falla; el trabajo es diseñar sistemas que fallen bien y se recuperen rápido.

Por qué a veces obtienes fallos “silenciosos”

El silencio no es malicia. Es mecánica:

  • El kernel mata el proceso de forma abrupta. Tu logger puede estar en búfer. Tus últimas líneas pueden nunca llegar a stdout/stderr.
  • Docker informa que el contenedor salió. A menos que consultes docker inspect o revises los logs del kernel, puede que nunca veas “OOMKilled”.
  • Algunos runtimes (o entrypoints) tragan códigos de salida reiniciando rápidamente, dejándote un servicio en flapping y con contexto mínimo.

Broma #1: Si crees que “has manejado todas las excepciones”, felicitaciones: Linux encontró una que no puedes atrapar.

Hechos e historia que cambian cómo depuras

Esto no es trivia por trivia. Cada punto empuja una decisión de solución de problemas en la dirección correcta.

  1. Los cgroups llegaron años antes de que “contenedores” fuera un producto. Ingenieros de Google propusieron cgroups a mediados de los 2000; Docker
    simplemente los hizo accesibles. Implicación: la autoridad es el kernel, no Docker.
  2. Los primeros cgroups de memoria eran conservadores y a veces sorprendentes. Históricamente, la contabilidad de memoria y el comportamiento OOM en cgroups
    maduraron a lo largo de varias versiones del kernel. Implicación: “funciona en mi portátil” puede ser una discrepancia de versión del kernel.
  3. cgroups v2 cambió los controles. En muchas distribuciones modernas, los archivos de límite de memoria pasaron de memory.limit_in_bytes (v1)
    a memory.max (v2). Implicación: tus scripts deben detectar en qué modo estás.
  4. Swap no es “RAM extra”, es dolor diferido. Swap retrasa el OOM a costa de picos de latencia y contención. Implicación:
    puedes “arreglar” un OOM habilitando swap y aún así provocar una caída—solo más lenta.
  5. Código de salida 137 es una pista, no un diagnóstico. 137 suele significar SIGKILL (128+9). OOM es una causa común, pero no la única.
    Implicación: confirma con cgroup/logs del kernel.
  6. La caché de páginas también es memoria. El kernel usa memoria libre para cache; en cgroups, la cache de páginas puede cargarse al cgroup según
    configuraciones y kernel. Implicación: contenedores con I/O intenso pueden OOM “sin fugas”.
  7. Overcommit es una política, no una promesa. Linux puede permitir asignaciones que exceden la memoria física (overcommit) y luego negarlas
    cuando se tocan. Implicación: una gran asignación puede tener éxito y aun así provocar un OOM más tarde bajo carga.
  8. El OOM killer elige víctimas según un scoring de severidad. El kernel calcula una puntuación; los consumidores de memoria altos con baja “importancia”
    son preferidos. Implicación: el proceso muerto puede no ser el que esperabas.
  9. Los contenedores no aíslan el kernel. Un OOM a nivel kernel puede seguir tumbando múltiples contenedores, o el runtime mismo.
    Implicación: el monitoreo del host sigue siendo importante en entornos “contenedorizados”.

Contabilización de memoria en cgroups: qué se cuenta, qué no, y por qué te importa

Antes de tocar un límite, entiende qué cuenta el kernel hacia él. Si no, “arreglarás” lo incorrecto y seguirás despertando al SRE los fines de semana.

Cubos de memoria que debes reconocer

Dentro de un contenedor, el uso de memoria no es solo heap. Los sospechosos habituales:

  • Memoria anónima: heap, stacks, arenas de malloc, runtimes de lenguaje, caches en memoria.
  • Memoria respaldada por archivo: archivos mapeados en memoria, bibliotecas compartidas y cache de páginas asociada a archivos.
  • Cache de páginas: lecturas en disco en caché; acelera hasta que mata.
  • Memoria del kernel: buffers de red, asignaciones slab. La contabilidad varía según versiones y modos de cgroup.
  • Memoria compartida: uso de /dev/shm; común en navegadores, bases de datos y apps con IPC intenso.

cgroups v1 vs v2: qué cambia en el comportamiento OOM

En cgroups v1, los controles de memoria están dispersos entre controladores, y el controlador “memsw” (memoria+swap) es opcional. En v2, las cosas están más
unificadas: memory.current, memory.max, memory.high, memory.swap.max y mejor eventing.

Operativamente, la gran mejora es memory.high en v2: puedes establecer un límite de “throttle” para inducir presión de reclamación antes de
alcanzar el límite duro de kill. No es magia, pero es una palanca real para “degradar en lugar de morir”.

OOM dentro del contenedor vs OOM fuera del contenedor

Si solo recuerdas una cosa: un OOM a nivel contenedor suele ser un problema de presupuesto; un OOM a nivel host suele ser un problema de capacidad u overbooking.

  • OOM del contenedor: configuraste --memory demasiado bajo, olvidaste la cache de páginas, tu app fuga memoria, o tuviste un pico puntual
    (calentamiento JIT, llenado de caché, compactación).
  • OOM del host: demasiados contenedores con límites generosos, contenedores sin límites, presión de memoria del nodo por procesos fuera de contenedores, o
    mala configuración de swap.

Los límites de memoria que importan (y los que solo te hacen sentir mejor)

Docker te da un puñado de flags que parecen directos. Lo son. La parte que no es directa es cómo interactúan con el swap,
el comportamiento de reclamación del kernel y los patrones de asignación de tu aplicación.

Límite duro: --memory

Esta es la línea en la arena. Si el contenedor la cruza y no puede recuperar lo suficiente, el kernel mata procesos en ese cgroup.

Qué hacer:

  • Establece un límite duro para cada contenedor de producción. Sin excepciones.
  • Deja margen por encima del uso en estado estable. Si tu servicio usa 600MiB, no pongas 650MiB y lo llames “ajustado.” Llámalo “frágil.”

Política de swap: --memory-swap

El comportamiento de swap de Docker confunde incluso a operadores experimentados porque el significado depende del modo de cgroup y del kernel, y porque el valor por defecto
no siempre es el que supones.

Postura práctica:

  • Si puedes permitirlo, prefiere poco o nada de swap para servicios sensibles a la latencia, y dimensiona correctamente el límite duro.
  • Si debes permitir swap, hazlo intencionalmente y monitorea faltas de página mayores y latencia. El swap oculta la presión de memoria hasta que se convierte en fuego.

Límites «suaves»: reservas y memory.high en v2

Docker expone --memory-reservation, que no es un mínimo garantizado. Es una pista, usada principalmente para decisiones de scheduler en algunos
orquestadores y para el comportamiento de reclamación. En sistemas cgroups v2, memory.high es la verdadera herramienta de “límite suave”.

En términos sencillos: las reservas son para planificación; los límites duros son para supervivencia; memory.high es para modelar comportamiento.

Qué deberías evitar

  • Contenedores ilimitados en un nodo compartido. Eso no es “flexible.” Es la ruleta con tu runtime.
  • Límites duros sin observabilidad. Si no mides memoria, solo estás escogiendo un momento de fallo.
  • Configurar límites iguales al máximo heap del JVM (o al “uso esperado” de Python) sin margen. Runtimes, libs nativas, threads y cache de páginas se reirán de tu hoja de cálculo.

Guía rápida de diagnóstico

Esta es la secuencia de “estoy de guardia y son las 02:13”. No lo pienses demasiado. Empieza aquí, obtén señal rápido y luego indaga más.

Primero: confirma que fue realmente OOM

  • Revisa el estado del contenedor: ¿fue OOMKilled, salió 137, o fue otro SIGKILL?
  • Revisa los logs del kernel para eventos OOM vinculados a ese cgroup o PID.

Segundo: determina dónde ocurrió el OOM

  • ¿Fue un OOM de cgroup (límite del contenedor) o un OOM del sistema (nodo exhausto)?
  • Mira la presión de memoria del nodo y el uso de otros contenedores en la misma marca temporal.

Tercero: decide si es un problema de dimensionamiento o una fuga/pico problemático

  • Compara el steady-state con el límite. Si el uso sube lentamente hasta la muerte, sospecha fuga.
  • Si muere durante un evento conocido (deploy, calentamiento de caché, job por lotes), sospecha pico y falta de margen.
  • Si muere bajo carga de I/O, sospecha cache de páginas, mmap o uso de tmpfs (/dev/shm).

Cuarto: arregla de forma segura

  • A corto plazo: sube el límite o reduce la concurrencia para detener la hemorragia.
  • A medio plazo: añade instrumentación, captura dumps/perfiles de heap y reproduce.
  • A largo plazo: aplica límites en todas partes; establece presupuestos; crea alarmas en “aproximación al límite” no solo en reinicios.

Tareas prácticas: comandos, salidas y decisiones (12+)

Estas están pensadas para ejecutarse en un host Docker. Algunas tareas requieren root. Todas producen información accionable.

Tarea 1: Comprueba si Docker cree que el contenedor fue OOMKilled

cr0x@server:~$ docker inspect -f '{{.Name}} OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}} Error={{.State.Error}}' api-1
/api-1 OOMKilled=true ExitCode=137 Error=

Qué significa: OOMKilled=true es tu pistola humeante; el código de salida 137 indica SIGKILL.
Decisión: Trátalo como violación del límite de memoria a menos que los logs del kernel muestren un OOM del sistema.

Tarea 2: Comprueba el contador de reinicios y la última hora de finalización (correlaciona con carga/deploy)

cr0x@server:~$ docker inspect -f 'RestartCount={{.RestartCount}} FinishedAt={{.State.FinishedAt}} StartedAt={{.State.StartedAt}}' api-1
RestartCount=6 FinishedAt=2026-01-02T01:58:11.432198765Z StartedAt=2026-01-02T01:58:13.019003214Z

Qué significa: Reinicios frecuentes muy juntos suelen indicar un bucle de fallo duro, no un caso aislado.
Decisión: Congela el pipeline de despliegue y recoge datos forenses antes de que el siguiente reinicio los sobrescriba.

Tarea 3: Lee la narrativa OOM del kernel

cr0x@server:~$ sudo dmesg -T | tail -n 20
[Thu Jan  2 01:58:11 2026] Memory cgroup out of memory: Killed process 24819 (python) total-vm:1328452kB, anon-rss:812340kB, file-rss:12044kB, shmem-rss:0kB, UID:1000 pgtables:2820kB oom_score_adj:0
[Thu Jan  2 01:58:11 2026] oom_reaper: reaped process 24819 (python), now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB

Qué significa: “Memory cgroup out of memory” indica un OOM a nivel contenedor, no un OOM a nivel host.
Decisión: Enfócate en límites del contenedor y comportamiento por contenedor, no en la capacidad del nodo—todavía.

Tarea 4: Confirma si el host experimentó un OOM del sistema

cr0x@server:~$ sudo dmesg -T | egrep -i 'out of memory|oom-killer|Killed process' | tail -n 10
[Thu Jan  2 01:58:11 2026] Memory cgroup out of memory: Killed process 24819 (python) total-vm:1328452kB, anon-rss:812340kB, file-rss:12044kB, shmem-rss:0kB, UID:1000 pgtables:2820kB oom_score_adj:0

Qué significa: Ves un evento OOM de cgroup pero no el preámbulo del sistema “Out of memory: Kill process …”.
Decisión: Probablemente no necesitas evacuar el nodo; necesitas evitar que ese contenedor golpee la pared.

Tarea 5: Comprueba los límites de memoria configurados del contenedor

cr0x@server:~$ docker inspect -f 'Memory={{.HostConfig.Memory}} MemorySwap={{.HostConfig.MemorySwap}} MemoryReservation={{.HostConfig.MemoryReservation}}' api-1
Memory=1073741824 MemorySwap=1073741824 MemoryReservation=0

Qué significa: Límite duro de 1GiB. El límite de swap es igual al de memoria (efectivamente sin swap más allá de la RAM para ese cgroup).
Decisión: Si el proceso necesita picos ocasionales por encima de 1GiB, o subes el límite o reduces el pico.

Tarea 6: Identifica la versión de cgroup (v1 vs v2) para elegir los archivos correctos

cr0x@server:~$ stat -fc %T /sys/fs/cgroup
cgroup2fs

Qué significa: cgroup2fs indica cgroups v2.
Decisión: Usa memory.current, memory.max y memory.events para señales precisas.

Tarea 7: Mapea un contenedor a su ruta de cgroup y lee el uso actual (v2)

cr0x@server:~$ CID=$(docker inspect -f '{{.Id}}' api-1); echo $CID
b1d6e0b6e4c7c14e2c8c3ad3b0b6e9b7d3c1a2f7d9f5d2e1c0b9a8f7e6d5c4b3
cr0x@server:~$ CG=$(systemctl show -p ControlGroup docker.service | cut -d= -f2); echo $CG
/system.slice/docker.service
cr0x@server:~$ sudo find /sys/fs/cgroup$CG -name "*$CID*" | head -n 1
/sys/fs/cgroup/system.slice/docker.service/docker/b1d6e0b6e4c7c14e2c8c3ad3b0b6e9b7d3c1a2f7d9f5d2e1c0b9a8f7e6d5c4b3
cr0x@server:~$ sudo cat /sys/fs/cgroup/system.slice/docker.service/docker/$CID/memory.current
965312512

Qué significa: Unos 920MiB en uso ahora mismo (bytes). Si el límite es 1GiB, estás cerca.
Decisión: Si esto es steady-state, sube el límite. Si está subiendo, comienza la investigación de fuga/pico.

Tarea 8: Comprueba el límite de memoria y el contador de eventos OOM (v2)

cr0x@server:~$ sudo cat /sys/fs/cgroup/system.slice/docker.service/docker/$CID/memory.max
1073741824
cr0x@server:~$ sudo cat /sys/fs/cgroup/system.slice/docker.service/docker/$CID/memory.events
low 0
high 12
max 3
oom 3
oom_kill 3

Qué significa: El cgroup alcanzó memory.max tres veces; ocurrieron tres kills por OOM. high es distinto de cero,
indicando presión de reclamación repetida.
Decisión: Esto no es un caso aislado. O subes el presupuesto o reduces demandas máximas de memoria (o ambas cosas).

Tarea 9: Ver qué procesos en el contenedor usan memoria

cr0x@server:~$ docker top api-1 -o pid,ppid,cmd,rss
PID    PPID   CMD                          RSS
25102  25071  python /app/server.py         612m
25134  25102  python /app/worker.py         248m
25160  25102  /usr/bin/ffmpeg -i pipe:0     121m

Qué significa: El proceso principal más worker y un binario nativo (ffmpeg) comparten el presupuesto de memoria del contenedor.
Decisión: Si el subproceso “tipo sidecar” no tiene límite, limita la concurrencia o muévelo a su propio contenedor con límites propios.

Tarea 10: Comprueba rápidamente la tendencia de uso de memoria del contenedor (Docker stats)

cr0x@server:~$ docker stats --no-stream api-1
CONTAINER ID   NAME    CPU %     MEM USAGE / LIMIT     MEM %     NET I/O     BLOCK I/O   PIDS
b1d6e0b6e4c7   api-1   184.21%   972.4MiB / 1GiB       94.96%    1.3GB/1.1GB  2.8GB/1.9GB  28

Qué significa: El contenedor está rondando ~95% de su límite. Eso no es “eficiente”, es “a una petición de desastre”.
Decisión: Aumenta el límite inmediatamente o reduce la carga. Luego investiga por qué está tan cerca.

Tarea 11: Diferencia memoria anónima vs cache de archivos (memory.stat en cgroup v2)

cr0x@server:~$ sudo egrep 'anon|file|shmem|slab' /sys/fs/cgroup/system.slice/docker.service/docker/$CID/memory.stat | head -n 20
anon 843018240
file 76292096
shmem 0
slab 31260672
file_mapped 21434368

Qué significa: La mayor parte del uso es memoria anónima (heap/stack/asignaciones nativas), no cache de páginas.
Decisión: Enfócate en las asignaciones de la aplicación: caza fugas, ajusta el runtime, tamaño de payloads, concurrencia.

Tarea 12: Verifica si /dev/shm es silenciosamente demasiado pequeño (o demasiado grande)

cr0x@server:~$ docker exec api-1 df -h /dev/shm
Filesystem      Size  Used Avail Use% Mounted on
shm              64M   12M   52M  19% /dev/shm

Qué significa: El tamaño por defecto de shm es 64MiB a menos que se configure. Algunas cargas (Chromium, extensiones de Postgres, inferencia ML) necesitan más.
Decisión: Si la app usa mucha memoria compartida, establece --shm-size y cuéntalo en tu presupuesto de memoria.

Tarea 13: Comprueba la presión de memoria del host (para descartar riesgo de OOM del nodo)

cr0x@server:~$ free -h
               total        used        free      shared  buff/cache   available
Mem:            62Gi        49Gi       1.2Gi       1.1Gi        12Gi        8.4Gi
Swap:            0B          0B          0B

Qué significa: El host tiene poco “free” pero buen “available” gracias a la cache; el swap está deshabilitado.
Decisión: El nodo no está OOM ahora, pero operas con márgenes estrechos. No ejecutes contenedores ilimitados.

Tarea 14: Ver límites por contenedor rápidamente para detectar minas “ilimitadas”

cr0x@server:~$ docker ps -q | xargs -n1 docker inspect -f '{{.Name}} mem={{.HostConfig.Memory}} swap={{.HostConfig.MemorySwap}}'
/api-1 mem=1073741824 swap=1073741824
/worker-1 mem=0 swap=0
/cache-1 mem=536870912 swap=536870912

Qué significa: mem=0 significa sin límite de memoria. Ese contenedor puede consumir el nodo.
Decisión: Arregla primero el contenedor ilimitado. Un proceso ilimitado puede convertir un OOM de contenedor en un OOM de nodo.

Tarea 15: Detecta si el contenedor fue matado pero reiniciado inmediatamente por la política

cr0x@server:~$ docker inspect -f 'RestartPolicy={{.HostConfig.RestartPolicy.Name}} MaxRetry={{.HostConfig.RestartPolicy.MaximumRetryCount}}' api-1
RestartPolicy=always MaxRetry=0

Qué significa: “always” restart hace las fallas ruidosas solo si miras reinicios; de lo contrario convierte OOM en “se arregló solo”.
Decisión: Mantén las políticas de reinicio, pero añade alertas sobre la tasa de reinicios y el estado OOMKilled.

Tarea 16: Confirma la vista de memoria desde el contenedor (importante para tuning de JVM/Go)

cr0x@server:~$ docker exec api-1 sh -lc 'cat /proc/meminfo | head -n 3'
MemTotal:       65843056 kB
MemFree:         824512 kB
MemAvailable:   9123456 kB

Qué significa: Algunos procesos aún “ven” la memoria del host vía /proc/meminfo, según kernel/runtime.
Decisión: Asegura que tu runtime sea consciente del contenedor (JVMs modernas y Go lo suelen ser). Si no, establece flags de heap explícitos.

Tres mini-historias corporativas desde las trincheras OOM

Mini-historia 1: Un incidente causado por una suposición equivocada

Una empresa SaaS de tamaño medio migró algunos endpoints monolíticos a un flamante contenedor “API”. El equipo puso un límite de 512MiB porque
el servicio “solo manejaba JSON” y “la memoria es sobre todo para bases de datos”. Esa suposición duró un día hábil.

El servicio usaba un stack web Python popular con algunas dependencias nativas. En tráfico normal rondaba 250–300MiB. Con un pico impulsado por marketing,
empezó a procesar payloads inusualmente grandes (JSON válido; simplemente enorme). Los cuerpos de las peticiones se parseaban, copiaban y validaban varias veces.
La memoria pico subió en pasos con la concurrencia.

Las fallas parecían reinicios aleatorios. Los logs terminaban a mitad de línea. Su APM mostraba traces incompletos y huecos raros. Alguien culpó al balanceador.
Alguien culpó al último deploy. El comandante del incidente hizo lo aburrido: docker inspect, luego dmesg.
Allí estaba: kills por OOM de cgroup.

Arreglarlo requirió dos movimientos. Primero, subieron el límite para detener la hemorragia y redujeron la concurrencia de workers. Segundo, añadieron límites de tamaño
de payload y parsing por streaming para cuerpos grandes. La lección no fue “dale más RAM a todo”. La lección fue que los límites de memoria deben fijarse
con conciencia del tamaño de entrada en el peor caso, no del comportamiento promedio.

Mini-historia 2: Una optimización que salió mal

Una plataforma relacionada con finanzas se obsesionó con la latencia p99. Un ingeniero notó lecturas frecuentes de disco y decidió “calentar la cache” agresivamente al
inicio: leer un gran set de datos de referencia, precomputar resultados y mantenerlo todo en memoria para velocidad. Funcionó de maravilla en staging.

Producción fue diferente: múltiples réplicas se reiniciaron durante despliegues, todas calentando simultáneamente. El nodo tenía RAM suficiente, pero cada
contenedor tenía un estricto límite de 1GiB porque el equipo intentaba empaquetar instancias densamente. La fase de warm-up empujó brevemente la memoria a ~1.1GiB por
instancia debido a asignaciones temporales durante parsing y construcción de índices. Las asignaciones temporales siguen siendo asignaciones; al kernel no le importa que tuvieses buena intención.

El patrón OOM fue cruel: los contenedores morían durante el warm-up, se reiniciaban, calentaban otra vez, morían de nuevo. Un clásico crash loop, salvo que no había stack de fallo.
La “optimización” del equipo se convirtió en un DoS auto-infligido durante despliegues.

La solución fue hacer el warm-up incremental y acotado. También introdujeron un presupuesto de memoria en el arranque: medir el pico durante el warm-up y fijar
el límite del contenedor con margen. El trabajo de performance que ignora presupuestos de memoria no es trabajo de performance; es solo una interrupción con mejores gráficos.

Mini-historia 3: Una práctica aburrida pero correcta que salvó el día

Una compañía logística ejecutaba una flota Docker en unos cuantos nodos robustos. Nada sofisticado. Su hábito más “innovador” era una auditoría semanal: cada contenedor tenía
límites explícitos de CPU y memoria, y cada servicio tenía un presupuesto de memoria acordado con un pequeño buffer.

Una semana, una actualización de una librería de un proveedor introdujo una fuga en una ruta de código raramente usada. La memoria subió lentamente—decenas de megabytes por hora—hasta
que el servicio alcanzó su límite de 768MiB y fue OOM-killado. Pero el radio de impacto fue pequeño: solo murió ese contenedor, no el nodo.

Sus alertas no eran “contenedor abajo”, lo cual llega demasiado tarde. Eran “memoria del contenedor al 85% del límite por 10 minutos”. El on-call vio la tendencia
temprano, revertió la librería y abrió un ticket para investigación profunda. Los usuarios vieron un breve parpadeo, no una caída multiservicio.

La práctica aburrida—límites en todas partes, presupuestos revisados y alarmas en aproximación al límite—no evitó el bug. Evitó que el bug se convirtiera en un incidente de plataforma. Ese es el estándar.

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

1) “El contenedor reinicia con código de salida 137, pero no hay flag OOMKilled”

Síntoma: Salida 137, reinicios, sin indicador claro de OOM en el estado de Docker.
Causa raíz: Fue matado por otra cosa: kill manual, timeout del orquestador, kill a nivel host, o un supervisor que envía SIGKILL tras expirar la gracia de SIGTERM.
Solución: Revisa dmesg por líneas OOM e inspecciona eventos del orquestador. Si no hay evidencia de OOM, trátalo como kill forzado y mira health checks y timeouts de parada.

2) “Pusimos un límite de memoria, pero el nodo sigue OOMeando”

Síntoma: El host sufre OOM del sistema, mueren múltiples contenedores, a veces dockerd/containerd se ve afectado.
Causa raíz: Algunos contenedores no tienen límites, o los límites sumados exceden la capacidad del nodo, o el host tiene grandes consumidores de memoria fuera de contenedores. Además: la cache de páginas y la memoria del kernel no negocian con tu hoja de cálculo.
Solución: Aplica límites a todos los contenedores, reserva memoria para el SO y deja espacio real. Si permites swap, hazlo intencionalmente y monitorea. Si corres sin swap, sé más estricto con el headroom.

3) “Aumentamos el límite y sigue OOMeando”

Síntoma: Mismo patrón, número mayor, misma muerte.
Causa raíz: Fuga de memoria o carga no acotada (profundidad de cola, concurrencia, tamaño de payload, cache). Subir el límite solo cambia el tiempo hasta el fallo.
Solución: Limita concurrencia, tamaño de cola, tamaño de payload, e instrumenta memoria. Luego perfila: heap dumps, profiling de allocs, seguimiento de memoria nativa para workloads mixtos.

4) “OOM solo durante deploys / reinicios”

Síntoma: Estable por días, luego muere durante rollout.
Causa raíz: Pico en el arranque: calentamiento de cache, compilación JIT, migraciones, construcción de índices, o efecto “thundering herd” cuando muchas réplicas se calientan a la vez.
Solución: Desfase reinicios, limita el trabajo de warm-up y fija límites basados en el uso pico de arranque, no solo en el steady state. Considera gates de readiness que eviten tráfico hasta completar el warm-up.

5) “OOM bajo carga I/O aunque el heap parece bien”

Síntoma: Métricas de heap estables; OOM durante lecturas/escrituras intensas.
Causa raíz: Cache de páginas y archivos mapeados, o uso de tmpfs (incluyendo /dev/shm), cobrados al cgroup. A veces también buffers del kernel para red o almacenamiento.
Solución: Examina el archivo memory.stat vs uso anon. Reduce footprint de mmap, ajusta la estrategia de caching o sube el límite para incluir budget de cache. Asegura que el tamaño de tmpfs/shm sea intencional.

6) “Deshabilitamos swap y ahora vemos más OOMs”

Síntoma: Aumentaron los kills por OOM después de desactivar swap.
Causa raíz: El swap enmascaraba la sobreasignación de memoria. Sin swap, el sistema alcanza restricciones duras antes.
Solución: No vuelvas a activar swap por reflejo. Primero, corrige presupuestos de contenedores y reduce la sobreasignación. Si se requiere swap, define reglas basadas en SLO: qué workloads pueden usar swap y cómo detectarás cuando el swap haga visible la latencia al usuario.

7) “OOM mata al proceso equivocado en el contenedor”

Síntoma: Muere un proceso auxiliar, o el proceso principal muere primero inesperadamente.
Causa raíz: Scoring del OOM y uso por proceso al momento del kill. Los contenedores multiproceso complican la selección de la víctima.
Solución: Prefiere un proceso principal por contenedor. Si debes ejecutar múltiples, aísla los helpers que consumen memoria en contenedores separados o diseña la arquitectura para que un solo kill no provoque fallos en cascada.

Broma #2: El OOM killer es el único compañero que nunca falla en una fecha límite—desafortunadamente, también tiene las habilidades sociales de una guillotina.

Listas de verificación / plan paso a paso

Lista: establece límites de memoria que eviten fallos silenciosos

  1. Mide la memoria en estado estable durante al menos un ciclo completo de tráfico (día/semana según la carga).
  2. Mide la memoria pico durante deploy/inicio, picos de tráfico y jobs en background.
  3. Elige un límite duro con margen por encima del pico (no por encima del promedio). Si no puedes permitir margen, tu densidad de nodos es fantasía.
  4. Decide la política de swap: nada para servicios sensibles a latencia; swap limitado para batch si es aceptable.
  5. Cuenta la sobrecarga no-heap: threads, libs nativas, memory maps, cache de páginas, tmpfs y metadata del runtime.
  6. Configura alertas al 80–90% del límite del contenedor, no solo en reinicios.
  7. Registra eventos OOM vía logs del kernel y contadores de cgroup.

Plan paso a paso: cuando un contenedor OOMea en producción

  1. Confirma OOM: docker inspect por OOMKilled y dmesg por líneas OOM de cgroup.
  2. Estabiliza: temporalmente sube el límite o reduce concurrencia/carga. Si hay un crash loop, considera pausar despliegues y reducir tráfico hacia la instancia afectada.
  3. Clasifica: fuga vs pico vs cache. Usa memory.stat para separar anon vs file.
  4. Recolecta evidencia: captura heap dumps/perfiles (donde aplique), tamaños de request, profundidades de cola y la correlación temporal con deploys.
  5. Arregla el desencadenante: añade límites (payload, concurrencia, tamaño de cache), corrige fuga o reduce el burst de arranque.
  6. Dificulta la repetición: aplica límites a todos los servicios y codifica presupuestos con checks en CI/CD o enforcement policy.

Lista: evita convertir un OOM de contenedor en OOM de nodo

  1. No contenedores con memoria ilimitada en nodos compartidos (salvo que sea un pool de nodos deliberado y aislado).
  2. Reserva memoria para el host: daemons del sistema, cache del filesystem, monitoring y overhead del runtime.
  3. No sumes límites duros hasta 100% de la RAM; deja margen real.
  4. Monitorea la presión de memoria del nodo y la actividad de reclamación, no solo la “memoria libre”.
  5. Decide la política de swap a nivel de nodo y asegúrate de que coincida con los SLOs de las cargas.

FAQ (las preguntas que la gente hace justo después del incidente)

1) ¿Cuál es la diferencia entre OOMKilled y el código de salida 137?

OOMKilled es Docker diciéndote que el kernel mató el contenedor por OOM en el cgroup. El código de salida 137 significa que el proceso recibió SIGKILL,
lo cual es consistente con OOM pero también puede venir de otros kills forzados. Siempre confirma con dmesg y contadores de eventos del cgroup.

2) Si el host tiene memoria libre, ¿por qué mi contenedor hace OOM?

Porque le diste al contenedor su propio presupuesto vía cgroups. El contenedor puede alcanzar --memory y ser matado aunque el nodo tenga RAM.
Esa es la frontera de aislamiento haciendo su trabajo—siempre que hayas fijado el presupuesto correctamente.

3) ¿Debo poner límites de memoria en cada contenedor?

Sí. Nodos de producción sin límites por contenedor terminan siendo “un mal deploy de distancia” de un OOM del nodo. Si necesitas un contenedor especial sin límite,
dale un nodo dedicado o al menos una conversación de riesgo dedicada.

4) ¿Cuánto margen debo dejar?

Suficiente para sobrevivir picos conocidos más un margen para lo desconocido. En la práctica: mide el pico y añade buffer. Si no puedes añadir buffer, reduce el pico mediante límites o reduce la densidad. El headroom es más barato que los incidentes.

5) ¿El swap es bueno o malo para contenedores Docker?

El swap es un trade-off. Para servicios sensibles a latencia, el swap suele convertir un “fallo duro” en una “fusión lenta”, que puede ser peor. Para jobs por lotes,
swap limitado puede mejorar el throughput evitando tormentas de kills. Decide por workload y monitorea faltas de página y latencia.

6) ¿Por qué mis logs se cortan justo antes del fallo?

Los kills por OOM suelen ser SIGKILL: el proceso no puede vaciar búferes. Si necesitas mejor observabilidad de último recurso, vacía logs con más frecuencia, escribe
eventos críticos de forma síncrona (con moderación) o usa un colector de logs externo que no dependa del proceso moribundo para ser educado.

7) ¿Puedo hacer que el kernel mate a otro proceso en su lugar?

Dentro de un cgroup de un solo contenedor, la selección de víctima está limitada a procesos en ese cgroup. A veces puedes influir con
oom_score_adj, pero no es un mecanismo fiable para “mata a ese”. La solución real es arquitectónica: evita contenedores multiproceso para helpers pesados de memoria, o aíslalos.

8) ¿Los límites de memoria de Docker Compose funcionan realmente?

Funcionan cuando se despliegan en un modo que los respeta. Compose en modo clásico mapea a las restricciones del runtime de Docker; en modo swarm, las restricciones se expresan de forma distinta.
La única respuesta segura es verificar con docker inspect y archivos de cgroup en el host.

9) ¿Cómo sé si es una fuga de memoria?

Busca una subida monótona en el tiempo bajo carga similar, terminando en OOM. Confirma con métricas a nivel de runtime (heap, RSS) y contadores de cgroup. Si sube solo durante eventos específicos, es más probable un pico o cache no acotada.

10) ¿Por qué la vista de memoria del contenedor parece la del host?

Dependiendo del kernel y la configuración del runtime, la información en /proc puede reflejar totales del host, mientras que la aplicación se aplica vía cgroups.
Los runtimes modernos suelen detectar límites de cgroup directamente, pero no lo asumas: establece flags de memoria explícitos para runtimes que se comporten mal.

Conclusión: pasos siguientes que realmente reducen interrupciones

Los OOM de contenedor son uno de los pocos modos de fallo que son tanto previsibles como prevenibles. Previsibles porque el límite es explícito.
Prevenibles porque puedes dimensionarlo, observarlo y modelar el comportamiento antes de que el kernel tire del enchufe.

Haz esto a continuación, en este orden:

  1. Aplica límites de memoria a cada contenedor y busca a los infractores con mem=0.
  2. Añade alertas al acercarse al límite (80–90%) y en eventos OOMKilled, no solo en reinicios.
  3. Mide la memoria pico durante deploy y warm-up; fija límites basados en el pico real, no en momentos tranquilos.
  4. Limita comportamientos no acotados: tamaño de payload, profundidad de cola, concurrencia, crecimiento de cache.
  5. Decide la política de swap intencionalmente en vez de heredar lo que tenga el nodo por defecto.

Los límites de memoria no hacen tu servicio “seguro”. Hacen que tu fallo esté contenido. Ese es todo el sentido de los contenedores: no prevenir el fallo, sino evitar que el fallo se propague como chisme en una oficina.

← Anterior
Debian 13: un error en fstab impide el arranque — la solución más rápida en modo rescate
Siguiente →
Proxmox “pmxcfs is not mounted”: por qué /etc/pve está vacío y cómo recuperarlo

Deja un comentario