DevOps

Explorando cgroups v2 en Kubernetes: Mejoras y estabilidad en la gestión de recursos

Por qué Kubernetes cambió a cgroup v2 (y por qué te importa)

Si administras un clúster Kubernetes con nodos Linux, llevas años viendo cómo el proyecto empuja el cambio de cgroup v1 a cgroup v2. La mudanza ya no es opcional: desde Kubernetes v1.35, el kubelet rechaza arrancar en un nodo que siga en cgroup v1 bajo configuración por defecto, y el fallback de cgroup v1 está programado para removerse en v1.38. En este artículo desarmamos lo que cambió, por qué importa para la estabilidad y aislamiento de tus cargas, qué necesitas en cada nodo Linux y cómo diagnosticar los límites de CPU y memoria ahora que la API, el OCI y el cgroupfs exponen nombres distintos para el mismo recurso.

TL;DR — Lo que vas a tener al final

  • cgroup v2 es estable en Kubernetes desde v1.25 y el kubelet rechaza cgroup v1 desde v1.35 (failCgroupV1: true por defecto).
  • Kernel mínimo: 5.8 (5.9 recomendado si activas Memory QoS con throttling).
  • Container runtime: containerd ≥ v1.4 (≥ v2.0 para autodetección del cgroup driver) o CRI-O ≥ v1.20.
  • Cgroup driver: usa systemd en clústeres administrados con kubeadm.
  • Memory QoS con tiered protection es Alpha en v1.36 y requiere cgroup v2; mapea memory.min, memory.low y memory.high a tus QoS classes.
  • PSI metrics (presión de CPU, memoria e I/O) pasaron a GA en v1.36 (KEP-4205) y dependen de cgroup v2 + kernel ≥ 4.20.
  • In-place resize de Pod-level resources es Beta por defecto en v1.36 y exige cgroup v2 para enforcement correcto.

Qué cambió entre cgroup v1 y v2

cgroups es un mecanismo del kernel Linux para agrupar procesos y aplicarles cuotas de CPU, memoria, I/O y otros controladores. La diferencia entre v1 y v2 no es solo cosmética: la versión 1 organizaba cada controlador en un árbol independiente (con reglas contradictorias entre ellos), mientras que v2 unifica todo bajo una sola jerarquía y expone una API coherente para delegar el control a usuarios sin root, leer presión real y proteger memoria de forma granular.

El cronograma del proyecto Kubernetes sigue tres hitos:

  • v1.25 (agosto 2022): cgroup v2 graduado a GA como API de gestión.
  • v1.31 (agosto 2024): soporte de cgroup v1 pasó a maintenance mode.
  • v1.35 (septiembre 2025): failCgroupV1 por defecto en true; el kubelet no arranca en un nodo cgroup v1.
  • v1.38 (futuro): removal planificado del fallback a cgroup v1 (KEP-5573).

Para kubeadm, el chequeo SystemVerification que ofrece k8s.io/system-validators falla durante kubeadm init/join/upgrade si detecta cgroup v1 con kubelet v1.35 o superior; con kubelet más antiguo el chequeo solo advierte. Esto blinda la migración desde el bootstrap.

Requisitos para adoptar cgroup v2 hoy

Antes de tocar nada, confirma que tu clúster cumple los prerrequisitos documentados por el proyecto Kubernetes:

Componente Requisito mínimo Notas
Kubernetes v1.25+ Todas las versiones soportadas ya incluyen cgroup v2 estable. v1.36 es la rama actual al cierre de este artículo (octubre 2026).
Kernel Linux ≥ 5.8 (recomendado ≥ 5.9 con Memory QoS) Antes de 5.2 falta el task freezer. memory.high tiene un livelock resuelto en 5.9. memory.peak se añadió en 5.19.
containerd ≥ v1.4 (≥ v2.0 para autodetección del cgroup driver) La autodetección (KEP-4033) graduó estable en v1.34.
CRI-O ≥ v1.20 (≥ v1.28 para autodetección) Implementa el RPC RuntimeConfig del CRI.
Sistema operativo Habilitado en boot con systemd.unified_cgroup_hierarchy=1 o soporte nativo en la distro Verifica con stat -fc %T /sys/fs/cgroup/; debe devolver cgroup2fs.
Cgroup driver Igual en kubelet y runtime; recomendado systemd con kubeadm El tutorial oficial explica los pasos por distro.

Para verificar el estado del nodo en cinco segundos:

