runc: qué es y cómo funciona el runtime detrás de Docker
Cuando corrés docker run -it ubuntu bash, el kernel de Linux no sabe qué es un contenedor: no existe tal cosa. Lo que arma ese comando es un runtime de contenedores llamado runc, y en pleno 2026 todavía tiene que hacer trampa, porque el lenguaje en que está escrito, Go, ni siquiera tiene una función fork() segura.
Esa trampa, reejecutarse a sí mismo desde /proc/self/exe, es solo uno de los trucos que sostienen todo el ecosistema de contenedores: Docker, Kubernetes, containerd. Ninguno usa magia de aislamiento nueva. Todos reutilizan piezas del kernel de Linux que existen desde hace más de una década.
TL;DR
- Un contenedor es un proceso Linux normal con namespaces, cgroups y pivot_root aplicados, nada más.- runc, el runtime que ejecuta cada docker run, está en Go: un lenguaje sin fork() nativo y runtime multihilo.- El truco para imitar fork() es reejecutar /proc/self/exe antes de que el runtime de Go lance sus hilos.- Una imagen Docker es solo una carpeta: pivot_root la convierte en la raíz del proceso sin copiar nada.- dockerd corre como root porque namespaces y cgroups exigen privilegios; rootless rehace la red en user space.
¿Qué es un runtime de contenedores?
Un runtime de contenedores es el programa de bajo nivel que traduce una imagen y un conjunto de restricciones en un proceso Linux aislado. Aplica namespaces, cgroups y pivot_root, y después ejecuta el proceso final con execve. runc es la implementación de referencia que usan Docker, containerd y Kubernetes.
Linux expone ocho tipos de namespace: PID, network, mount, UTS, IPC, user, cgroup y time.
Namespaces, cgroups y pivot_root: las piezas reales detrás de "contenedor"
No hay una línea en el kernel de Linux que diga "esto es un contenedor". En cambio, un runtime combina mecanismos independientes que ya existían antes de que Docker existiera. Los namespaces esconden qué puede ver el proceso: su propio árbol de PIDs, su propia interfaz de red, su propio hostname. Los cgroups limitan cuánta CPU, memoria y E/S puede consumir ese proceso, y pivot_root cambia cuál carpeta del disco actúa como raíz del sistema de archivos para ese proceso en particular.
Docker activa casi todos los namespaces por defecto cuando corrés un contenedor normal, pero podés desactivarlos selectivamente con flags como --pid=host o --network=host. Cada namespace es una vista distinta del mismo kernel: dos procesos en namespaces de red distintos pueden tener ambos una interfaz llamada eth0 sin pisarse, porque cada uno vive en su propia tabla de rutas.
Los cgroups, por su parte, se organizan en controladores: cpu, memory, io y pids. El controlador cpu reparte tiempo de procesador con pesos relativos; memory pone un techo duro de RAM y puede matar el proceso con el OOM killer si lo supera; io limita el ancho de banda de disco, y pids pone un tope a cuántos procesos puede crear ese grupo antes de bloquear nuevos fork(). Docker expone estos límites con flags como --memory, --cpus y --pids-limit, pero por dentro son solo escrituras a archivos dentro de /sys/fs/cgroup/.
flowchart TD
A["docker run ubuntu bash"] --> B["Namespaces aislan PID, red y hostname"]
A --> C["Cgroups limitan CPU y memoria"]
A --> D["pivot_root cambia la raíz del proceso"]
subgraph "Kernel de Linux"
B
C
D
end
Cómo runc arma el proceso: de docker run a execve
Una imagen de Docker no es más que una carpeta. Cuando bajás ubuntu desde Docker Hub, lo que estás descargando son capas comprimidas que, una vez descomprimidas, forman un árbol de directorios idéntico al de una instalación mínima de Ubuntu: /bin, /etc, /lib, /usr. No hay nada ejecutándose ahí todavía.
El flujo completo de docker run -it --rm ubuntu /bin/bash pasa por cuatro programas antes de llegar al kernel. El cliente docker habla con el daemon dockerd. dockerd delega en containerd, que gestiona el ciclo de vida del contenedor. containerd invoca a runc con la ruta de la imagen y la especificación OCI del contenedor, y runc es quien finalmente hace las llamadas al sistema: clona el proceso con namespaces y cgroups, llama a pivot_root para que esa carpeta descomprimida se convierta en la raíz del proceso, y por último ejecuta /bin/bash con execve.
La diferencia entre pivot_root y el chroot clásico de toda la vida es que pivot_root mueve la raíz vieja a un punto de montaje aparte y permite desmontarla después, mientras que chroot deja la raíz anterior accesible si el proceso logra escapar con un descriptor de archivo abierto desde antes. Por eso runc usa pivot_root cuando el kernel lo permite, y cae a chroot solo como alternativa en sistemas donde pivot_root no está disponible.
sequenceDiagram
participant U as Usuario
participant D as dockerd
participant C as containerd
participant R as runc
participant K as Kernel
U->>D: docker run ubuntu bash
D->>C: crear contenedor
C->>R: create y start
R->>K: clone con namespaces y cgroups
R->>K: pivot_root a la imagen
R-->>K: execve del proceso final
Note over R,K: runc termina, el proceso final queda solo
El problema de Go: por qué runc necesita fingir un fork()
runc está escrito en Go, y Go tiene un problema de diseño para este trabajo puntual: su runtime arranca varios hilos del sistema operativo apenas el programa inicia, para manejar el recolector de basura y el scheduler de goroutines. La llamada clásica fork() de Unix solo duplica el hilo que la invoca, no los demás. Si runc llamara fork() directamente después de que el runtime de Go ya levantó esos hilos, el proceso hijo quedaría con un runtime de Go a medio inicializar y corrupto.
La solución que adoptó el equipo de runc fue no usar fork() en absoluto. En su lugar, el binario se reejecuta a sí mismo leyendo su propio ejecutable desde /proc/self/exe, con un argumento especial que le indica al nuevo proceso que actúe como el "init" del contenedor en lugar del comando normal. Ese proceso hijo, recién arrancado desde cero con su propio runtime de Go limpio, es el que hace las llamadas de namespaces, cgroups y pivot_root antes de ejecutar el comando real con execve.
Hay otro detalle que complica más las cosas. Algunas de las llamadas al sistema que necesita un runtime de contenedores, como setns() para unirse a un namespace existente, exigen ejecutarse en un solo hilo del sistema operativo, sin que el scheduler de Go mueva la goroutina a otro hilo a mitad de camino. Por eso runc incluye un pequeño módulo en C que corre antes de que el runtime de Go termine de inicializarse, en una ventana donde todavía hay un solo hilo activo.
flowchart TD
A["runc arranca en Go"] --> B["El runtime de Go ya levantó varios hilos"]
B --> C["fork() clásico dejaría el hijo con un runtime corrupto"]
C --> D["runc re-ejecuta /proc/self/exe"]
D --> E["El proceso hijo arranca con un runtime de Go limpio"]
E --> F["Configura namespaces, cgroups y pivot_root"]
F --> G["execve() reemplaza el proceso por el comando real"]
Por qué Docker corre como root
Si usás Docker Desktop en macOS o Windows nunca lo notás, porque el daemon corre dentro de una máquina virtual Linux que Docker Desktop administra por vos. Pero en un servidor Linux real, dockerd corre como root, y durante años esa fue la única opción: crear namespaces, especialmente el de red, y manipular cgroups eran operaciones que el kernel reservaba a procesos con privilegios de superusuario.
Eso cambió parcialmente con los user namespaces y con el modo rootless de Docker, que permite correr el daemon y los contenedores sin privilegios de root reales. El problema aparece en la red: sin acceso de root a las interfaces del host, el modo rootless tiene que reimplementar buena parte del stack de redes en espacio de usuario, con herramientas como slirp4netns o pasta, para lograr algo parecido al bridge de red que el modo normal arma directamente en el kernel.
Incluso cuando el proceso dentro del contenedor corre como UID 0, Docker le recorta las capabilities de Linux por defecto: elimina permisos como CAP_SYS_ADMIN o CAP_NET_ADMIN que en el host le permitirían, por ejemplo, montar sistemas de archivos arbitrarios o reconfigurar interfaces de red ajenas. Podés revisar qué capabilities tiene activas un contenedor corriendo cat /proc/1/status | grep Cap desde adentro, y decodificar el valor hexadecimal con capsh --decode=<valor>.
> 📌 Nota: correr como root dentro del contenedor no es lo mismo que el daemon corriendo como root en el host: son dos aislamientos distintos, y Docker recorta las capabilities de Linux dentro del contenedor incluso cuando el usuario ahí adentro es root.
BusyBox empaqueta decenas de comandos Unix en un solo binario estático sin dependencias.
Cómo empezar: tu propio mini runtime con unshare y chroot
No necesitás escribir Go ni leer la especificación OCI para sentir en carne propia lo que hace runc. Linux trae las piezas sueltas en el comando unshare, que ya viene instalado en casi toda distribución moderna como parte del paquete util-linux. Esto corre en Linux nativo: no funciona dentro de Docker Desktop en macOS o Windows porque ahí ya estás dentro de una VM Linux, así que hace falta una terminal Linux real o una VM.
Primero armá una carpeta mínima con un binario estático de BusyBox, que trae sus propios comandos sin depender de librerías del sistema:
mkdir -p /tmp/minicontainer/bin
cd /tmp/minicontainer
curl -L -o bin/busybox https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox
chmod +x bin/busybox
./bin/busybox --install bin
Ese último comando crea enlaces simbólicos para que ls, ps, sh y el resto de los comandos de BusyBox queden disponibles dentro de bin/. Ahora aislá un namespace de PID, de mount y de UTS, y usá chroot para cambiar la raíz a esa carpeta:
sudo unshare --pid --mount --uts --mount-proc --fork \
chroot /tmp/minicontainer /bin/sh
Vas a caer en un shell donde ps solo ve sus propios procesos, porque el namespace de PID le oculta todo lo del host:
/ # ps
PID USER TIME COMMAND
1 root 0:00 sh
2 root 0:00 ps
Eso es, literalmente, el mismo principio que usa runc, con namespaces y una raíz propia. Le falta cgroups para limitar recursos y pivot_root en lugar de chroot para ser completo, pero la idea central (un proceso normal con una vista recortada del sistema) ya está ahí.
Para confirmar que el namespace de PID realmente aisló el proceso, abrí otra terminal en el host y compará los enlaces de namespace antes y después de entrar al chroot:
readlink /proc/1/ns/pid
readlink /proc/$(pgrep -f 'chroot /tmp/minicontainer')/ns/pid
Si los dos comandos devuelven un número de inodo distinto, por ejemplo algo como pid:[4026531836] contra pid:[4026532891], confirmás que el proceso dentro del chroot vive en un namespace de PID diferente al del sistema. Si devuelven el mismo número, el namespace no se aplicó.
> ⚠️ Ojo: chroot por sí solo no es una frontera de seguridad. Un proceso con privilegios de root dentro del chroot puede escapar si tiene acceso a syscalls como mknod o a un descriptor de archivo abierto fuera de la carpeta. Por eso runc combina chroot o pivot_root con namespaces de usuario y con capabilities recortadas, nunca lo usa solo.
Casos de uso reales
Kubernetes no ejecuta contenedores directamente: delega en containerd o CRI-O, que a su vez llaman a runc o a alguna alternativa compatible con OCI. Cuando un Pod se reinicia o se reprograma en otro nodo, lo que en realidad se destruye y se vuelve a crear es ese árbol de namespaces, cgroups y la raíz de archivos, no una "máquina" en el sentido tradicional.
Los entornos de CI también dependen de este mismo mecanismo para aislar builds. Cuando GitHub Actions o GitLab CI corren un job dentro de un contenedor, están usando el mismo runtime de contenedores para evitar que un paso malicioso de un pipeline toque el resto del runner. Los proveedores de serverless que ofrecen funciones de corta duración suelen usar variantes más livianas de este mismo stack, a veces reemplazando namespaces por microVMs cuando necesitan un aislamiento más fuerte que el que da un proceso compartiendo kernel.
En el borde, proyectos como K3s o MicroK8s usan el mismo stack de containerd y runc, pero recortado para correr en dispositivos con pocos recursos, como gateways industriales o cámaras con cómputo embebido. La ventaja frente a instalar la aplicación directamente sobre el sistema operativo del dispositivo es que un bug en el proceso queda contenido dentro de sus namespaces y cgroups, sin poder leer archivos de configuración de otros servicios que corren en el mismo gateway.
Errores comunes y buenas prácticas
El primer error clásico es asumir que un contenedor aísla tanto como una máquina virtual. No lo hace: todos los contenedores de un host comparten el mismo kernel, así que una vulnerabilidad de escalada de privilegios en el kernel (no en la aplicación) puede saltar de un contenedor a otro o al host. Por eso corren con seccomp y capabilities recortadas por defecto, y por eso --privileged es una bandera que hay que tratar como una alarma, no como un atajo cómodo para "que funcione".
El segundo error es no entender que la imagen comprimida que bajás de un registry no incluye el kernel ni el init de verdad. Cualquier proceso que corra como PID 1 dentro del contenedor hereda responsabilidades que normalmente maneja systemd, como reapear procesos zombie. Si tu aplicación no maneja señales ni hijos huérfanos, vas a terminar con procesos zombie acumulándose hasta que el contenedor se reinicie.
El tercero es confundir cgroups v1 con cgroups v2. La mayoría de distribuciones modernas, como Fedora o Ubuntu reciente, ya usan cgroups v2 por defecto, con una sola jerarquía unificada en lugar de controladores montados por separado. Si un contenedor se comporta distinto en tu laptop que en un servidor viejo, confirmá la versión con:
cat /sys/fs/cgroup/cgroup.controllers
Si ese archivo existe y lista controladores como cpu memory io pids, estás en cgroups v2. Si el comando falla, el sistema todavía usa la jerarquía v1.
Comparativa con alternativas
runc no es la única implementación de la especificación OCI, y elegir otra puede tener sentido según qué estés priorizando: velocidad de arranque, menor huella de memoria, o un aislamiento más fuerte que el de compartir kernel.
OpciónCuándo usarlaVentajaLimitaciónruncRuntime por defecto de Docker y KubernetesImplementa el estándar OCI y es compatible con todo el ecosistema existenteEscrito en Go, necesita el truco de reejecución para simular fork()crunHosts con cgroups v2 que priorizan arranque rápidoEscrito en C, binario más chico y liviano que runcMenor adopción por defecto en algunas distribucionesgVisorMulti-tenant donde no confiás en el kernel compartidoIntercepta syscalls en espacio de usuario como capa extra de aislamientoOverhead en cargas con muchas llamadas al sistemaFirecrackerServerless con aislamiento tipo máquina virtualArranca una microVM liviana con su propio kernel por workloadNo es un runtime OCI puro, necesita su propio orquestador
Profundizando: cgroups v2, user namespaces y OCI hooks
Los user namespaces son la pieza que hace posible que "root dentro del contenedor" no sea "root en el host". Mapean un rango de UIDs del contenedor a un rango distinto, sin privilegios, en el host. El proceso puede creerse UID 0 adentro mientras en realidad corre como un UID sin privilegios, como 100000, desde afuera. Docker lo soporta desde hace años con la bandera --userns-remap, aunque pocos lo activan porque complica el manejo de volúmenes compartidos con el host.
La especificación OCI que sigue runc también define "hooks", puntos de extensión donde un orquestador puede inyectar su propia lógica antes o después de ciertas etapas del ciclo de vida del contenedor, como prestart, createRuntime o poststop. Kubernetes y Docker usan estos hooks internamente para tareas como configurar la red del Pod antes de que el proceso principal arranque, sin tener que modificar runc.
Otro detalle que separa a los runtimes OCI entre sí es cómo manejan CLONE_NEWCGROUP, el namespace de cgroup. Montarlo mal puede dejar que un proceso dentro del contenedor vea la jerarquía completa de cgroups del host en lugar de solo la suya, lo que filtra información sobre qué otros contenedores corren en la misma máquina.
seccomp es la cuarta pieza que casi nunca se menciona junto a namespaces y cgroups. Filtra qué syscalls puede hacer un proceso, no qué puede ver ni cuánto puede consumir. El perfil por defecto de Docker bloquea una lista de syscalls peligrosas, como ptrace sin restricciones o mount arbitrario, incluso si el proceso tiene las capabilities que en teoría se lo permitirían. Podés ver el perfil activo de un contenedor corriendo docker inspect --format '{{.HostConfig.SecurityOpt}}' <contenedor>.
Tu próximo paso: correteá el mini runtime de la sección anterior, agregale un cgroup propio escribiendo límites directamente en /sys/fs/cgroup/, y confirmá que podés limitarle la memoria a 50 MB antes de que el kernel mate el proceso con el OOM killer.
📖 Resumen en Telegram: Ver resumen
Preguntas frecuentes
¿Qué diferencia a runc de una máquina virtual?
Una máquina virtual arranca su propio kernel completo sobre un hipervisor; un contenedor ejecutado por runc comparte el mismo kernel que el host y solo cambia qué puede ver y cuánto puede consumir ese proceso. Por eso un contenedor arranca en milisegundos y una VM tarda segundos, pero también por eso una falla del kernel compartido afecta a todos los contenedores a la vez.
¿Por qué dockerd necesita privilegios de root?
Porque crear ciertos namespaces, sobre todo el de red, y manipular cgroups son operaciones que el kernel de Linux históricamente reservó a procesos con privilegios de superusuario. El modo rootless de Docker evita esto reimplementando partes del stack de red en espacio de usuario.
¿Puedo usar namespaces de Linux sin instalar Docker?
Sí. El comando unshare, parte del paquete util-linux que ya viene en casi toda distribución, permite crear namespaces de PID, red, mount, UTS e IPC directamente desde la terminal, sin pasar por dockerd ni por runc.
¿Qué es pivot_root y por qué runc lo prefiere sobre chroot?
pivot_root cambia cuál punto de montaje actúa como raíz del sistema de archivos para un proceso, y permite desmontar la raíz anterior después del cambio. chroot solo cambia la raíz aparente sin ese paso extra, lo que deja una vía de escape si el proceso ya tenía un descriptor de archivo abierto fuera de la nueva raíz.
¿El modo rootless de Docker tiene las mismas prestaciones de red que el modo normal?
No del todo. Al no tener acceso de root a las interfaces del host, el modo rootless depende de herramientas como slirp4netns o pasta para simular la red en espacio de usuario, lo que agrega una capa de traducción que el modo con privilegios no necesita.
¿Cuándo conviene usar gVisor o Firecracker en lugar de runc?
Cuando el aislamiento de namespaces y cgroups no alcanza, por ejemplo en entornos multi-tenant donde cargas de distintos clientes comparten el mismo host. gVisor intercepta las syscalls en espacio de usuario y Firecracker arranca una microVM liviana con su propio kernel, ambos a costa de más overhead que runc puro.
Referencias
- Code and Cake: el artículo original que documenta estas rarezas de Docker mientras su autor construye un runtime de contenedores propio.- GitHub: opencontainers/runc: código fuente de la implementación de referencia del runtime OCI que usan Docker y Kubernetes.- GitHub: opencontainers/runtime-spec: la especificación OCI que define cómo debe comportarse cualquier runtime de contenedores.- Wikipedia: Linux namespaces: resumen de los ocho tipos de namespace que expone el kernel de Linux.- Documentación oficial de Docker: referencia del daemon, el modo rootless y la arquitectura cliente-servidor.
📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.
How Docker Works Under the Hood: Watch Namespaces Isolate a Container and cgroups Stop a Fork Bomb
On a Linux host, start an nginx container, then run ps aux. nginx is right there in the list, with an ordinary host PID. No guest kernel booted and no hypervisor started. That one observation is the key to how Docker works under the hood: a container is a normal Linux process that the kernel agrees to lie to (namespaces) and to ration (cgroups). The rest of docker run is plumbing that sets those two things up.

