Limitar memoria y CPU de un proceso con systemd
Configura MemoryHigh, MemoryMax, CPUQuota y TasksMax en una unidad systemd. Evita que un proceso bloquee el VPS y comprueba el registro del OOM kill.
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.serviceEsto 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=128sudo systemctl daemon-reload
sudo systemctl restart myapp.service
systemctl show myapp.service -p MemoryHigh -p MemoryMax -p CPUQuotaPerSecUSec -p TasksMaxsystemctl 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 anterior hace que systemd registre Assignment outside of section. Ignoring. e inicie 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 sin llenarlo
Un proceso que alcanza un límite estricto de memoria termina en aproximadamente un segundo y el servicio se reinicia. Ese es el caso favorable. El caso problemático es aquel en el que no termina nada: el servidor 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 operaciones 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 bucle. Esto 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 ser compartido, por lo que cada fallo tarda 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 sea lentamente, el kernel considera que está avanzando y no activa el out of memory (OOM) killer. Un servidor puede permanecer en ese estado durante muchos minutos antes de que termine algún proceso.
Puede observar cómo sucede. El kernel exporta pressure stall information (PSI) en Linux 4.20 y versiones posteriores:
cat /proc/pressure/memory
cat /proc/pressure/iosome avg10=63.72 avg60=41.02 avg300=12.33 total=13729481
full avg10=48.15 avg60=30.44 avg300=8.90 total=9114233La 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 servidor estuvieron bloqueadas esperando operaciones de memoria, por lo que no se ejecutó nada. En un servidor en buen estado, full se mantiene cerca de 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 ralentiza en lugar de terminar, por lo que permanece activa y lenta. systemd no la reinicia porque, desde su punto de vista, nunca falló. Una unidad con límite que todavía puede usar swap genera lecturas y escrituras que se contabilizan a esa unidad, pero que atiende un único dispositivo compartido. Por tanto, puede aumentar /proc/pressure/io para todos los demás servicios del servidor. Los límites determinan quién asume el coste de la escasez, pero no pueden crear capacidad.
Compruebe que su VPS ejecuta cgroup v2
stat -fc %T /sys/fs/cgroupcgroup2fs es la jerarquía unificada que necesitan todos los ajustes siguientes. tmpfs indica que el sistema arrancó con la disposición anterior de v1, 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 hacen.
En cgroup v2, systemd activa de forma predeterminada la contabilidad de memoria para cada unidad, por lo que los datos ya están disponibles:
systemd-cgtop -mEsto muestra los cgroups ordenados por uso de memoria. Es la forma más rápida de saber «qué está consumiendo este servidor» mientras todavía puede responder. Si el servidor es nuevo, las tareas de creación de la cuenta y configuración del firewall descritas en los primeros diez minutos en un VPS nuevo deben hacerse antes que esto.
MemoryHigh aplica limitación gradual. MemoryMax finaliza procesos.
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 de ese cgroup y ralentiza deliberadamente sus asignaciones. El uso puede superar el valor y no se finaliza ningún proceso.MemoryMax=es un límite estricto. Cuando no se puede satisfacer una asignación dentro de ese límite, se ejecuta el OOM killer dentro de ese cgroup y finaliza uno de los procesos propios de esa unidad.
Esta segunda parte es el motivo real para definir MemoryMax= en cualquier servicio en el que no confíe por completo. Sin un límite, la 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 finalización se produce dentro de la unidad que causó el problema.
Defina ambos valores y mantenga MemoryHigh= 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 un servicio que se vuelve lento, mientras que un aumento repentino atraviesa directamente Max y provoca su finalización.
Los valores porcentuales se calculan a partir de la memoria física instalada. Por tanto, 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 finalización rápida y evidente.
Algunos servicios permiten definir de antemano la memoria que consumirán en lugar de medirla. Una unidad de Ollama calcula el tamaño de su caché KV a partir de la ventana de contexto indicada. Lea cuánto cuesta aumentar num_ctx en RAM antes de elegir un límite para ella.
Un límite debe tener una política de reinicio asociada. De lo contrario, la finalización sólo deja el servicio detenido.
[Unit]
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Restart=on-failure
RestartSec=5sStartLimit* debe estar en [Unit] y Restart= en [Service]. Si coloca cualquiera de ellos en la sección incorrecta, systemd lo ignora. Cinco reinicios en cinco minutos indican una fuga, no un fallo puntual. Después de eso, systemd abandona los intentos y deja la unidad en estado fallido. Ese es el estado que debe detectar 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= consume un porcentaje del tiempo disponible en una CPU. CPUQuota=50% equivale a medio núcleo. CPUQuota=200% equivale a dos núcleos, que la unidad puede distribuir entre tantos hilos como necesite. En un plan con 2 vCPU, CPUQuota=200% equivale a toda la máquina.
CPUWeight= es la mejor opción predeterminada 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 limita el uso cuando existe competencia: un trabajo de copia de seguridad con CPUWeight=20 cede tiempo a un servidor web con 100 bajo carga, y sigue usando toda la máquina cuando está inactiva. Una cuota estricta desperdicia esa capacidad disponible.
Sea preciso sobre lo que consigue con 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 puede dejar una máquina fuera de servicio. Use CPUQuota= cuando necesite un límite predecible, por ejemplo para una compilación o un agente que, de otro modo, funcionaría al máximo 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 elevado, la causa puede estar al otro lado del hipervisor. Se trata del tiempo de CPU robado por un vecino ruidoso, y ninguna cuota que configure cambiará esta 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 fork falla dentro de la unidad en lugar de agotar los identificadores de proceso del sistema.
TasksMax=128Cuando una unidad alcanza el límite, el kernel registra una línea con el nombre del cgroup:
cgroup: fork rejected by pids controller in /system.slice/myapp.serviceEl propio programa suele informar de fork: retry: Resource temporarily unavailable. Compruebe qué 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 el terminal después de mostrar Running scope as unit: run-r7c1a....scope. La salida permanece en pantalla y los límites desaparecen cuando termina el comando. Cualquier propiedad de systemd.resource-control funciona después de -p.
Para un trabajo largo, omita --scope y asígnele un nombre. Se ejecutará en segundo plano como un servicio transitorio y registrará los mensajes en el journal:
sudo systemd-run --unit=nightly-import -p MemoryMax=1G -p CPUWeight=20 ./import-data.sh
journalctl -u nightly-import -fLas 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 cuestión de la swap, respondida con honestidad
La 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, la swap le permite continuar. Si es un proceso descontrolado, la swap convierte una interrupción de cinco segundos en una espera de veinte minutos. La espera es peor, porque un proceso muerto todavía deja un shell operativo, mientras que un sistema saturado por thrashing no.
swapon --show
free -hUna opción equilibrada 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 impredecibles alcanzan rápidamente el límite 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 enviar páginas anónimas a la swap. Ambas operaciones requieren después una lectura desde disco. Cambia qué páginas sufren thrashing, pero no si el sistema sufre thrashing.
Un daemon de OOM temprano mata 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 porque supervisan la memoria por su cuenta y matan procesos antes.
earlyoom supervisa la memoria disponible y el espacio de swap libre. Mata 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 earlyoomEl paquete de Debian y Ubuntu inicia el servicio durante la instalación. Sus opciones se encuentran 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 es el umbral de 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 equivale a la mitad del primero. Aplique un cambio con sudo systemctl restart earlyoom. Consulte journalctl -u earlyoom para ver qué proceso eliminó y cuánta memoria mantenía ese proceso.
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, no sobre procesos individuales. Por tanto, mata una unidad y no un proceso hijo aislado. Las unidades se incluyen explícitamente con ManagedOOMMemoryPressure=kill o ManagedOOMSwap=kill. Los umbrales se encuentran en /etc/systemd/oomd.conf.
systemctl status systemd-oomd
oomctloomctl muestra lo que supervisa actualmente. En una imagen de servidor, a menudo no supervisa nada porque la configuración debe activarse explícitamente para cada unidad. Elija un daemon y use sólo ese. Ejecutar ambos hace que compitan para elegir una víctima y dificulta reconstruir el motivo de cada eliminació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 proceso terminado por el OOM killer global aparece así:
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:0anon-rss es la memoria que ese proceso tenía en RAM cuando terminó, aproximadamente 1.8 GB en este caso. Interprete con cautela el nombre entre corchetes. Es la víctima que eligió el kernel. El kernel elige el proceso más grande, que no siempre es el que causó la falta de memoria.
Un proceso terminado por un límite de cgroup tiene un prefijo diferente. El informe que aparece encima indica 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:0Ese 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 sistema funcionaba correctamente. Un Out of memory sin más indica que la máquina se quedó sin memoria en conjunto. En ese caso, los límites faltaban o eran demasiado altos para el consumo total.
Después, pregunte a systemd qué detectó:
systemctl status myapp.service
journalctl -u myapp.service -n 50myapp.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 expresa lo mismo en una línea, como Active: failed (Result: oom-kill).
Los contadores del cgroup son la tercera fuente. Son la única fuente que registra la limitación de recursos, que nunca genera una línea en los registros:
cat /sys/fs/cgroup/system.slice/myapp.service/memory.events
cat /sys/fs/cgroup/system.slice/myapp.service/memory.peaklow 0
high 4213
max 118
oom 12
oom_kill 12high cuenta cuántas veces la unidad superó MemoryHigh= y fue limitada. max cuenta cuántas veces alcanzó el límite estricto. oom_kill cuenta los procesos que realmente fueron terminados. Un valor alto de high junto con oom_kill 0 es el caso silencioso descrito antes: el servicio sigue ejecutándose, funciona muy lentamente y no ha informado de ningún fallo. memory.peak (Linux 5.19 y versiones posteriores) contiene el uso máximo que alcanzó el cgroup. Ese es el valor con el que debe dimensionar MemoryMax=. Ambos archivos se restablecen cuando se reinicia la unidad, porque systemd vuelve a crear el cgroup.
Hay un requisito previo para todo esto. Si /var/log/journal no existe, el journal se almacena en RAM. Todas las líneas desaparecen después del reinicio que necesitaba para recuperar el sistema.
sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald
journalctl --list-bootsSi journalctl --list-boots muestra más de un arranque, el historial ya se conserva. Por tanto, journalctl -k -b -1 puede mostrarle los mensajes del kernel del arranque que terminó.
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 limite todo lo demás de forma conservadora.
[Service]
MemoryHigh=256M
MemoryMax=384M
MemorySwapMax=0
CPUWeight=20
TasksMax=64
Restart=on-failure
RestartSec=5sConviene conservar una vía de acceso con 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. Esto puede marcar la diferencia entre reparar el servidor y 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. El runtime de contenedores los crea, no los archivos de unidad. Por tanto, un límite en docker.service no se convierte en un límite para un contenedor concreto. Los equivalentes por contenedor de MemoryMax= y CPUQuota= se explican en configuración de límites de memoria y CPU en Docker Compose.
FAQ
¿Por qué mi VPS se bloqueó en lugar de finalizar el proceso descontrolado?
Porque el kernel evalúa el progreso según si la recuperación libera 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 en 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 finaliza 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 throttling. El kernel recupera memoria de forma estricta dentro de la unidad y ralentiza sus asignaciones, pero el uso puede superar ese número y no se finaliza 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 en el cgroup propio 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 puedo saber 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 todo el equipo. Después, ejecute journalctl -u <unit> -n 50 y busque Failed with result 'oom-kill'. Si /var/log/journal no existe en el servidor, el journal se almacenó en la RAM y las pruebas se perdieron con el reinicio, por lo que debe crear ese directorio antes del siguiente incidente.
¿Debo añadir swap a un VPS pequeño?
Un archivo de swap pequeño ayuda con las páginas frías que se asignan una vez y nunca vuelven a utilizarse. No ayuda con un proceso descontrolado: retrasa la finalización y convierte una interrupción breve en un bloqueo prolongado en el que no puede iniciar sesión para corregir el problema. Mantenga la swap en un tamaño moderado y establezca MemorySwapMax=0 en las unidades que esté dispuesto a perder, de modo 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 el terminal dentro de un scope transitorio con esos límites, que desaparecen cuando 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.