SSD Nodes Learn 🎉 VPS desde $5.50/mes
Guías Matt ConnorPor Matt Connor

Limitar memoria y CPU de un proceso con systemd

Configura MemoryHigh, MemoryMax, CPUQuota y TasksMax en una unidad systemd y revisa el error de OOM kill para evitar que un proceso bloquee tu VPS.

Limitar la memoria y la CPU de un proceso con un drop-in de systemd

Puede limitar la memoria y la CPU de un proceso en un VPS Linux añadiendo unas líneas a la unidad que ejecuta el proceso. MemoryMax= es el límite máximo de memoria. CPUQuota= es el límite de tiempo de procesador. Ambos se aplican mediante cgroup v2 (grupos de control, versión 2), la función del kernel que systemd ya utiliza para contabilizar cada servicio del sistema.

sudo systemctl edit myapp.service

Esto abre un archivo drop-in con instrucciones en los comentarios. Añada lo siguiente encima de esas instrucciones:

[Service]
MemoryHigh=512M
MemoryMax=768M
MemorySwapMax=0
CPUQuota=80%
TasksMax=128
sudo systemctl daemon-reload
sudo systemctl restart myapp.service
systemctl show myapp.service -p MemoryHigh -p MemoryMax -p CPUQuotaPerSecUSec -p TasksMax

systemctl show debe devolver sus valores en las unidades propias del kernel: MemoryMax=805306368 y CPUQuotaPerSecUSec=800ms. Si muestra MemoryMax=infinity, el drop-in no se ha cargado. Compruebe que el archivo se haya creado en /etc/systemd/system/myapp.service.d/override.conf y que comience con la cabecera [Service], porque una línea de configuración sin una sección previa hace que systemd registre Assignment outside of section. Ignoring. y arranque el servicio sin ningún límite.

El resto de esta guía explica cómo elegir esos valores y qué problemas pueden seguir produciéndose después de configurarlos.

Por qué un proceso descontrolado congela un VPS aunque no lo llene

Un proceso que alcanza un límite estricto de memoria termina en aproximadamente un segundo y el servicio se reinicia. Ese es el caso bueno. El caso malo es aquel en el que no termina ningún proceso: el equipo responde al ping, SSH acepta la conexión, pero el prompt del shell nunca aparece. La máquina está activa y ocupada, pero ninguna de esas tareas resulta útil.

Este es el mecanismo, porque no es evidente. Cuando queda poca memoria libre, el kernel recupera páginas en lugar de asignar otras nuevas. Las páginas más fáciles de recuperar son las respaldadas por archivos, y la caché de páginas contiene el código ejecutable de todo lo que está en ejecución. Por eso el kernel expulsa las páginas de texto de sshd, y la siguiente instrucción que ejecuta sshd provoca un fallo de página que debe volver a leer esos bytes desde el almacenamiento. Todos los procesos terminan esperando al disco en lugar de ejecutarse. Las mismas páginas salen y vuelven en un ciclo. Este fenómeno se denomina thrashing.

Dos factores empeoran esta situación en un VPS frente a un portátil. El almacenamiento suele estar conectado por red o compartido, por lo que cada fallo cuesta más milisegundos que en un dispositivo NVMe local. Además, el kernel no mide el tiempo, sino los fallos: mientras la recuperación siga devolviendo una página, aunque lo haga lentamente, el kernel considera que está progresando y no ejecuta el out of memory (OOM) killer. Un equipo puede permanecer en ese estado durante muchos minutos antes de que se termine algún proceso.

Puede observar cómo ocurre. El kernel exporta la información de presión de recursos (PSI) en Linux 4.20 y versiones posteriores:

cat /proc/pressure/memory
cat /proc/pressure/io
some avg10=63.72 avg60=41.02 avg300=12.33 total=13729481
full avg10=48.15 avg60=30.44 avg300=8.90 total=9114233

La línea full es la importante. full avg10=48.15 significa que, durante los últimos diez segundos, el 48% del tiempo todas las tareas ejecutables del equipo estuvieron bloqueadas esperando operaciones de memoria, por lo que no se ejecutó ninguna. En un servidor en buen estado, full muestra un valor cercano a cero. Por encima de 10, una persona percibe lentitud; con 40 o más, el estado se describe como congelado.

Por eso un límite, por sí solo, no constituye una garantía. Una unidad limitada mediante MemoryHigh= se estrangula en lugar de terminar, por lo que permanece activa y lenta. Además, nada la reinicia porque, desde el punto de vista de systemd, nunca falló. Una unidad limitada que todavía puede usar swap genera lecturas y escrituras que se contabilizan a esa unidad, pero que son atendidas por un único dispositivo compartido. Por tanto, puede aumentar /proc/pressure/io para todos los demás servicios del equipo. Los límites determinan quién asume el coste de una escasez, pero no pueden crear capacidad.