# ¿Está habilitado cgroup v2?
stat -fc %T /sys/fs/cgroup/
# cgroup2fs
# ¿Qué versiones de kernel, kubelet y containerd reportan?
uname -r
kubelet --version
containerd --version
# ¿Qué unidades slice tiene systemd registradas para Kubernetes?
systemctl list-units 'kube*' --type=slice --type=scope --no-pager

Cómo configurar el cgroup driver correctamente

Kubeadm administra el kubelet como unidad systemd, así que el camino recto es usar el cgroup driver systemd. Si usas otra herramienta de gestión, revisa su documentación; la regla de oro es que kubelet y runtime compartan driver para que los slices y scopes se mapeen uno a uno.

Cuando tu runtime implementa el RPC RuntimeConfig del CRI (containerd v2.0+ o CRI-O v1.28+), la autodetección estable desde v1.34 deja que el kubelet herede el driver del runtime y evita el típico bug “el kubelet usa cgroupfs pero el runtime usa systemd” que rompe el exec de pods. Si no puedes actualizar el runtime, fijalo manualmente:

apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd

Tras editar el archivo (normalmente /var/lib/kubelet/config.yaml), reinicia el kubelet y verifica que el log no reporte inconsistencias:

journalctl -u kubelet -n 50 --no-pager | grep -i "cgroup driver"
systemctl restart kubelet

Memory QoS con protección jerarquizada (Beta en v1.36)

El cambio cualitativo más útil de cgroup v2 para sysadmins es la posibilidad de separar throttling de reservation en memoria. Memory QoS usa tres controles del controlador de memoria de cgroup v2:

  • memory.high → techo blando: el kernel throttlea cuando se supera. Lo aplica el kubelet a contenedores Burstable cuando memoryThrottlingFactor se cumple (default 0.9).
  • memory.min → protección dura para Pods Guaranteed; la memoria no se reclaima bajo presión.
  • memory.low → protección blanda para Pods Burstable; el kernel intenta no reclamarla.

Para activar la tiered reservation en v1.36:

apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
featureGates:
  MemoryQoS: true
memoryReservationPolicy: TieredReservation
memoryThrottlingFactor: 0.9

Advertencias honestas antes de prenderlo en producción: la recomendación general del proyecto es no activar features Alpha en producción, pero si decidís hacerlo, testealo y ten en cuenta la memoria dura que reservás con memory.min, porque no queda disponible para otras cargas. El kubelet expone métricas Alpha con el total de memory.min y memory.low por nodo; lleva los paneles de Grafana actualizados.

PSI: medir presión real (no utilización)

Durante mucho tiempo la única señal para el Horizontal Pod Autoscaler fue utilización: “la CPU está al 70 %”. El problema es que un nodo puede estar ocupado pero no presionado, o al revés: utilización baja con tasks esperando por I/O. Pressure Stall Information (PSI) reporta el porcentaje de tiempo en que al menos una task estuvo bloqueada esperando CPU, memoria o I/O. PSI está disponible desde kernel 4.20 con CONFIG_PSI=y.

En Kubernetes v1.36, PSI metrics pasaron a GA (KEP-4205). El kubelet las expone en el Summary API y en /metrics/cadvisor cuando KubeletPSI está activo. Para consumirlas en Grafana:

# Catálogo de métricas PSI disponibles
kubectl get --raw /api/v1/nodes/<node>/proxy/metrics | grep psi_
# container_pressure_cpu_waiting_seconds_total
# container_pressure_memory_waiting_seconds_total
# container_pressure_io_waiting_seconds_total

El valor práctico es alimentar el VPA o el HPA con some/10s de presión y no con promedio de utilización; reduce falsos positivos cuando hay picos cortos de carga.

Cómo verificar que un límite de Pod se aplicó al cgroup

El pipeline “API → CRI → OCI → cgroupfs” pasa por cuatro representaciones de los mismos números. Si quieres auditar el enforcement real, bajá por las capas y compara. Con el ejemplo siguiente asumimos systemd cgroup driver y cgroup v2:

# 1) ¿Qué pidió el Pod y qué se aplicó?
kubectl get pod web -n prod 
  -o jsonpath='{range .spec.containers[*]}{.name}{" spect"}{.resources}{"n"}{end}'
kubectl get pod web -n prod 
  -o jsonpath='{range .status.containerStatuses[*]}{.name}{" statust"}{.resources}{"n"}{end}'
# 2) Identificá el container runtime ID y su PID
CONTAINER_ID=$(crictl ps 
  --label io.kubernetes.pod.namespace=prod 
  --label io.kubernetes.pod.name=web 
  --name app -q | head -n1)
