OOMKilled en Kubernetes: por qué pasa y por qué subir el límite no basta

OOMKilled significa que el kernel mató tu proceso porque pidió más memoria de la que tenía permitido usar. No es un error de Kubernetes ni una falla de la aplicación: es el sistema haciendo cumplir un límite que alguien escribió.

Cómo confirmarlo, y las dos variantes que se confunden

`kubectl describe pod <nombre>` muestra `Last State: Terminated` con `Reason: OOMKilled` y `Exit Code: 137`. Ese 137 es 128 más 9, la señal SIGKILL: nadie le pidió al proceso que terminara ordenadamente, se lo mataron.

Hay dos situaciones distintas detrás del mismo nombre y conviene separarlas. La primera es que **tu contenedor pasó su propio límite**: el cgroup lo mata y solo ese contenedor muere. El pod sigue existiendo y se reinicia.

La segunda es que **el nodo entero se quedó sin memoria**. Ahí el kubelet empieza a desalojar pods para salvar al nodo, y verás `Evicted` con un mensaje de presión de memoria, no `OOMKilled`. Los pods desalojados se reprograman en otro nodo. Si estás persiguiendo un OOMKilled y en realidad tienes desalojos, estás mirando el contenedor equivocado: el problema es de capacidad del cluster.

Requests y limits: qué hace cada uno

El `request` es lo que el planificador reserva para decidir en qué nodo cabe el pod. El `limit` es el techo que el kernel hace cumplir. Solo el segundo provoca un OOMKilled.

Si defines `limit` y no `request`, Kubernetes asume que el request es igual al limit. Si defines `request` y no `limit`, el contenedor puede crecer hasta consumir el nodo — y ahí terminas con desalojos en vez de con un OOMKilled prolijo.

Cuando `request` y `limit` son iguales, el pod recibe la clase de servicio Guaranteed y es el último candidato al desalojo. Cuando son distintos es Burstable, y cuando no hay ninguno es BestEffort, el primero en caer cuando el nodo se aprieta. Para un servicio que importa, igualarlos es lo que compra estabilidad.

La JVM se mata sola si no la configuras para el contenedor

Esta es la causa más común en empresas con Java, y la que menos aparece explicada. Por omisión, la JVM dimensiona su heap máximo como una fracción de la memoria que ve disponible. En una máquina normal eso funciona. Dentro de un contenedor, versiones antiguas veían la memoria del **nodo** completo, no el límite del cgroup.

El resultado es una JVM que cree tener 64 GB para repartir mientras el contenedor tiene un límite de 2 GB. Crece con tranquilidad hasta pasarse, y el kernel la mata. Los logs de la aplicación no muestran nada raro, porque desde su punto de vista todo iba bien.

Las JVM modernas respetan el cgroup con `-XX:+UseContainerSupport`, que viene activo, y el heap se ajusta con `-XX:MaxRAMPercentage`. Aun así conviene dejar margen: el proceso Java usa memoria fuera del heap —metaspace, buffers directos, pilas de hilos— y ese consumo no está en el porcentaje del heap.

La regla práctica es no dar al heap más del 70% u 80% del límite del contenedor. Si le das el 100%, el heap solo alcanza el techo antes que la JVM alcance a recolectar.

Por qué subir el límite es el arreglo equivocado la mitad de las veces

Subir el límite desbloquea el servicio, y a veces es lo correcto: el límite estaba mal calculado desde el principio y el consumo real es estable en un valor más alto.

Pero si hay una fuga de memoria, subir el límite solo cambia cuándo se cae. Con 512 MB se moría cada dos horas; con 2 GB se muere cada ocho. Nadie nota la diferencia hasta el fin de semana largo.

La forma de distinguirlos es mirar la forma del consumo en el tiempo. Un consumo que sube, se estabiliza y se queda plano es un límite mal calculado. Un consumo que sube en escalera y nunca baja, aunque el tráfico baje de noche, es una fuga.