Compruebe que su VPS ejecuta cgroup v2

stat -fc %T /sys/fs/cgroup

cgroup2fs es la jerarquía unificada, que necesitan todos los ajustes siguientes. tmpfs indica que el sistema arrancó con la disposición v1 anterior, donde MemoryHigh= y MemorySwapMax= no existen y el comportamiento OOM por unidad es diferente. Ubuntu 22.04 y posteriores, y Debian 11 y posteriores, usan v2 de forma predeterminada. Una imagen antigua o un kernel arrancado con systemd.unified_cgroup_hierarchy=0 no lo hace.

En cgroup v2, systemd activa de forma predeterminada la contabilidad de memoria para cada unidad, por lo que los valores ya están disponibles:

systemd-cgtop -m

Esto muestra los cgroups ordenados por uso de memoria. Es la forma más rápida de responder a «qué está consumiendo este servidor» mientras todavía puede responder. Si el servidor es nuevo, primero debe completar la configuración de la cuenta y del firewall descrita en los primeros diez minutos en un VPS nuevo.

MemoryHigh aplica limitación. MemoryMax provoca terminaciones.

La diferencia entre estos dos ajustes de memoria determina cómo se manifiesta un fallo.

  • MemoryHigh= es un límite flexible. Cuando se supera, el kernel recupera memoria de forma agresiva en ese cgroup y ralentiza deliberadamente sus asignaciones. El uso puede seguir superando el valor y no se termina ningún proceso.
  • MemoryMax= es un límite estricto. Cuando no se puede satisfacer una asignación respetando ese límite, se ejecuta el OOM killer dentro de ese cgroup y termina uno de los procesos propios de esa unidad.

Esta segunda parte es la razón principal para establecer MemoryMax= en cualquier elemento en el que no confíe plenamente. Sin un límite, una falta de memoria afecta a todo el sistema y el OOM killer global elige la víctima según oom_score, lo que normalmente significa el proceso más grande. El proceso más grande suele ser la base de datos, no el script que provocó la fuga. Con un límite, la terminación se produce dentro de la unidad que causó el problema.

Establezca ambos valores, con MemoryHigh= aproximadamente entre un 20 y un 30 por ciento por debajo de MemoryMax=. La diferencia es una zona de advertencia: una fuga lenta supera High y se manifiesta como una ralentización del servicio, mientras que un aumento repentino atraviesa directamente Max y provoca su terminación.

Los valores porcentuales se calculan a partir de la memoria física instalada, por lo que MemoryMax=25% en un plan de 4 GB equivale a 1 GB y sigue representando una cuarta parte del sistema después de ampliar el plan. MemorySwapMax=0 impide por completo que esa unidad use swap, lo que convierte una degradación prolongada en una terminación rápida y evidente.

Un límite debe ir acompañado de una política de reinicio; de lo contrario, la terminación sólo deja el servicio detenido.

[Unit]
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Restart=on-failure
RestartSec=5s

StartLimit* pertenece a [Unit] y Restart= a [Service]. Si coloca cualquiera de ellos en la sección incorrecta, systemd lo ignora. Cinco reinicios en cinco minutos indican una fuga y no un fallo puntual. Después de eso, systemd deja de intentarlo y marca la unidad como fallida. Ese es el estado que conviene encontrar más adelante, en lugar de un bucle de fallos que oculte el problema.

Limite la CPU con CPUQuota o compártala con CPUWeight

CPUQuota= representa un porcentaje del tiempo disponible en una CPU. CPUQuota=50% equivale a la mitad de un núcleo. CPUQuota=200% equivale a dos núcleos, que la unidad puede distribuir entre tantos hilos como necesite. En un plan de 2 vCPU, CPUQuota=200% representa toda la máquina.

CPUWeight= es la opción predeterminada más adecuada para la mayoría de los servicios. Es una proporción relativa de 1 a 10000, y el valor predeterminado del kernel es 100. Sólo tiene efecto cuando hay competencia por la CPU: un trabajo de copia de seguridad con CPUWeight=20 cede recursos ante un servidor web con 100 bajo carga, pero sigue usando toda la máquina cuando está inactiva. Una cuota estricta desperdicia esa capacidad disponible.

