wandres.dev
SMP, RT Y CGROUPS · balanceo, tiempo real, recursos

cgroups: agrupar procesos para gobernar sus recursos

Qué es un grupo de control, cómo el kernel modela la jerarquía única de cgroup v2 como un árbol de directorios en cgroupfs, cómo cada controlador (cpu, memory, io, pids) se activa por subárbol con cgroup.subtree_control, y por qué la regla de no procesos internos convierte la jerarquía en una repartición limpia y sin ambigüedades del hardware.

⏱ 17 min

El planificador reparte CPU entre tareas; el asignador reparte páginas entre quien las pide. Pero ninguno de los dos sabe de grupos: no entienden que estos cuarenta procesos son “el contenedor del cliente A” y aquellos otros “el del cliente B”, y que cada cliente pagó por una porción distinta de la máquina. Los grupos de control —cgroups— añaden esa dimensión que faltaba: agrupan procesos en un árbol y atan a cada nodo del árbol unos límites y garantías de recursos. Son la abstracción sobre la que se construyen Docker, Kubernetes y systemd, y en este nivel entiendes su anatomía antes de usarlos.

🎯 Al terminar esta lección sabrás
  • Entender qué es un cgroup y qué problema resuelve frente al planificador y el asignador.
  • Manejar la jerarquía única de cgroup v2 como un árbol en cgroupfs.
  • Activar controladores por subárbol con cgroup.controllers y cgroup.subtree_control.
  • Comprender la regla de “no procesos internos” y por qué ordena la jerarquía.

Qué es un cgroup y qué añade

Un cgroup es un conjunto de procesos al que se le atan uno o más controladores de recursos. Los procesos entran y salen del grupo; los controladores deciden cuánta CPU, memoria o E/S puede consumir el grupo en conjunto. La interfaz no es una llamada al sistema sino un sistema de archivos virtual, cgroupfs, montado en /sys/fs/cgroup, donde cada directorio es un cgroup y cada archivo es una perilla:

# la raiz de la jerarquia unica de cgroup v2
mount | grep cgroup2
#  cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime)

# crear un cgroup es crear un directorio: el kernel puebla sus archivos solo
mkdir /sys/fs/cgroup/clienteA
ls /sys/fs/cgroup/clienteA
#  cgroup.procs  cgroup.controllers  cgroup.subtree_control  cpu.max  memory.max ...

# meter un proceso es escribir su PID en cgroup.procs
echo $$ > /sys/fs/cgroup/clienteA/cgroup.procs   # esta shell y su descendencia

Escribir un PID en cgroup.procs mueve el proceso entero; un hilo suelto se mueve por cgroup.threads en los controladores que admiten granularidad de hilo. La pertenencia es exclusiva: un proceso está en exactamente un cgroup de la jerarquía, nunca en dos. Y es hereditaria: un fork deja al hijo en el mismo cgroup que el padre, así que meter una shell mete a toda su descendencia futura.

ℹ️
v2 unificó lo que v1 tenía disperso

La primera generación, cgroup v1, montaba una jerarquía distinta por controlador: un árbol para cpu, otro para memory, otro para blkio, y un proceso podía ocupar posiciones incoherentes en cada uno. Fue flexible y un caos. cgroup v2 impone una jerarquía única —todos los controladores comparten el mismo árbol— y hoy es el modo por defecto en toda distribución moderna (systemd en modo unificado). Este nivel y el siguiente son cgroup v2; v1 solo lo verás en sistemas heredados. Comprobar cuál corres es leer el tipo del montaje: cgroup2 es v2.

La jerarquía y los controladores

El árbol de cgroups permite subdividir recursos: repartes la máquina entre dos clientes, y dentro de cada cliente entre sus servicios. Cada nivel acota al de abajo. Pero un controlador no está activo en todas partes por defecto: un cgroup padre debe delegarlo explícitamente a sus hijos escribiendo en cgroup.subtree_control. Dos archivos gobiernan esto:

# que controladores tengo DISPONIBLES (los que mi padre me delego)
cat /sys/fs/cgroup/cgroup.controllers
#  cpuset cpu io memory hugetlb pids rdma misc

# habilitar cpu y memory para MIS HIJOS (con el signo +)
echo "+cpu +memory" > /sys/fs/cgroup/cgroup.subtree_control

# ahora los hijos ya ven cpu.max, memory.max, etc.; se quita con -cpu
cat /sys/fs/cgroup/clienteA/cgroup.controllers
#  cpu memory

La distinción es fina y esencial: cgroup.controllers lista lo que este cgroup tiene disponible para sí; cgroup.subtree_control lista lo que entrega a sus hijos. Un controlador solo aparece en un hijo si el padre lo puso en su subtree_control. Así se propagan los controladores hacia abajo, nivel a nivel, y cada tramo del árbol puede exponer solo las perillas que su gestor decida delegar.

# arbol tipico impuesto por systemd
/sys/fs/cgroup/
├── system.slice/        # demonios del sistema
   ├── nginx.service/
   └── postgresql.service/
├── user.slice/          # sesiones de usuario
   └── user-1000.slice/
└── machine.slice/       # contenedores y maquinas virtuales
    └── docker-abc123.scope/

