cgroups (grupos de control) es el mecanismo del kernel Linux que organiza los procesos en una jerarquía y reparte entre ellos la CPU, la memoria, la E/S de disco y el número de procesos. La versión 2 usa una única jerarquía montada en /sys/fs/cgroup y es la base sobre la que systemd, Docker y Kubernetes aplican sus límites. En este tutorial comprobarás que Ubuntu 24.04 usa cgroups v2, aplicarás límites a mano sobre los ficheros del kernel para entender cómo funcionan y después harás lo mismo de forma persistente con systemd, que es como deberías hacerlo en producción.
Requisitos previos
- Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath. Debian 12 y Rocky Linux 9 también usan cgroups v2 por defecto y los comandos son los mismos.
- Un usuario no root con privilegios
sudo. - La herramienta
stress-ngpara generar carga de prueba:
sudo apt update
sudo apt install stress-ng
Paso 1: Comprobar que el sistema usa cgroups v2
Consulta el tipo de sistema de ficheros montado en /sys/fs/cgroup:
stat -fc %T /sys/fs/cgroup/
cgroup2fs
Si la salida es tmpfs, el sistema está en modo v1 o híbrido, algo que solo ocurre en distribuciones antiguas o si alguien añadió systemd.unified_cgroup_hierarchy=0 a los parámetros del kernel en /etc/default/grub.
Mira qué controladores ofrece el kernel y cuáles tiene activados la raíz para sus hijos:
cat /sys/fs/cgroup/cgroup.controllers
cat /sys/fs/cgroup/cgroup.subtree_control
cpuset cpu io memory hugetlb pids rdma misc
cpuset cpu io memory pids
Un controlador solo puede usarse en un cgroup si su padre lo tiene en cgroup.subtree_control. systemd ya activa cpu, io, memory y pids en la raíz, así que no necesitas hacerlo tú.
Paso 2: Explorar la jerarquía que crea systemd
systemd organiza todos los procesos en slices: system.slice para los servicios, user.slice para las sesiones de usuario y init.scope para el propio PID 1. Visualiza el árbol:
systemd-cgls --no-pager | head -n 15
Control group /:
-.slice
├─user.slice
│ └─user-1000.slice
│ ├─[email protected]
│ └─session-3.scope
│ ├─1840 sshd: your_user [priv]
│ └─1901 -bash
├─init.scope
│ └─1 /sbin/init
└─system.slice
├─ssh.service
│ └─1022 "sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startups"
...
Cada nodo del árbol es un directorio en /sys/fs/cgroup. Para saber en qué cgroup está tu shell:
cat /proc/self/cgroup
0::/user.slice/user-1000.slice/session-3.scope
Los ficheros de interfaz más importantes de cada cgroup son estos:
| Fichero | Qué hace |
|---|---|
cgroup.procs | PIDs del cgroup. Escribir un PID mueve el proceso aquí. |
cpu.max | Límite de CPU: cuota y periodo en microsegundos (50000 100000 = 50 % de un núcleo). |
cpu.weight | Peso relativo de CPU cuando hay competencia (1 a 10000, por defecto 100). |
memory.max | Límite duro de memoria. Si se supera y no se puede recuperar, actúa el OOM killer. |
memory.high | Límite suave: por encima, el kernel frena al grupo y recupera memoria de forma agresiva. |
memory.swap.max | Máximo de swap que puede usar el grupo. |
io.max | Límites de E/S por dispositivo en bytes o operaciones por segundo. |
pids.max | Número máximo de procesos e hilos. |
Paso 3: Limitar memoria a mano
Crear cgroups directamente en /sys/fs/cgroup es útil para entender el mecanismo, pero systemd no conoce esos grupos. Úsalo solo para pruebas y borra el grupo al terminar.
Crea un cgroup de prueba y comprueba que hereda los controladores:
sudo mkdir /sys/fs/cgroup/prueba
cat /sys/fs/cgroup/prueba/cgroup.controllers
cpuset cpu io memory pids
Fija un límite de 200 MB de memoria sin swap:
echo 200M | sudo tee /sys/fs/cgroup/prueba/memory.max
echo 0 | sudo tee /sys/fs/cgroup/prueba/memory.swap.max
Ahora lanza un proceso que intente usar 400 MB dentro del grupo. El sh intermedio escribe su propio PID en cgroup.procs y después se reemplaza por stress-ng, que hereda el cgroup:
sudo sh -c 'echo $$ > /sys/fs/cgroup/prueba/cgroup.procs; exec stress-ng --vm 1 --vm-bytes 400M --timeout 15s'
stress-ng informará de que su trabajador ha sido terminado y lo relanzará varias veces. Comprueba que el kernel ha aplicado el límite con los contadores de eventos del grupo:
cat /sys/fs/cgroup/prueba/memory.events
low 0
high 0
max 212
oom 6
oom_kill 6
oom_group_kill 0
max cuenta las veces que el grupo tocó el límite y oom_kill los procesos que el kernel tuvo que matar.
Paso 4: Limitar CPU a mano
cpu.max fija una cuota de tiempo de CPU por periodo. Limita el grupo al 50 % de un núcleo:
echo "50000 100000" | sudo tee /sys/fs/cgroup/prueba/cpu.max
Lanza dos trabajadores de CPU dentro del grupo durante 20 segundos en segundo plano y observa el consumo con top desde otra terminal:
sudo sh -c 'echo $$ > /sys/fs/cgroup/prueba/cgroup.procs; exec stress-ng --cpu 2 --timeout 20s' &
En top verás que los dos procesos stress-ng-cpu suman alrededor del 50 % de CPU aunque intenten usar dos núcleos completos. Al terminar, comprueba cuántas veces se frenó al grupo:
grep -E 'nr_throttled|throttled_usec' /sys/fs/cgroup/prueba/cpu.stat
nr_throttled 198
throttled_usec 19654210
Cuando no quedan procesos en el grupo, puedes borrarlo. rmdir es la única forma: no uses rm -r, porque los ficheros de interfaz no se pueden borrar:
cat /sys/fs/cgroup/prueba/cgroup.procs
sudo rmdir /sys/fs/cgroup/prueba
Paso 5: Ejecutar un comando con límites mediante systemd-run
En la práctica no necesitas escribir en /sys/fs/cgroup. systemd-run lanza un comando como unit transitoria, dentro de un cgroup que systemd gestiona, y acepta los límites como propiedades. Ejecuta la misma prueba de CPU y memoria:
sudo systemd-run --unit=prueba-limites -p CPUQuota=50% -p MemoryMax=200M -p MemorySwapMax=0 stress-ng --cpu 2 --timeout 60s
Running as unit: prueba-limites.service; invocation ID: 3f1c...
Mientras se ejecuta, comprueba que systemd ha traducido las propiedades a los ficheros del kernel:
cat /sys/fs/cgroup/system.slice/prueba-limites.service/cpu.max
cat /sys/fs/cgroup/system.slice/prueba-limites.service/memory.max
50000 100000
209715200
CPUQuota=50% se ha convertido en 50000 100000 y 200M en bytes. Para lanzar un comando interactivo con límites y ver su salida en la terminal, usa --scope, que lo ejecuta en primer plano:
sudo systemd-run --scope -p MemoryMax=200M -p MemorySwapMax=0 stress-ng --vm 1 --vm-bytes 400M --timeout 10s
Paso 6: Aplicar límites persistentes a un servicio
Para un servicio real, define los límites en la propia unit o en un fichero de ajuste. Como ejemplo, crea un servicio que consume CPU sin parar:
sudo nano /etc/systemd/system/carga-demo.service
[Unit]
Description=Servicio de carga de demostración
[Service]
ExecStart=/usr/bin/stress-ng --cpu 2 --vm 1 --vm-bytes 150M
DynamicUser=yes
CPUQuota=50%
CPUWeight=50
MemoryHigh=300M
MemoryMax=400M
TasksMax=50
[Install]
WantedBy=multi-user.target
Estas directivas se corresponden con los ficheros del paso 2: CPUQuota con cpu.max, CPUWeight con cpu.weight, MemoryHigh y MemoryMax con memory.high y memory.max, y TasksMax con pids.max. Arranca el servicio:
sudo systemctl daemon-reload
sudo systemctl start carga-demo.service
systemctl status carga-demo.service --no-pager | grep -E 'Tasks|Memory|CPU'
Tasks: 4 (limit: 50)
Memory: 152.3M (high: 300.0M max: 400.0M available: 147.7M peak: 153.1M)
CPU: 12.405s
Puedes cambiar un límite en caliente con systemctl set-property. El cambio se aplica al instante y se guarda en /etc/systemd/system.control/, así que sobrevive a los reinicios:
sudo systemctl set-property carga-demo.service CPUQuota=25%
cat /sys/fs/cgroup/system.slice/carga-demo.service/cpu.max
ls /etc/systemd/system.control/carga-demo.service.d/
25000 100000
50-CPUQuota.conf
Si solo quieres probar un valor hasta el próximo reinicio, añade --runtime: el ajuste se guarda en /run y desaparece al reiniciar.
Paso 7: Limitar la E/S de disco
El controlador io limita el ancho de banda o las operaciones por segundo de un dispositivo de bloque completo (no de una partición). Averigua el nombre del disco:
lsblk -d -o NAME,MAJ:MIN,SIZE,TYPE
NAME MAJ:MIN SIZE TYPE
vda 253:0 80G disk
En un VPS lo habitual es vda. Añade a carga-demo.service un límite de escritura de 10 MB/s en ese disco:
sudo systemctl set-property carga-demo.service IOWriteBandwidthMax="/dev/vda 10M"
cat /sys/fs/cgroup/system.slice/carga-demo.service/io.max
253:0 rbps=max wbps=10485760 riops=max wiops=max
Para comprobar un límite de escritura puedes lanzar dd con escritura directa dentro de un scope limitado; la velocidad que muestra al final no debería superar el límite:
sudo systemd-run --scope -p IOWriteBandwidthMax="/dev/vda 10M" dd if=/dev/zero of=/var/tmp/prueba-io bs=1M count=100 oflag=direct
sudo rm /var/tmp/prueba-io
104857600 bytes (105 MB, 100 MiB) copied, 10.02 s, 10.5 MB/s
Paso 8: Agrupar servicios en un slice propio
Si varios servicios deben compartir un presupuesto común, crea un slice y colócalos dentro. El límite del slice se aplica a la suma de todos:
sudo nano /etc/systemd/system/aplicaciones.slice
[Unit]
Description=Slice para servicios de aplicación
[Slice]
CPUQuota=150%
MemoryMax=1G
Mueve el servicio de demostración a ese slice con un fichero de ajuste:
sudo systemctl edit carga-demo.service
[Service]
Slice=aplicaciones.slice
Reinicia el servicio y comprueba su nueva ruta en la jerarquía:
sudo systemctl restart carga-demo.service
systemctl show -p ControlGroup carga-demo.service
ControlGroup=/aplicaciones.slice/carga-demo.service
Paso 9: Supervisar el consumo por cgroup
systemd-cgtop muestra en tiempo real CPU, memoria, E/S y número de tareas de cada cgroup, ordenado por consumo:
sudo systemd-cgtop
La presión de recursos (PSI) indica cuánto tiempo pasan los procesos de un grupo esperando por CPU, memoria o E/S, y es mejor indicador de saturación que el porcentaje de uso:
cat /sys/fs/cgroup/aplicaciones.slice/cpu.pressure
some avg10=48.12 avg60=47.90 avg300=39.51 total=91532008
full avg10=0.00 avg60=0.00 avg300=0.00 total=0
some avg10=48.12 significa que durante el 48 % de los últimos 10 segundos al menos un proceso del grupo estaba esperando CPU, lo esperable con una cuota que lo frena.
Al terminar, elimina el servicio y el slice de demostración:
sudo systemctl stop carga-demo.service
sudo rm -r /etc/systemd/system/carga-demo.service /etc/systemd/system/carga-demo.service.d /etc/systemd/system.control/carga-demo.service.d /etc/systemd/system/aplicaciones.slice
sudo systemctl daemon-reload
Solución de problemas
echo: write error: Device or resource busy al activar un controlador en cgroup.subtree_control. cgroups v2 no permite que un grupo con procesos propios reparta controladores a sus hijos (salvo la raíz). Mueve los procesos a un grupo hijo antes de activar el controlador.
rmdir: failed to remove ... Device or resource busy. El cgroup todavía tiene procesos o grupos hijos. Revisa cgroup.procs y espera a que terminen o muévelos a otro grupo.
El límite de memoria no parece aplicarse. Si el grupo puede usar swap, el proceso se mueve a swap en lugar de morir. Fija MemorySwapMax=0 (o memory.swap.max a 0) si quieres un límite estricto de memoria total.
IOWriteBandwidthMax no tiene efecto. El límite solo cuenta la E/S que llega al disco. Las escrituras que se quedan en la caché de página se contabilizan cuando se vuelcan, así que prueba con oflag=direct y comprueba que has usado el disco completo (/dev/vda) y no una partición.
Conclusión
Has comprobado que Ubuntu 24.04 usa cgroups v2, has limitado memoria, CPU y E/S escribiendo en los ficheros del kernel y has hecho lo mismo con systemd-run, directivas en la unit, systemctl set-property y un slice compartido. Como siguientes pasos, define MemoryMax y CPUQuota en los servicios críticos de tus servidores, vigila memory.events y los ficheros de presión para detectar límites demasiado ajustados y revisa los límites de tus contenedores, que usan exactamente estos mismos ficheros.