Sea realista sobre lo que ofrece un límite de CPU. Un proceso limitado por CPU rara vez bloquea Linux, porque el planificador sigue asignando tiempo a todos los procesos. La memoria es lo que suele dejar una máquina fuera de servicio. Use CPUQuota= cuando necesite un límite predecible, por ejemplo, en una compilación o en un agente que, de otro modo, usaría toda la CPU durante una hora. Dimensionar ese tipo de carga es otra cuestión, que se trata en cuánta RAM y CPU necesita un VPS para un agente de programación.

Si la CPU aparece ocupada mientras ninguno de sus procesos hace un uso significativo, la causa puede estar en el otro lado del hipervisor. Se trata del tiempo de espera de CPU causado por un vecino ruidoso, y ninguna cuota que configure cambiará esa situación.

TasksMax detiene un bucle de fork

TasksMax= es el número de procesos e hilos que una unidad puede mantener. Los hilos cuentan, por lo que un servicio Java o Go necesita más margen del que sugiere la lista de procesos. Es la protección más sencilla contra un script que ejecuta fork en un bucle, porque el fork falla dentro de la unidad en lugar de agotar los identificadores de proceso del equipo.

TasksMax=128

Cuando una unidad alcanza el límite, el kernel registra una línea que identifica el cgroup:

cgroup: fork rejected by pids controller in /system.slice/myapp.service

El propio programa suele informar fork: retry: Resource temporarily unavailable. Compruebe qué valor aplica el gestor de forma predeterminada con systemctl show -p DefaultTasksMax.

Limitar un trabajo puntual con systemd-run

No necesita un archivo de unidad para usar nada de esto. systemd-run crea una unidad transitoria alrededor de un único comando.

sudo systemd-run --scope -p MemoryMax=1G -p MemorySwapMax=0 -p CPUQuota=50% -p TasksMax=64 ./import-data.sh

--scope ejecuta el comando en la terminal después de mostrar Running scope as unit: run-r7c1a....scope. La salida permanece en pantalla y los límites desaparecen cuando finaliza el comando. Cualquier propiedad de systemd.resource-control funciona después de -p.

Para un trabajo de larga duración, omita --scope y asígnele un nombre. Se ejecutará en segundo plano como un servicio transitorio y escribirá los registros en el journal:

sudo systemd-run --unit=nightly-import -p MemoryMax=1G -p CPUWeight=20 ./import-data.sh
journalctl -u nightly-import -f

Las mismas opciones funcionan con --user cuando no es root, aunque el gestor de usuario sólo tiene los controladores que se le hayan delegado, por lo que una propiedad puede rechazarse. Ejecútelo con sudo si ocurre. Cuando un trabajo necesita una unidad permanente, traslade la configuración sin cambios a una unidad real: consulte ejecutar un script como servicio y temporizador de systemd.

La pregunta sobre swap, respondida con honestidad

Swap cambia la forma del fallo, pero no lo evita.

Sin swap, una fuga alcanza el límite y algo muere en cuestión de segundos. La interrupción es evidente, breve y fácil de interpretar después en el journal. Con swap, el kernel escribe en disco las páginas anónimas inactivas y gana tiempo. Si el proceso iba a estabilizarse, swap lo salva. Si es un proceso descontrolado, swap convierte una interrupción de cinco segundos en una saturación de veinte minutos. La saturación es peor, porque un proceso muerto todavía deja un shell funcional, mientras que un sistema ocupado con thrashing no.

swapon --show
free -h

Una opción práctica en un VPS pequeño es mantener un archivo de swap moderado para las páginas que se asignan una vez y nunca vuelven a utilizarse, y establecer MemorySwapMax=0 en las unidades que esté dispuesto a perder. Los servicios importantes conservan su swap. Los servicios impredecibles alcanzan el límite rápidamente y se reinician.

Reducir vm.swappiness es una medida poco eficaz, y conviene saber por qué. Sólo desplaza el equilibrio entre expulsar la caché de páginas y intercambiar páginas anónimas, y ambas operaciones requieren después una lectura desde el disco. Cambia qué páginas sufren thrashing, no si el sistema sufre thrashing.

Un daemon OOM anticipado finaliza procesos antes del bloqueo

El kernel espera a que la recuperación de memoria falle por completo. En una VPS pequeña, esa espera es precisamente el intervalo en el que se pierde el acceso a la máquina. Dos daemons de espacio de usuario reducen ese intervalo. Supervisan la memoria por su cuenta y finalizan procesos antes.

earlyoom supervisa la memoria disponible y el swap libre. Finaliza el proceso con la puntuación más alta cuando cualquiera de los dos valores cae por debajo de un umbral.

sudo apt install earlyoom
systemctl status earlyoom

El paquete de Debian y Ubuntu inicia el servicio durante la instalación. Sus opciones están en /etc/default/earlyoom:

