Los containers no son una abstracción nueva del kernel ni un tipo especial de proceso. Son procesos normales que el kernel trata de forma diferente gracias a un conjunto de características que llevan años en Linux. Docker, Podman, LXC: todos hacen exactamente lo mismo por debajo — configurar namespaces, cgroups, capabilities y seccomp antes de hacer execve() al proceso principal. La “magia” del container es esa configuración inicial.
La diferencia fundamental con una VM: una VM emula hardware y arranca su propio kernel. Un container comparte el kernel del host. Eso explica por qué un container arranca en milisegundos y por qué, si el kernel del host tiene un bug explotable, todos los containers del sistema están en riesgo. Mayor densidad, menor aislamiento. No es un defecto de diseño, es el trade-off consciente.
Cuando algo va mal dentro de un container — un proceso que consume toda la CPU, un binding de red que no funciona, un UID que no coincide — el diagnóstico siempre pasa por entender qué namespace o qué cgroup está involucrado. Hacer eso sin conocer los mecanismos subyacentes es adivinar.
Si configuras mal los límites de cgroup, un container puede consumir todos los recursos del host y tumbar otros servicios. Si no filtras syscalls con seccomp, un proceso dentro del container puede explotar superficies de ataque del kernel que no necesita. Si usas --privileged sin pensarlo, has anulado prácticamente todo el aislamiento.
Los namespaces: lo que el proceso puede ver
Un namespace es una vista del sistema. Cuando un proceso está en un namespace determinado, solo puede ver los recursos que pertenecen a ese namespace. El kernel mantiene múltiples instancias de ciertos recursos globales, y cada namespace tiene la suya.
Los seis namespaces relevantes para containers:
- PID namespace: el proceso dentro del container tiene su propio árbol de PIDs. El proceso principal del container es PID 1 dentro del namespace, aunque el host lo vea con un PID distinto.
- network namespace: interfaz de red propia, tabla de rutas propia, puertos propios. Por eso dos containers pueden escuchar en el puerto 80 simultáneamente.
- mount namespace: vista del filesystem propia. Los montajes dentro del namespace no son visibles fuera.
- UTS namespace: hostname y domainname propios (
uname -ndevuelve el nombre del container, no el del host). - IPC namespace: colas de mensajes, semáforos y memoria compartida POSIX aislados.
- user namespace: mapeo de UIDs y GIDs. El UID 0 dentro del namespace puede mapearse al UID 1000 del host. Esto permite “rootless containers”.
Los cgroups: lo que el proceso puede consumir
Los cgroups (control groups) son el mecanismo del kernel para contabilizar y limitar recursos. Están organizados en una jerarquía: cada proceso pertenece a un cgroup, y ese cgroup puede tener límites de CPU, memoria, I/O de disco y red.
Debian Bookworm usa cgroup v2 por defecto (jerarquía unificada en /sys/fs/cgroup). La diferencia con v1 es que en v2 hay un único árbol en lugar de múltiples jerarquías por subsistema. systemd gestiona los cgroups del sistema; los containers crean sub-cgroups dentro de ese árbol.
Capabilities y seccomp: reducir la superficie de ataque
El modelo tradicional de Unix era binario: root o no-root. Las capabilities dividen los privilegios de root en unidades más pequeñas. Un container puede necesitar CAP_NET_BIND_SERVICE para escuchar en puertos bajos sin tener CAP_SYS_ADMIN. Por defecto, Podman y Docker eliminan la mayoría de capabilities del conjunto permitido.
seccomp (secure computing) va un paso más allá: filtra qué syscalls puede hacer el proceso. Si tu aplicación nunca necesita mount(2) o ptrace(2), un perfil seccomp puede prohibirlos. Si alguien explota un bug en tu aplicación e intenta usar esas syscalls, el kernel las bloquea antes de que lleguen al código vulnerable.
Ejemplo: inspeccionar y limitar un container con systemd-nspawn
systemd-nspawn está incluido en systemd-container y es el container manager más directo para entender qué ocurre realmente. No necesita daemon, no necesita configuración de red compleja, y expone los mecanismos del kernel sin capas de abstracción adicionales.
# Instalar las herramientas necesarias
apt install systemd-container debootstrap
# Crear un rootfs mínimo de Debian Bookworm en /var/lib/machines/demo
debootstrap --variant=minbase bookworm /var/lib/machines/demo
# Arrancar el container con límites de recursos explícitos.
# --private-network: network namespace propio (sin acceso a la red del host)
# -M demo: nombre del container (UTS namespace recibirá este hostname)
# systemd-run lo mete en un cgroup transitorio con límites de CPU y memoria
systemd-run \
--property=CPUQuota=50% \
--property=MemoryMax=256M \
--pty --same-dir --wait --collect \
systemd-nspawn \
--directory=/var/lib/machines/demo \
--private-network \
-M demo \
/bin/bash
# --- Desde dentro del container ---
# El proceso bash es PID 1 en su propio namespace
cat /proc/1/status | grep -E '^(Name|Pid|NSpid)'
# NSpid muestra el PID dentro del namespace y el PID en el namespace padre
# Verificar que el hostname es el nombre del container, no el del host
hostname
# Debería devolver: demo
# Ver que no hay interfaces de red salvo loopback
ip link show
# Solo verá: lo (loopback) — la interfaz del host no es visible
# Intentar ver procesos: solo verá los del propio container
ls /proc/ | grep -E '^[0-9]+$' | sort -n
# El número más bajo será 1 (el bash inicial)
# --- Desde el host, en otra terminal ---
# Listar los namespaces del proceso principal del container
# Primero encontrar el PID del bash en el host
CONTAINER_PID=$(machinectl show demo --property=Leader --value)
echo "PID del container en el host: $CONTAINER_PID"
# Ver los namespaces enlazados a ese proceso
ls -la /proc/${CONTAINER_PID}/ns/
# Cada archivo apunta a un namespace distinto al del proceso init del host
# Comparar el network namespace del container con el del proceso 1 del host
# Si los inodes son distintos, están en namespaces separados
ls -lai /proc/1/ns/net /proc/${CONTAINER_PID}/ns/net
# Ver el cgroup del container: está bajo el cgroup transitorio de systemd-run
cat /proc/${CONTAINER_PID}/cgroup
# La ruta incluirá el nombre de la unit transitoria (run-*.scope)
# Inspeccionar los límites efectivos del cgroup v2
CGROUP_PATH=$(cat /proc/${CONTAINER_PID}/cgroup | cut -d: -f3)
cat /sys/fs/cgroup${CGROUP_PATH}/memory.max
# Debería mostrar 268435456 (256 MiB en bytes)
cat /sys/fs/cgroup${CGROUP_PATH}/cpu.max
# Formato: cuota_us periodo_us
# 50000 100000 significa 50% de un core (50ms de cada 100ms)
# Terminar el container desde el host si es necesario
machinectl terminate demo
Qué está pasando realmente
Cuando systemd-nspawn arranca, llama a clone(2) con las flags de namespace correspondientes (CLONE_NEWPID, CLONE_NEWNET, CLONE_NEWUTS, CLONE_NEWNS, CLONE_NEWIPC). Eso crea un proceso hijo que vive en namespaces recién creados. El flag --private-network activa CLONE_NEWNET y no conecta la interfaz virtual al host — el container solo tiene loopback.
El comando ls -lai /proc/1/ns/net /proc/${CONTAINER_PID}/ns/net es la prueba concreta de que el network namespace es distinto: cada namespace tiene un inode único en el pseudosistema de ficheros. Si los inodes coinciden, el proceso comparte namespace con init — algo que nunca quieres en producción sin motivo explícito.
La línea cat /proc/${CONTAINER_PID}/cgroup muestra la posición del container en el árbol de cgroups v2. Todo proceso del sistema tiene una entrada ahí. El truco es que systemd-run creó una unit transitoria (un .scope) antes de lanzar systemd-nspawn, así que los límites que pusimos con --property=MemoryMax=256M se aplicaron a ese scope, no directamente a nspawn. Cuando lees /sys/fs/cgroup${CGROUP_PATH}/memory.max, estás leyendo el límite que el kernel va a hacer cumplir — si el container intenta superar 256 MiB, el OOM killer del kernel actúa dentro de ese cgroup.
El valor de cpu.max en formato 50000 100000 es el CFS bandwidth control: en cada período de 100 ms, el scheduler solo dará 50 ms de CPU a todos los procesos de ese cgroup. No importa cuántos cores tenga el host — si metes un bucle infinito dentro del container, consumirá como máximo el 50% de un core.
machinectl en el bloque del host es la interfaz de systemd para gestionar containers arrancados con nspawn. Leader devuelve el PID del proceso principal del container en el espacio de PID del host — el puente entre la vista del container y la vista del host.
Docker y Podman hacen exactamente lo mismo que hemos visto aquí: llaman a clone(2) con las flags correctas, crean un cgroup para el container, aplican un perfil seccomp predeterminado (que filtra ~40-50 syscalls consideradas peligrosas), y reducen el conjunto de capabilities. La diferencia es que Podman puede hacer todo esto sin daemon y como usuario sin privilegios gracias a los user namespaces — el UID 0 dentro del container es tu UID en el host.
[Ubuntu]: En Ubuntu 22.04+ el límite de cgroups de usuario no privilegiado para systemd-nspawn rootless puede requerir ajustar kernel.unprivileged_userns_clone; en Debian Bookworm ese sysctl no existe porque el soporte está habilitado por defecto en el kernel oficial.
Lo que nsenter(1) hace — entrar en el namespace de un proceso existente — es el mismo mecanismo que usa docker exec o podman exec: setns(2) para unirse a los namespaces del container y luego execve() del comando. No hay nada especial en los comandos de los runtimes de alto nivel; son wrappers ergonómicos sobre syscalls que puedes llamar directamente.
N° 103