This post walks the plumbing with two browser simulators, then shows the same kernel features on a real machine, no Docker required. Disclosure: I help build the simulators; they are free, run in the browser, and need no signup.
The path of one docker run
The How Docker Works Under the Hood simulator takes one command, docker run -p 8080:80 nginx, and steps down the stack in eight stages: the Docker CLI, the Docker daemon, the registry pull, containerd, the OCI bundle, runc, the running container, and the Linux kernel. Press Play, or use Prev and Next to go at your own pace. Tick "nginx already pulled" and the registry stage drops out (the counter goes from Step 1 / 8 to Step 1 / 7), the first lesson: a pull only happens on a cache miss, and only missing layers come down.
The stages worth pausing on:
1. The CLI is a REST client. It sends an HTTP request to dockerd over /var/run/docker.sock. The stage shows a curl --unix-socket request to the daemon's /info endpoint: it starts nothing, but it uses the same transport the CLI does.
2. dockerd does not start your process. It hands off to containerd, which unpacks the image layers into a snapshot and tracks the container's lifecycle.
3. The OCI bundle is the whole recipe. It is a config.json plus a root filesystem. The excerpt in the simulator lists the five namespaces to create, with the type names from the OCI runtime spec: pid, network, mount, uts, ipc. The kernel shortens two of them: /proc/<pid>/ns and lsns say net and mnt, as you will see below. The command for this stage is runc spec, which writes a sample config.json so you can read the real shape.
4. runc does the kernel work and leaves. It creates the namespaces and the cgroup, pivots into the rootfs, drops capabilities, and execs nginx as PID 1 inside the container. Then runc exits and a shim keeps the container attached to containerd.
Every stage has a copy button next to its real command. The last stage ends on four labels: namespaces, cgroups, networking, mounts. Those are the container.
Namespaces: what the process can see
You can look at your own namespaces right now on any Linux box. Every process has a set, and each one is a link in /proc:
$ ls -l /proc/self/ns # trimmed: link targets only, 6 of 10 entries
cgroup -> cgroup:[4026531835]
ipc -> ipc:[4026531839]
mnt -> mnt:[4026531841]
net -> net:[4026531840]
pid -> pid:[4026531836]
uts -> uts:[4026531838]
Two processes share a namespace when the numbers match; a container's process shows different ones. The unshare tool from util-linux makes new ones by hand. On Debian 12, as a normal user:
$ unshare --user --map-root-user --pid --fork --mount-proc ps -o pid,user,cmd
PID USER CMD
1 root ps -o pid,user,cmd
ps believes it is PID 1 and that it runs as root. Both are namespace tricks: a new PID namespace starts counting at 1, and the user namespace maps your normal UID to root inside it only. The UTS namespace does the same for the hostname:
$ unshare --user --map-root-user --uts sh -c 'hostname demo-box; hostname'
demo-box
$ hostname
raspberrypi
If your distro restricts unprivileged user namespaces, run them with sudo and drop --user --map-root-user. The namespaces(7) man page lists all eight types.
cgroups: what the process can use
Namespaces hide things. They do not limit anything. A process in its own PID namespace can still create processes until the host runs out. The classic proof is the fork bomb: a tiny shell function named : that calls itself twice, one copy piped into another in the background. Every copy makes two more, the process table fills, and the machine stops responding.
Do not run it. Use the Fork Bomb Simulator instead, which draws the process tree without forking anything real. Hover the code explainer to see what each of its ten pieces does, then press Start. The process count climbs until it hits the PID limit (a slider from 64 to 1024, 512 by default), the log prints FATAL: fork() failed - Resource temporarily unavailable, and the screen reads SYSTEM CRASHED.
Now pick one of the three Protection cards and start again: ulimit -u (a per-user process cap), cgroups (pids.max), or systemd (TasksMax in a unit file). In the simulation, each one caps the run at 100 processes, and it ends with SYSTEM PROTECTED instead. That only works while the PID limit slider is above 100: set it to 64 and the system runs out first, protection or not. Same bomb, same growth, stopped at a line you drew in advance. On a real system they count different things. ulimit -u counts every process and thread of your user ID across the whole machine, and the kernel does not enforce it for root. When I ran ulimit -u 50 in a shell on the Pi, the very first fork() failed, because my user already owned 191 tasks elsewhere. pids.max and TasksMax count tasks inside one cgroup, whoever owns them. (The cgroups card shows the cgroup v2 form: echo 100 > /sys/fs/cgroup/user.slice/user-1000.slice/pids.max.)
Try the limit for real
On a systemd machine with cgroup v2, where the user manager has the pids controller delegated to it, you can put a command in a throwaway cgroup with a task limit, no root needed. To check, cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/cgroup.controllers must list pids (on the Pi it prints cpu pids). This is not a fork bomb. It is a loop that stops after 50 children, run inside a scope capped at 10 tasks:
$ systemd-run --user --scope -p TasksMax=10 python3 -c '
import os, time
n = 0
try:
for _ in range(50):
if os.fork() == 0:
time.sleep(2)
os._exit(0)
n += 1
except OSError as e:
print("started", n, "children, then:", e)
'
Running scope as unit: run-r01155c5e085f497f94c998714d8323f5.scope
started 9 children, then: [Errno 11] Resource temporarily unavailable
Nine children plus the Python parent is ten tasks, the value systemd wrote to the scope's pids.max. The tenth fork() failed with EAGAIN, which is what the kernel's cgroup v2 docs promise. In the simulator, the same refusal shows as cgroup: fork rejected, pids.max (100) reached on the cgroups card. Nothing else on the machine noticed. A version of the script that also reads its own cgroup files printed pids.events: max 1 at the end: that file counts refused forks, so it is the one to alert on.
Docker exposes the same controller per container: docker run --pids-limit 100 nginx sets that ceiling on the container's cgroup (docs). Without that flag, a runaway container is bounded only by the limits the host already has. On the Pi, systemd's DefaultTasksMax is 3914 (15% of kernel.threads-max), but whether a container's unit inherits a default like that depends on the runtime. Check instead of guessing: on a cgroup v2 host, docker run --rm alpine cat /sys/fs/cgroup/pids.max prints the container's limit, and max means none.
The whole trick
Side by side, the two simulators put how Docker works under the hood in one sentence: runc asks the kernel for new namespaces so the process sees its own small world, and puts it in a cgroup where the limits you set cap what it can use. A cgroup with no limits caps nothing, and a PID cap does not cap CPU or memory. A limit also needs its controller: the Pi boots with cgroup_disable=memory, and a scope with MemoryMax=50M let a process allocate 100 MiB without an error. dockerd, containerd and the registry are how the recipe gets built and delivered.
For the longer tour, with commands to run at each layer of a real Docker host, read How Docker Really Works, From docker run to the Kernel. For the controllers themselves, see cgroups(7).
What surprised you most the first time you looked under the hood of a container? Tell us in the comments.
22 reactions · 3 comments
Mentions
runc: qué es y cómo funciona el runtime detrás de Docker Cuando corrés docker run -it ubuntu bash, el kernel de Linux no sabe qué es un contenedor: no existe tal cosa. Lo que arma ese comando es un runtime de contenedores llamado runc, y en pleno 2026 todavía tiene que hacer trampa, porque el lenguaje en que está escrito, Go, ni siquiera tiene una función fork() segura. Esa trampa, reejecutarse a sí mismo desde /proc/self/exe, es solo uno de los trucos que sostienen todo el ecosistema de contenedores: Docker, Kubernetes, containerd. Ninguno usa magia de aislamiento nueva. Todos reutilizan piezas del kernel de Linux que existen desde hace más de una década. TL;DR - Un contenedor es un proceso Linux normal con namespaces, cgroups y pivot_root aplicados, nada más.- runc, el runtime que ejecuta cada docker run, está en Go: un lenguaje sin fork() nativo y runtime multihilo.- El truco para imitar fork() es reejecutar /proc/self/exe antes de que el runtime de Go lance sus hilos.- Una imagen Docker es solo una carpeta: pivot_root la convierte en la raíz del proceso sin copiar nada.- dockerd corre como root porque namespaces y cgroups exigen privilegios; rootless rehace la red en user space. ¿Qué es un runtime de contenedores? Un runtime de contenedores es el programa de bajo nivel que traduce una imagen y un conjunto de restricciones en un proceso Linux aislado. Aplica namespaces, cgroups y pivot_root, y después ejecuta el proceso final con execve. runc es la implementación de referencia que usan Docker, containerd y Kubernetes. Linux expone ocho tipos de namespace: PID, network, mount, UTS, IPC, user, cgroup y time. Namespaces, cgroups y pivot_root: las piezas reales detrás de "contenedor" No hay una línea en el kernel de Linux que diga "esto es un contenedor". En cambio, un runtime combina mecanismos independientes que ya existían antes de que Docker existiera. Los namespaces esconden qué puede ver el proceso: su propio árbol de PIDs, su propia interfaz de red, su propio hostname. Los cgroups limitan cuánta CPU, memoria y E/S puede consumir ese proceso, y pivot_root cambia cuál carpeta del disco actúa como raíz del sistema de archivos para ese proceso en particular. Docker activa casi todos los namespaces por defecto cuando corrés un contenedor normal, pero podés desactivarlos selectivamente con flags como --pid=host o --network=host. Cada namespace es una vista distinta del mismo kernel: dos procesos en namespaces de red distintos pueden tener ambos una interfaz llamada eth0 sin pisarse, porque cada uno vive en su propia tabla de rutas. Los cgroups, por su parte, se organizan en controladores: cpu, memory, io y pids. El controlador cpu reparte tiempo de procesador con pesos relativos; memory pone un techo duro de RAM y puede matar el proceso con el OOM killer si lo supera; io limita el ancho de banda de disco, y pids pone un tope a cuántos procesos puede crear ese grupo antes de bloquear nuevos fork(). Docker expone estos límites con flags como --memory, --cpus y --pids-limit, pero por dentro son solo escrituras a archivos dentro de /sys/fs/cgroup/. flowchart TD A["docker run ubuntu bash"] --> B["Namespaces aislan PID, red y hostname"] A --> C["Cgroups limitan CPU y memoria"] A --> D["pivot_root cambia la raíz del proceso"] subgraph "Kernel de Linux" B C D end Cómo runc arma el proceso: de docker run a execve Una imagen de Docker no es más que una carpeta. Cuando bajás ubuntu desde Docker Hub, lo que estás descargando son capas comprimidas que, una vez descomprimidas, forman un árbol de directorios idéntico al de una instalación mínima de Ubuntu: /bin, /etc, /lib, /usr. No hay nada ejecutándose ahí todavía. El flujo completo de docker run -it --rm ubuntu /bin/bash pasa por cuatro programas antes de llegar al kernel. El cliente docker habla con el daemon dockerd. dockerd delega en containerd, que gestiona el ciclo de vida del contenedor. containerd invoca a runc con la ruta de la imagen y la especificación OCI del contenedor, y runc es quien finalmente hace las llamadas al sistema: clona el proceso con namespaces y cgroups, llama a pivot_root para que esa carpeta descomprimida se convierta en la raíz del proceso, y por último ejecuta /bin/bash con execve. La diferencia entre pivot_root y el chroot clásico de toda la vida es que pivot_root mueve la raíz vieja a un punto de montaje aparte y permite desmontarla después, mientras que chroot deja la raíz anterior accesible si el proceso logra escapar con un descriptor de archivo abierto desde antes. Por eso runc usa pivot_root cuando el kernel lo permite, y cae a chroot solo como alternativa en sistemas donde pivot_root no está disponible. sequenceDiagram participant U as Usuario participant D as dockerd participant C as containerd participant R as runc participant K as Kernel U->>D: docker run ubuntu bash D->>C: crear contenedor C->>R: create y start R->>K: clone con namespaces y cgroups R->>K: pivot_root a la imagen R-->>K: execve del proceso final Note over R,K: runc termina, el proceso final queda solo El problema de Go: por qué runc necesita fingir un fork() runc está escrito en Go, y Go tiene un problema de diseño para este trabajo puntual: su runtime arranca varios hilos del sistema operativo apenas el programa inicia, para manejar el recolector de basura y el scheduler de goroutines. La llamada clásica fork() de Unix solo duplica el hilo que la invoca, no los demás. Si runc llamara fork() directamente después de que el runtime de Go ya levantó esos hilos, el proceso hijo quedaría con un runtime de Go a medio inicializar y corrupto. La solución que adoptó el equipo de runc fue no usar fork() en absoluto. En su lugar, el binario se reejecuta a sí mismo leyendo su propio ejecutable desde /proc/self/exe, con un argumento especial que le indica al nuevo proceso que actúe como el "init" del contenedor en lugar del comando normal. Ese proceso hijo, recién arrancado desde cero con su propio runtime de Go limpio, es el que hace las llamadas de namespaces, cgroups y pivot_root antes de ejecutar el comando real con execve. Hay otro detalle que complica más las cosas. Algunas de las llamadas al sistema que necesita un runtime de contenedores, como setns() para unirse a un namespace existente, exigen ejecutarse en un solo hilo del sistema operativo, sin que el scheduler de Go mueva la goroutina a otro hilo a mitad de camino. Por eso runc incluye un pequeño módulo en C que corre antes de que el runtime de Go termine de inicializarse, en una ventana donde todavía hay un solo hilo activo. flowchart TD A["runc arranca en Go"] --> B["El runtime de Go ya levantó varios hilos"] B --> C["fork() clásico dejaría el hijo con un runtime corrupto"] C --> D["runc re-ejecuta /proc/self/exe"] D --> E["El proceso hijo arranca con un runtime de Go limpio"] E --> F["Configura namespaces, cgroups y pivot_root"] F --> G["execve() reemplaza el proceso por el comando real"] Por qué Docker corre como root Si usás Docker Desktop en macOS o Windows nunca lo notás, porque el daemon corre dentro de una máquina virtual Linux que Docker Desktop administra por vos. Pero en un servidor Linux real, dockerd corre como root, y durante años esa fue la única opción: crear namespaces, especialmente el de red, y manipular cgroups eran operaciones que el kernel reservaba a procesos con privilegios de superusuario. Eso cambió parcialmente con los user namespaces y con el modo rootless de Docker, que permite correr el daemon y los contenedores sin privilegios de root reales. El problema aparece en la red: sin acceso de root a las interfaces del host, el modo rootless tiene que reimplementar buena parte del stack de redes en espacio de usuario, con herramientas como slirp4netns o pasta, para lograr algo parecido al bridge de red que el modo normal arma directamente en el kernel. Incluso cuando el proceso dentro del contenedor corre como UID 0, Docker le recorta las capabilities de Linux por defecto: elimina permisos como CAP_SYS_ADMIN o CAP_NET_ADMIN que en el host le permitirían, por ejemplo, montar sistemas de archivos arbitrarios o reconfigurar interfaces de red ajenas. Podés revisar qué capabilities tiene activas un contenedor corriendo cat /proc/1/status | grep Cap desde adentro, y decodificar el valor hexadecimal con capsh --decode=<valor>. > 📌 Nota: correr como root dentro del contenedor no es lo mismo que el daemon corriendo como root en el host: son dos aislamientos distintos, y Docker recorta las capabilities de Linux dentro del contenedor incluso cuando el usuario ahí adentro es root. BusyBox empaqueta decenas de comandos Unix en un solo binario estático sin dependencias. Cómo empezar: tu propio mini runtime con unshare y chroot No necesitás escribir Go ni leer la especificación OCI para sentir en carne propia lo que hace runc. Linux trae las piezas sueltas en el comando unshare, que ya viene instalado en casi toda distribución moderna como parte del paquete util-linux. Esto corre en Linux nativo: no funciona dentro de Docker Desktop en macOS o Windows porque ahí ya estás dentro de una VM Linux, así que hace falta una terminal Linux real o una VM. Primero armá una carpeta mínima con un binario estático de BusyBox, que trae sus propios comandos sin depender de librerías del sistema: mkdir -p /tmp/minicontainer/bin cd /tmp/minicontainer curl -L -o bin/busybox https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox chmod +x bin/busybox ./bin/busybox --install bin Ese último comando crea enlaces simbólicos para que ls, ps, sh y el resto de los comandos de BusyBox queden disponibles dentro de bin/. Ahora aislá un namespace de PID, de mount y de UTS, y usá chroot para cambiar la raíz a esa carpeta: sudo unshare --pid --mount --uts --mount-proc --fork \ chroot /tmp/minicontainer /bin/sh Vas a caer en un shell donde ps solo ve sus propios procesos, porque el namespace de PID le oculta todo lo del host: / # ps PID USER TIME COMMAND 1 root 0:00 sh 2 root 0:00 ps Eso es, literalmente, el mismo principio que usa runc, con namespaces y una raíz propia. Le falta cgroups para limitar recursos y pivot_root en lugar de chroot para ser completo, pero la idea central (un proceso normal con una vista recortada del sistema) ya está ahí. Para confirmar que el namespace de PID realmente aisló el proceso, abrí otra terminal en el host y compará los enlaces de namespace antes y después de entrar al chroot: readlink /proc/1/ns/pid readlink /proc/$(pgrep -f 'chroot /tmp/minicontainer')/ns/pid Si los dos comandos devuelven un número de inodo distinto, por ejemplo algo como pid:[4026531836] contra pid:[4026532891], confirmás que el proceso dentro del chroot vive en un namespace de PID diferente al del sistema. Si devuelven el mismo número, el namespace no se aplicó. > ⚠️ Ojo: chroot por sí solo no es una frontera de seguridad. Un proceso con privilegios de root dentro del chroot puede escapar si tiene acceso a syscalls como mknod o a un descriptor de archivo abierto fuera de la carpeta. Por eso runc combina chroot o pivot_root con namespaces de usuario y con capabilities recortadas, nunca lo usa solo. Casos de uso reales Kubernetes no ejecuta contenedores directamente: delega en containerd o CRI-O, que a su vez llaman a runc o a alguna alternativa compatible con OCI. Cuando un Pod se reinicia o se reprograma en otro nodo, lo que en realidad se destruye y se vuelve a crear es ese árbol de namespaces, cgroups y la raíz de archivos, no una "máquina" en el sentido tradicional. Los entornos de CI también dependen de este mismo mecanismo para aislar builds. Cuando GitHub Actions o GitLab CI corren un job dentro de un contenedor, están usando el mismo runtime de contenedores para evitar que un paso malicioso de un pipeline toque el resto del runner. Los proveedores de serverless que ofrecen funciones de corta duración suelen usar variantes más livianas de este mismo stack, a veces reemplazando namespaces por microVMs cuando necesitan un aislamiento más fuerte que el que da un proceso compartiendo kernel. En el borde, proyectos como K3s o MicroK8s usan el mismo stack de containerd y runc, pero recortado para correr en dispositivos con pocos recursos, como gateways industriales o cámaras con cómputo embebido. La ventaja frente a instalar la aplicación directamente sobre el sistema operativo del dispositivo es que un bug en el proceso queda contenido dentro de sus namespaces y cgroups, sin poder leer archivos de configuración de otros servicios que corren en el mismo gateway. Errores comunes y buenas prácticas El primer error clásico es asumir que un contenedor aísla tanto como una máquina virtual. No lo hace: todos los contenedores de un host comparten el mismo kernel, así que una vulnerabilidad de escalada de privilegios en el kernel (no en la aplicación) puede saltar de un contenedor a otro o al host. Por eso corren con seccomp y capabilities recortadas por defecto, y por eso --privileged es una bandera que hay que tratar como una alarma, no como un atajo cómodo para "que funcione". El segundo error es no entender que la imagen comprimida que bajás de un registry no incluye el kernel ni el init de verdad. Cualquier proceso que corra como PID 1 dentro del contenedor hereda responsabilidades que normalmente maneja systemd, como reapear procesos zombie. Si tu aplicación no maneja señales ni hijos huérfanos, vas a terminar con procesos zombie acumulándose hasta que el contenedor se reinicie. El tercero es confundir cgroups v1 con cgroups v2. La mayoría de distribuciones modernas, como Fedora o Ubuntu reciente, ya usan cgroups v2 por defecto, con una sola jerarquía unificada en lugar de controladores montados por separado. Si un contenedor se comporta distinto en tu laptop que en un servidor viejo, confirmá la versión con: cat /sys/fs/cgroup/cgroup.controllers Si ese archivo existe y lista controladores como cpu memory io pids, estás en cgroups v2. Si el comando falla, el sistema todavía usa la jerarquía v1. Comparativa con alternativas runc no es la única implementación de la especificación OCI, y elegir otra puede tener sentido según qué estés priorizando: velocidad de arranque, menor huella de memoria, o un aislamiento más fuerte que el de compartir kernel. OpciónCuándo usarlaVentajaLimitaciónruncRuntime por defecto de Docker y KubernetesImplementa el estándar OCI y es compatible con todo el ecosistema existenteEscrito en Go, necesita el truco de reejecución para simular fork()crunHosts con cgroups v2 que priorizan arranque rápidoEscrito en C, binario más chico y liviano que runcMenor adopción por defecto en algunas distribucionesgVisorMulti-tenant donde no confiás en el kernel compartidoIntercepta syscalls en espacio de usuario como capa extra de aislamientoOverhead en cargas con muchas llamadas al sistemaFirecrackerServerless con aislamiento tipo máquina virtualArranca una microVM liviana con su propio kernel por workloadNo es un runtime OCI puro, necesita su propio orquestador Profundizando: cgroups v2, user namespaces y OCI hooks Los user namespaces son la pieza que hace posible que "root dentro del contenedor" no sea "root en el host". Mapean un rango de UIDs del contenedor a un rango distinto, sin privilegios, en el host. El proceso puede creerse UID 0 adentro mientras en realidad corre como un UID sin privilegios, como 100000, desde afuera. Docker lo soporta desde hace años con la bandera --userns-remap, aunque pocos lo activan porque complica el manejo de volúmenes compartidos con el host. La especificación OCI que sigue runc también define "hooks", puntos de extensión donde un orquestador puede inyectar su propia lógica antes o después de ciertas etapas del ciclo de vida del contenedor, como prestart, createRuntime o poststop. Kubernetes y Docker usan estos hooks internamente para tareas como configurar la red del Pod antes de que el proceso principal arranque, sin tener que modificar runc. Otro detalle que separa a los runtimes OCI entre sí es cómo manejan CLONE_NEWCGROUP, el namespace de cgroup. Montarlo mal puede dejar que un proceso dentro del contenedor vea la jerarquía completa de cgroups del host en lugar de solo la suya, lo que filtra información sobre qué otros contenedores corren en la misma máquina. seccomp es la cuarta pieza que casi nunca se menciona junto a namespaces y cgroups. Filtra qué syscalls puede hacer un proceso, no qué puede ver ni cuánto puede consumir. El perfil por defecto de Docker bloquea una lista de syscalls peligrosas, como ptrace sin restricciones o mount arbitrario, incluso si el proceso tiene las capabilities que en teoría se lo permitirían. Podés ver el perfil activo de un contenedor corriendo docker inspect --format '{{.HostConfig.SecurityOpt}}' <contenedor>. Tu próximo paso: correteá el mini runtime de la sección anterior, agregale un cgroup propio escribiendo límites directamente en /sys/fs/cgroup/, y confirmá que podés limitarle la memoria a 50 MB antes de que el kernel mate el proceso con el OOM killer. 📖 Resumen en Telegram: Ver resumen Preguntas frecuentes ¿Qué diferencia a runc de una máquina virtual? Una máquina virtual arranca su propio kernel completo sobre un hipervisor; un contenedor ejecutado por runc comparte el mismo kernel que el host y solo cambia qué puede ver y cuánto puede consumir ese proceso. Por eso un contenedor arranca en milisegundos y una VM tarda segundos, pero también por eso una falla del kernel compartido afecta a todos los contenedores a la vez. ¿Por qué dockerd necesita privilegios de root? Porque crear ciertos namespaces, sobre todo el de red, y manipular cgroups son operaciones que el kernel de Linux históricamente reservó a procesos con privilegios de superusuario. El modo rootless de Docker evita esto reimplementando partes del stack de red en espacio de usuario. ¿Puedo usar namespaces de Linux sin instalar Docker? Sí. El comando unshare, parte del paquete util-linux que ya viene en casi toda distribución, permite crear namespaces de PID, red, mount, UTS e IPC directamente desde la terminal, sin pasar por dockerd ni por runc. ¿Qué es pivot_root y por qué runc lo prefiere sobre chroot? pivot_root cambia cuál punto de montaje actúa como raíz del sistema de archivos para un proceso, y permite desmontar la raíz anterior después del cambio. chroot solo cambia la raíz aparente sin ese paso extra, lo que deja una vía de escape si el proceso ya tenía un descriptor de archivo abierto fuera de la nueva raíz. ¿El modo rootless de Docker tiene las mismas prestaciones de red que el modo normal? No del todo. Al no tener acceso de root a las interfaces del host, el modo rootless depende de herramientas como slirp4netns o pasta para simular la red en espacio de usuario, lo que agrega una capa de traducción que el modo con privilegios no necesita. ¿Cuándo conviene usar gVisor o Firecracker en lugar de runc? Cuando el aislamiento de namespaces y cgroups no alcanza, por ejemplo en entornos multi-tenant donde cargas de distintos clientes comparten el mismo host. gVisor intercepta las syscalls en espacio de usuario y Firecracker arranca una microVM liviana con su propio kernel, ambos a costa de más overhead que runc puro. Referencias - Code and Cake: el artículo original que documenta estas rarezas de Docker mientras su autor construye un runtime de contenedores propio.- GitHub: opencontainers/runc: código fuente de la implementación de referencia del runtime OCI que usan Docker y Kubernetes.- GitHub: opencontainers/runtime-spec: la especificación OCI que define cómo debe comportarse cualquier runtime de contenedores.- Wikipedia: Linux namespaces: resumen de los ocho tipos de namespace que expone el kernel de Linux.- Documentación oficial de Docker: referencia del daemon, el modo rootless y la arquitectura cliente-servidor. 📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.
How Docker Works Under the Hood: Watch Namespaces Isolate a Container and cgroups Stop a Fork Bomb On a Linux host, start an nginx container, then run ps aux. nginx is right there in the list, with an ordinary host PID. No guest kernel booted and no hypervisor started. That one observation is the key to how Docker works under the hood: a container is a normal Linux process that the kernel agrees to lie to (namespaces) and to ration (cgroups). The rest of docker run is plumbing that sets those two things up.  This post walks the plumbing with two browser simulators, then shows the same kernel features on a real machine, no Docker required. Disclosure: I help build the simulators; they are free, run in the browser, and need no signup. The path of one docker run The How Docker Works Under the Hood simulator takes one command, docker run -p 8080:80 nginx, and steps down the stack in eight stages: the Docker CLI, the Docker daemon, the registry pull, containerd, the OCI bundle, runc, the running container, and the Linux kernel. Press Play, or use Prev and Next to go at your own pace. Tick "nginx already pulled" and the registry stage drops out (the counter goes from Step 1 / 8 to Step 1 / 7), the first lesson: a pull only happens on a cache miss, and only missing layers come down. The stages worth pausing on: 1. The CLI is a REST client. It sends an HTTP request to dockerd over /var/run/docker.sock. The stage shows a curl --unix-socket request to the daemon's /info endpoint: it starts nothing, but it uses the same transport the CLI does. 2. dockerd does not start your process. It hands off to containerd, which unpacks the image layers into a snapshot and tracks the container's lifecycle. 3. The OCI bundle is the whole recipe. It is a config.json plus a root filesystem. The excerpt in the simulator lists the five namespaces to create, with the type names from the OCI runtime spec: pid, network, mount, uts, ipc. The kernel shortens two of them: /proc/<pid>/ns and lsns say net and mnt, as you will see below. The command for this stage is runc spec, which writes a sample config.json so you can read the real shape. 4. runc does the kernel work and leaves. It creates the namespaces and the cgroup, pivots into the rootfs, drops capabilities, and execs nginx as PID 1 inside the container. Then runc exits and a shim keeps the container attached to containerd. Every stage has a copy button next to its real command. The last stage ends on four labels: namespaces, cgroups, networking, mounts. Those are the container. Namespaces: what the process can see You can look at your own namespaces right now on any Linux box. Every process has a set, and each one is a link in /proc: $ ls -l /proc/self/ns # trimmed: link targets only, 6 of 10 entries cgroup -> cgroup:[4026531835] ipc -> ipc:[4026531839] mnt -> mnt:[4026531841] net -> net:[4026531840] pid -> pid:[4026531836] uts -> uts:[4026531838] Two processes share a namespace when the numbers match; a container's process shows different ones. The unshare tool from util-linux makes new ones by hand. On Debian 12, as a normal user: $ unshare --user --map-root-user --pid --fork --mount-proc ps -o pid,user,cmd PID USER CMD 1 root ps -o pid,user,cmd ps believes it is PID 1 and that it runs as root. Both are namespace tricks: a new PID namespace starts counting at 1, and the user namespace maps your normal UID to root inside it only. The UTS namespace does the same for the hostname: $ unshare --user --map-root-user --uts sh -c 'hostname demo-box; hostname' demo-box $ hostname raspberrypi If your distro restricts unprivileged user namespaces, run them with sudo and drop --user --map-root-user. The namespaces(7) man page lists all eight types. cgroups: what the process can use Namespaces hide things. They do not limit anything. A process in its own PID namespace can still create processes until the host runs out. The classic proof is the fork bomb: a tiny shell function named : that calls itself twice, one copy piped into another in the background. Every copy makes two more, the process table fills, and the machine stops responding. Do not run it. Use the Fork Bomb Simulator instead, which draws the process tree without forking anything real. Hover the code explainer to see what each of its ten pieces does, then press Start. The process count climbs until it hits the PID limit (a slider from 64 to 1024, 512 by default), the log prints FATAL: fork() failed - Resource temporarily unavailable, and the screen reads SYSTEM CRASHED. Now pick one of the three Protection cards and start again: ulimit -u (a per-user process cap), cgroups (pids.max), or systemd (TasksMax in a unit file). In the simulation, each one caps the run at 100 processes, and it ends with SYSTEM PROTECTED instead. That only works while the PID limit slider is above 100: set it to 64 and the system runs out first, protection or not. Same bomb, same growth, stopped at a line you drew in advance. On a real system they count different things. ulimit -u counts every process and thread of your user ID across the whole machine, and the kernel does not enforce it for root. When I ran ulimit -u 50 in a shell on the Pi, the very first fork() failed, because my user already owned 191 tasks elsewhere. pids.max and TasksMax count tasks inside one cgroup, whoever owns them. (The cgroups card shows the cgroup v2 form: echo 100 > /sys/fs/cgroup/user.slice/user-1000.slice/pids.max.) Try the limit for real On a systemd machine with cgroup v2, where the user manager has the pids controller delegated to it, you can put a command in a throwaway cgroup with a task limit, no root needed. To check, cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/cgroup.controllers must list pids (on the Pi it prints cpu pids). This is not a fork bomb. It is a loop that stops after 50 children, run inside a scope capped at 10 tasks: $ systemd-run --user --scope -p TasksMax=10 python3 -c ' import os, time n = 0 try: for _ in range(50): if os.fork() == 0: time.sleep(2) os._exit(0) n += 1 except OSError as e: print("started", n, "children, then:", e) ' Running scope as unit: run-r01155c5e085f497f94c998714d8323f5.scope started 9 children, then: [Errno 11] Resource temporarily unavailable Nine children plus the Python parent is ten tasks, the value systemd wrote to the scope's pids.max. The tenth fork() failed with EAGAIN, which is what the kernel's cgroup v2 docs promise. In the simulator, the same refusal shows as cgroup: fork rejected, pids.max (100) reached on the cgroups card. Nothing else on the machine noticed. A version of the script that also reads its own cgroup files printed pids.events: max 1 at the end: that file counts refused forks, so it is the one to alert on. Docker exposes the same controller per container: docker run --pids-limit 100 nginx sets that ceiling on the container's cgroup (docs). Without that flag, a runaway container is bounded only by the limits the host already has. On the Pi, systemd's DefaultTasksMax is 3914 (15% of kernel.threads-max), but whether a container's unit inherits a default like that depends on the runtime. Check instead of guessing: on a cgroup v2 host, docker run --rm alpine cat /sys/fs/cgroup/pids.max prints the container's limit, and max means none. The whole trick Side by side, the two simulators put how Docker works under the hood in one sentence: runc asks the kernel for new namespaces so the process sees its own small world, and puts it in a cgroup where the limits you set cap what it can use. A cgroup with no limits caps nothing, and a PID cap does not cap CPU or memory. A limit also needs its controller: the Pi boots with cgroup_disable=memory, and a scope with MemoryMax=50M let a process allocate 100 MiB without an error. dockerd, containerd and the registry are how the recipe gets built and delivered. For the longer tour, with commands to run at each layer of a real Docker host, read How Docker Really Works, From docker run to the Kernel. For the controllers themselves, see cgroups(7). What surprised you most the first time you looked under the hood of a container? Tell us in the comments.