Docker, Podman, LXC y Kubernetes no inventan el aislamiento: lo construyen sobre dos mecanismos del kernel. Los namespaces controlan qué ve un proceso (sus procesos, su red, su nombre de host, sus puntos de montaje) y los cgroups controlan cuánto puede consumir (CPU, memoria, procesos, disco). En este tutorial usarás ambos directamente desde la línea de comandos en Ubuntu 24.04 y terminarás montando a mano un "contenedor" mínimo con recursos limitados.

Requisitos previos

Para seguir esta guía necesitas:

  • Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath. Debian 12 y Rocky Linux 9 también usan cgroups v2 por defecto y los comandos son los mismos.
  • Un usuario no root con privilegios sudo.
  • Dos sesiones SSH abiertas, porque varios pasos comparan lo que se ve dentro y fuera de un namespace.

Paso 1: Instalar las herramientas y revisar el sistema

unshare, nsenter y lsns forman parte de util-linux e ip de iproute2, y ambos vienen instalados. Añade stress-ng para generar carga en las pruebas de límites:

sudo apt update
sudo apt install stress-ng

Cada proceso tiene un enlace por namespace en /proc/<pid>/ns/. Míralo para tu propia shell:

ls -l /proc/$$/ns
lrwxrwxrwx 1 your_user your_user 0 ... cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 your_user your_user 0 ... ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 your_user your_user 0 ... mnt -> 'mnt:[4026531841]'
lrwxrwxrwx 1 your_user your_user 0 ... net -> 'net:[4026531840]'
lrwxrwxrwx 1 your_user your_user 0 ... pid -> 'pid:[4026531836]'
...
lrwxrwxrwx 1 your_user your_user 0 ... user -> 'user:[4026531837]'
lrwxrwxrwx 1 your_user your_user 0 ... uts -> 'uts:[4026531838]'

Dos procesos están en el mismo namespace si el número entre corchetes coincide. lsns muestra todos los namespaces del sistema:

sudo lsns --type net

Comprueba también que el sistema usa cgroups v2 (la jerarquía unificada):

stat -fc %T /sys/fs/cgroup
cgroup2fs

Si la salida fuera tmpfs, el sistema estaría usando cgroups v1, algo que solo ocurre en distribuciones antiguas o configuradas a propósito. Esta guía asume cgroups v2.

Paso 2: Aislar el árbol de procesos con un PID namespace

Un PID namespace da a sus procesos una numeración propia: el primero es el PID 1 y no ven nada de fuera. Crea uno con una shell dentro:

sudo unshare --pid --fork --mount-proc bash
  • --pid crea el PID namespace.
  • --fork hace que bash se ejecute como hijo de unshare, que es necesario para que sea el PID 1 del nuevo namespace.
  • --mount-proc monta un /proc nuevo, porque ps lee de /proc y con el antiguo seguiría viendo todos los procesos del host.

Dentro, lista los procesos:

ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.1   9168  5376 pts/0    S    10:12   0:00 bash
root           8  0.0  0.0  10884  4352 pts/0    R+   10:12   0:00 ps aux

Desde la segunda sesión SSH, el mismo bash es un proceso normal con un PID alto:

ps -ef | grep 'unshare --pid'

El aislamiento solo funciona en un sentido: el host ve los procesos del namespace, pero no al revés. Sal con exit.

Paso 3: Cambiar el nombre de host con un UTS namespace

El UTS namespace aísla el nombre de host. Así cada contenedor puede tener el suyo sin afectar al servidor:

sudo unshare --uts bash
hostname contenedor-demo
hostname
contenedor-demo

En la otra sesión, hostname sigue mostrando el nombre original del servidor. Sal con exit.

Paso 4: Crear una pila de red aislada con un network namespace

Un network namespace tiene sus propias interfaces, direcciones, rutas y reglas de firewall. ip netns los crea con nombre para poder reutilizarlos:

sudo ip netns add demo
sudo ip netns exec demo ip -br link
lo               DOWN           00:00:00:00:00:00 <LOOPBACK>

El namespace nace solo con la interfaz lo, apagada. Para comunicarlo con el host se usa un par veth, un cable virtual con dos extremos: uno se queda en el host y el otro se mueve al namespace:

sudo ip link add veth-host type veth peer name veth-demo
sudo ip link set veth-demo netns demo