PID=$(crictl inspect "$CONTAINER_ID" | jq -r '.info.pid')
# 3) Resolución del cgroup path real
CGROUP="/sys/fs/cgroup$(awk -F: '$1=="0"{print $3}' /proc/$PID/cgroup)"
# 4) Los archivos que importan en cgroup v2
cat "$CGROUP/cpu.weight"   # derivado de requests.cpu
cat "$CGROUP/cpu.max"      # quota/period del limit.cpu; "max 100000" = sin límite
cat "$CGROUP/memory.max"   # limit.memory; "max" = sin límite
# 5) Si activaste Memory QoS, también estos aparecen:
cat "$CGROUP/memory.high" "$CGROUP/memory.min" "$CGROUP/memory.low"
# 6) Vista desde systemd (misma unidad, atributos del scope)
SCOPE=$(basename "$CGROUP")
systemctl show "$SCOPE" 
  -p CPUWeight -p CPUQuotaPerSecUSec -p CPUQuotaPeriodUSec -p MemoryMax

Dato clave para evitar confusiones: aunque corras en cgroup v2, los nombres en la API protobuf del CRI siguen siendo estilo v1 (cpu_shares, cpu_quota, cpu_period, memory_limit_in_bytes). crictl inspect ya no los expone así: muestra el OCI spec que el runtime armó a partir de ellos. Si quieres verlos, pide .info.runtimeSpec.linux.resources.

El cambio sutil: shares ≠ weight, y la fórmula nueva

En cgroup v1 el parámetro era cpu.shares; en cgroup v2 es cpu.weight, con un rango de 1 a 10000. La conversión clásica (v1 shares / 1024) le quitaba prioridad al workload Kubernetes frente a procesos del sistema. Los runtimes OCI modernos usan una fórmula no lineal que preserva la prioridad por defecto y da más granularidad a requests chicos. El cambio vive en el runtime, no en Kubernetes:

Otras mejoras que aterrizan en cgroup v2

Container-aware OOM handling

En cgroup v2 el kubelet pone singleProcessOOMKill en false por defecto y escribe memory.oom.group=1 en el cgroup del contenedor. Resultado: cuando el OOM killer del kernel dispara dentro del cgroup, mata todos los procesos del contenedor (no deja un multi-process container medio funcional). configura singleProcessOOMKill: true solo si necesitas el comportamiento legacy de cgroup v1.

Rootless containers

cgroup v2 soporta delegación oficial a usuarios sin root; las implementaciones rootless suelen apoyarse en systemd para entregar los controllers. Esto es ortogonal al Pod user namespaces, que graduó estable en v1.36 y mapea UIDs del contenedor a UIDs no privilegiados del host. revisa los prerrequisitos de filesystem, kernel, CRI y OCI runtime antes de prenderlo.

eBPF y Cilium

El controlador de devices de cgroup v2 no expone interface files: se implementa con cgroup BPF. Cilium se apoya en esto para el kubeproxy-free load balancing; su default cgroup root es /run/cilium/cgroupv2. Para auditar los BPF cgroup programs:

