cgroup v1 ya no existe. Migra bien.
systemd 258 eliminó cgroup v1 por completo, así que tus hosts ya arrancan con la jerarquía unificada tanto si alguien lo pidió como si no. Aquí está la auditoría, la conversión archivo por archivo y las cuatro afirmaciones sobre esta migración que sencillamente son falsas.
- Linux
- cgroups
- systemd
- Contenedores
Casi todas las migraciones empiezan con una decisión. Esta empieza con un hecho: en septiembre de 2025, systemd 258 eliminó por completo el soporte de cgroup v1, junto con la vía de escape SYSTEMD_CGROUP_ENABLE_LEGACY_FORCE=1 que la versión 256 había introducido un año antes. La jerarquía unificada se monta ahora en el arranque, en toda máquina con ese systemd o uno posterior, y no existe forma soportada de pedir otra cosa. Piense lo que piense tu plataforma de contenedores sobre los cgroups, el sistema operativo que hay debajo ya ha votado.

Lo que sigue es la migración tal y como ocurre en flotas reales: cómo saber en qué jerarquía está un host sin que te engañe el modo híbrido, una tabla de conversión archivo por archivo con su directiva de systemd equivalente, las dos traducciones que cambian el comportamiento en silencio si te limitas a copiar los números, la fórmula de peso de CPU que se sustituyó discretamente en los runtimes OCI durante 2025, y un relato honesto de lo que Docker y Kubernetes han hecho y no han hecho — porque en ese último punto casi todo lo que está escrito es falso, y actuar en consecuencia te cuesta o una caída de servicio o un año de pánico innecesario.
No lo has decidido tú, y ese es el asunto
Los síntomas son molestamente indirectos, porque nada se presenta como un problema de cgroups. Un agente de monitorización empieza a reportar ceros. El límite de memoria de un contenedor deja de aplicarse. Una carga rootless que funcionaba sin problemas en la máquina antigua se niega a arrancar. Un script que lleva ocho años leyendo /sys/fs/cgroup/memory/memory.usage_in_bytes empieza a registrar un fichero no encontrado que nadie ve, porque escribe en un log que nadie lee. Todo eso es el mismo suceso visto desde ángulos distintos: las rutas se movieron, la semántica cambió por debajo y nada falló lo bastante alto como para detener un despliegue.[sd258]
| Lo que ves | Lo que suele significar | Dónde se trata |
|---|---|---|
| Un agente de monitorización reporta cero memoria o cero CPU para todos los contenedores | Lee /sys/fs/cgroup/memory/… o cpu,cpuacct, que no existen bajo la jerarquía unificada | La tabla de conversión |
write error: Device or resource busy al añadir un PID a un cgroup | La restricción de no procesos internos: ese cgroup ya delega controladores a sus hijos | Tres reglas |
| Un contenedor hace muchísimo más swap que antes, con los mismos límites | Se copió memory.memsw.limit_in_bytes a memory.swap.max, que significa solo swap | Semántica de memoria |
Podman rootless acepta --memory y no lo aplica | systemd no ha delegado el controlador de memoria al gestor de usuario | Contenedores |
| Los contenedores reciben menos CPU en contención que antes | La conversión lineal de shares a peso dejaba una petición de una CPU en peso 39 frente a un valor por defecto de 100 | Peso de CPU |
El kubelet se niega a arrancar tras actualizar la imagen del nodo | Nodo con cgroup v1, kubelet v1.35 o posterior, failCgroupV1 en su valor por defecto true | Kubernetes |
--oom-kill-disable no tiene ningún efecto y no avisa | Se descarta en cgroup v2. No hay equivalente ni está previsto ninguno | Contenedores |
Hay una costumbre que conviene abandonar antes que ninguna otra. Si alguna herramienta tuya escribe directamente en /sys/fs/cgroup en un host con systemd, no va a sobrevivir a esta migración de una forma que te guste. systemd es el dueño de ese árbol y reimpone su visión de él cada vez que cambia una unidad; el propio documento de delegación upstream es explícito: un único escritor por subárbol es la regla, no una sugerencia. Con v1 normalmente podías saltártela y salir indemne. Con v2, con su delegación estricta de arriba abajo, no.[sddeleg]
¿En qué jerarquía está realmente este host?
Empieza por establecer la verdad, porque los comandos del folclore mienten de una manera concreta y consistente. La comprobación que documenta Kubernetes es la buena y es un solo comando: pregunta el tipo de sistema de archivos de /sys/fs/cgroup. Bajo la jerarquía unificada ese punto de montaje es un sistema de archivos cgroup2, así que stat devuelve cgroup2fs. Bajo v1 — y, esto es lo importante, también bajo el antiguo modo híbrido — es un tmpfs con los directorios de los controladores montados dentro.[k8scg]
# The only check that cannot lie. /sys/fs/cgroup is a tmpfs under v1 and under
# the old hybrid layout, and a cgroup2 filesystem under the unified hierarchy.
stat -fc %T /sys/fs/cgroup/
# cgroup2fs -> unified, cgroup v2 only
# tmpfs -> cgroup v1, or hybrid: v2 mounted under a v1 tmpfs
# Why `mount | grep cgroup2` is not enough: hybrid mounts a cgroup2 hierarchy
# too, at /sys/fs/cgroup/unified, with no controllers attached to it. Grepping
# for the string finds it and tells you the opposite of the truth.
mount | grep -E '^cgroup' | sed 's/ (.*//'
# cgroup2 on /sys/fs/cgroup type cgroup2 <- unified: good
# cgroup2 on /sys/fs/cgroup/unified type cgroup2 <- hybrid: not good
# What the kernel will actually let you control here. Under the unified
# hierarchy this file exists at the top of the tree and lists the controllers.
# Under hybrid it is not here at all - it is one level down, at
# /sys/fs/cgroup/unified/cgroup.controllers, and it is empty. Its absence from
# the top level is the clearest single tell that you are not on v2.
cat /sys/fs/cgroup/cgroup.controllers 2>/dev/null
# cpuset cpu io memory hugetlb pids rdma misc
# Where a given process ended up. Under v2 there is exactly one line and it
# starts with `0::`. More than one line means controllers are still split.
cat /proc/self/cgroup
# 0::/user.slice/user-1000.slice/session-3.scope
# And the two userspace pieces that have to agree with the kernel:
systemctl --version | head -1
docker info --format 'driver={{.CgroupDriver}} version={{.CgroupVersion}}' 2>/dev/nullEl caso híbrido es la razón por la que mount | grep cgroup2 no es simplemente incompleto, sino activamente engañoso: el modo híbrido monta una jerarquía cgroup2 real en /sys/fs/cgroup/unified, sin ningún controlador asociado. El grep coincide, tú concluyes que estás en v2, y cada límite que configures a partir de ahí aterriza en un controlador de v1. La segunda pista es /proc/self/cgroup: bajo v2 contiene exactamente una línea que empieza por 0::, y bajo v1 o híbrido contiene una línea por controlador. Pasa la auditoría de abajo por toda la flota antes de planificar nada, porque en mi experiencia la respuesta nunca es uniforme.[cgman]
#!/usr/bin/env bash
# Fleet audit. Read-only: it changes nothing. Run it before you plan anything,
# because the answer is almost never uniform across a real estate of servers.
set -u
host=$(hostname -s)
ver=$(stat -fc %T /sys/fs/cgroup/ 2>/dev/null)
case "$ver" in
cgroup2fs) mode=unified ;;
tmpfs) [ -d /sys/fs/cgroup/unified ] && mode=hybrid || mode=legacy ;;
*) mode=unknown ;;
esac
kernel=$(uname -r)
sd=$(systemctl --version 2>/dev/null | awk 'NR==1{print $2}')
printf '%-16s mode=%-8s kernel=%-14s systemd=%s\n' "$host" "$mode" "$kernel" "$sd"
# --- the things that will break, rather than the things that will complain ---
# a) v1-only controllers with no v2 equivalent. If anything you run writes to
# these, it needs an eBPF replacement, not a path change.
for c in net_cls net_prio devices; do
[ -d "/sys/fs/cgroup/$c" ] && echo " uses v1-only controller: $c"
done
# b) anything with a hardcoded v1 path. This is the single most common cause of
# a migration failing three weeks later in an agent nobody remembered.
grep -rIl --exclude-dir=.git \
-e '/sys/fs/cgroup/memory/' \
-e '/sys/fs/cgroup/cpu,cpuacct/' \
-e 'memory.limit_in_bytes' \
-e 'cpu.cfs_quota_us' \
/etc /opt /usr/local 2>/dev/null | sed 's/^/ hardcoded v1 path: /'
# c) kernel command line pinning the old hierarchy. On systemd 258 and later
# this parameter no longer does anything, which is its own kind of trap:
# the host silently boots unified and the runbook still says otherwise.
grep -o 'systemd\.unified_cgroup_hierarchy=[01]' /proc/cmdline \
| sed 's/^/ kernel cmdline: /'
grep -o 'SYSTEMD_CGROUP_ENABLE_LEGACY_FORCE=1' /proc/cmdline \
| sed 's/^/ legacy force flag (removed in systemd 258): /'
# d) container runtimes and their drivers, which have to match the kernel
command -v docker >/dev/null && docker info 2>/dev/null \
| grep -E 'Cgroup (Driver|Version)' | sed 's/^/ /'
command -v podman >/dev/null && podman info --format \
' podman cgroupVersion={{.Host.CgroupsVersion}} manager={{.Host.CgroupManager}}' 2>/dev/null
[ -f /var/lib/kubelet/config.yaml ] && \
grep -E '^(cgroupDriver|failCgroupV1):' /var/lib/kubelet/config.yaml | sed 's/^/ kubelet /'Ejecútalo en todos los hosts, no en uno representativo. Las máquinas que en 2026 siguen en v1 son, casi por definición, las máquinas que nadie ha tocado: el appliance, el agente de build que alguien levantó a mano en 2019, el nodo de base de datos que está deliberadamente excluido de la gestión de configuración. Esos son exactamente los hosts donde hay una ruta
/sys/fs/cgroup/memory/escrita a fuego esperando su momento, y exactamente los hosts donde nadie se va a enterar de que se rompió.
Quién eliminó qué y quién solo dijo que lo haría
Hay cuatro proyectos implicados y van en calendarios completamente distintos, que es la mayor fuente de confusión de toda esta migración. systemd se movió el primero y con más contundencia. La versión 256, en junio de 2024, dejó de arrancar cgroup v1 por defecto pero dejó una vía de escape. La versión 258, en septiembre de 2025, eliminó el código — las notas de la versión no dejan lugar a dudas: se ha eliminado el soporte de cgroup v1 (jerarquías «legacy» e «híbrida»), y cgroup v2 se montará siempre durante el arranque del sistema. Esa misma versión elevó el mínimo de kernel a 5.4, con 5.7 recomendado.[sdnews][sd256]
| Proyecto | Qué pasó realmente | Cuándo | Qué significa para ti |
|---|---|---|---|
| systemd 256 | Dejó de arrancar cgroup v1 por defecto; añadió SYSTEMD_CGROUP_ENABLE_LEGACY_FORCE=1 | Junio de 2024 | La 257 sigue respetando ese flag, así que la 257 es la última versión en la que puedes pedir v1 y obtenerlo |
| systemd 258 | Eliminó por completo el soporte de cgroup v1, vía de escape incluida; mínimo de kernel elevado a 5.4, 5.7 recomendado | Septiembre de 2025 | Esta es la fecha límite. Unificado es el único modo que tiene el código |
| Kubernetes 1.35 | Marcó cgroup v1 como obsoleto; el kubelet se niega a arrancar en un nodo v1 por defecto | Diciembre de 2025 | Anulable con failCgroupV1: false. No es una eliminación |
| Kubernetes 1.38+ | Versión más temprana en la que KEP-5573 eliminará el código | Sin fecha | Tienes más margen del que sugieren los titulares |
| Docker Engine 29.0 | Marcó cgroup v1 como obsoleto; sin versión de eliminación fijada | Noviembre de 2025 | Solo obsolescencia. La documentación afirma que el soporte continúa hasta mayo de 2029 |
| runc / crun | Sustituyeron la conversión lineal de shares a peso por una logarítmica | Durante 2025 | Cambia la prioridad de CPU en nodos que ya habías migrado |
Los otros tres no han hecho lo que probablemente has leído que hicieron, y la diferencia importa a la hora de planificar. Kubernetes marcó cgroup v1 como obsoleto en la v1.35 — el kubelet ahora se niega a arrancar en un nodo v1 por defecto, pero ese valor por defecto es un campo de configuración que puedes cambiar, y KEP-5573 dice con todas las letras que la eliminación del código no se hará antes de la 1.38, sin ninguna fecha asociada. Docker marcó cgroup v1 como obsoleto en Engine v29.0, publicada en noviembre de 2025, sin fijar ninguna versión de eliminación; su propia página de obsolescencias afirma que el soporte continúa hasta mayo de 2029, que es cuando llegan a fin de vida las instalaciones empresariales que todavía lo necesitan. Resumiendo: el sistema operativo ya lo ha eliminado, los orquestadores solo lo han anunciado. Planifica contra el sistema operativo.[kep5573][dockdep]
| Distribución | Jerarquía por defecto | Nota |
|---|---|---|
| Fedora 31 y posteriores | Unificada (v2) | Primera distribución mayoritaria en cambiar; Fedora 43 hereda la eliminación de systemd 258 |
| Debian 11 y posteriores | Unificada (v2) | Debian 13 lleva systemd 257, así que es la última con algún camino legacy |
| Ubuntu 21.10 y posteriores | Unificada (v2) | 22.04 LTS y posteriores son las que probablemente sigas ejecutando |
| RHEL 9 y posteriores | Unificada (v2) | RHEL 9.4 declaró v1 formalmente obsoleto; RHEL 10 directamente no arrancará v1 |
| SLES 15 SP6 y posteriores | Unificada (v2) | De SP3 a SP5 el modo por defecto era híbrido, que es el que engaña al grep |
| Cualquier cosa anterior | v1 o híbrida | Además, a estas alturas, fuera de soporte. La cuestión de los cgroups no es la urgente |
Lo cual significa que la fecha límite real de cada máquina es la versión de systemd que empaqueta su distribución, no la hoja de ruta de ninguna plataforma de contenedores. La mayoría de las flotas ya están unificadas y lo llevan estando años sin que nadie se diera cuenta: Fedora desde la 31, Debian desde la 11, Ubuntu desde la 21.10, RHEL desde la 9. El trabajo se concentra en lo que quede fuera de eso, y en el instrumental que sigue asumiendo rutas de v1 independientemente de lo que haga el host.[rhel10][moby51111]
Tres reglas que rompen las jerarquías hechas a mano
Tres reglas estructurales distinguen v2 de v1, y cada una de ellas va a romper una jerarquía construida a mano bajo v1. La primera es la jerarquía unificada en sí: un proceso ocupa una posición en un árbol, y todos los controladores leen esa misma posición, que es por lo que /proc/self/cgroup pasó de una docena de líneas a una. La segunda es la delegación de arriba abajo: un controlador existe en un cgroup hijo solo si el padre se lo cede explícitamente mediante cgroup.subtree_control. La tercera es la que duele de verdad.[kdoc][knoint]
# DEMONSTRATION ONLY. This writes into the cgroup tree by hand, which is the
# exact thing the rest of this article tells you not to do on a systemd host.
# Read it to understand the rules, then set limits through systemd.
# Rule 1 - one tree. Under v1 a process had a position in each controller's
# hierarchy independently, which is why /proc/PID/cgroup had a dozen lines.
# Under v2 it has one, and every controller reads the same position.
cd /sys/fs/cgroup
mkdir -p demo/worker demo/batch
# Rule 2 - a controller only exists in a child if the parent hands it down.
# cgroup.controllers is what you HAVE; cgroup.subtree_control is what you GIVE.
cat demo/cgroup.controllers # what the parent has delegated so far
echo '+cpu +memory +io' > cgroup.subtree_control # root delegates to demo
cat demo/cgroup.controllers # cpu io memory
echo '+cpu +memory' > demo/cgroup.subtree_control # demo delegates to its kids
ls demo/worker/ | grep -E '^(cpu|memory)\.' # the knobs now exist
# Rule 3 - no internal processes. A cgroup may hold processes, or hand
# resources to children, never both. This is the rule that breaks hand-built
# v1 layouts, and it fails at write() time with a very unhelpful error.
echo $$ > demo/cgroup.procs
# bash: echo: write error: Device or resource busy
#
# ... because demo already has subtree_control set. Processes live on leaves:
echo $$ > demo/worker/cgroup.procs # fine
# The root cgroup is exempt from rule 3, which is why the mistake survives
# testing at the top level and only shows up one directory down.La restricción de «no procesos internos» dice que un cgroup que no sea la raíz puede contener procesos o repartir recursos entre sus hijos, pero nunca ambas cosas. Existe para eliminar una ambigüedad real de v1, donde los procesos propios de un padre competían con sus hijos sin ninguna regla definida. En la práctica significa que una jerarquía de v1 con límites en todos los niveles del árbol no se traduce: tienes que empujar los procesos hasta las hojas y dejar vacíos los nodos intermedios. Y falla de la manera menos útil posible — un escueto write error: Device or resource busy al hacer echo sobre cgroup.procs, sin nada que indique cuál de las dos reglas has roto. El cgroup raíz está exento, que es justo por lo que el error sobrevive a una prueba rápida en el nivel superior.[kdeleg]
La tabla de conversión, archivo por archivo
Aquí está el mapeo, con la directiva de systemd junto a cada par, porque en cualquier host con systemd la directiva es lo que deberías estar tocando en realidad. Cambian más los nombres que los valores, pero tres filas cambian de significado y no solo de grafía, y están marcadas. Fíjate en particular en que cpu.max fusiona dos archivos de v1 en un único archivo de dos valores, y en que la escala de peso de CPU no es la escala de shares — solo los valores por defecto ya difieren en un factor de diez.[sdresctl]
| cgroup v1 | cgroup v2 | Directiva de systemd | Nota |
|---|---|---|---|
memory.limit_in_bytes | memory.max | MemoryMax= | Cambio de nombre. Mismo significado: límite duro, OOM kill al superarlo |
memory.soft_limit_in_bytes | memory.high | MemoryHigh= | Mejora. El límite blando se ignoraba casi siempre; memory.high frena de verdad |
| — | memory.low / memory.min | MemoryLow= / MemoryMin= | Nuevo. Suelos de protección, sin equivalente en v1 |
memory.memsw.limit_in_bytes | memory.swap.max | MemorySwapMax= | Significado distinto. memsw era memoria+swap; esto es solo swap |
memory.usage_in_bytes | memory.current | — | Cambio de nombre |
memory.failcnt | memory.events | — | Mejor: contadores separados para low, high, max, oom y oom_kill |
cpu.shares | cpu.weight | CPUWeight= | Escala distinta. El 1024 por defecto pasa a 100 por defecto; mira la conversión |
cpu.cfs_quota_us + cpu.cfs_period_us | cpu.max | CPUQuota= + CPUQuotaPeriodSec= | Dos archivos se vuelven uno, escrito como "$MAX $PERIOD". CPUQuota= fija solo la mitad de la cuota |
cpuacct.usage | cpu.stat | — | Ahora incluye nr_throttled y throttled_usec |
blkio.weight | io.weight | IOWeight= | Cambio de nombre, pero la contabilidad de debajo por fin es correcta |
blkio.throttle.*_bps_device | io.max | IOReadBandwidthMax= etc. | Un archivo de claves anidadas en vez de cuatro; ahora cubre escrituras con búfer |
pids.max | pids.max | TasksMax= | Sin cambios |
freezer.state | cgroup.freeze | — | Cambio de nombre; se escribe 1 o 0 |
devices.allow / devices.deny | — (eBPF) | DeviceAllow= | Sin controlador. Sustituido por BPF_PROG_TYPE_CGROUP_DEVICE |
net_cls.classid / net_prio.* | — (eBPF) | — | Sin controlador y sin archivo sustituto. Usa eBPF sobre rutas de cgroup |
| — | cpu.pressure / memory.pressure / io.pressure | — | Nuevo. PSI: la razón para migrar, no un coste de migrar |
Tres controladores de v1 no tienen ningún equivalente en v2, y esta es la fila que convierte una migración mecánica en una tarea de ingeniería. net_cls y net_prio se eliminaron sin más, en lugar de reimplementarse; la clasificación y el modelado de tráfico por cgroup se hacen ahora con programas eBPF asociados a rutas de cgroup v2, con soporte equivalente en iptables y nftables. El controlador devices siguió el mismo camino: en lugar de un archivo de lista blanca, un programa eBPF de tipo BPF_PROG_TYPE_CGROUP_DEVICE recibe los números mayor y menor, el tipo de dispositivo y el tipo de acceso, y devuelve permitido o -EPERM. Si tu plataforma usaba alguno de estos directamente, presupuesta trabajo de verdad, no una sustitución de rutas.[cgman][bpfdev]
La memoria es donde de verdad cambió la semántica
La memoria es donde una migración descuidada hace su daño silencioso, porque los números siguen encajando y el comportamiento cambia igualmente. Bajo v1 tenías un límite duro y un límite blando que la mayoría de los kernels ignoraba en la práctica. Bajo v2 tienes cuatro niveles, y solo uno de ellos puede matar algo: memory.max es el límite duro y dispara un OOM kill dentro del cgroup; memory.high es un freno que somete al cgroup a una recuperación agresiva de páginas y que, en palabras del propio kernel, nunca invoca al OOM killer; memory.low es protección de mejor esfuerzo; memory.min es protección dura que no se reclama jamás.[kmem][kv1mem]
# --- cgroup v1: two numbers, and the second one is not what people think ---
# memory.limit_in_bytes = 2G -> hard limit on memory
# memory.memsw.limit_in_bytes = 3G -> hard limit on memory PLUS swap
# (so: 2G RAM + up to 1G of swap)
# memory.soft_limit_in_bytes = 1G -> best-effort, and widely ignored
#
# --- cgroup v2: four memory tiers, plus a separate swap cap ---------------
cd /sys/fs/cgroup/demo/worker
echo 2G > memory.max # hard limit. Over this, OOM kill inside the cgroup.
echo 1800M > memory.high # throttle. Over this, heavy reclaim - never an OOM kill.
echo 512M > memory.low # best-effort protection. Reclaimed only as a last resort.
echo 256M > memory.min # hard protection. Never reclaimed, at all.
echo 1G > memory.swap.max # SWAP ONLY. Not memory+swap. Read that twice.
# The migration trap, stated as arithmetic:
# v1: memsw.limit=3G with limit=2G -> 2G RAM, 1G swap
# v2: memory.swap.max=3G -> memory.max RAM, 3G swap
# Copying 3G across gives the workload three times the swap it used to have.
# The correct translation is (memsw.limit - limit), and if that is zero you
# want memory.swap.max=0, not "unset".
# What is actually happening, rather than what you configured:
cat memory.current # bytes in use right now
cat memory.events # low high max oom oom_kill oom_group_kill
# low 0
# high 148 <- throttled 148 times: memory.high is doing work
# max 0
# oom 0
# oom_kill 0 <- and it never had to kill anything
cat memory.pressure # PSI: how much time was lost waiting on memory
# some avg10=0.42 avg60=0.31 avg300=0.11 total=9214430
# full avg10=0.00 avg60=0.00 avg300=0.00 total=118221
# memory.high plus memory.events is the pair that turns "the box OOMs at 3am"
# into a number you can alert on before it happens. v1 could not do this.| Archivo de v2 | Qué hace | ¿Puede matar? | Equivalente más cercano en v1 |
|---|---|---|---|
memory.min | Protección dura: la memoria por debajo de esto no se reclama nunca, bajo ninguna presión | Indirectamente | Ninguno |
memory.low | Protección de mejor esfuerzo: se reclama solo cuando no queda nada desprotegido | No | Ninguno |
memory.high | Freno: por encima de esto, el cgroup entra en recuperación agresiva y sus procesos se ralentizan | No | memory.soft_limit_in_bytes, a grandes rasgos |
memory.max | Límite duro: por encima de esto y sin poder reclamar, entra el OOM killer dentro del cgroup | Sí | memory.limit_in_bytes |
memory.swap.max | Tope del uso de swap únicamente, independiente de memory.max | Indirectamente | memory.memsw.limit_in_bytes menos el límite de memoria |
memory.events | Contadores: cuántas veces se alcanzó cada uno de los anteriores, incluido oom_kill | — | memory.failcnt, sin desglose |
La trampa es memory.swap.max, y conviene ser rotundo con esto porque copiar el número tal cual es lo natural. En v1, memory.memsw.limit_in_bytes limitaba memoria más swap en conjunto; en v2, memory.swap.max limita el swap solo. Un contenedor que tenía limit=2G, memsw=3G disponía de 2 GB de RAM y 1 GB de swap. Pon memory.swap.max=3G y acabas de darle el triple de swap del que tenía, y el modo de fallo no es una caída sino una máquina que se vuelve más lenta bajo carga de una forma que no aparece en ninguna gráfica de memoria. La traducción correcta es la diferencia entre los dos números de v1, y si esa diferencia es cero lo que quieres es un 0 explícito, no un archivo sin configurar. A cambio obtienes memory.events, que desglosa los contadores que v1 amontonaba en un único failcnt, y memory.pressure, que no tiene ningún equivalente en v1: una medida de estancamiento que te permite alertar sobre una carga que lo está pasando mal mucho antes de que entre el OOM killer.[psi]
Cuota de CPU y un controlador de E/S que por fin cuenta bien
La CPU se separa limpiamente en dos ideas que v1 mezclaba. cpu.weight es una participación relativa del tiempo de CPU en disputa — no hace absolutamente nada en una máquina ociosa, y es la herramienta correcta para prioridad. cpu.max es un techo absoluto que se escribe como "$MAX $PERIOD" en microsegundos, y sustituye a los dos archivos separados de v1 cuya relación la gente invertía sistemáticamente. El archivo cpu.stat es el que merece la pena llevar a la monitorización: nr_throttled y throttled_usec responden a la pregunta "¿este servicio va lento porque le hemos puesto un tope?", que de otro modo es casi imposible de establecer desde fuera.[kio]
# --- CPU: two knobs, and only one of them is a limit ---------------------
cd /sys/fs/cgroup/demo/worker
# Weight: relative share of contended CPU. Default 100, range 1-10000.
# It does nothing at all while the machine is idle.
echo 200 > cpu.weight
# Quota: an absolute ceiling, written as "$MAX $PERIOD" in microseconds.
# 150000 out of every 100000us = 1.5 CPUs. "max" removes the ceiling.
echo '150000 100000' > cpu.max
echo 20000 > cpu.max.burst # allow short bursts above quota (v2 only)
# The v1 equivalents were three files, and the period was easy to forget:
# cpu.shares = 200 -> but the scale was different: default 1024
# cpu.cfs_quota_us = 150000
# cpu.cfs_period_us = 100000
# Throttling, which is the number people actually need and rarely find:
cat cpu.stat
# usage_usec 918422311
# nr_periods 41822
# nr_throttled 219 <- how often the quota was hit
# throttled_usec 411920 <- and how much time was lost to it
# --- IO: blkio became io, and the numbers finally mean something ----------
# v1's blkio.throttle.* only saw direct IO; buffered writes were charged to
# whatever kernel thread flushed them, so the accounting was fiction.
echo '259:0 rbps=104857600 wbps=52428800 riops=max wiops=2000' > io.max
echo 'default 100' > io.weight
cat io.stat
# 259:0 rbytes=2841579520 wbytes=1120043008 rios=48211 wios=22103 dbytes=0 dios=0
cat io.pressure
# some avg10=1.94 avg60=0.88 avg300=0.31 total=41822193
# Device numbers, because io.max will not take a path:
lsblk -no MAJ:MIN,NAME /dev/nvme0n1| Archivo de v2 | Tipo | Por defecto | Para qué sirve |
|---|---|---|---|
cpu.weight | Relativo | 100 (rango 1–10000) | Prioridad en contención. Ningún efecto en una máquina ociosa |
cpu.max | Absoluto | max 100000 | Un techo. 150000 100000 son 1,5 CPU |
cpu.max.burst | Absoluto | 0 | Permite excesos breves sobre la cuota en vez de frenado inmediato |
cpu.stat | Solo lectura | — | nr_throttled y throttled_usec: la prueba de que un tope está haciendo daño |
io.weight | Relativo | default 100 | Participación relativa del tiempo de disco, por dispositivo o global |
io.max | Absoluto | sin fijar | rbps, wbps, riops, wiops por MAJ:MIN |
io.latency | Objetivo | sin fijar | Protege un objetivo de latencia en vez de una cifra de ancho de banda |
El controlador de E/S es la parte de v2 que supone una mejora real y no un simple cambio de nombre. El throttling de blkio en v1 solo veía la E/S directa; las escrituras con búfer se imputaban al hilo del kernel que acabara volcándolas, así que la contabilidad de escritura por cgroup era, siendo amables, ficción. El controlador io de v2 entiende el writeback y lo atribuye al cgroup que ensució las páginas. Ese único cambio hace que io.max e io.weight merezcan la pena en un host de base de datos, donde los ajustes equivalentes de v1 mayormente no la merecían. io.latency e io.cost van más allá y protegen el objetivo de latencia de una carga en vez de su ancho de banda — y si no sabes por dónde empezar, io.latency es el menos invasivo de los dos.[kdoc]
El cambio de peso de CPU que nadie te anunció
Este merece sección propia porque movió la prioridad de CPU de todas las cargas en contenedor, y lo hizo en un componente cuyo changelog casi nadie lee. Kubernetes siempre ha derivado las shares de CPU a partir de la petición como milliCPU × 1024 / 1000, así que un contenedor que pedía una CPU obtenía 1024 shares. El runtime OCI convertía después esas shares en un peso de v2, y la conversión original era lineal sobre el rango de shares [2, 262144] del kernel. Haz esa cuenta para 1024 shares y te sale un peso de 39 — frente a un valor por defecto de 100 en cgroup v2.[k8scpu][runcissue]
# A quiet change that moved every containerised workload's CPU priority, with
# no release note in most people's changelog because it happened in the OCI
# runtime rather than in the orchestrator.
# Kubernetes derives shares from the CPU request, and always has:
# cpu.shares = milliCPU * 1024 / 1000
# request 1000m -> 1024 shares request 100m -> 102 shares
# runc then converted shares to a v2 weight. The original conversion was
# linear over the kernel's [2, 262144] share range:
# weight = 1 + ((shares - 2) * 9999) / 262142
python3 -c 'print(1 + ((1024 - 2) * 9999) // 262142)'
# 39
#
# 39. Against a cgroup v2 default of 100. Every container asking for a full CPU
# was scheduled at roughly a third of the weight of anything not in a
# container - including the kubelet and the runtime themselves.
# The replacement is log-based, and is chosen so that one CPU lands on the
# default rather than well below it:
python3 - <<'PY'
import math
def weight(shares):
if shares == 0: return 0
if shares <= 2: return 1
if shares >= 262144: return 10000
l = math.log2(shares)
return math.floor(10 ** ((l*l + 125*l) / 612.0 - 7/34) + 0.99)
for req, sh in (("100m",102), ("500m",512), ("1",1024), ("4",4096), ("16",16384)):
print("%-6s shares=%-6d weight=%d" % (req, sh, weight(sh)))
PY
# 100m shares=102 weight=17
# 500m shares=512 weight=59
# 1 shares=1024 weight=100
# 4 shares=4096 weight=303
# 16 shares=16384 weight=942
#
# 1024 lands on exactly 100 because the curve is fitted through three fixed
# points: 2 -> 1, 1024 -> 100, and 262144 -> 10000.
# Check what your nodes are doing, because this depends on the runtime version
# and not on the Kubernetes version. The new conversion ships in runc 1.3.2 and
# later, and in crun 1.23 and later:
runc --version; crun --version 2>/dev/null
cat /sys/fs/cgroup/kubepods.slice/*/*/cpu.weight 2>/dev/null | sort -n | uniq -cLa consecuencia era que un contenedor que pedía una CPU entera competía con aproximadamente un tercio de la prioridad de cualquier cosa que no estuviera en un contenedor, en el mismo nodo, incluidos los demonios del sistema y el propio kubelet. La corrección sustituye el mapeo lineal por una curva logarítmica ajustada a tres puntos fijos — 2 shares a peso 1, 1024 a 100 y 262144 a 10000 — de modo que una petición de una CPU aterriza ahora exactamente en el valor por defecto de cgroup v2. Llega en runc 1.3.2 y posteriores y en crun 1.23 y posteriores. De ahí se siguen dos cosas fáciles de pasar por alto. Primera: este es un cambio del runtime, no de Kubernetes; llega cuando actualizas runc o crun, lo que puede coincidir o no con una actualización del clúster, así que mira la versión del runtime y no la del clúster. Segunda: cambia las prioridades relativas entre cargas en nodos que ya habías migrado, así que si mediste el comportamiento de CPU en v2 antes del cambio, esa medición está caducada.[runcpr]
Hazlo con systemd, no escribiendo en /sys
En cualquier host con systemd la interfaz correcta es systemd, no el sistema de archivos. Esto no es una cuestión de estilo. systemd crea el árbol de cgroups y reaplica su propia visión de los ajustes de recursos de una unidad cada vez que esa unidad se recarga, reinicia o reconfigura — de modo que un valor que hayas escrito con echo en memory.max sobrevive exactamente hasta el siguiente cambio no relacionado, y entonces desaparece sin una sola línea de log. El documento de delegación upstream enuncia la regla sin rodeos: un escritor por subárbol. Pasar por systemd te da además persistencia entre reinicios gratis, cosa que editar a mano no da nunca.[sddeleg][sdresctl]
# Writing into /sys/fs/cgroup by hand works exactly until systemd next touches
# that unit, at which point your values are overwritten without warning.
# systemd owns the tree; ask it, and the setting also survives a reboot.
# Try a limit on something already running, for this boot only:
systemctl set-property --runtime nginx.service MemoryHigh=1G IOWeight=50
# Make it permanent. This writes a drop-in for you - under
# /etc/systemd/system.control/nginx.service.d/, not /etc/systemd/system/, which
# is why hand-searching for your setting in the obvious place turns up nothing.
# No daemon-reload needed.
systemctl set-property nginx.service MemoryMax=2G MemoryHigh=1800M CPUWeight=200
# Or write the drop-in yourself, which is what you want in configuration
# management: /etc/systemd/system/nginx.service.d/50-resources.conf
#
# [Service]
# MemoryMax=2G # -> memory.max
# MemoryHigh=1800M # -> memory.high
# MemoryMin=256M # -> memory.min
# MemorySwapMax=0 # -> memory.swap.max
# CPUWeight=200 # -> cpu.weight
# CPUQuota=150% # -> cpu.max (150% of one CPU)
# IOWeight=50 # -> io.weight
# IOReadBandwidthMax=/dev/nvme0n1 100M
# TasksMax=512 # -> pids.max
#
# Note CPUQuota is a percentage of ONE CPU, not of the machine: 150% is 1.5
# cores. This is the systemd unit that trips people most often.
# Put a limit on a command you are about to run, without writing a unit:
systemd-run --scope --user -p MemoryMax=4G -p CPUQuota=200% -- ./import-job.sh
# And look at the tree systemd actually built, not the one you configured:
systemd-cgls --unit nginx.service
systemd-cgtop --order=memory --iterations=1
# Reading the values back. Note that there is no CPUQuota property to query:
# the unit-file setting CPUQuota= is exposed as CPUQuotaPerSecUSec, and asking
# for the name you wrote is the usual reason this returns nothing.
systemctl show nginx.service -p MemoryMax -p MemoryHigh -p CPUQuotaPerSecUSecTres comandos cubren casi todo el trabajo del día a día. systemctl set-property aplica un límite de inmediato y lo escribe en disco para futuros arranques, salvo que le pases --runtime para hacerlo temporal. systemd-run --scope -p … pone un límite alrededor de un comando que estás a punto de ejecutar, que es la forma honesta de acotar una importación o un backup puntual en vez de cruzar los dedos. Y systemd-cgtop muestra el uso de recursos por cgroup en lugar de por proceso, que es la vista que realmente quieres cuando un host de contenedores está ocupado y top te enseña doscientos procesos y ninguna estructura. Hay una directiva que pilla a todo el mundo: CPUQuota= es un porcentaje de una sola CPU, así que 150% significa un núcleo y medio, no el 150% de la máquina.[sdctl][sdcgtop]
Docker, Podman y los límites rootless que ahora sí se aplican
Para contenedores la noticia es mayormente buena, porque la traducción la hace el runtime. --memory, --cpus, --memory-reservation y --pids-limit siguen significando lo mismo; simplemente aterrizan ahora en memory.max, cpu.max, memory.low y pids.max. En un host unificado, Docker usa por defecto el driver de cgroups systemd y un espacio de nombres de cgroup privado, y ambos son los valores por defecto correctos. La excepción que conviene conocer es --oom-kill-disable, que la propia documentación de Docker dice que se descarta en v2 — ni se traduce, ni se avisa: se descarta. No hay equivalente en v2 por diseño, así que cualquier cosa que dependa de ello hay que repensarla, no portarla.[dockrun]
# --- Docker ---------------------------------------------------------------
docker info --format 'version={{.CgroupVersion}} driver={{.CgroupDriver}}'
# version=2 driver=systemd <- the defaults on a unified host
# Most flags are unchanged, because the daemon translates them for you:
docker run --memory 2g --memory-reservation 1g --cpus 1.5 --pids-limit 512 nginx
# -> memory.max memory.low cpu.max pids.max
# Two that are not:
# --oom-kill-disable is discarded on cgroup v2. Not translated - discarded.
# There is no v2 equivalent, by design.
# --kernel-memory removed from the Engine in v23.0. It is gone, not moved.
# Setting the driver explicitly, in /etc/docker/daemon.json. Use systemd unless
# something specific stops you: it is the default on v2 and it is the only
# option that keeps one writer per subtree.
# { "exec-opts": ["native.cgroupdriver=systemd"] }
# --- Podman rootless: this is the part that only works on v2 --------------
# Under v1, an unprivileged user could not be given controllers at all, so
# rootless resource limits silently did nothing. Under v2 they work, but only
# once systemd delegates the controllers to the user manager:
#
# /etc/systemd/system/user@.service.d/delegate.conf
# [Service]
# Delegate=cpu cpuset io memory pids
#
sudo systemctl daemon-reload # then log out and back in
# Verify from inside the user session, before blaming the container:
cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/cgroup.controllers
# cpuset cpu io memory pids <- if memory is missing, --memory does nothing
podman info --format '{{.Host.CgroupsVersion}} {{.Host.CgroupManager}} {{.Host.OCIRuntime.Name}}'
# v2 systemd crunLos contenedores rootless son el único sitio donde v2 no es un peaje sino una funcionalidad. Ceder controladores de v1 a un usuario sin privilegios nunca se consideró seguro, así que la mayoría de las implementaciones rootless directamente no soportaban límites de recursos en un host con v1. Bajo v2, la delegación segura de subárboles hace que funcionen de verdad — pero solo después de que systemd delegue los controladores al gestor de usuario, lo que es un drop-in para user@.service y volver a iniciar sesión, y es el paso que falta detrás de casi todos los informes de "Podman rootless ignora --memory". Comprueba cgroup.controllers dentro de tu propio slice de usuario antes de culpar al contenedor: si memory no aparece ahí, ningún flag que pases se va a aplicar. Delegar cpuset requiere además systemd 244 o posterior.[podman][crun]
Kubernetes: qué es cierto y qué sigues leyendo por ahí
Ahora la parte que más se cuenta mal, dicha con cuidado. Kubernetes no ha eliminado cgroup v1. La documentación lo marca como obsoleto desde la v1.35, y la consecuencia práctica es que el kubelet se niega a arrancar en un nodo con cgroup v1 por defecto. Ese valor por defecto es un campo de KubeletConfiguration, failCgroupV1, y ponerlo a false restaura el comportamiento anterior. KEP-5573 — la mejora que acabará haciendo la eliminación — dice que esta no se hará antes de la 1.38. Si algún artículo te ha dicho que la 1.36 borró cgroup v1, estaba equivocado, y la diferencia entre "obsoleto con opción de anularlo" y "eliminado" es la diferencia entre una migración planificada y un fin de semana.[k8scg][kep5573]
# What the cluster thinks it is standing on. Run this first; mixed node pools
# are the normal case, not the exception.
kubectl get nodes -o custom-columns=\
'NODE:.metadata.name,KERNEL:.status.nodeInfo.kernelVersion,'\
'RUNTIME:.status.nodeInfo.containerRuntimeVersion,OS:.status.nodeInfo.osImage'
# The kernel version alone does not tell you the hierarchy. Ask each node.
# Note the -it: without it, kubectl debug does not attach, the output goes to
# the debug pod's log instead of your terminal, and you are left with one
# orphaned pod per node.
kubectl get nodes -o name | while read -r n; do
printf '%-40s ' "${n#node/}"
kubectl debug "$n" -it --image=busybox --profile=general -- \
stat -fc %T /host/sys/fs/cgroup/ 2>/dev/null || echo '(debug unavailable)'
done
# Clean up afterwards - the debug pods are not removed for you:
kubectl delete pod -l app.kubernetes.io/managed-by=kubectl-debug 2>/dev/null
# On the node itself - the three files that have to agree:
stat -fc %T /sys/fs/cgroup/ # cgroup2fs
grep -E '^(cgroupDriver|failCgroupV1):' /var/lib/kubelet/config.yaml
grep -A2 'runc.options' /etc/containerd/config.toml # SystemdCgroup = true
# --- what is actually true about Kubernetes and cgroup v1 -----------------
# cgroup v1 is DEPRECATED as of v1.35, not removed. The kubelet refuses to
# start on a v1 node by default, and that default is overridable:
#
# /var/lib/kubelet/config.yaml
# apiVersion: kubelet.config.k8s.io/v1beta1
# kind: KubeletConfiguration
# cgroupDriver: systemd
# failCgroupV1: false # <- the escape hatch. Buys time, not a fix.
#
# KEP-5573 states the code removal will happen no earlier than 1.38.
# The v2-only features you get in exchange, and how to see them:
NODE=$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}')
kubectl get --raw "/api/v1/nodes/$NODE/proxy/metrics/cadvisor" \
| grep -E '^container_pressure_(cpu|memory|io)_' | head
# container_pressure_memory_stalled_seconds_total{...}
# container_pressure_memory_waiting_seconds_total{...}Los requisitos del lado del nodo son modestos y conviene comprobarlos en vez de darlos por hechos: kernel 5.8 o posterior, containerd v1.4+ o CRI-O v1.20+, y que el kubelet y el runtime estén configurados para usar concretamente el driver de cgroups systemd, no simplemente el mismo driver. Esa última condición era una fuente recurrente de nodos que funcionaban a medias, porque dependía de que dos archivos de configuración estuvieran de acuerdo; desde la v1.34 el kubelet le pregunta directamente al runtime CRI qué driver usa, lo que retira toda esa clase de problema para quien tenga un runtime suficientemente reciente. Lo que te llevas a cambio de la migración es un conjunto de funcionalidades que solo existen en v2:[k8sdriver][k8spsi]
- Métricas PSI, GA en la v1.36. El kubelet lee
cpu.pressure,memory.pressureeio.pressurepor cgroup y los expone a través de la Summary API y del endpoint de métricas de cAdvisor. Estos archivos no existen bajo v1, así que no es una funcionalidad que se pueda retroportar: es una razón para migrar. - Memory QoS. El kubelet puede fijar
memory.higha partir dememoryThrottlingFactorpara que un contenedor sufra recuperación agresiva antes de que lo maten y, de forma independiente, bajomemoryReservationPolicy: TieredReservation, puede fijarmemory.minpara los pods Guaranteed ymemory.lowpara los Burstable. A fecha de la v1.36 sigue en alpha y desactivado por defecto, así que trátalo como algo que probar y no como algo en lo que apoyarte — pero su forma es exactamente para lo que se diseñó el modelo de memoria de cuatro niveles. - Cargas rootless y con espacios de nombres de usuario que sí aplican los límites. Todo lo de la sección de Podman vale también para un nodo de Kubernetes, y es la razón por la que los espacios de nombres de usuario llegando a GA y cgroup v2 son historias relacionadas y no coincidencias.
- Contabilidad de E/S honesta por pod. Escrituras con búfer atribuidas al cgroup que las provocó, lo que convierte el uso de disco por pod en un número sobre el que puedes actuar en vez de un número por el que pides disculpas.
Una nota de planificación que es fácil equivocar en un parque mixto. Como la función forzadora es systemd y no Kubernetes, los grupos de nodos tienden a migrarse solos a medida que avanzan sus imágenes base, muy por delante de cualquier decisión a nivel de clúster. Eso está bien, pero significa que puedes acabar con un clúster donde la mitad de los nodos están en v2 y la otra mitad no, ejecutando las mismas cargas con un comportamiento de memoria y CPU materialmente distinto — y ninguna alerta te lo va a decir. Audita los nodos, no los deduzcas de la versión del clúster.[k8sqos]
Activar v2 donde todavía no lo está
Si todavía tienes hosts en v1 o en híbrido, esta es la parte mecánica, y es corta. Ten en cuenta primero que en systemd 258 y posteriores no hay nada que activar ni nada que desactivar: unificado es el único modo que soporta el código. Aun así, no dejes ahí el viejo parámetro de kernel: quítalo. systemd 258 ya no actúa sobre él, pero si un initrd todavía lo hace y monta una jerarquía v1, el PID 1 se niega a arrancar y te dice que elimines esa opción obsoleta de la línea de comandos — que es un fallo mucho mejor que uno silencioso, y aun así un arranque que tienes que rescatar desde una consola. Todo lo de abajo se aplica únicamente a hosts lo bastante antiguos como para todavía tener elección — y en esos, la elección debería hacerse en una ventana de mantenimiento que mueva también los runtimes de contenedores, porque un nodo que reinicia en v2 con el driver cgroupfs es un nodo que vuelve en un estado interesante.[sd258]
# Only needed on hosts old enough to still default to v1 or hybrid. On
# systemd 258 and later there is nothing to enable: unified is the only mode.
# 1. Check you can. Kubernetes wants kernel 5.8+; systemd 258 needs 5.4 as an
# absolute floor and recommends 5.7. Below that, upgrade the OS instead.
uname -r
# 2. Set the kernel parameter. Debian and Ubuntu. The grep guard matters:
# without it, running this twice adds the parameter twice.
grep -q 'systemd.unified_cgroup_hierarchy' /etc/default/grub || \
sudo sed -i 's/^GRUB_CMDLINE_LINUX="/&systemd.unified_cgroup_hierarchy=1 /' \
/etc/default/grub
sudo update-grub
# RHEL, Fedora, Rocky, Alma - grubby, and note ALL rather than the running
# kernel, or the setting vanishes at the next kernel update:
sudo grubby --update-kernel=ALL --args="systemd.unified_cgroup_hierarchy=1"
# 3. Line up the container runtimes in the SAME maintenance window. A node
# that reboots into v2 with a cgroupfs driver is a node that does not come
# back cleanly.
# /etc/docker/daemon.json -> "exec-opts": ["native.cgroupdriver=systemd"]
# /etc/containerd/config.toml -> SystemdCgroup = true
# /var/lib/kubelet/config.yaml -> cgroupDriver: systemd
sudo reboot
# 4. Verify, in this order. If step one disagrees with step three, stop.
stat -fc %T /sys/fs/cgroup/ # cgroup2fs
cat /sys/fs/cgroup/cgroup.controllers # non-empty
systemctl --failed
docker info --format '{{.CgroupVersion}}/{{.CgroupDriver}}' # 2/systemd
# Rolling back is removing the parameter and rebooting - but only while your
# systemd is older than 258. After that the parameter is inert and the only
# way back is downgrading the OS, which is not a rollback plan.| Qué se rompe | Por qué | Qué hacer en su lugar |
|---|---|---|
Scripts que leen /sys/fs/cgroup/memory/… | Los directorios por controlador no existen bajo la jerarquía unificada | Leer las rutas planas de v2, o pedirle el valor a systemctl show |
| Herramientas que escriben en cgroups gestionados por systemd | systemd reimpone sus ajustes ante cualquier cambio de unidad, en silencio | systemctl set-property, o un archivo drop-in |
Marcado de tráfico basado en net_cls | Controlador eliminado sin archivo sustituto | eBPF asociado a la ruta del cgroup, con coincidencia desde nftables |
Listas blancas con devices.allow | El controlador se sustituyó por un tipo de programa eBPF | DeviceAllow= en una unidad, o un programa eBPF de dispositivos |
| Jerarquías con límites en todos los niveles del árbol | Restricción de no procesos internos | Empujar los procesos a las hojas; dejar vacíos los cgroups intermedios |
--oom-kill-disable | Se descarta en v2, por diseño | Dimensionar bien memory.max y usar memory.high para tener aviso antes |
| Límites de swap copiados tal cual | memory.swap.max es solo swap, no memoria más swap | Poner la diferencia entre los dos números de v1, o un 0 explícito |
Dos notas sobre la vuelta atrás, ya que nadie planifica una migración sin ella. Mientras tu systemd sea la 257 o anterior, la vuelta atrás consiste en quitar el parámetro de kernel y reiniciar, y es genuinamente barata. Una vez estás en la 258 o posterior, no hay ningún camino de vuelta soportado salvo degradar el sistema operativo, lo cual no es un plan de vuelta atrás: es una reinstalación. Secuencia la flota en consecuencia: haz primero los hosts que todavía tienen salida de emergencia, aprende de ellos, y solo entonces mueve los que no la tienen.[dockrun]
Verificar, en lugar de confiar
La verificación no es cuestión de gustos. La gracia del script de abajo es que cada línea o imprime OK o se explica sola, y el código de salida es el número de fallos, de modo que puede ir directo a lo que sea que se ejecuta después de un reinicio. Dos de sus partes se ganan el sitio. La primera imprime un inventario de todas las unidades en ejecución que sí tienen un MemoryMax fijado, que comparas con la lista de las que pretendías configurar: una unidad que falta en esa salida tiene su drop-in en el directorio equivocado, y no hay forma de detectarlo sin conocer el estado previsto. La segunda recorre todo el árbol en busca de frenados, porque un nr_throttled que sube en un servicio del que nadie se queja es la señal clásica de un límite traducido demasiado justo.[sdcgls]
#!/usr/bin/env bash
# Post-migration verification. Every check either prints OK or explains
# itself; nothing here is judged by eye. Exit code is the number of failures.
fail=0
chk() { if eval "$2" >/dev/null 2>&1; then printf 'OK %s\n' "$1";
else printf 'FAIL %s\n' "$1"; fail=$((fail+1)); fi; }
chk 'unified hierarchy' '[ "$(stat -fc %T /sys/fs/cgroup/)" = cgroup2fs ]'
chk 'controllers available' '[ -s /sys/fs/cgroup/cgroup.controllers ]'
chk 'memory controller' 'grep -qw memory /sys/fs/cgroup/cgroup.controllers'
chk 'io controller' 'grep -qw io /sys/fs/cgroup/cgroup.controllers'
chk 'single cgroup line' '[ "$(wc -l < /proc/self/cgroup)" -eq 1 ]'
chk 'no v1 leftovers mounted' '! mount | grep -q "type cgroup "'
chk 'no failed units' '[ -z "$(systemctl list-units --state=failed --no-legend)" ]'
chk 'PSI available' '[ -r /sys/fs/cgroup/cpu.pressure ]'
# Limits are actually applied, rather than merely configured. A unit whose
# MemoryMax reads "infinity" after you set it is a unit whose drop-in is in
# the wrong place - a very common outcome of hand-editing.
for u in $(systemctl list-units --type=service --state=running \
--no-legend --plain | awk '{print $1}'); do
m=$(systemctl show "$u" -p MemoryMax --value)
[ "$m" != "infinity" ] && printf ' %-34s MemoryMax=%s\n' "$u" "$m"
done
# Nothing is being silently throttled. nr_throttled climbing on a service that
# is not busy means cpu.max is too tight, and it will not appear in load
# average. Search the whole tree, not just the top-level slices: throttling
# happens on the leaf that holds the process.
find /sys/fs/cgroup -name cpu.stat -exec \
awk '/^nr_throttled/ && $2>0 {print FILENAME": "$0}' {} + 2>/dev/null
# Containers agree with the host.
command -v docker >/dev/null && \
chk 'docker on v2/systemd' '[ "$(docker info -f "{{.CgroupVersion}}/{{.CgroupDriver}}")" = 2/systemd ]'
printf '\n%d failure(s)\n' "$fail"; exit "$fail" Ejecútalo también antes de la migración y guarda la salida. Buena parte de lo que se le echa en cara a una migración de cgroups ya era cierto de antes, y la única forma de saberlo es haberlo medido. Si nr_throttled ya estaba subiendo en ese servicio la semana pasada, la jerarquía nueva no lo causó.
El orden en el que hacer esto
La decisión, comprimida. El resumen honesto es que la mayoría de la gente ya ha migrado sin proyecto, y lo que queda no son los hosts sino el instrumental: los scripts, agentes y cuadros de mando que siguen leyendo rutas de v1 y seguirán devolviendo ceros calladamente hasta que alguien lo mire. Kubernetes te da hasta la 1.38 como muy pronto, y Docker bastante más que eso, pero ninguna de esas fechas es tu fecha límite. Tu fecha límite es el momento en que la siguiente actualización de imagen base lleve systemd más allá de la 258 — y en la mayoría de los parques eso ya ha pasado.[kep5573]
| Si tu situación es… | Entonces la fecha límite es… | Y el trabajo es… |
|---|---|---|
Todo devuelve ya cgroup2fs | Ya pasó, sin hacer ruido | Solo el instrumental: encontrar las rutas de v1 que se siguen leyendo y arreglarlas |
| Un puñado de hosts antiguos en v1 o híbrido | Cuando llegue su siguiente actualización de sistema operativo | Parámetro de kernel más drivers de runtime, una ventana de mantenimiento por host |
| Nodos de Kubernetes con imágenes base mezcladas | A medida que avanzan las imágenes de nodo, no al actualizar el clúster | Auditar nodo a nodo; no deducir la jerarquía de la versión del clúster |
| Hosts Docker, sin orquestador | Engine v29.0 lo declaró obsoleto; la eliminación queda a años vista | Poca urgencia por parte de Docker, pero systemd moverá el host antes de todas formas |
| Instrumental propio que escribe archivos de cgroup directamente | Ahora, y es el proyecto de verdad | Reescribirlo contra las interfaces de systemd antes de que los hosts se muevan bajo tus pies |
| Un appliance o agente de proveedor que no puedes cambiar | El calendario del proveedor, que no es el tuyo | Pedirlo por escrito, y aislar el host si la respuesta no convence |
- Audita antes de planificar. Pasa el script de flota por todos los hosts y guarda la salida. Buscas dos cosas: hosts que sigan en v1 o híbrido, y rutas de v1 escritas a fuego en
/etc,/opty/usr/local. La segunda lista casi siempre es más larga que la primera y es el trabajo de verdad. - Arregla primero el instrumental, mientras las dos jerarquías todavía existen. Cualquier cosa que lea
/sys/fs/cgroupdebería manejar ambos modos, o leer a través desystemctl show. Hacer esto antes de mover los hosts te permite probar el arreglo contra aquello que se supone que arregla. - Traduce los límites por significado, no por nombre. Dos filas de la tabla de conversión cambian de comportamiento: swap, donde
memory.swap.maxes solo swap, y el peso de CPU, cuya escala difiere de la de shares en más de un factor de diez. Todas las demás filas son un cambio de nombre. - Mueve los runtimes y los hosts en la misma ventana. Parámetro de kernel, driver de Docker,
SystemdCgroupde containerd,cgroupDriverdel kubelet — los cuatro, un reinicio, y verificar antes de pasar al siguiente lote. - Cobra el beneficio. Conecta
memory.highymemory.events, pon PSI en un panel y activa los límites de E/S por cgroup que bajo v1 no merecían la pena. Esta migración no tiene ninguna funcionalidad al final salvo que la reclames.
Esto va de la mano de otras tres piezas del mismo cambio, y se acumulan: migrar scripts de init SysV y rc.local a unidades de systemd, porque ambos cambios aterrizan en las mismas versiones de systemd y en los mismos servidores; la actualización de servidor de Ubuntu 24.04 a 26.04, que es donde la mayoría de las flotas van a cruzar la línea de verdad; y los cambios incompatibles de Docker Engine 29, cuya declaración de cgroup v1 como obsoleto es la mitad de esta historia que toca a los contenedores. Y si estás valorando cuánta de toda esta complejidad necesitas realmente, cuándo no usar Kubernetes es la otra cara de ese argumento.
Preguntas frecuentes
¿Cómo sé si estoy en cgroup v1 o v2?
Ejecuta stat -fc %T /sys/fs/cgroup/. Si imprime cgroup2fs estás en la jerarquía unificada; si imprime tmpfs estás en v1 o en el modo híbrido. No uses mount | grep cgroup2: el modo híbrido monta una jerarquía cgroup2 en /sys/fs/cgroup/unified sin ningún controlador asociado, así que el grep coincide y te dice justo lo contrario de la verdad. Una segunda confirmación es /proc/self/cgroup, que bajo v2 contiene exactamente una línea que empieza por 0::.
¿Kubernetes 1.36 eliminó cgroup v1?
No. cgroup v1 está marcado como obsoleto desde Kubernetes v1.35, y el efecto práctico es que el kubelet se niega a arrancar en un nodo con cgroup v1 por defecto. Ese valor por defecto es un campo de KubeletConfiguration, failCgroupV1, y ponerlo a false restaura el comportamiento anterior. KEP-5573, la mejora que acabará eliminando el código, indica que la eliminación no ocurrirá antes de la v1.38. Varios artículos muy compartidos dicen lo contrario; la autoridad es el KEP.
¿memory.swap.max es lo mismo que memory.memsw.limit_in_bytes?
No, y este es el malentendido más dañino de toda la migración. En cgroup v1, memory.memsw.limit_in_bytes limitaba memoria y swap en conjunto, así que un cgroup con limit=2G y memsw=3G podía usar 2 GB de RAM más 1 GB de swap. En cgroup v2, memory.swap.max limita solo el swap. Copiar el 3G tal cual concede el triple del swap anterior. La traducción correcta es la diferencia entre los dos valores de v1, y donde esa diferencia sea cero hay que poner un 0 explícito.
¿Cuál es la diferencia entre memory.high y memory.max?
memory.max es un límite duro: cuando un cgroup lo alcanza y no puede reclamar memoria, el OOM killer actúa dentro de ese cgroup. memory.high es un freno: superarlo somete al cgroup a una fuerte presión de recuperación de páginas y ralentiza sus procesos, y la documentación del kernel es explícita en que rebasarlo nunca invoca al OOM killer. En la práctica se fija memory.high algo por debajo de memory.max y se alerta sobre el contador high de memory.events, lo que te da aviso antes de que muera nada. cgroup v1 no tenía ningún mecanismo equivalente.
¿Por qué mis contenedores reciben menos CPU tras pasar a cgroup v2?
Por la conversión de shares a peso, no por v2 en sí. Kubernetes deriva cpu.shares de la petición de CPU como milliCPU × 1024 / 1000, así que una petición de una CPU producía 1024 shares. La conversión original de los runtimes OCI mapeaba eso linealmente sobre el rango de pesos de v2 y daba 39, frente a un valor por defecto de 100 en cgroup v2 — de modo que los contenedores competían con aproximadamente un tercio de la prioridad de los procesos que estaban fuera de todo contenedor. La sustitución logarítmica sitúa 1024 shares exactamente en 100, y llega en runc 1.3.2 y posteriores y en crun 1.23 y posteriores. Mira la versión de tu runtime, no la de Kubernetes: esto llega con una actualización de imagen de nodo o de runtime, no con una del plano de control.
¿Puedo seguir forzando cgroup v1 con systemd.unified_cgroup_hierarchy=0?
Solo en systemd 257 y anteriores, y ahí hace falta poner en la línea de comandos del kernel tanto systemd.unified_cgroup_hierarchy=0 como SYSTEMD_CGROUP_ENABLE_LEGACY_FORCE=1. systemd 256 dejó de arrancar cgroup v1 por defecto e introdujo esa vía de escape; la 257 la sigue respetando; systemd 258 eliminó por completo el soporte de cgroup v1, vía de escape incluida. En la 258 y posteriores systemd ya no actúa sobre esa opción — pero quítala en lugar de dejarla ahí, porque si tu initrd todavía actúa sobre ella y monta una jerarquía v1, el PID 1 se niega a arrancar y te dice que elimines esa opción obsoleta de la línea de comandos.
¿Qué sustituye a los controladores devices, net_cls y net_prio?
eBPF, en los tres casos. El control de acceso a dispositivos es ahora un programa eBPF de tipo BPF_PROG_TYPE_CGROUP_DEVICE, que recibe los números mayor y menor, el tipo de dispositivo y el tipo de acceso, y devuelve permitido o -EPERM; en un host con systemd, la directiva de unidad DeviceAllow= se encarga de esto por ti. La clasificación y priorización de red no tienen controlador en v2 ni archivo de interfaz sustituto: se asocia un programa eBPF a la ruta del cgroup y se hace coincidir desde iptables o nftables. Estas son las tres filas de la tabla de conversión que exigen ingeniería y no una sustitución de rutas.
¿Por qué Podman rootless ignora mi límite de memoria en cgroup v2?
Casi siempre porque systemd no ha delegado el controlador de memoria a tu gestor de usuario. Crea un drop-in para user@.service con Delegate=cpu cpuset io memory pids, recarga systemd y cierra y vuelve a abrir sesión. Verifícalo con cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/cgroup.controllers: si memory no está en esa lista, ningún flag que le pases a Podman se puede aplicar. Delegar cpuset en concreto requiere systemd 244 o posterior. Bajo cgroup v1, ceder controladores a un usuario sin privilegios no se consideraba seguro y la mayoría de las implementaciones rootless no lo soportaban, así que esto es una capacidad que v2 añade y no una regresión que v2 introduce.
¿Tengo que cambiar mis comandos de Docker?
Mayoritariamente no. --memory, --cpus, --memory-reservation y --pids-limit mantienen su significado y se traducen a memory.max, cpu.max, memory.low y pids.max. Importan dos excepciones: --oom-kill-disable se descarta en cgroup v2 sin equivalente, y --kernel-memory se eliminó del Engine ya en la v23.0. En un host unificado Docker usa por defecto el driver de cgroups systemd y un espacio de nombres de cgroup privado, y deberías dejar ambos como están salvo que algo concreto te obligue a lo contrario.
¿Hay algún beneficio de rendimiento o esto es puro coste de migración?
Hay un beneficio real, concentrado en la E/S y en la observabilidad. El controlador blkio de v1 solo contabilizaba la E/S directa, así que las escrituras con búfer se imputaban al hilo del kernel que las volcara y los límites de escritura por cgroup eran básicamente decorativos; el controlador io de v2 entiende el writeback y lo atribuye correctamente, lo que hace que io.max e io.latency merezcan la pena en hosts de base de datos y de build. Además, PSI — cpu.pressure, memory.pressure, io.pressure — solo existe bajo v2, y es la diferencia entre saber que una máquina está bajo presión y enterarte cuando algo se muere.
El runtime que hay debajo va al mismo reloj: containerd 1.7 sale del soporte extendido en septiembre de 2026, y para Kubernetes 1.36 la matriz de soporte del proyecto solo lista 2.3.0+ y 2.2.0+, sin ninguna entrada 1.x. la migración de containerd 1.7 a 2.x explica la reescritura de la configuración a la versión 3, la conversión de registros que impide que cargue el plugin de CRI, y por qué 2.3 es la única rama a la que merece la pena apuntar.
El plano de datos de los Services en esos mismos nodos va a su propio ritmo: Kubernetes 1.37 marcó como obsoleto el modo ipvs de kube-proxy detrás de un feature gate, 1.40 lo desactiva por defecto y 1.43 borra el código. la migración de kube-proxy de IPVS a nftables explica el mínimo de kernel 5.13, el comportamiento de los NodePort que cambia sin avisar y el kube-ipvs0 que queda atrás y se traga el tráfico si nadie lo limpia.
Una nota a nivel de versión, porque el balance de la 1.37 no es el que cuenta casi todo el mundo: el cambio que de verdad puede dejar pods en ContainerCreating es SELinuxMount al llegar a GA, mientras que el fallo de cgroup v1 aterrizó en la 1.35, la restricción de los pods estáticos en la 1.34 y el precipicio de containerd sigue por delante, en la 1.38. qué se rompe de verdad al actualizar a Kubernetes 1.37 separa las tres columnas y da la auditoría que hay que hacer antes de actualizar, no después.
Fuentes
Fuentes primarias primero: la documentación del kernel define cada archivo de interfaz citado aquí, y las notas de versión y propuestas de mejora de los propios proyectos son la única declaración fiable de qué se eliminó y cuándo. Donde este artículo contradice a la cobertura secundaria — en el caso de Kubernetes y de Docker en particular — el desacuerdo es con el titular, no con la fuente primaria.
- Linux kernel — Control Group v2: the normative document. Every interface file, default value and range quoted in this article was checked here, including the fact that memory.max defaults to "max" and cpu.max defaults to "max 100000"
- Control Group v2 — Memory interface files: memory.min, memory.low, memory.high and memory.max, and the sentence that going over memory.high never invokes the OOM killer. This is the four-tier model cgroup v1 did not have
- Control Group v2 — IO interface files: io.weight, io.max with its rbps/wbps/riops/wiops keys, io.latency and io.cost. The v2 io controller is also the first one that accounts for writeback correctly
- Control Group v2 — No Internal Process Constraint: non-root cgroups can only distribute resources to children when they hold no processes of their own. This single rule is what breaks hand-rolled v1 layouts on contact
- Control Group v2 — Delegation: the model that makes rootless containers possible, and the reason a delegated subtree must not be allowed to write its own resource-control files
- Linux kernel — Memory Resource Controller (cgroup v1): the source for what memory.limit_in_bytes and memory.memsw.limit_in_bytes actually meant, which is the only way to see how different memory.swap.max is
- cgroups(7) — the manual page, including the statement that there is no direct equivalent of the net_cls and net_prio controllers, and that iptables gained support for eBPF filters hooking on cgroup v2 pathnames instead
- BPF_PROG_TYPE_CGROUP_DEVICE — the eBPF program type that replaced the v1 devices controller: it receives major, minor, device type and access type, and returns allow or -EPERM
- systemd — NEWS: the upstream changelog and the authoritative statement of what happened in which release. The v258 section carries both the cgroup v1 removal and the kernel baseline bump quoted here
- systemd v258 release notes — "Support for cgroup v1 ('legacy' and 'hybrid' hierarchies) has been removed", and the bump of the minimum kernel baseline to v5.4 with v5.7 recommended
- systemd v256 release notes — the release that stopped booting cgroup v1 by default and introduced the SYSTEMD_CGROUP_ENABLE_LEGACY_FORCE=1 escape hatch that v258 then took away
- systemd.resource-control(5) — MemoryMax=, MemoryHigh=, MemoryLow=, MemoryMin=, MemorySwapMax=, CPUWeight=, CPUQuota=, IOWeight=, IOReadBandwidthMax= and TasksMax=: the directive names for every interface file in the conversion table
- systemctl(1) — set-property, and the fact that it applies changes immediately and stores them on disk for future boots unless --runtime is passed
- systemd-run(1) — --scope and --property=, the pair that lets you put a limit on a command you are about to run without writing a unit file first
- systemd-cgls(1) — recursively show control group contents: the fastest way to see the tree systemd actually built, as opposed to the one you think you configured
- systemd-cgtop(1) — top control groups by resource usage, which is the per-cgroup view that plain top cannot give you
- systemd — Control Group APIs and Delegation: upstream's own rules for who owns which part of the tree, and why writing into systemd's cgroups from outside systemd is a bug rather than a technique
- Kubernetes — About cgroup v2: the requirements (kernel 5.8 or later, containerd v1.4+, cri-o v1.20+, systemd cgroup driver), the stat -fc %T check, and the deprecation notice marking cgroup v1 deprecated as of v1.35
- KEP-5573, Remove cgroup v1 support — the document that says removal "will be done no earlier than 1.38". Worth reading before believing any headline that says Kubernetes has already removed it
- Kubernetes blog — New Conversion from cgroup v1 CPU Shares to v2 CPU Weight: why a container requesting 1 CPU ended up below the default weight on v2, and the replacement formula
- runc pull request 4785 — the dependency bump that pulls the new shares-to-weight conversion into runc. The conversion itself lives in the opencontainers/cgroups library, which is where the change reaches everyone regardless of orchestrator
- runc issue 4772 — the report behind that change: the linear conversion gave 1024 shares a weight of 39 against a default of 100, so containers lost CPU to everything not in a container
- Kubernetes blog — Autoconfiguration for Node Cgroup Driver Goes GA: the kubelet now asks the CRI runtime which cgroup driver it uses instead of trusting two files to agree
- Kubernetes — Understand PSI metrics: pressure stall information read from cpu.pressure, memory.pressure and io.pressure, which exist only under cgroup v2
- Kubernetes blog — Tiered Memory Protection with Memory QoS: the kubelet writing memory.high and, under memoryReservationPolicy, memory.min and memory.low. None of this has a cgroup v1 equivalent
- Docker — Runtime metrics: the cgroup v2 requirements (containerd v1.4+, kernel v4.15+ with v5.2+ recommended), the default driver being systemd on v2 and cgroupfs on v1, and the sentence that --oom-kill-disable is discarded on v2
- Docker Engine — Deprecated features: the table row recording that support for cgroup v1 was deprecated in Engine v29.0, with no removal version set, and that the kernel memory limit was removed back in v23.0
- moby issue 51111 — the proposal to deprecate cgroup v1 while maintaining it until the enterprise distributions that still need it reach end of life. This is why Docker's deadline is much later than systemd's
- Rootless Containers — cgroup v2: the systemd user-manager Delegate= drop-in that gives an unprivileged user real cpu, memory, io and pids limits, and the note that delegating cpuset needs systemd 244 or newer
- crun — the OCI runtime with native cgroup v2 support and the default on current Podman installations, which matters because runc reached v2 later and older builds handle it badly
- Red Hat Enterprise Linux 10 release notes — the release where systemd no longer supports booting in cgroup v1 mode at all, for readers whose deadline is an enterprise distribution rather than upstream
- Linux kernel — PSI, Pressure Stall Information: what the numbers in cpu.pressure, memory.pressure and io.pressure mean, and why "some" and "full" are different questions
¿Te ha resultado útil?