⏱️ Lectura: 19 min
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.
📑 En este artículo
- TL;DR
- ¿Qué es un runtime de contenedores?
- Namespaces, cgroups y pivot_root: las piezas reales detrás de «contenedor»
- Cómo runc arma el proceso: de docker run a execve
- El problema de Go: por qué runc necesita fingir un fork()
- Por qué Docker corre como root
- Cómo empezar: tu propio mini runtime con unshare y chroot
- Casos de uso reales
- Errores comunes y buenas prácticas
- Comparativa con alternativas
- Profundizando: cgroups v2, user namespaces y OCI hooks
- Preguntas frecuentes
- ¿Qué diferencia a runc de una máquina virtual?
- ¿Por qué dockerd necesita privilegios de root?
- ¿Puedo usar namespaces de Linux sin instalar Docker?
- ¿Qué es pivot_root y por qué runc lo prefiere sobre chroot?
- ¿El modo rootless de Docker tiene las mismas prestaciones de red que el modo normal?
- ¿Cuándo conviene usar gVisor o Firecracker en lugar de runc?
- Referencias
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.
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.
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ón | Cuándo usarla | Ventaja | Limitación |
|---|---|---|---|
| runc | Runtime por defecto de Docker y Kubernetes | Implementa el estándar OCI y es compatible con todo el ecosistema existente | Escrito en Go, necesita el truco de reejecución para simular fork() |
| crun | Hosts con cgroups v2 que priorizan arranque rápido | Escrito en C, binario más chico y liviano que runc | Menor adopción por defecto en algunas distribuciones |
| gVisor | Multi-tenant donde no confiás en el kernel compartido | Intercepta syscalls en espacio de usuario como capa extra de aislamiento | Overhead en cargas con muchas llamadas al sistema |
| Firecracker | Serverless con aislamiento tipo máquina virtual | Arranca una microVM liviana con su propio kernel por workload | No 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.
Imagen destacada: Foto de Markus Spiske en Unsplash
¿Te sirvió? ¿Te dio otro error? Contalo abajo: las preguntas se responden y le sirven al siguiente que llegue.
Dejar un comentario
0 Comentarios