Asigna direcciones de una red privada que no uses (aquí 10.200.0.0/24) y levanta las interfaces:

sudo ip addr add 10.200.0.1/24 dev veth-host
sudo ip link set veth-host up
sudo ip netns exec demo ip addr add 10.200.0.2/24 dev veth-demo
sudo ip netns exec demo ip link set veth-demo up
sudo ip netns exec demo ip link set lo up

Comprueba la conectividad desde el namespace hacia el host:

sudo ip netns exec demo ping -c 2 10.200.0.1
64 bytes from 10.200.0.1: icmp_seq=1 ttl=64 time=0.051 ms
64 bytes from 10.200.0.1: icmp_seq=2 ttl=64 time=0.043 ms

El namespace no tiene ruta por defecto, así que no sale a Internet. Esto es lo que hace Docker con su bridge docker0, más NAT. La guía de redes avanzadas con iproute2 muestra cómo añadir un bridge y NAT. Por ahora, elimina el namespace (el par veth se borra con él):

sudo ip netns delete demo

Paso 5: Ser root sin serlo con un user namespace

El user namespace mapea identificadores de usuario de dentro a otros de fuera. Es lo que permite los contenedores sin privilegios (rootless): un proceso ve UID 0 dentro, pero para el kernel sigue siendo tu usuario normal. Ejecútalo sin sudo:

unshare --user --map-root-user bash
id
uid=0(root) gid=0(root) groups=0(root),65534(nogroup)

Consulta el mapeo aplicado. Las columnas son: primer UID dentro, primer UID fuera y cuántos UID se mapean:

cat /proc/$$/uid_map
         0       1000          1

Desde la otra sesión, ps -o user,pid,cmd -C bash muestra ese proceso como tu usuario, no como root. Sal con exit.

Paso 6: Limitar recursos con cgroups v2 a mano

En cgroups v2 todo es un sistema de archivos bajo /sys/fs/cgroup. Cada directorio es un cgroup y los archivos de dentro son sus límites y estadísticas. Mira qué controladores están disponibles para los cgroups hijos de la raíz:

cat /sys/fs/cgroup/cgroup.subtree_control
cpuset cpu io memory hugetlb pids rdma misc

Crea un cgroup de prueba. Al crear el directorio, el kernel rellena automáticamente sus archivos:

sudo mkdir /sys/fs/cgroup/demo
ls /sys/fs/cgroup/demo | grep -E '^(cpu|memory|pids)\.max$'
cpu.max
memory.max
pids.max

Fija tres límites: la mitad de una CPU (50 000 µs de cada periodo de 100 000 µs), 200 MB de memoria sin swap y un máximo de 50 procesos:

echo "50000 100000" | sudo tee /sys/fs/cgroup/demo/cpu.max
echo 200M | sudo tee /sys/fs/cgroup/demo/memory.max
echo 0 | sudo tee /sys/fs/cgroup/demo/memory.swap.max
echo 50 | sudo tee /sys/fs/cgroup/demo/pids.max

Un proceso entra en un cgroup escribiendo su PID en cgroup.procs, y sus hijos heredan el cgroup. Lanza una carga de CPU dentro del cgroup:

sudo sh -c 'echo $$ > /sys/fs/cgroup/demo/cgroup.procs && exec stress-ng --cpu 1 --timeout 30s'

Mientras se ejecuta, en la otra sesión observa el consumo con top: el proceso stress-ng-cpu no pasa del 50 % aunque tenga una CPU entera libre. El archivo cpu.stat confirma que el kernel lo ha estado frenando:

grep -E 'nr_throttled|throttled_usec' /sys/fs/cgroup/demo/cpu.stat
nr_throttled 297
throttled_usec 14823410

Ahora prueba el límite de memoria intentando reservar 400 MB:

sudo sh -c 'echo $$ > /sys/fs/cgroup/demo/cgroup.procs && exec stress-ng --vm 1 --vm-bytes 400M --timeout 15s'

El OOM killer del kernel mata el proceso al superar los 200 MB. Compruébalo en memory.events:

cat /sys/fs/cgroup/demo/memory.events
low 0
high 0
max 1523
oom 12
oom_kill 12
oom_group_kill 0

Cuando el cgroup queda vacío, puedes borrarlo con rmdir (no con rm -r, porque sus archivos no se pueden eliminar):

sudo rmdir /sys/fs/cgroup/demo

