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
--pidcrea el PID namespace.--forkhace quebashse ejecute como hijo deunshare, que es necesario para que sea el PID 1 del nuevo namespace.--mount-procmonta un/procnuevo, porquepslee de/procy 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.
NotaUbuntu 24.04 restringe mediante AppArmor lo que pueden hacer los procesos sin privilegios dentro de un user namespace (
kernel.apparmor_restrict_unprivileged_userns = 1). Crear el namespace funciona, pero operaciones como montar sistemas de archivos se deniegan salvo que el programa tenga un perfil de AppArmor que lo permita. No desactives esta protección solo para experimentar.
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,CPUQuotayTasksMaxa 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.