EARLYOOM_ARGS="-m 5,2 -s 5,2 --avoid '^(sshd|systemd)$' --prefer '^(node|python3)$'"

-m PERCENT establece el mínimo de memoria disponible y -s PERCENT el mínimo de swap libre. Ambos valores son del 10 por ciento de forma predeterminada. El segundo número de cada par indica el punto en el que se envía SIGKILL. earlyoom envía SIGTERM cuando el valor cae por debajo del primero y SIGKILL cuando cae por debajo del segundo. De forma predeterminada, el segundo valor es la mitad del primero. Aplique los cambios con sudo systemctl restart earlyoom. Consulte journalctl -u earlyoom para ver qué proceso finalizó y cuánta memoria ocupaba.

systemd-oomd es la otra opción. Su página del manual lo describe como "un servicio del sistema que usa cgroups-v2 y la información de presión de bloqueo (PSI) para supervisar el sistema y aplicar medidas correctivas antes de que se produzca un OOM en el espacio del kernel". Actúa sobre cgroups completos en lugar de procesos individuales. Por tanto, finaliza una unidad y no un proceso hijo aislado. Las unidades se incluyen explícitamente con ManagedOOMMemoryPressure=kill o ManagedOOMSwap=kill. Los umbrales están en /etc/systemd/oomd.conf.

systemctl status systemd-oomd
oomctl

oomctl muestra lo que supervisa actualmente. En una imagen de servidor, a menudo no muestra nada porque la configuración se habilita de forma explícita para cada unidad. Elija un daemon y use sólo ese. Ejecutar ambos hace que compitan para elegir una víctima. También dificulta reconstruir el motivo de cada finalización.

¿Qué unidad fue responsable?

Empiece por el kernel, porque registra cada proceso que termina.

journalctl -k --grep "Killed process" --since "2 hours ago"

Un kill del OOM killer global tiene este aspecto:

Out of memory: Killed process 4127 (node) total-vm:2731084kB, anon-rss:1874232kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:4212kB oom_score_adj:0

anon-rss es la memoria que el proceso tenía en RAM cuando terminó; aquí, unos 1.8 GB. Lea con cautela el nombre entre corchetes. Ese es el proceso que eligió el kernel como víctima. El kernel elige el proceso más grande, que no siempre es el que causó la falta de memoria.

Un kill causado por el límite de un cgroup lleva un prefijo diferente, y el informe que aparece encima identifica el cgroup que alcanzó su propio límite:

Memory cgroup out of memory: Killed process 8811 (python3) total-vm:1044320kB, anon-rss:769112kB, file-rss:0kB, shmem-rss:0kB, UID:998 pgtables:1720kB oom_score_adj:0

Ese prefijo contiene la mayor parte del diagnóstico. Memory cgroup out of memory significa que una unidad alcanzó el MemoryMax= que se le asignó y que el resto del servidor funcionaba correctamente. Un Out of memory sin más datos significa que se agotó la memoria de toda la máquina. En ese caso, los límites faltaban o eran demasiado altos en conjunto.

Después, pregunte a systemd qué registró:

systemctl status myapp.service
journalctl -u myapp.service -n 50
myapp.service: A process of this unit has been killed by the OOM killer.
myapp.service: Main process exited, code=killed, status=9/KILL
myapp.service: Failed with result 'oom-kill'.

systemctl status indica lo mismo en una línea, como Active: failed (Result: oom-kill).

Los contadores del cgroup son la tercera fuente y la única que registra la limitación de recursos, que nunca genera una línea de registro:

cat /sys/fs/cgroup/system.slice/myapp.service/memory.events
cat /sys/fs/cgroup/system.slice/myapp.service/memory.peak
low 0
high 4213
max 118
oom 12
oom_kill 12

high cuenta cuántas veces la unidad superó MemoryHigh= y quedó limitada. max cuenta cuántas veces alcanzó el límite estricto, y oom_kill cuenta los procesos que realmente terminaron. Un valor alto de high junto con oom_kill 0 corresponde al caso silencioso anterior: el servicio sigue funcionando, se ha ralentizado hasta casi detenerse y no ha informado del fallo a nadie. memory.peak (Linux 5.19 y posteriores) contiene el uso máximo que alcanzó el cgroup. Ese es el valor que debe usar para dimensionar MemoryMax=. Ambos archivos se reinician cuando se reinicia la unidad, porque systemd vuelve a crear el cgroup.

Todo esto depende de un requisito previo. Si /var/log/journal no existe, el journal se almacena en RAM y todas las líneas desaparecen después del reinicio que necesitaba para recuperar el servidor.

sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald
journalctl --list-boots