Paso 7: Usar cgroups a través de systemd

En Ubuntu, systemd es el dueño del árbol de cgroups: cada servicio vive en su propio cgroup dentro de system.slice. Crear cgroups a mano sirve para aprender, pero en un servidor real conviene pedírselos a systemd, que además los limpia al terminar. Visualiza el árbol:

systemd-cgls --no-pager | head -n 20

systemd-run ejecuta un comando en un cgroup temporal con los límites que le indiques. El mismo experimento del paso anterior queda así:

sudo systemd-run --scope -p CPUQuota=50% -p MemoryMax=200M -p MemorySwapMax=0 -p TasksMax=50 stress-ng --cpu 1 --timeout 20s
Running scope as unit: run-r3f1c2a9d8e7b4c6a.scope

Mientras se ejecuta, systemd-cgtop muestra el consumo de CPU y memoria por cgroup en tiempo real:

sudo systemd-cgtop

Para aplicar límites permanentes a un servicio existente usa systemctl set-property, que guarda la configuración en un archivo drop-in. Por ejemplo, para limitar cron a 256 MB:

sudo systemctl set-property cron.service MemoryMax=256M
systemctl show cron.service -p MemoryMax
MemoryMax=268435456

Para quitar el límite, ejecuta sudo systemctl set-property cron.service MemoryMax=infinity.

Paso 8: Construir un contenedor mínimo

Ahora combina todo: una shell con su propio árbol de procesos, nombre de host, red e IPC, dentro de un cgroup con memoria y CPU limitadas:

sudo systemd-run --scope -p MemoryMax=256M -p CPUQuota=100% \
  unshare --pid --fork --mount-proc --uts --net --ipc bash

Dentro, comprueba el aislamiento:

hostname mini-contenedor
ps aux
ip -br link
cat /proc/self/cgroup
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.1   9168  5504 pts/0    S    10:40   0:00 bash
root           9  0.0  0.0  10884  4480 pts/0    R+   10:40   0:00 ps aux
lo               DOWN           00:00:00:00:00:00 <LOOPBACK>
0::/system.slice/run-r8b2e5d0c1a9f4e37.scope

La shell solo se ve a sí misma, no tiene red y su cgroup es el scope creado por systemd. Lo que le falta para ser un contenedor como los de Docker es un sistema de archivos raíz propio (con pivot_root y un mount namespace), un user namespace para no ejecutarse como root real y filtros como seccomp y capabilities. Sal con exit.

Si tienes Docker instalado, puedes comprobar que usa exactamente estos mecanismos:

docker run -d --name limite --memory 256m --cpus 0.5 nginx:alpine
cat /sys/fs/cgroup/system.slice/docker-$(docker inspect -f '{{.Id}}' limite).scope/memory.max
docker rm -f limite
268435456

Solución de problemas

unshare: unshare failed: Operation not permitted: la mayoría de tipos de namespace requieren privilegios. Usa sudo, salvo para --user, que debe ejecutarse sin él.

tee: /sys/fs/cgroup/demo/cpu.max: No such file or directory: el controlador cpu no está habilitado para los hijos del cgroup padre. Comprueba cgroup.subtree_control del padre y actívalo con echo "+cpu" | sudo tee /sys/fs/cgroup/cgroup.subtree_control.

rmdir: failed to remove '/sys/fs/cgroup/demo': Device or resource busy: aún queda algún proceso dentro. Compruébalo con cat /sys/fs/cgroup/demo/cgroup.procs y espera a que terminen o muévelos a otro cgroup.

El proceso no respeta el límite de memoria y el servidor usa swap: falta memory.swap.max a 0 (o MemorySwapMax=0 en systemd). Sin él, el cgroup puede seguir creciendo en swap.

Conclusión

Has usado PID, UTS, network y user namespaces para aislar lo que ve un proceso, y cgroups v2 (directamente y mediante systemd) para limitar cuánto consume. Con esto entiendes qué hacen por debajo opciones como docker run --memory o --network none. Como siguientes pasos puedes:

  • Profundizar en la red de los namespaces con bridges, NAT y control de tráfico usando iproute2.
  • Aplicar MemoryMax, CPUQuota y TasksMax a tus propios servicios de systemd para que un fallo no tumbe el servidor.
  • Probar Podman en modo rootless para ver los user namespaces en un entorno de contenedores completo.