Y hay un costo que no se ve: memoria reservada de más en todos los pods de un cluster es sobregasto puro. Los `requests` inflados «por si acaso» hacen que el planificador crea que el nodo está lleno cuando está a medias, y el autoescalador agrega nodos que nadie usa. Es una de las fuentes de sobrecosto más frecuentes que encontramos en trabajos de FinOps.

Si antes no pasaba, algo cambió

Cuando un servicio estable empieza a morir por memoria, las causas suelen ser tres: alguien bajó el límite en un commit, una versión nueva de la aplicación consume más, o una dependencia nueva trajo su propio consumo.

Las tres son cambios con fecha y hora, y las tres se responden con el historial de revisiones: qué imagen y qué commit estaban activos antes del primer OOMKilled, y qué cambió entre esa revisión y la siguiente — incluido el propio valor del límite, que vive en el manifiesto.

Reconstruir eso a mano es lo que estira el tiempo de recuperación. El diagnóstico de un OOMKilled es de los más simples que hay; lo que toma tiempo es averiguar desde cuándo y por qué.

Cómo Moonin acorta esa parte

Moonin mantiene el historial de revisiones de cada servicio con su imagen, su commit y lo que cambió respecto de la anterior, incluidos los límites de recursos. Ver que el límite de memoria bajó de 1 Gi a 512 Mi en la revisión de anteayer es la respuesta completa en una pantalla.

Y registra los reinicios asociándolos a la revisión activa, así que se distingue un servicio que siempre reinició de uno que empezó con el último despliegue.

Lo que no hace: no ajusta límites, no reinicia pods y no toma acciones por su cuenta. La única mutación posible en todo el producto es el ajuste de HPA y réplicas por el Scaling Rules Agent, que es opcional y viene desactivado.

Ver el historial de tu propio cluster

La demo está abierta y sin formulario: puedes revisar cómo se ve el historial de revisiones con sus límites de recursos y los reinicios asociados, antes de instalar nada.

ConócenosLicenciamientoSeguridad y permisosPrograma de partnersvs Datadogvs Prometheus y Grafanavs New Relicvs Dynatracevs ELK y ElasticsearchCrashLoopBackOffImagePullBackOffPod en PendingDocumentaciónDemoRegístrateContáctanosArguz, la consultora

Preguntas frecuentes

¿Qué significa el código de salida 137?

Es 128 más 9: la señal SIGKILL. El proceso no recibió una petición de terminar ordenadamente, lo mataron. En Kubernetes casi siempre es el kernel haciendo cumplir el límite de memoria del contenedor, y `kubectl describe pod` lo confirma con `Reason: OOMKilled`.

¿Cuál es la diferencia entre OOMKilled y Evicted?

OOMKilled es tu contenedor pasando su propio límite: el cgroup lo mata y el pod se reinicia en el mismo nodo. Evicted es el nodo entero quedándose sin memoria: el kubelet desaloja pods para salvarse y esos pods se reprograman en otro nodo. El primero es un problema de límites; el segundo, de capacidad del cluster.

¿Por qué mi aplicación Java muere por memoria si le di suficiente?

Porque el heap máximo de la JVM se calcula como fracción de la memoria disponible, y si no respeta el cgroup ve la memoria del nodo completo en vez del límite del contenedor. Con `-XX:+UseContainerSupport` respeta el límite, y `-XX:MaxRAMPercentage` ajusta el heap. Conviene no pasar del 70% u 80% del límite, porque la JVM también usa memoria fuera del heap: metaspace, buffers directos y pilas de hilos.

¿Subo el límite y listo?

Solo si el consumo es estable en un valor más alto que el límite que pusiste. Si hay una fuga, subir el límite cambia cuándo se cae, no si se cae. Se distingue mirando la forma del consumo: sube y se aplana, es un límite mal calculado; sube en escalera y nunca baja aunque el tráfico caiga de noche, es una fuga.

¿Conviene poner request igual a limit?

Para un servicio que importa, sí. Cuando son iguales el pod recibe la clase Guaranteed y es el último candidato al desalojo cuando el nodo se aprieta. Cuando son distintos es Burstable, y sin ninguno es BestEffort, el primero en caer. El costo es que reservas la memoria completa aunque no la uses todo el tiempo.