Los controladores centrales son cuatro. cpu reparte tiempo de CPU por peso y limita por cuota (nivel 47.5). memory acota la memoria del grupo y decide a quién sacrifica el OOM killer (nivel 25). io limita ancho de banda y IOPS por dispositivo de bloque. pids pone techo al número de procesos, la defensa más simple contra un fork bomb. Hay más —cpuset para clavar núcleos y nodos NUMA, hugetlb, rdma, misc— pero con esos cuatro se construye casi todo el aislamiento de recursos que ves en la nube.

🧮

cpu

Reparte tiempo de CPU: proporcional con cpu.weight, absoluto con cpu.max. La cara del planificador dentro del grupo.

🧠

memory

Techo (memory.max), presión (memory.high) y protección (memory.low) de la RAM del grupo. Dirige al OOM killer del nivel 25.

💾

io

Limita ancho de banda e IOPS por dispositivo de bloque, o reparte por peso con io.weight. Gobierna el acceso al almacenamiento.

🔢

pids

Pone un máximo de procesos en el subárbol. La barrera barata y directa contra una bomba de forks.

La regla de los procesos internos

cgroup v2 impone una restricción que al principio sorprende y luego revela su sabiduría: un cgroup no puede contener procesos y tener controladores activos en sus hijos a la vez. En términos llanos, solo las hojas del árbol tienen procesos; los nodos intermedios son puros repartidores. La raíz es la única excepción.

# ESTO FALLA con -EBUSY: clienteA tiene procesos Y quiere delegar a hijos
echo 1234 > /sys/fs/cgroup/clienteA/cgroup.procs
echo "+cpu" > /sys/fs/cgroup/clienteA/cgroup.subtree_control   # -EBUSY

# la forma correcta: los procesos van en una hoja, el reparto en el padre
mkdir /sys/fs/cgroup/clienteA/servicio
echo 1234 > /sys/fs/cgroup/clienteA/servicio/cgroup.procs      # hoja: OK
echo "+cpu" > /sys/fs/cgroup/clienteA/cgroup.subtree_control   # padre: OK
flowchart TD
R[raiz: unico nodo que mezcla procesos y reparto] --> A[clienteA: nodo repartidor sin procesos]
R --> B[clienteB: nodo repartidor sin procesos]
A --> A1[servicio web: hoja con procesos]
A --> A2[servicio db: hoja con procesos]
B --> B1[batch: hoja con procesos]
style A1 fill:#a6e3a1,color:#11111b
style A2 fill:#a6e3a1,color:#11111b
style B1 fill:#a6e3a1,color:#11111b
style A fill:#89b4fa,color:#11111b
style B fill:#89b4fa,color:#11111b

La razón es evitar una competencia mal definida. Si un nodo intermedio tuviera procesos propios y cgroups hijos, esos procesos competirían con los hijos por los recursos del padre, y no habría forma clara de decir cuánto le toca a “los procesos sueltos del nodo” frente a “el subárbol entero”. Prohibiéndolo, cada nivel del árbol es o bien un repartidor puro o bien un contenedor puro de trabajo, y el reparto de recursos queda sin ambigüedad en cada bifurcación.

cgroups son la segunda mitad de la virtualización: repartir lo que namespaces esconden

Detente en el lugar exacto que ocupan los cgroups en el edificio de Linux, porque sin él no se entiende la nube moderna. Los namespaces —que verás en otro nivel— responden a la pregunta “¿qué ve un proceso?”: le dan su propia vista de PIDs, de red, de sistema de archivos, de forma que un contenedor cree estar solo en la máquina. Pero ver un mundo propio no es tener recursos propios. Un proceso puede estar convencido de ser el PID 1 de su universo aislado y aun así quemar los treinta y dos núcleos y toda la RAM del anfitrión, ahogando a los demás contenedores que comparten el hierro. Namespaces dan aislamiento de visión; no dan aislamiento de consumo. Ahí entran los cgroups, la otra mitad exacta de la ecuación: responden a la pregunta complementaria, “¿cuánto puede consumir un grupo de procesos?”. Un contenedor es, con precisión técnica, la intersección de ambas cosas: un conjunto de procesos metidos en unos namespaces que les recortan la vista y en un cgroup que les recorta los recursos. Quítale los namespaces y verá toda la máquina; quítale el cgroup y podrá devorarla. Docker, Kubernetes y systemd no son tecnologías de contenedores en el sentido de haber inventado un mecanismo nuevo: son orquestadores que combinan estas dos primitivas del kernel —una que oculta, otra que reparte— y les ponen una cara amable. Cuando internalizas que “contenedor” no es una cosa sino la conjunción de esconder y limitar, dejas de ver la nube como magia y empiezas a ver dos árboles del kernel, el de namespaces y el de cgroups, colgando de cada proceso.

⚔️ Construye un árbol de reparto a mano
  1. Crea clienteA y clienteB bajo /sys/fs/cgroup, mételes shells con cgroup.procs y verifica con cat /proc/self/cgroup en qué grupo cae cada una.
  2. Habilita +cpu +memory en cgroup.subtree_control de la raíz y comprueba cómo aparecen cpu.max y memory.max en los hijos.
  3. Reproduce a propósito el error -EBUSY metiendo un proceso en un nodo que también quiere delegar controladores, y explica la regla de los procesos internos que acabas de violar.
  4. Dibuja el árbol de tu sistema con systemd-cgls e identifica system.slice, user.slice y las hojas .service y .scope.
  5. Explica, con un ejemplo, por qué namespaces sin cgroups no bastan para aislar un contenedor de verdad.