Que journalctl --list-boots muestre más de un arranque significa que el historial ya se conserva. Por tanto, journalctl -k -b -1 puede mostrar los mensajes del kernel del arranque que falló.

Un punto de partida para un VPS pequeño

En un plan de 2 GB, reserve entre 300 y 400 MB para el kernel y la caché de páginas. No deje que la suma de los límites alcance los 2 GB completos, porque todas las unidades pueden alcanzar su pico al mismo tiempo. Asigne la mayor parte al servicio más importante y establezca límites para todo lo demás alrededor de él.

[Service]
MemoryHigh=256M
MemoryMax=384M
MemorySwapMax=0
CPUWeight=20
TasksMax=64
Restart=on-failure
RestartSec=5s

Mantener una forma de acceso disponible justifica un ajuste adicional. OOMScoreAdjust=-500 en un drop-in para ssh.service reduce mucho la probabilidad de que el OOM killer global elija el daemon SSH como víctima. Esa diferencia puede permitir reparar el sistema en lugar de reiniciarlo desde el panel de control. Sólo cambia la elección de la víctima por parte del kernel. No acorta la pausa.

Los contenedores se ejecutan en sus propios cgroups, creados por el runtime de contenedores y no por los archivos de unidad. Por eso, un límite en docker.service no se convierte en un límite para un contenedor. Los equivalentes por contenedor de MemoryMax= y CPUQuota= se explican en configurar límites de memoria y CPU en Docker Compose.

FAQ

¿Por qué mi VPS se bloqueó en lugar de terminar el proceso descontrolado?

Porque el kernel evalúa el progreso según si la recuperación devuelve páginas, no según cuánto tarda. Cuando falta memoria, expulsa la caché de páginas, incluidas las páginas ejecutables de los programas en ejecución, y después las vuelve a leer para ejecutar la siguiente instrucción. Todo queda esperando al almacenamiento y ninguna asignación ha fallado técnicamente, por lo que nunca se llama al OOM killer. Compruebe /proc/pressure/memory mientras ocurre: un full avg10 superior a 40 significa que casi ninguna tarea pudo ejecutarse durante los últimos diez segundos. Un daemon de espacio de usuario como earlyoom termina los procesos antes de que el servidor llegue a ese estado.

¿Cuál es la diferencia entre MemoryHigh y MemoryMax?

MemoryHigh= es un límite flexible que aplica limitación. El kernel recupera memoria de forma agresiva de la unidad y ralentiza sus asignaciones, pero el uso puede superar el número y no se termina ningún proceso. MemoryMax= es un límite estricto: una asignación que no se puede satisfacer dentro de ese límite invoca el OOM killer dentro del propio cgroup de la unidad, por lo que muere el proceso que causó el problema en lugar del proceso más grande del servidor. Establezca MemoryHigh= por debajo de MemoryMax= y trate la diferencia entre ambos como una zona de advertencia.

¿Cómo averiguo qué servicio afectó el OOM killer?

Ejecute journalctl -k --grep "Killed process" --since "2 hours ago". Una línea que empieza por Memory cgroup out of memory significa que una unidad alcanzó su propio MemoryMax=, mientras que un Out of memory sin prefijo significa que se agotó la memoria de toda la máquina. Después, ejecute journalctl -u <unit> -n 50 y busque Failed with result 'oom-kill'. Si /var/log/journal no existe en su servidor, el journal se mantuvo en la RAM y las pruebas se perdieron al reiniciar, por lo que debe crear ese directorio antes del próximo incidente.

¿Debo añadir swap a un VPS pequeño?

Un archivo de swap pequeño ayuda con las páginas inactivas que se asignan una vez y no se vuelven a utilizar. No ayuda con un proceso descontrolado: retrasa la terminación y convierte una interrupción breve en un bloqueo prolongado al que no puede iniciar sesión para solucionarlo. Mantenga la swap en un tamaño moderado y establezca MemorySwapMax=0 en las unidades que esté dispuesto a perder, para que alcancen su límite y se reinicien rápidamente mientras los servicios importantes conservan su swap.

¿Puedo limitar un comando sin escribir un archivo de unidad?

Sí. sudo systemd-run --scope -p MemoryMax=1G -p CPUQuota=50% ./script.sh ejecuta el comando en su terminal dentro de un ámbito transitorio con esos límites, y los límites desaparecen cuando el comando termina. Todas las propiedades de systemd.resource-control están disponibles después de -p, por lo que MemorySwapMax=, TasksMax= y CPUWeight= también funcionan ahí. Quite --scope y añada --unit=name para ejecutar el trabajo en segundo plano con su salida en el journal.