Qué sucede al iniciar un container
Cuando se ejecuta docker run -it ubuntu bash, Docker llama a containerd, que llama a un proceso "shim", que llama al OCI runtime (runc por defecto). El OCI runtime es donde sucede la creación real del contenedor. Esta es la secuencia:
docker run → dockerd → containerd → containerd-shim → runc create → runc start
│
├── clone(CLONE_NEWNS | CLONE_NEWPID | ...)
├── mount rootfs
├── pivot_root
├── setup cgroups
├── apply seccomp filter
├── drop capabilities
└── execve("/bin/bash")
runc en sí es un programa Go de ~15,000 líneas. Hace cinco cosas fundamentales:
- Crea namespaces de Linux para aislar la vista del sistema del contenedor
- Configura un filesystem (mount namespace + pivot_root)
- Configura cgroups para limitar uso de recursos
- Aplica políticas de seguridad (seccomp, capabilities, AppArmor/SELinux)
- Ejecuta el proceso del contenedor
A continuación se examina cada uno.
Namespaces: la base del aislamiento
Los namespaces de Linux particionan recurse es del kernel para que un set de procese es vea un set de recursos, y otro set vea uno diferente. Los containers típicamente usan 7 tipos de namespace:
// Esto es esencialmente lo que runc hace con clone()
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int child_fn(void *arg) {
// Dentro del container, tenemos nuestro propio PID namespace
printf("PID del Container: %d\n", getpid()); // Siempre 1
sethostname("container", 9);
// Montar proc para el nuevo PID namespace
mount("proc", "/proc", "proc", 0, NULL);
execve("/bin/sh", (char*[]){"/bin/sh", NULL}, NULL);
return 0;
}
int main() {
char stack[1024 * 1024];
int flags = CLONE_NEWNS // Namespace de montaje
| CLONE_NEWPID // Namespace de PID
| CLONE_NEWUTS // Namespace de hostname
| CLONE_NEWNET // Namespace de red
| CLONE_NEWIPC // Namespace de IPC
| CLONE_NEWUSER // Namespace de usuario
| CLONE_NEWCGROUP; // Namespace de cgroup
pid_t pid = clone(child_fn, stack + sizeof(stack), flags | SIGCHLD, NULL);
waitpid(pid, NULL, 0);
return 0;
}
El user namespace es el más interesante para seguridad. Mapea el UID 0 (root) del container a un UID sin privilegios en el host:
# Verificar mapeo de user namespace
$ cat /proc/<container-pid>/uid_map
0 100000 65536
# UID 0 del container → UID 100000 del host
Esto significa que incluso si un proceso escapa del container como "root", en realidad es UID 100000 en el host, un usuario sin privilegios. Eso son los rootless containers en pocas palabras.
cgroup v2: control de recurse es bien hecho
cgroup v1 era un problema complejo. Cada controlador de recurse es (cpu, memoria, io, pids) tenía su propia jerarquía, y un proceso podía estar en diferentes cgroups para diferentes controladores. cgroup v2 unifica todo en una sola jerarquía:
# Jerarquía unificada de cgroup v2
/sys/fs/cgroup/
├── system.slice/
│ ├── docker-abc123.scope/
│ │ ├── cgroup.controllers # "cpu memory io pids"
│ │ ├── cpu.max # "100000 100000" (100% de 1 CPU)
│ │ ├── memory.max # "536870912" (512MB)
│ │ ├── memory.swap.max # "0" (sin swap)
│ │ ├── io.max # "8:0 rbps=104857600 wbps=52428800"
│ │ ├── pids.max # "1024"
│ │ └── cgroup.procs # PIDs en este cgroup
La característica más destacada de cgroup v2 es la información de pressure stall (PSI):
# ¿Cuánto tiempo están los tasks en este cgroup stalleados por recursos?
$ cat /sys/fs/cgroup/system.slice/docker-abc123.scope/cpu.pressure
some avg10=4.52 avg60=2.18 avg300=1.05 total=182394
full avg10=0.00 avg60=0.00 avg300=0.00 total=0
PSI te dice cuánto un cgroup está afectado por contención de recursos, no solo si pegó un límite. Esto es dramáticamente más útil que la señal booleana de "hicimos OOM" de v1.
Acá está cómo runc configura cgroups programáticamente:
// Simplificado del setup de cgroups de runc
func setupCgroupV2(cgroupPath string, resources *specs.LinuxResources) error {
if err := os.MkdirAll(cgroupPath, 0755); err != nil {
return err
}
// CPU: 50% de un core (50000us de un periodo de 100000us)
if resources.CPU != nil {
quota := fmt.Sprintf("%d %d", *resources.CPU.Quota, *resources.CPU.Period)
os.WriteFile(filepath.Join(cgroupPath, "cpu.max"), []byte(quota), 0644)
}
// Memoria: límite duro
if resources.Memory != nil && resources.Memory.Limit != nil {
limit := strconv.FormatInt(*resources.Memory.Limit, 10)
os.WriteFile(filepath.Join(cgroupPath, "memory.max"), []byte(limit), 0644)
os.WriteFile(filepath.Join(cgroupPath, "memory.swap.max"), []byte("0"), 0644)
}
// Límite de PIDs, previene fork bombs
if resources.Pids != nil {
limit := strconv.FormatInt(resources.Pids.Limit, 10)
os.WriteFile(filepath.Join(cgroupPath, "pids.max"), []byte(limit), 0644)
}
// Mover el proceso init del container a este cgroup
return os.WriteFile(
filepath.Join(cgroupPath, "cgroup.procs"),
[]byte(strconv.Itoa(containerPid)),
0644,
)
}
Son solo escrituras a archivos. Eso es cgroups, el kernel expone una interfaz de filesystem, y usted escribirs valores de configuración a archivos. Hermosamente simple, terriblemente poderoso.
youki: runc en Rust
youki es una reescritura de runc en Rust, y no es solo un proyecto de vanidad. Hay razones de ingeniería reales para que te importe:
Tiempo de arranque: compará runtimes con la misma imagen, kernel, configuración de cgroups y ejecuciones repetidas. El repositorio de youki es la referencia de implementación. Esta guía no incluye un benchmark reproducible de arranque.
Seguridad de memoria: Rust puede prevenir categorías de errores de memoria en código seguro. No evita automáticamente fugas de descriptores ni escapes de contenedores. CVE 2024 21626 involucra descriptores filtrados y requiere analizar el comportamiento del runtime, no deducir su seguridad del lenguaje.
Reemplazo drop-in: youki pasa los tests completos de compliance del OCI runtime-spec:
# Reemplazar runc con youki en containerd
# /etc/containerd/config.toml
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
BinaryName = "/usr/local/bin/youki"
Reiniciás containerd, y cada container en la máquina ahora usa youki. Hice esto en un cluster de staging hace tres meses y literalmente nadie se dio cuenta. Ese es el mejor tipo de cambio de infraestructura.
kata-containers: cuando los namespaces no alcanzan
Una limitación fundamental de los containers estándar es que los namespaces son un mecanismo de aislamiento de userspace. Particionan la vista del kernel de los recursos, pero cada container comparte el mismo kernel. Un exploit de kernel en un container compromete todos los containers del host.
kata-containers resuelve esto corriendo cada container dentro de una VM liviana (microVM):
Container estándar:
┌──────────┐ ┌──────────┐ ┌──────────┐
│Container1│ │Container2│ │Container3│
├──────────┴─┴──────────┴─┴──────────┤
│ Kernel del Host │ ← Superficie de ataque compartida
└─────────────────────────────────────┘
kata-container:
┌──────────┐ ┌──────────┐ ┌──────────┐
│Container1│ │Container2│ │Container3│
├──────────┤ ├──────────┤ ├──────────┤
│ Kernel G │ │ Kernel G │ │ Kernel G │ ← Kernels separados
├──────────┤ ├──────────┤ ├──────────┤
│ VMM │ │ VMM │ │ VMM │
├──────────┴─┴──────────┴─┴──────────┤
│ Kernel del Host │
└─────────────────────────────────────┘
El overhead de rendimiento es real pero manejable. Para nuestra plataforma multi-tenant, usamos kata con Cloud Hypervisor para workloads de clientes y runc para nuestros servicios de infraestructura propios. La API RuntimeClass en Kubernetes lo hace trivial:
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: kata-clh
handler: kata-clh
overhead:
podFixed:
memory: "60Mi"
cpu: "50m"
---
apiVersion: v1
kind: Pod
metadata:
name: workload-no-confiable
spec:
runtimeClassName: kata-clh
containers:
- name: codigo-usuario
image: customer-function:latest
resources:
limits:
memory: "256Mi"
cpu: "500m"
gVisor: el término medio
gVisor (runsc) toma un approach diferente. En vez de correr una VM completa, interpone un kernel de userspace que intercepta syscalls del proceso del container:
Arquitectura gVisor:
┌──────────────────┐
│ App Container │
├──────────────────┤
│ Sentry │ ← Kernel userspace (intercepta syscalls)
│ (implementa │
│ ~380 syscalls) │
├──────────────────┤
│ Gofer │ ← Proxy de filesystem (sandboxeado)
├──────────────────┤
│ Kernel del Host │ ← Solo Sentry habla con el kernel del host
└──────────────────┘
Los trade-offs comparados con kata:
- Startup: gVisor agrega ~50ms (vs ~150ms de kata)
- Memoria: gVisor usa ~15MB de overhead (vs ~40-80MB de kata)
- Compatibilidad: gVisor no soporta todos los syscalls, apps que usan syscalls poco comunes (como io_uring) pueden no funcionar
- Rendimiento: Workloads con muchos syscalls sufren un hit de rendimiento de 2-10x
seccomp: la última línea de defensa
Sin importar qué runtime uses, seccomp filtra las syscalls que un container puede hacer. El perfil seccomp default de Docker bloquea 44 syscalls de ~380+:
# Perfilá tu aplicación real para encontrar syscalls necesarias
$ strace -c -f tu-aplicacion 2>&1 | tail -20
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
35.42 0.182346 12 15234 write
22.18 0.114218 8 14028 read
8.91 0.045889 45 1020 io_uring_enter # ¡Necesitás esta!
El árbol de decisión de runtimes
Después de correr los cuatro runtimes en producción, así decido:
runc: Default para workloads internos de confianza. Bajo overhead, máxima compatibilidad, modelo de seguridad bien entendido. El 90% de los containers deberían correr en runc.
youki: Reemplazo drop-in de runc cuando se desea mejor tiempo de startup y las garantías de memory safety de Rust. No hay razón para no usarlo si te soportás software más nuevo.
kata-containers: Workloads no confiables que necesitan aislamiento a nivel VM. Plataformas multi-tenant, FaaS, ejecución de código de clientes. Aceptá los ~150ms de overhead de startup y ~40MB de overhead de memoria.
gVisor: Cuando se necesita mejor aislamiento que runc pero no te es posible soportar el overhead de recurse es de kata. Bueno para procesamiento batch de datos no confiables, servidores web manejando input no confiable. Ojo con la compatibilidad de syscalls.
El ecosistema de containers está convergiendo en un modelo de seguridad por capas: namespaces para aislamiento básico, cgroups para límites de recursos, seccomp para filtrado de syscalls, y capas opcionales de VM/sandbox para defensa en profundidad. Entender estas capas, y saber qué perillas girar, es la diferencia entre containers que están "probablemente bien" y containers en los que apostarías tu empresa.