bpftool cgroup list /sys/fs/cgroup/*
bpftool cgroup tree /sys/fs/cgroup/ 2>/dev/null | head -40

In-place resize de Pod-level resources

El vertical scaling in-place de containers graduó estable en v1.35; en v1.36 se habilita por defecto como Beta para Pod-level resources. El kubelet coordina los cambios entre los cgroups de container y el slice del Pod: cuando subís un limit, crea headroom antes de tocar el container; cuando lo bajas, primero achica el container y después el slice. Para que el enforcement agregado sea correcto, necesitas cgroup v2.

El bug histórico de active_file

El kubelet trata la memoria active_file como no reclamable; en cargas I/O-bound eso dispara evictión innecesaria (issue #43916). Migrar a cgroup v2 no lo corrige por sí solo; el workaround documentado es igualar requests y limits después de medir el working set real del contenedor.

Plan de migración en cinco pasos

  1. Auditoría: para cada nodo, confirmá stat -fc %T /sys/fs/cgroup/ devuelva cgroup2fs. Si no, actualiza la distro o cambiá el kernel cmdline a systemd.unified_cgroup_hierarchy=1 y reinicia.
  2. Versiones: kernel ≥ 5.8 (≥ 5.9 si vas a usar Memory QoS), containerd ≥ v1.4 o CRI-O ≥ v1.20, kubelet ≥ v1.25 (ideal ≥ v1.34 para autodetección estable).
  3. configura el cgroup driver: fija cgroupDriver: systemd en el kubelet o deja que containerd v2.0+ / CRI-O v1.28+ lo reporten vía RuntimeConfig.
  4. prueba en staging: tras el upgrade, verifica que los pods arranquen, que el eviction por presión funcione, y que el Memory QoS (si lo activas) no provoque throttling excesivo. mira kubectl describe node y los PSI panels.
  5. habilita features gradualmente: PSI GA y Pod-level in-place resize vienen prendidos por defecto en v1.36. Para Memory QoS, empieza en memoryReservationPolicy: None, validá, y después cambiá a TieredReservation cuando tengas claro el modelo de protección que quieres.

Cuándo NO migrar todavía (o cómo hacerlo con cuidado)

  • Si tienes un nodo con kernels viejos o distribuciones LTS que no traen cgroup v2 por defecto, primero actualiza kernel/distro; migrar runtime sin tocar sistema operativo va a fallar en el primer reinicio del nodo.
  • Software de monitoring o políticas de admisión que asumen el nombre de archivo v1 (cpu.shares, memory.limit_in_bytes) deben actualizarse a v2 (cpu.weight, memory.max) antes de cortar el tráfico. El doc oficial lista cAdvisor ≥ v0.43.0 y versiones compatibles de Java (JVM options), Node.js (--cpu-shares legacy) y automaxprocs.
  • Si tu cluster aún corre Kubernetes v1.34 o inferior, puedes mantener el override temporal failCgroupV1: false, pero planea la migración antes de subir a v1.35.

Resumen

El cambio a cgroup v2 ya es la base sobre la que Kubernetes construye la nueva generación de features de recursos: Memory QoS con tiered reservation, PSI metrics GA, in-place resize de Pod-level resources, autodetección del cgroup driver. Si administras un clúster con Linux moderno, tienes la oportunidad única de planificar la migración mientras el fallback a cgroup v1 todavía existe; cuando llegue v1.38 ya será obligatorio. Empezá por auditar nodos, actualizar versiones, configurar el driver systemd y después activa las features nuevas una por una midiéndolas con PSI.

Preguntas frecuentes

¿Qué versión de kernel es estrictamente necesaria?

Kubernetes documenta 5.8 como mínimo para cgroup v2 (allí se añadió cpu.stat a nivel root). Si activas Memory QoS con throttling, el livelock conocido en memory.high está corregido desde 5.9, por eso el proyecto recomienda 5.9+ en ese escenario.

¿Qué pasa si todavía tengo un nodo en cgroup v1?

Desde kubelet v1.35 el binario rechaza arrancar en cgroup v1 bajo configuración por defecto (failCgroupV1: true). puedes posponerlo temporalmente con failCgroupV1: false en KubeletConfiguration, pero conviene migrar antes de la próxima minor version. El fallback completo se elimina en v1.38 (KEP-5573).

¿Necesito cambiar el cgroup driver cuando migro?

Si tu runtime no implementa autodetección (KEP-4033, estable desde v1.34), sí: fija cgroupDriver: systemd en el kubelet, especialmente con kubeadm, para mantener consistencia con el slice del runtime y evitar crashes en exec.

¿Memory QoS funciona en cgroup v1?

No. La separación entre throttling (memory.high) y reservation (memory.min/memory.low) es exclusiva de cgroup v2. Por eso esta feature permanece Alpha en v1.36 y solo se ofrece en nodos cgroup v2.

¿Por qué PSI solo aparece en cgroup v2?

PSI mide tiempo que las tasks pasan esperando CPU, memoria o I/O. Lo calcula el kernel leyendo contadores que viven en los cgroups; el controlador unificado de cgroup v2 los expone de forma consistente, mientras que cgroup v1 los reparte entre jerarquías separadas.

Lee también


Alfonso Moreno | Sysadmin Senior

Soy Alfonso, sysadmin senior con RHCE y más de 10 años administrando sistemas Linux para empresas y proyectos personales. Me especializo en infraestructura, redes, virtualización, monitoring, automation y self-hosting. Sobre SoyLinux.com SoyLinux.com es un blog técnico en español sobre Linux, software libre y self-hosting. Lo mantengo en mi tiempo libre. Áreas de expertise - Linux server administration: RHEL, CentOS, Rocky, Alma, Fedora, Debian, Ubuntu - Network services: Postfix, BIND, Samba, NFS, Squid - Virtualización y containers: KVM, Proxmox, Docker, Podman - Monitoring: Prometheus, Grafana, Zabbix - Automation: Ansible, Bash scripting - Self-hosting: Vaultwarden, Nextcloud, Jellyfin, Immich, Homelab Contacto - Email: [email protected]

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *