La lección anterior terminó con una objeción de peso: una máquina virtual aísla muy bien, pero para conseguirlo carga con un núcleo entero, un init, un sistema de ficheros completo, entre 200 y 500 MB de RAM solo para existir y decenas de segundos de arranque. Si lo que quieres es ejecutar tu aplicación con sus dependencias, sin que vea el resto del sistema y sin que se coma la máquina, estás pagando por un sistema operativo que no necesitas.
Los contenedores responden a eso con un cambio de perspectiva radical, y esta es la frase que debes llevarte de toda la lección: un contenedor no es una máquina virtual pequeña; es un proceso normal del núcleo anfitrión al que se le ha limitado lo que ve y lo que consume. No hay hipervisor, no hay núcleo invitado, no hay emulación de hardware. Hay un fork y un exec como los de 02-01, con tres ingredientes añadidos: espacios de nombres que le mienten sobre qué existe, grupos de control que le acotan cuánto puede usar, y un conjunto de restricciones de seguridad —capabilities, seccomp, AppArmor— que ya conoces del módulo 5.
Aquí vas a ver los tres ingredientes por dentro, uno a uno y con demostraciones que puedes ejecutar. Después entenderás de verdad overlayfs, que es lo que convierte una imagen en algo compartible entre cien contenedores, y cerraremos empaquetando meteo-api en una imagen con un Dockerfile comentado línea a línea. La orquestación y la nube son la lección siguiente; aquí nos quedamos en la máquina.
Contenido
- La idea central: procesos limitados, no máquinas pequeñas
- Espacios de nombres, uno a uno
- Inspección real de los espacios de nombres en
/proc - cgroups v2: la jerarquía unificada
- Los controladores en la práctica:
cpu,memory,ioypids - Cómo usa systemd los cgroups
- El tercer ingrediente: seguridad dentro del contenedor
- Sistemas de archivos en capas:
overlayfsde verdad - Qué es una imagen y qué es un contenedor
- Runtimes, estándares OCI y la pila real
- Práctica: empaquetar
meteo-api - Contenedores frente a máquinas virtuales
- Riesgos concretos y buenas prácticas
La idea central: procesos limitados, no máquinas pequeñas
Empecemos por una comprobación que desmonta la intuición equivocada. Si en un anfitrión Linux arrancas un contenedor y luego miras los procesos desde fuera:
docker run -d --name prueba alpine sleep 3600 # arrancar un contenedor
ps -eo pid,user,comm | grep sleep # buscarlo DESDE EL ANFITRIÓN
# 8123 root sleepQué demuestra. Ahí está: sleep es un proceso del anfitrión, con PID 8123 en la tabla de procesos del anfitrión, planificado por el CFS-EEVDF del anfitrión, con sus páginas gestionadas por el gestor de memoria del anfitrión. No hay ningún núcleo intermedio. Si haces kill 8123 desde fuera, el contenedor muere. Si el anfitrión entra en pánico, el contenedor muere. Compara con una VM, donde desde fuera solo verías un qemu-system-x86 y jamás los procesos de dentro.
La diferencia con un proceso normal está solo en lo que ese proceso percibe:
| Pregunta que se hace el proceso | Proceso normal | Proceso "contenido" |
|---|---|---|
| ¿Qué ficheros existen? | El árbol del anfitrión | Solo su raíz propia (mnt) |
| ¿Qué otros procesos hay? | Todos | Solo los suyos, y él es el PID 1 (pid) |
| ¿Qué red y qué nombre tengo? | Los del anfitrión | Los suyos propios (net, uts) |
| ¿Cuánta RAM y CPU hay? | Toda la máquina | Lo que le deje su cgroup |
De ahí se deducen las tres consecuencias que gobiernan todo lo demás: arranque instantáneo, porque no hay que arrancar un núcleo sino solo hacer clone() y execve() (milisegundos, no segundos); densidad altísima, porque sin núcleo invitado ni init el coste base son unos pocos megabytes; y aislamiento más débil, porque se comparte el núcleo y la frontera pasa a ser la tabla de llamadas al sistema —de 300 a 400 puntos de entrada— frente al puñado de manejadores de VM exit de 06-01.
Espacios de nombres, uno a uno
Un espacio de nombres (namespace) envuelve un recurso global del sistema de forma que los procesos que están dentro ven su propia instancia de ese recurso. Linux tiene ocho. Ya los citamos en 03-02 como los flags CLONE_NEW* de clone(); ahora los abrimos.
Hay tres llamadas al sistema implicadas: clone() con los flags CLONE_NEW* crea un proceso ya dentro de espacios nuevos, unshare() mueve al proceso actual a espacios nuevos, y setns() mete un proceso en un espacio existente (es lo que hace docker exec). La herramienta unshare envuelve las dos primeras, y todos los ejemplos que siguen se pueden ejecutar.
mnt: espacio de nombres de montaje
Fue el primero (Linux 2.4.19, año 2002) y es el que da al contenedor su propio sistema de ficheros. Retoma directamente 04-03: la tabla de montajes no es global, cada proceso ve la suya, y /proc/<pid>/mountinfo dice cuál.
sudo unshare --mount bash
mount -t tmpfs tmpfs /mnt # dentro: un tmpfs propio
echo "solo yo veo esto" > /mnt/secreto.txt
ls /mnt # secreto.txt
# En OTRA terminal del anfitrión: ls /mnt → vacíoQué ha pasado. El montaje existe de verdad, pero solo dentro de ese espacio de nombres. Al salir de la shell, el espacio desaparece y el montaje con él, sin necesidad de desmontar nada. Este es el mecanismo que permite que un contenedor monte lo que quiera sin ensuciar el anfitrión.
Ahora bien, tener tu propia tabla de montajes no basta: hay que cambiar la raíz. Aquí conviene distinguir tres cosas que se confunden:
chroot() cambia el / de ese proceso, pero el sistema de ficheros anterior sigue montado y alcanzable: no es seguridad. pivot_root() cambia la raíz del espacio de nombres de montaje y permite desmontar la antigua: sí lo es, combinado con mnt. Y el montaje bind expone un subárbol en otro punto con opciones distintas: es una herramienta, no una barrera.
Sobre chroot hay que ser tajante, porque es un error clásico: chroot nunca fue un mecanismo de seguridad, y su propia página de manual lo dice. Un proceso con CAP_SYS_CHROOT escapa con una receta conocida desde los años 90 —abrir un directorio, hacer chroot a un subdirectorio y subir después con chdir("..") desde el descriptor abierto, porque el núcleo comprueba la raíz solo en las rutas absolutas—, y también creando un nodo de dispositivo con mknod para leer el disco crudo, o con ptrace sobre un proceso de fuera.
Por eso los contenedores usan pivot_root dentro de un espacio de nombres de montaje propio, y después desmontan la raíz antigua: entonces no queda ninguna referencia al sistema de ficheros del anfitrión, no porque esté prohibida, sino porque ya no está montado en ese espacio. Esa es la diferencia entre esconder algo y quitarlo.
pid: espacio de nombres de procesos
Da al contenedor su propia tabla de PID. El primer proceso dentro es el PID 1.
sudo unshare --pid --fork --mount-proc bash
ps aux
# USER PID ... COMMAND
# root 1 ... bash
# root 12 ... ps aux ← solo dos procesos en todo el "sistema"
echo $$ # 1Por qué hacen falta las tres opciones, que es la pregunta que todo el mundo se hace: --pid crea el espacio nuevo; --fork es obligatorio porque quien llama a unshare() no entra en él, solo sus hijos, de modo que sin --fork la shell seguiría en el espacio antiguo; y --mount-proc remonta /proc —lo que implica también un espacio de montaje nuevo—, porque sin eso ps seguiría leyendo el /proc del anfitrión y mostraría todos los procesos: ps no consulta al núcleo, lee ficheros.
Ese detalle de --mount-proc es muy instructivo: demuestra que el aislamiento de PID lo da el núcleo, pero la vista que tienes depende de qué /proc estés leyendo. Un contenedor mal construido que monte el /proc del anfitrión enseña todos los procesos de la máquina.
Y aquí reaparece un tema de 02-01: los zombis. El núcleo impone al PID 1 dos responsabilidades especiales. Primero, adoptar a los huérfanos y hacerles wait() para liberar su entrada en la tabla de procesos: si el PID 1 del contenedor es tu aplicación y no hace wait(), los zombis se acumulan hasta agotar el límite de PID. Y segundo, gestionar las señales, porque al PID 1 no se le aplican las acciones por defecto: si no instala un manejador para SIGTERM, la señal se ignora. La consecuencia práctica es que docker stop envía SIGTERM, el proceso lo ignora, y a los 10 segundos llega un SIGKILL: el contenedor tarda siempre 10 segundos en pararse y nunca cierra ordenadamente sus ficheros.
La solución estándar es usar un init mínimo como PID 1 (tini, dumb-init, o --init en Docker), que reenvía señales y cosecha zombis, dejando la aplicación como PID 2.
net: espacio de nombres de red
Da al contenedor su propia pila de red: interfaces, direcciones, tablas de rutas, reglas de nftables y puertos. Es la razón de que cien contenedores puedan escuchar todos en el puerto 443 sin colisionar.
sudo ip netns add meteo-ns # crear un espacio de red
sudo ip netns exec meteo-ns ip link # solo hay 'lo', y caída
sudo ip link add veth-host type veth peer name veth-cont # cable virtual
sudo ip link set veth-cont netns meteo-ns # un extremo, dentro
sudo ip addr add 10.10.0.1/24 dev veth-host && sudo ip link set veth-host up
sudo ip netns exec meteo-ns ip addr add 10.10.0.2/24 dev veth-cont
sudo ip netns exec meteo-ns sh -c 'ip link set veth-cont up; ip link set lo up'
sudo ip netns exec meteo-ns ping -c1 10.10.0.1 # ¡funciona!Qué hace, paso a paso. Un par veth es un cable virtual con dos extremos: lo que entra por uno sale por el otro. Se crean los dos en el anfitrión y luego se mueve uno al espacio de nombres del contenedor. A partir de ahí son dos máquinas conectadas por un cable: cada extremo tiene su IP y se hablan. Para conectar muchos contenedores, el extremo del anfitrión se enchufa a un puente (br0, o docker0), que actúa como conmutador exactamente igual que el puente de las VM en 06-01. Y para dar salida a internet se añade NAT con nftables, que es literalmente lo que hace Docker por ti.
Fíjate en el paralelismo: el mecanismo de red es conceptualmente idéntico al de una máquina virtual (interfaz virtual + puente + NAT). Lo que cambia es que aquí la pila TCP/IP sigue siendo la del anfitrión, solo que instanciada varias veces.
uts: nombre de máquina y de dominio
El más simple. UTS viene de UNIX Time-Sharing System, por la estructura que devuelve uname().
sudo unshare --uts bash
hostname meteo-api-c1 ; hostname # meteo-api-c1
exit ; hostname # meteo-01 ← el anfitrión, intactoParece cosmético, pero no lo es: muchísimo software (registros, clústeres, licencias, métricas) se identifica por el nombre de máquina, y sin este espacio de nombres todos los contenedores dirían llamarse igual que el anfitrión.
ipc: comunicación entre procesos
Aísla los objetos IPC de System V —memoria compartida, colas de mensajes, semáforos— y las colas POSIX, es decir, buena parte de lo que estudiamos en 03-03.
ipcmk -M 1024 ; ipcs -m | tail -2 # crear un segmento y verlo
sudo unshare --ipc bash
ipcs -m # ¡vacío! no ve el del anfitriónPor qué importa. Los identificadores IPC de System V son un espacio de nombres plano y global: dos aplicaciones que elijan la misma clave chocan. Sin este aislamiento, dos contenedores con la misma aplicación se pisarían los segmentos. Un matiz importante para Meteora: la caché de /dev/shm/meteora-cache no es System V, es memoria compartida POSIX, que se implementa como ficheros en /dev/shm y por tanto la aísla el espacio de nombres de montaje, no el ipc. Son dos mecanismos distintos con el mismo nombre coloquial, y confundirlos lleva a errores reales.
user: la pieza clave de seguridad
Es el más reciente de los importantes (Linux 3.8) y, sin discusión, la mejora de seguridad más importante en la historia de los contenedores. Permite mapear rangos de UID y GID: un proceso puede ser root (UID 0) dentro del espacio de nombres y corresponder a un usuario sin privilegios fuera.
unshare --user --map-root-user bash # ¡sin sudo!
id -u # 0 ← soy root aquí dentro
cat /proc/self/uid_map
# 0 1000 1
touch /etc/prueba # Permission deniedQué acaba de pasar, que es lo importante. El uid_map dice: "el UID 0 de dentro es el UID 1000 de fuera, con un rango de longitud 1". Dentro del espacio de nombres tengo todas las capabilities: puedo crear otros namespaces, montar sistemas de ficheros, cambiar el nombre de máquina. Pero cuando toco un objeto del anfitrión —/etc, que pertenece al UID 0 real— el núcleo evalúa los permisos con el UID real, el 1000, y me deniega.
Las consecuencias son enormes. Habilita los contenedores sin privilegios (rootless), donde un usuario normal crea contenedores completos sin sudo y sin un demonio privilegiado, que es el modelo de Podman. Acota el daño de un escape: quien rompa el aislamiento aterriza en el anfitrión como el UID 1000, no como root, lo que convierte una catástrofe en un incidente. Y cumple el mínimo privilegio de 05-01 en su forma más pura: la aplicación cree tener root y no lo tiene.
El precio es una cierta complejidad: los ficheros creados dentro pertenecen a UID mapeados (de ahí newuidmap, /etc/subuid y los rangos de 65.536 UID por usuario), y algunas operaciones —montar ciertos sistemas de ficheros, usar puertos por debajo del 1024— siguen sin estar permitidas.
cgroup y time
cgroup (Linux 4.6) oculta la posición real del proceso en la jerarquía de grupos de control: dentro del contenedor, su cgroup parece ser la raíz, y sin él un proceso leería en /proc/self/cgroup la ruta completa del anfitrión, filtrando la topología del sistema. time (Linux 5.6) permite desplazar los relojes CLOCK_MONOTONIC y CLOCK_BOOTTIME; su caso de uso real es la migración de contenedores con CRIU, donde al restaurar en otra máquina el tiempo de arranque debe seguir siendo coherente. No afecta al reloj de pared, que sigue siendo el del anfitrión.
Resumen
| Namespace | Flag | Qué aísla | Desde |
|---|---|---|---|
mnt |
CLONE_NEWNS |
Tabla de montajes, sistema de ficheros | 2.4.19 |
uts |
CLONE_NEWUTS |
Nombre de máquina y de dominio | 2.6.19 |
ipc |
CLONE_NEWIPC |
IPC System V y colas POSIX | 2.6.19 |
pid |
CLONE_NEWPID |
Numeración de procesos | 2.6.24 |
net |
CLONE_NEWNET |
Interfaces, rutas, puertos, nftables |
2.6.29 |
user |
CLONE_NEWUSER |
Mapeo de UID/GID y capabilities | 3.8 |
cgroup |
CLONE_NEWCGROUP |
Vista de la jerarquía de cgroups | 4.6 |
time |
CLONE_NEWTIME |
Relojes monótono y de arranque | 5.6 |
Lo que no aíslan, y conviene tener muy claro: el reloj de pared, los sysctl que no están namespaced, los módulos del núcleo, el estado del hardware, dmesg y —sobre todo— el núcleo mismo. Un fallo del núcleo lo es para todos.
Inspección real de los espacios de nombres en /proc
Los espacios de nombres se ven en /proc/<pid>/ns/, donde cada uno es un enlace simbólico cuyo destino incluye un número de inodo. Dos procesos con el mismo inodo comparten ese espacio de nombres.
ls -l /proc/self/ns/ # mnt -> 'mnt:[4026531841]', pid -> 'pid:[4026531836]', ...
# Comparación directa con el proceso de un contenedor
PID=$(docker inspect -f '{{.State.Pid}}' prueba)
for ns in mnt pid net uts ipc user cgroup; do
printf "%-7s host=%-22s cont=%s\n" "$ns" \
"$(readlink /proc/self/ns/$ns)" "$(sudo readlink /proc/$PID/ns/$ns)"
doneCómo leer la salida. Donde los dos inodos coincidan, el contenedor comparte ese espacio con el anfitrión; donde difieran, está aislado. En un Docker por defecto verás distintos mnt, pid, net, uts e ipc, e iguales user y a veces cgroup: eso significa que ese contenedor no usa espacio de usuario, y por tanto el root de dentro es el root de fuera. Es la comprobación más útil de toda la lección para auditar un despliegue.
Dos comandos complementarios: lsns -t pid -t net resume todos los espacios del sistema con su proceso raíz, y nsenter -t $PID -m -u -n -p -i bash entra en los espacios de otro proceso. nsenter usa setns() y es en esencia lo que hace docker exec; también es la herramienta de depuración definitiva, porque permite entrar en la red de un contenedor que no tiene ni ping ni ss instalados, llevándose las del anfitrión.
cgroups v2: la jerarquía unificada
Los namespaces controlan lo que se ve. Los grupos de control controlan lo que se consume. Son mecanismos ortogonales: se pueden usar por separado, y de hecho systemd lleva años usando cgroups sin namespaces para todos tus servicios.
La versión 1 tenía una jerarquía por controlador: un árbol para cpu, otro para memory, otro para blkio, y un proceso podía estar en sitios incoherentes de cada uno. Fue una fuente inagotable de confusión. cgroups v2 (por defecto en Debian 11+, Fedora 31+, RHEL 9+) impone una jerarquía unificada: un solo árbol montado en /sys/fs/cgroup, donde cada proceso está en un único nodo y los controladores se activan por rama.
mount | grep cgroup # cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,...)
cat /sys/fs/cgroup/cgroup.controllers # cpuset cpu io memory hugetlb pids ...
cat /proc/self/cgroup # 0::/user.slice/user-1000.slice/session-3.scopeQué significa. El 0:: indica jerarquía unificada (en v1 habría varias líneas numeradas). La ruta dice exactamente en qué nodo del árbol está tu shell, y refleja la organización que hace systemd.
Dos reglas de v2 provocan errores desconcertantes y hay que conocerlas. La regla "no procesos internos" dice que solo los nodos hoja pueden contener procesos, de modo que mover uno a un nodo intermedio devuelve EBUSY sin más explicación. Y la delegación explícita: para que un hijo pueda usar un controlador, el padre debe habilitarlo escribiendo +cpu +memory en su cgroup.subtree_control; olvidarlo es la causa número uno de "he escrito el límite y no pasa nada".
Los controladores en la práctica: cpu, memory, io y pids
Vamos a crear un grupo para el agregador, que es la carga de Meteora que puede desbocarse al procesar los 17.280.000 bytes del fichero diario, y aplicarle límites reales.
echo "+cpu +memory +io +pids" | sudo tee /sys/fs/cgroup/cgroup.subtree_control
sudo mkdir -p /sys/fs/cgroup/meteora-agregador # crear el grupo: basta un mkdir
cd /sys/fs/cgroup/meteora-agregador && ls
# cpu.max cpu.stat io.max memory.max memory.high memory.current
# memory.events pids.max pids.current cgroup.procs cgroup.controllers ...Qué acaba de ocurrir. Un simple mkdir ha creado un grupo de control con todos sus ficheros de configuración y de estadísticas. Es el sistema de ficheros virtual cgroup2 que mencionamos en la tabla de 04-03: los ficheros no existen en disco, los genera el núcleo al leerlos. Toda la API de cgroups es leer y escribir ficheros de texto.
cpu: cuota y peso
# Límite duro: 50.000 µs de CPU cada 100.000 µs → medio núcleo
echo "50000 100000" | sudo tee cpu.max
# Peso relativo en caso de contención (por defecto 100, rango 1-10000)
echo "50" | sudo tee cpu.weightQué hacen y en qué se diferencian, que es la distinción que más se confunde. cpu.max es una cuota absoluta: en cada periodo de 100 ms el grupo puede consumir 50 ms, y cuando los agota sus tareas se estrangulan (throttling) hasta el siguiente periodo, aunque la máquina esté completamente ociosa; es un techo. cpu.weight es la versión cgroup del nice de 02-02: solo importa cuando hay contención y reparte proporcionalmente —con pesos 50 y 100, uno recibe un tercio y otro dos tercios si ambos quieren CPU; si uno está ocioso, el otro usa el 100 %—.
El problema del estrangulamiento con cuotas mal puestas merece un párrafo aparte, porque es una de las patologías más frecuentes y peor diagnosticadas del mundo de los contenedores. Imagina meteo-api con cpu.max = 100000 100000 (un núcleo) y una aplicación con 4 hilos. Los 4 hilos pueden ejecutarse en paralelo en 4 núcleos físicos: consumen los 100 ms de cuota en 25 ms de tiempo real, y luego quedan 75 ms completamente parados. El resultado es una latencia de cola espantosa —peticiones que tardan 80 ms cuando deberían tardar 2— con la CPU media del contenedor en un tranquilizador 100 %, es decir, sin ninguna señal obvia de problema.
El diagnóstico está en un fichero concreto:
cat cpu.stat
# usage_usec 4820193
# nr_periods 2500
# nr_throttled 1840 ← estrangulado en el 73 % de los periodos
# throttled_usec 138200000 ← 138 segundos parado a la fuerzaSi nr_throttled es una fracción alta de nr_periods, tienes un problema de cuota, no de código. Las soluciones: subir la cuota, reducir el número de hilos de la aplicación para que coincida con la cuota, o usar cpu.weight en lugar de cuota si el objetivo era prioridad y no techo.
memory: el límite y el OOM por grupo
echo "512M" | sudo tee memory.max # techo duro: al superarlo, OOM del grupo
echo "400M" | sudo tee memory.high # umbral blando: presión y reclamo agresivo
echo "0" | sudo tee memory.swap.max # prohibir swap para este grupomemory.max es el techo absoluto: cuando el grupo lo supera y no se puede reclamar nada, se invoca el OOM killer acotado al grupo, que mata un proceso de dentro y no del sistema. Esta es la diferencia crucial con 02-04, donde el agregador desbocado podía provocar la muerte de meteo-api: con cgroups, un fallo global se convierte en un fallo local. memory.high no mata; cuando se supera, el núcleo estrangula al proceso y reclama memoria agresivamente. Es un freno progresivo, y la buena práctica es ponerlo un 20-25 % por debajo de memory.max para que la aplicación tenga ocasión de comportarse antes de morir.
Verificación del efecto:
echo $$ | sudo tee cgroup.procs # meter el shell en el grupo
python3 -c "d=bytearray(600*1024*1024)" # pedir 600 MB con un techo de 512
# Killed
cat memory.events
# low 0 / high 128 / max 47 / oom 1 / oom_kill 1Cómo leerlo. memory.events es el mejor diagnóstico de memoria en contenedores: high 128 dice que se cruzó el umbral blando 128 veces (hubo presión), max 47 que se tocó el techo 47 veces, y oom_kill 1 que hubo que matar un proceso. Un contenedor que reinicia con código 137 (128 + 9, es decir SIGKILL) y tiene oom_kill distinto de cero está muriendo por memoria, no por un fallo de la aplicación.
io: limitar el disco
Sobre lo visto en 02-05, el controlador io acota el ancho de banda y las IOPS por dispositivo, identificado por su par mayor:menor.
lsblk -o NAME,MAJ:MIN /dev/md0 # p. ej. 9:0
# Máximo 20 MB/s de lectura, 10 MB/s de escritura, 200 IOPS de escritura
echo "9:0 rbps=20971520 wbps=10485760 wiops=200" | sudo tee io.maxPara qué sirve en Meteora. El caso real es el trabajo nocturno que comprime los ficheros diarios: sin límite satura el RAID 1 y las consultas de meteo-api sufren latencias de cientos de milisegundos por espera de disco; con io.max el trabajo tarda más pero no daña al servicio. Es el razonamiento del ionice de 02-05, con un techo garantizado en lugar de una prioridad.
Un matiz importante: io.max regula bien las lecturas y las escrituras directas, pero las que pasan por la caché de página se contabilizan cuando las vuelca el hilo de escritura diferida, no cuando las hace la aplicación; para esas es más efectivo io.latency o el control combinado con memory.
pids: el freno contra las bombas de bifurcación
Es un límite de una sola línea que evita que un contenedor con un bucle de fork() —accidental o malicioso— agote la tabla de procesos del anfitrión entero e impida hasta iniciar sesión. Cuesta nada ponerlo y es de los pocos límites que no tienen contrapartida. Ponlo siempre.
Cómo usa systemd los cgroups
Aquí viene una revelación útil: ya llevas cinco módulos usando cgroups sin saberlo. systemd organiza todo el sistema en una jerarquía de cgroups con tres tipos de unidad: los slices (system.slice, user.slice) son ramas del árbol para repartir recursos, los services (meteo-api.service) agrupan los procesos de un servicio, y los scopes (session-3.scope) agrupan procesos creados externamente.
systemd-cgls # árbol completo de cgroups del sistema
systemd-cgtop # como 'top', pero por cgroup
systemctl show meteo-api.service -p ControlGroup
# ControlGroup=/system.slice/meteo-api.service
# En caliente, sin reiniciar (con --runtime, sin persistir):
sudo systemctl set-property meteo-api.service MemoryMax=768MY los límites se declaran con directivas de unidad, que systemd traduce a los ficheros que acabamos de escribir a mano:
[Service]
CPUQuota=50% # → cpu.max "50000 100000"
CPUWeight=200 # → cpu.weight
MemoryMax=512M # → memory.max
MemoryHigh=400M # → memory.high
TasksMax=100 # → pids.max
IOReadBandwidthMax=/dev/md0 20M # → io.maxPor qué esto importa. Recuerda el aviso de 02-02: nice no limita el consumo. Aquí está el mecanismo que sí lo hace, y está disponible sin contenedores: añadir MemoryMax y TasksMax a la unidad endurecida de 05-03 aporta la mitad del beneficio de contenerizar con una décima parte del cambio. Y cuando ejecutas un contenedor, Docker o Podman escriben en esos mismos ficheros.
El tercer ingrediente: seguridad dentro del contenedor
Namespaces y cgroups no son mecanismos de seguridad completos. Un proceso root dentro de un contenedor sin espacio de usuario es root en el anfitrión, y le sobran caminos para escapar si tiene capabilities suficientes. Por eso todo runtime serio aplica, además, las herramientas de 05-01:
| Capa | Qué aporta | Cómo se aplica |
|---|---|---|
| Espacio de usuario | El root de dentro no es el de fuera | --userns-remap, Podman rootless |
| Capabilities recortadas | Se elimina casi todo el poder de root | --cap-drop=ALL --cap-add=NET_BIND_SERVICE |
| seccomp y AppArmor/SELinux | Se bloquean syscalls peligrosas y se añade MAC | Perfiles por defecto: ~44 syscalls de ~350 bloqueadas |
no-new-privileges |
Ningún execve puede ganar privilegio |
--security-opt=no-new-privileges |
El perfil seccomp por defecto de Docker es superficie mínima bien aplicada: bloquea mount, reboot, kexec_load, init_module, bpf y keyctl, entre otras, y ha neutralizado por sí solo varias vulnerabilidades del núcleo antes de que existiera el parche; desactivarlo "porque así funciona" es una de las peores decisiones habituales. Y no-new-privileges activa el bit PR_SET_NO_NEW_PRIVS de la unidad endurecida de 05-03, de modo que ningún execve posterior pueda aumentar privilegios: neutraliza de un plumazo cualquier setuid residual en la imagen, en una línea y sin contrapartidas.
La conclusión honesta: un contenedor es seguro cuando se combinan las tres cosas. Namespaces sin capabilities recortadas ni espacio de usuario es aislamiento de juguete.
Sistemas de archivos en capas: overlayfs de verdad
Nos falta el ingrediente que explica por qué los contenedores se distribuyen tan bien. Si cada contenedor necesitara su copia completa de un sistema de ficheros Debian (unos 120 MB), cien contenedores serían 12 GB de disco duplicado y cien copias distintas en la caché de página.
overlayfs resuelve esto superponiendo directorios. Ya apareció en la tabla de 04-03; ahora lo abrimos. Tiene cuatro piezas:
| Directorio | Papel |
|---|---|
lowerdir |
Una o varias capas de solo lectura, apiladas (la primera de la lista es la de arriba) |
upperdir |
La capa escribible. Aquí van todos los cambios |
workdir / merged |
Espacio de trabajo interno del núcleo (junto a upperdir) y punto de montaje con la vista unificada |
Las reglas de resolución son sencillas y merecen memorizarse. Para leer, se busca de arriba abajo y gana la primera capa que tenga el fichero. Para escribir un fichero que está en lower se hace copy-up: se copia entero a upperdir y se modifica allí, sin tocar nunca la capa inferior. Y para borrar un fichero de lower, como no se puede borrar lo que es de solo lectura, se crea en upperdir un whiteout —un dispositivo de caracteres 0:0 con ese nombre— que oculta el de abajo.
Demostración manual, que es la mejor forma de entenderlo:
cd /tmp && mkdir -p ovl/{base,extra,upper,work,merged}
echo "config de la imagen base" > ovl/base/meteora.conf
echo "binario base" > ovl/base/meteo-api
echo "parche de la capa 2" > ovl/extra/meteo-api
sudo mount -t overlay overlay \
-o lowerdir=ovl/extra:ovl/base,upperdir=ovl/upper,workdir=ovl/work \
ovl/merged
ls ovl/merged # meteo-api meteora.conf
cat ovl/merged/meteo-api # "parche de la capa 2" ← gana la capa superiorQué demuestra. En lowerdir=ovl/extra:ovl/base, extra está encima de base. Los dos tienen meteo-api, y gana el de extra. meteora.conf solo está en base y se ve igualmente. Así se construye una imagen: cada instrucción del Dockerfile añade una capa encima.
Ahora el copy-up y el whiteout:
echo "modificado por el contenedor" >> ovl/merged/meteora.conf
ls -l ovl/upper/ # ¡ahora meteora.conf está aquí, copiado entero!
cat ovl/base/meteora.conf # la capa base, intacta
rm ovl/merged/meteo-api
ls ovl/merged # ya no está
ls -l ovl/upper/meteo-api # c--------- 1 root root 0, 0 ... meteo-api
# ← un whiteout: dispositivo de caracteres 0:0
sudo umount ovl/mergedLa consecuencia práctica más importante. El copy-up copia el fichero completo la primera vez que se escribe, aunque cambies un solo byte: abrir para escritura un fichero de 2 GB que viene de la imagen copia 2 GB antes de que tu escritura ocurra. Por eso los datos que cambian van en un volumen montado y nunca en la capa escribible, y por eso los ficheros diarios de 17,3 MB de /var/lib/meteora/lecturas/ no deben estar dentro de la imagen. Y la razón de que la técnica funcione tan bien: cien contenedores de la misma imagen comparten las mismas capas inferiores, en disco y en la caché de página, de modo que ocupan 120 MB y no 12 GB. Es el clonado enlazado de 06-01 llevado a su extremo.
Qué es una imagen y qué es un contenedor
Con overlayfs explicado, la distinción se vuelve trivial y deja de ser una fuente de confusión:
| Imagen | Contenedor | |
|---|---|---|
| Qué es | Capas de solo lectura + metadatos (JSON) | Una imagen + una capa escribible + namespaces + cgroups |
| Estado | Inmutable, identificada por un digest SHA-256 | Efímero, con estado propio |
| Analogía | El binario en disco | El proceso en ejecución |
| Cuántos | Una | Muchos contenedores por imagen |
La analogía binario/proceso es exacta y resuelve casi todas las dudas de principiante: igual que un /usr/bin/python3 da lugar a muchos procesos independientes, una imagen da lugar a muchos contenedores independientes, cada uno con su capa escribible. De ahí el corolario que más disgustos evita: todo lo que un contenedor escribe en su capa escribible desaparece cuando el contenedor se borra. Los datos van en volúmenes.
Runtimes, estándares OCI y la pila real
La palabra "Docker" designa coloquialmente varias capas que conviene separar, porque en producción rara vez se usan todas:
La pila va de arriba abajo así: CLI (docker, podman, nerdctl) → runtime de alto nivel (containerd o CRI-O: imágenes, red, almacenamiento, ciclo de vida) → shim (containerd-shim, conmon) → runtime de bajo nivel (runc, crun: crea namespaces, escribe cgroups, hace pivot_root y ejecuta) → núcleo Linux.
runc es el runtime de bajo nivel de referencia, y hace exactamente lo que hemos visto a mano en esta lección: lee un JSON de configuración, crea los namespaces, escribe los cgroups, hace pivot_root, aplica capabilities y seccomp, y ejecuta el proceso; su trabajo dura milisegundos y después desaparece (crun es una reimplementación en C, más rápida). containerd gestiona lo de alto nivel: descargar imágenes, montar los overlays, configurar redes y supervisar el ciclo de vida. Docker añade encima la experiencia de usuario, la construcción de imágenes y una API, con un demonio que se ejecuta como root que es su principal debilidad. Y Podman hace lo mismo sin demonio —cada contenedor es un hijo directo del usuario— y sin privilegios, apoyándose en el espacio de nombres de usuario, con una CLI compatible e integración con systemd.
Los estándares OCI (Open Container Initiative, 2015) hacen que todo esto sea intercambiable: la image-spec define el formato de las imágenes, la runtime-spec cómo se ejecuta un contenedor a partir de un directorio y un JSON, y la distribution-spec el protocolo de los registros. Gracias a ellas, una imagen construida con Docker se ejecuta con Podman o CRI-O sin cambios, y Kubernetes habla con containerd o CRI-O sin necesitar Docker en absoluto.
Práctica: empaquetar meteo-api
Vamos a construir la imagen de meteo-api, respetando todas las convenciones del curso: UID 990, usuario sin shell, puerto 443, configuración en /etc/meteora/meteora.conf y datos en /var/lib/meteora.
# ---------- Etapa 1: construcción ----------
FROM python:3.12-slim AS constructor
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/instalacion -r requirements.txt
# ---------- Etapa 2: imagen final ----------
FROM python:3.12-slim
# Usuario de servicio con el MISMO UID que en meteo-01, sin shell
RUN groupadd --system --gid 990 meteora \
&& useradd --system --uid 990 --gid 990 \
--home-dir /var/lib/meteora --no-create-home \
--shell /usr/sbin/nologin meteora
# Solo lo instalado en la etapa anterior: ni compiladores ni cabeceras
COPY --from=constructor /instalacion /usr/local
WORKDIR /app
COPY --chown=990:990 app/ /app/
# Directorios de datos y registros, con la propiedad correcta
RUN mkdir -p /var/lib/meteora/lecturas /var/log/meteora \
&& chown -R 990:990 /var/lib/meteora /var/log/meteora \
&& chmod 750 /var/lib/meteora
USER 990:990
EXPOSE 8443
ENV PYTHONUNBUFFERED=1 METEORA_CONF=/etc/meteora/meteora.conf
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD python3 -c "import urllib.request as u,sys; sys.exit(0 if u.urlopen('http://127.0.0.1:8443/salud',timeout=2).status==200 else 1)"
ENTRYPOINT ["python3", "-m", "meteo_api"]
CMD ["--puerto", "8443"]Línea a línea, y el porqué de cada decisión:
FROM python:3.12-slim: base mínima oficial. La varianteslimpesa ~130 MB frente a ~1 GB de la completa, y cada megabyte de más es superficie de ataque y CVE que parchear. Se fija la versión menor (3.12) para reproducibilidad; en producción se fija incluso el digest SHA-256.- Construcción multietapa: la etapa
constructorinstala las dependencias, que pueden requerir compilador y cabeceras. La imagen final solo copia el resultado. Así elgccy las herramientas de desarrollo no viajan a producción: ni pesan ni sirven a un atacante para compilar un exploit dentro. groupadd/useraddcon UID 990: el mismo UID que enmeteo-01. Esto no es cosmético: cuando montes/var/lib/meteoradel anfitrión como volumen, el núcleo compara números, no nombres. Un UID distinto dentro y fuera daPermission deniedincomprensibles.--shell /usr/sbin/nologincumple la regla de 05-02 para cuentas de servicio.COPY --chown=990:990ychmod 750: se fija la propiedad al copiar, porque unchown -Rposterior sobre ficheros ya copiados duplicaría esa capa en el overlay por el copy-up que acabamos de estudiar; y los permisos son coherentes con elumask 027del curso.USER 990:990: la línea más importante de todo el fichero. Sin ella el proceso se ejecuta como root dentro del contenedor, y sin espacio de nombres de usuario eso es root en el anfitrión. Con ella, un compromiso de la aplicación no da ni siquiera root de contenedor.EXPOSE 8443: se usa 8443 y no 443 a propósito, porque un proceso sin privilegios no puede abrir puertos por debajo de 1024 sinCAP_NET_BIND_SERVICE(05-01); en lugar de conceder la capability, se escucha alto y se publica en el 443 desde fuera.HEALTHCHECK: el runtime ejecuta la comprobación periódicamente y marca el contenedor comounhealthysi falla tres veces, que es lo que permite a un orquestador reemplazarlo;--start-periodda margen al arranque.ENTRYPOINTen forma exec (lista JSON, no cadena): en forma de cadena el proceso se lanza bajo/bin/sh -c, la shell es el PID 1, no reenvíaSIGTERMy vuelves al problema de las señales.
Construcción y ejecución con todos los límites que hemos estudiado:
docker build -t meteora/meteo-api:1.4.0 .
docker run -d --name meteo-api \
--user 990:990 \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m \
--cap-drop=ALL --cap-add=NET_BIND_SERVICE \
--security-opt=no-new-privileges \
--pids-limit=100 \
--cpus=1.5 --memory=512m --memory-reservation=400m \
--restart=on-failure:5 \
-v /etc/meteora/meteora.conf:/etc/meteora/meteora.conf:ro \
-v meteora-datos:/var/lib/meteora \
-p 443:8443 \
--init \
meteora/meteo-api:1.4.0Cada opción y a qué mecanismo de esta lección corresponde:
| Opción | Mecanismo | Efecto |
|---|---|---|
--read-only + --tmpfs /tmp |
Montajes (mnt) |
Sistema de ficheros inmutable; equivale a ProtectSystem=strict de 05-03 |
--cap-drop=ALL --cap-add=... + no-new-privileges |
Capabilities (05-01) y PR_SET_NO_NEW_PRIVS |
De ~14 capabilities a una, y ningún setuid residual sirve |
--pids-limit=100 y --cpus=1.5 |
cgroups pids.max y cpu.max |
Bomba de bifurcación contenida y techo de CPU |
--memory=512m --memory-reservation=400m |
memory.max y memory.high |
OOM acotado al contenedor |
-v ...meteora.conf:ro |
Montaje bind (04-03) | La configuración con secretos entra de fuera y en solo lectura |
-v meteora-datos:/var/lib/meteora |
Volumen | Los datos sobreviven al contenedor y evitan el copy-up |
-p 443:8443 |
net + NAT |
El anfitrión publica en 443 lo que el contenedor sirve en 8443 |
--init |
PID 1 | tini como PID 1: reenvía señales y cosecha zombis |
Y la verificación, que es donde se cierra el círculo con toda la lección:
CID=$(docker inspect -f '{{.State.Pid}}' meteo-api)
sudo grep -E 'Uid|CapEff|NoNewPrivs|Seccomp' /proc/$CID/status
# Uid: 990 990 990 990 / CapEff: 0000000000000400 (solo CAP_NET_BIND_SERVICE)
# NoNewPrivs: 1 / Seccomp: 2 (modo filtro activo)
cat /sys/fs/cgroup/system.slice/docker-*.scope/{memory.max,cpu.stat}Contenedores frente a máquinas virtuales
La comparación honesta, sin las exageraciones habituales de cada bando:
| Aspecto | Máquina virtual | Contenedor |
|---|---|---|
| Qué aísla | Hardware completo, núcleo propio | Vista y consumo, núcleo compartido |
| Arranque | 20-60 s (BIOS, núcleo, init, servicios) |
20-200 ms |
| Sobrecarga de memoria | 200-500 MB por instancia | 1-10 MB |
| Sobrecarga de CPU | 1-3 % | ~0 % (es un proceso normal) |
| Tamaño de imagen | 1-20 GB | 20-300 MB, con capas compartidas |
| Densidad por anfitrión | Decenas | Cientos o miles |
| Superficie de ataque expuesta | Manejadores de VM exit + virtio |
300-400 llamadas al sistema |
| Aislamiento de fallos | Un pánico afecta a una VM | Un pánico afecta a todo |
SO, núcleos o sysctl distintos |
Sí (Windows sobre Linux) | No: mismo núcleo, misma familia |
La conclusión práctica no es "uno gana", sino qué frontera necesitas. Si es una frontera de seguridad entre partes que no se fían —inquilinos distintos, código de clientes, cumplimiento normativo estricto—, máquina virtual. Si lo que buscas es empaquetado, densidad y velocidad de despliegue dentro de un ámbito de confianza, contenedor. Y lo habitual en la industria son los dos: contenedores dentro de máquinas virtuales, tal como anticipamos al final de 06-01.
Riesgos concretos y buenas prácticas
| Práctica peligrosa | Por qué es grave | Qué hacer |
|---|---|---|
--privileged |
Todas las capabilities, sin seccomp ni AppArmor, todos los dispositivos: escapar es tan trivial como montar el disco del anfitrión | Si algo lo "necesita", el problema es el diseño |
Montar /var/run/docker.sock |
Equivale a root en el anfitrión sin matices: quien habla con ese socket lanza un contenedor privilegiado con / dentro |
Evitarlo; si es imprescindible, un proxy que filtre la API |
| Ejecutar como root dentro | Es el comportamiento por defecto sin USER, y sin espacio de usuario ese root es el del anfitrión |
USER 990:990 + --userns-remap o Podman rootless |
| Imágenes sin verificar ni actualizar | latest no es una versión, es una lotería; la base acumula CVE aunque tu código no cambie |
Digests fijos, registro propio, trivy/grype, reconstrucción periódica |
| Secretos en la imagen | Un COPY de meteora.conf o un ENV TOKEN=... quedan en la capa para siempre; docker history los recupera |
Secretos en tiempo de ejecución: bind de solo lectura o gestor de secretos |
| Límites ausentes | Sin --memory se dispara el OOM killer del anfitrión, con el efecto de 02-04 sobre todo lo demás |
Límites de memoria y de PID siempre |
Errores Comunes y Consejos
Llamar "máquina virtual ligera" a un contenedor, u olvidar --fork con unshare --pid. Lo primero lleva a decisiones malas: instalar systemd y sshd dentro, ejecutar varios procesos, tratarlo como un servidor con estado; un contenedor es un proceso empaquetado, sin estado local y reemplazable. Lo segundo es la causa del clásico "he creado el namespace y no ha pasado nada": el proceso que llama a unshare() no entra en el nuevo espacio de PID, solo sus hijos.
No habilitar los controladores en cgroup.subtree_control. Escribes en memory.max y no ocurre nada, o el fichero ni siquiera existe: el padre debe delegar el controlador antes.
Poner una cuota de CPU sin ajustar los hilos, o guardar datos en la capa escribible. Lo primero es la patología descrita arriba —con --cpus=1 y 8 hilos se consume la cuota en un octavo del periodo y el resto se pasa estrangulado, con latencias de cola horribles y sin síntomas evidentes; revisa nr_throttled—. Lo segundo hace que los datos desaparezcan al borrar el contenedor, y además dispara un copy-up completo en cada modificación de un fichero grande.
Usar ENTRYPOINT en forma de cadena y construir imágenes gigantes. Lo primero convierte a /bin/sh en PID 1, que ignora SIGTERM y no reenvía señales: parada sucia y siempre lenta; usa la forma exec (["prog","arg"]) y --init si tu proceso genera hijos. Lo segundo —un FROM ubuntu con apt install build-essential— da 1,5 GB, decenas de CVE y un arsenal de herramientas para un atacante: multietapa, bases slim o distroless, y .dockerignore para no meter el .git entero.
Consejo: audita con /proc/<pid>/status. Los campos Uid, CapEff, NoNewPrivs y Seccomp del proceso de un contenedor dicen en cuatro líneas si el despliegue está bien hecho. Es más fiable que leer la documentación del runtime.
Consejo: empieza por systemd, y prueba primero sin privilegios. Si tu problema es limitar recursos y aislar un servicio en un servidor concreto, MemoryMax, TasksMax y las directivas de 05-03 dan casi todo el beneficio sin cambiar el modelo de despliegue; contenerizar tiene sentido cuando además necesitas empaquetado reproducible. Y cuando lo hagas, arranca con Podman rootless o --userns-remap: si funciona, has ganado la mejora de seguridad más grande disponible, y si no, el fallo te dirá exactamente qué privilegio pide tu aplicación y por qué.
Ejercicios
Ejercicio 1: construir un contenedor a mano, sin runtime
Sin usar Docker ni Podman, construye un "contenedor" mínimo para el agregador con solo unshare, mount y los ficheros de /sys/fs/cgroup. Debe cumplir: (a) espacios propios de PID, montaje, UTS y red; (b) /proc propio, de forma que ps aux solo muestre sus procesos; (c) nombre de máquina agregador-c1; (d) límite de 256 MB de memoria y medio núcleo de CPU; (e) máximo de 50 procesos. Escribe los comandos, verifica cada requisito, e indica qué le falta para considerarse seguro.
Ejercicio 2: diagnosticar dos contenedores enfermos
Dos contenedores de Meteora dan problemas. Para cada uno, di cuál es la causa exacta, con qué comando lo confirmarías y cómo lo arreglarías.
Contenedor A — meteo-api, límite --cpus=1, aplicación con 4 hilos de trabajo. Los usuarios dicen que "a veces va lentísimo". CPU media del contenedor 98 %, memoria estable en 210 MB, latencia p50 de 3 ms y p99 de 340 ms. cpu.stat: nr_periods 6000, nr_throttled 4380, throttled_usec 271000000. memory.events: todo a 0.
Contenedor B — agregador, límite --memory=512m. Se reinicia cada pocas horas con código de salida 137 y sin ningún mensaje en su registro. Procesa el fichero diario de 17.280.000 bytes cargándolo entero en memoria. memory.events: high 0, max 312, oom_kill 5; memory.high está en max.
Ejercicio 3: revisar un Dockerfile y una ejecución
Encuentra todos los problemas de seguridad y de eficiencia en lo siguiente, explica el riesgo concreto de cada uno y escribe la versión corregida.
FROM ubuntu:latest
RUN apt-get update && apt-get install -y python3 python3-pip curl git build-essential
COPY . /app
COPY /etc/meteora/meteora.conf /etc/meteora/meteora.conf
ENV METEORA_TOKEN=sk-prod-8f3a91c4b2
RUN pip3 install -r /app/requirements.txt
RUN chmod 777 -R /app /var/lib/meteora
EXPOSE 443
CMD python3 /app/meteo_api.pydocker run -d --privileged --net=host \
-v /:/host -v /var/run/docker.sock:/var/run/docker.sock \
meteora/api:latestSoluciones
Solución 1
# 1. cgroup con los límites, ANTES de arrancar
echo "+cpu +memory +pids" | sudo tee /sys/fs/cgroup/cgroup.subtree_control
G=/sys/fs/cgroup/agregador-c1 ; sudo mkdir -p $G
echo "268435456" | sudo tee $G/memory.max # 256 MB
echo "50000 100000" | sudo tee $G/cpu.max # 0,5 núcleo
echo "50" | sudo tee $G/pids.max
# 2. Namespaces
sudo unshare --pid --fork --mount-proc --mount --uts --net bash
# 3. Dentro: identidad y entrada en el grupo
hostname agregador-c1 ; ip link set lo up
echo $$ > /sys/fs/cgroup/agregador-c1/cgroup.procsExplicación. El cgroup se crea primero porque hay que habilitar los controladores en el padre antes de que existan los ficheros de límite en el hijo; --fork es obligatorio porque quien llama a unshare() no entra en el espacio de PID nuevo; --mount-proc implica --mount y remonta /proc para que ps lea la tabla correcta; y meter el PID en cgroup.procs incluye al proceso y a todos sus futuros hijos, que heredan el grupo.
Verificaciones:
echo $$ ; ps aux | wc -l # (a,b) 1, y 3-4 líneas en vez de cientos
hostname ; ip link # (c) agregador-c1 ; (a) solo 'lo'
python3 -c "d=bytearray(300*1024*1024)" # (d) Killed
grep oom_kill /sys/fs/cgroup/agregador-c1/memory.events # oom_kill 1
for i in $(seq 60); do sleep 100 & done # (e) falla al llegar a 50
sudo readlink /proc/$PID_DE_DENTRO/ns/pid # distinto de /proc/self/ns/pidQué le falta para ser seguro, y es mucho: no hay espacio de nombres de usuario, así que el root de dentro es el root de fuera —el defecto más grave—; no hay pivot_root, de modo que sigue viendo todo el sistema de ficheros del anfitrión, /etc/shadow incluido; no se han recortado capabilities, así que conserva CAP_SYS_ADMIN y CAP_SYS_MODULE; no hay seccomp ni perfil AppArmor ni no-new-privileges; y /dev y /sys son los del anfitrión, con acceso a dispositivos de bloque crudos. La conclusión pedagógica es la de la lección: namespaces y cgroups por sí solos no son seguridad, son la mitad visible del mecanismo, y la otra mitad son las restricciones del módulo 5.
Solución 2
Contenedor A: estrangulamiento de CPU por cuota mal dimensionada.
Causa. nr_throttled 4380 sobre nr_periods 6000 significa estrangulamiento en el 73 % de los periodos, con 271 segundos de parada forzada acumulada. Con --cpus=1 la cuota es de 100 ms por periodo de 100 ms, pero los 4 hilos se ejecutan en paralelo y la agotan en ~25 ms de tiempo real; en los 75 ms restantes todos los hilos están detenidos, y una petición que llega en ese hueco espera al siguiente periodo: de ahí un p99 de 340 ms con un p50 de 3 ms. La "CPU media del 98 %" engaña porque se mide contra la cuota, no contra la máquina.
Confirmación. Comparar nr_throttled con nr_periods en cpu.stat: cualquier cociente por encima del 5 % ya es sospechoso. Y nproc dentro del contenedor devolverá los núcleos del anfitrión, que es justo lo que engaña a las bibliotecas que dimensionan sus pools de hilos.
Arreglo, en orden de preferencia: ajustar los hilos a la cuota, porque con 1 CPU de cuota no hay paralelismo real que aprovechar con 4 hilos; subir la cuota a --cpus=2 o 4 si la carga lo justifica; o, si lo que se quería era prioridad y no techo, sustituir la cuota por cpu.weight, que no estrangula nunca cuando hay CPU libre. Como medida general, exponer la cuota a la aplicación para que dimensione sus pools correctamente.
Contenedor B: OOM del cgroup por pico de memoria previsible.
Causa. El código de salida 137 = 128 + 9 indica SIGKILL, y oom_kill 5 confirma que fue el OOM killer del cgroup, no un fallo de la aplicación: por eso el registro está vacío, el proceso muere sin oportunidad de escribir. El max 312 dice que se tocó el techo 312 veces. La causa concreta es cargar los 17.280.000 bytes del fichero diario de una vez: con la sobrecarga de Python (objetos y copias intermedias) el pico multiplica varias veces los 17 MB crudos, y si se acumulan resultados de varios días se rebasan los 512 MB.
Confirmación. docker inspect -f '{{.State.OOMKilled}}' devuelve true, memory.events muestra oom_kill, y journalctl -k | grep -i "memory cgroup out of memory" en el anfitrión da la traza del núcleo con el proceso elegido.
Arreglo en dos frentes. Inmediato: fijar memory.high —que estaba en max, es decir, desactivado— un 20-25 % por debajo del techo (--memory-reservation=400m) para que el núcleo aplique presión antes de matar, y subir --memory si el consumo legítimo lo requiere, midiendo antes el pico real con memory.peak. De fondo, que es el arreglo bueno: procesar el fichero por bloques. Como las lecturas son registros de tamaño fijo de 24 bytes, se puede leer en trozos de 64 KB (2.730 lecturas) o mapear el fichero con mmap (02-04) para que el núcleo gestione y reclame las páginas. El consumo pasaría de cientos de megabytes a unos pocos, y el sistema quedaría además independiente del tamaño del fichero diario.
Solución 3
Problemas del Dockerfile:
| # | Problema | Riesgo concreto |
|---|---|---|
| 1 | ubuntu:latest |
No reproducible: la misma construcción da imágenes distintas según el día |
| 2 | build-essential, git, curl en la imagen final |
~500 MB extra, decenas de CVE y herramientas de ataque listas |
| 3 | COPY . /app |
Mete .git (historial y posibles secretos), .env y ficheros locales; además invalida la caché de pip install en cada cambio de código |
| 4 | COPY de meteora.conf y ENV METEORA_TOKEN=... |
Los secretos quedan en la capa para siempre, recuperables con docker history o desempaquetando |
| 5 | Sin USER y chmod 777 -R |
Se ejecuta como root y cualquiera puede escribir en el código y en los datos: viola todo 04-06 |
| 6 | EXPOSE 443 |
Puerto privilegiado: obliga a root o a CAP_NET_BIND_SERVICE |
| 7 | CMD en forma de cadena, sin HEALTHCHECK |
/bin/sh como PID 1 ignora SIGTERM; y nadie detecta un proceso vivo pero inservible |
Problemas de la ejecución:
| # | Problema | Riesgo concreto |
|---|---|---|
| 8 | --privileged |
Todas las capabilities, sin seccomp ni AppArmor, todos los dispositivos: escape trivial |
| 9 | -v /:/host |
El sistema de ficheros entero del anfitrión, escribible: se edita /etc/shadow o /etc/sudoers |
| 10 | -v /var/run/docker.sock |
Equivale a root en el anfitrión: se lanza otro contenedor privilegiado |
| 11 | --net=host, sin límites, etiqueta latest |
Se ven todos los puertos locales; un fallo agota memoria o PID del anfitrión; y no se sabe qué versión está en producción |
Versión corregida:
FROM python:3.12-slim@sha256:<digest> AS constructor
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/instalacion -r requirements.txt
FROM python:3.12-slim@sha256:<digest>
RUN groupadd --system --gid 990 meteora \
&& useradd --system --uid 990 --gid 990 --no-create-home \
--shell /usr/sbin/nologin meteora
COPY --from=constructor /instalacion /usr/local
WORKDIR /app
COPY --chown=990:990 app/ /app/
USER 990:990
EXPOSE 8443
HEALTHCHECK --interval=30s --retries=3 CMD ["python3", "/app/salud.py"]
ENTRYPOINT ["python3", "/app/meteo_api.py"]Con un .dockerignore que excluya .git, .env, *.conf, tests/ y __pycache__/, y con la ejecución exactamente igual a la del apartado 11 (--user 990:990, --read-only, --cap-drop=ALL, no-new-privileges, --pids-limit, --cpus, --memory, volúmenes, -p 443:8443 y --init), sobre la etiqueta 1.4.0 y no latest.
Los cambios clave: la construcción multietapa deja fuera compiladores y herramientas; el digest fijado la hace reproducible; el COPY de la configuración desaparece y el secreto entra en tiempo de ejecución como bind de solo lectura; USER 990:990 y --cap-drop=ALL implantan el mínimo privilegio; el puerto pasa a 8443 y se publica en el 443, evitando toda capability de red; ENTRYPOINT en forma exec más --init arreglan señales y zombis; y --privileged, -v /:/host, el socket del demonio y --net=host se eliminan sin sustituto, porque no había ninguna razón legítima para ninguno de los cuatro.
Conclusión
La frase que resume la lección es la que la abrió: un contenedor no es una máquina virtual pequeña, es un proceso normal del núcleo anfitrión al que se le ha limitado lo que ve y lo que consume. Lo demuestra un ps desde fuera: el proceso está ahí, en la tabla del anfitrión, planificado por el mismo CFS-EEVDF de 02-02 y con sus páginas gestionadas por el mismo gestor de 02-04. De ahí salen las tres consecuencias que gobiernan todo: arranque en milisegundos, densidad de cientos por máquina, y un aislamiento más débil porque la frontera son las 300-400 llamadas al sistema del núcleo compartido.
Los espacios de nombres son el primer ingrediente y controlan lo que se ve: mnt da un sistema de ficheros propio —con pivot_root como mecanismo correcto y chroot como el que nunca fue seguridad—, pid da una tabla de procesos propia con las dos trampas del PID 1 (adoptar zombis y no ignorar SIGTERM), net da una pila de red propia mediante pares veth y un puente, exactamente como una VM, uts da el nombre de máquina, ipc aísla System V —pero no /dev/shm, que lo aísla mnt—, cgroup oculta la posición en la jerarquía, time habilita la migración, y user es la mejora de seguridad más importante de todas, porque mapea el root de dentro a un usuario sin privilegios de fuera y convierte un escape catastrófico en un incidente acotado. Todo ello se audita en /proc/<pid>/ns/ comparando inodos, y se recorre con lsns y nsenter.
Los cgroups v2 son el segundo ingrediente y controlan lo que se consume, con una jerarquía unificada donde toda la API es escribir ficheros de texto en /sys/fs/cgroup: cpu.max como techo absoluto —con el estrangulamiento como patología estrella cuando los hilos no encajan con la cuota, diagnosticable en nr_throttled— frente a cpu.weight como prioridad relativa; memory.max con OOM acotado al grupo, que convierte el desastre global de 02-04 en un fallo local, y memory.high como freno progresivo; io.max para que el trabajo nocturno no ahogue al servicio; y pids.max, una línea que contiene cualquier bomba de bifurcación. Y la revelación práctica: systemd ya usa cgroups para todos tus servicios, así que MemoryMax y TasksMax en la unidad endurecida de 05-03 dan buena parte del beneficio sin contenerizar nada.
El tercer ingrediente es la seguridad, y sin él los dos anteriores son aislamiento de juguete: capabilities recortadas, el perfil seccomp por defecto que ha neutralizado vulnerabilidades del núcleo antes de que existiera el parche, AppArmor o SELinux, y no-new-privileges. overlayfs completa el cuadro explicando la economía del modelo: lowerdir de solo lectura, upperdir escribible, copy-up que copia el fichero entero al primer cambio y whiteouts para borrar lo imborrable; de ahí que cien contenedores de Debian ocupen 120 MB y no 12 GB, que la imagen sea al contenedor lo que el binario al proceso, y que los datos vayan en volúmenes y nunca en la capa escribible. Encima se apila la pila real —runc haciendo en milisegundos lo que aquí hicimos a mano, containerd, Docker con su demonio root frente a Podman sin demonio y sin privilegios— unificada por los estándares OCI.
Y la práctica cerró el círculo: Dockerfile multietapa con base mínima, usuario UID 990 para que el volumen de /var/lib/meteora tenga los permisos correctos, puerto 8443 para no necesitar capability de red, HEALTHCHECK y ENTRYPOINT en forma exec; y una ejecución donde cada opción corresponde a un mecanismo estudiado, verificable en cuatro líneas de /proc/<pid>/status. Frente a las máquinas virtuales, la conclusión no es que uno gane, sino qué frontera necesitas: VM para separar lo que no se fía, contenedor para empaquetar y densificar dentro de un ámbito de confianza, y en la industria, casi siempre, los dos a la vez.
Ahora bien: todo lo que hemos hecho ha sido sobre una máquina. Hemos limitado, aislado y empaquetado meteo-api, pero sigue habiendo un meteo-01 concreto, con un disco concreto, que alguien instaló y que si se avería deja el servicio caído. ¿Quién decide en qué máquina se ejecuta cada contenedor cuando hay treinta máquinas? ¿Qué pasa cuando una se muere a las tres de la madrugada? ¿Cómo se comparte un fichero de datos entre contenedores que ni siquiera están en el mismo servidor? ¿Y qué queda del sistema operativo cuando la máquina deja de ser un objeto físico y pasa a ser una llamada a una API que devuelve una instancia en cuarenta segundos —y que puede desaparecer con el mismo aviso—?
Ahí las peticiones y los límites que acabamos de escribir a mano en /sys/fs/cgroup se convierten en declaraciones de un fichero YAML, y el planificador de 02-02 reaparece un nivel más arriba, repartiendo contenedores entre nodos en lugar de procesos entre núcleos. Es El Sistema Operativo en la Nube.
Fundamentos de Sistemas Operativos
Módulo 1: Introducción a los Sistemas Operativos
- Conceptos Básicos de Sistemas Operativos
- Historia y Evolución de los Sistemas Operativos
- Tipos de Sistemas Operativos
- Funciones Principales de un Sistema Operativo
- Arquitectura del Núcleo: Monolítico, Microkernel e Híbrido
- Modo Usuario, Modo Núcleo y Llamadas al Sistema
Módulo 2: Gestión de Recursos
- Gestión de Procesos
- Planificación de la CPU
- Gestión de Memoria
- Memoria Virtual y Paginación
- Gestión de Almacenamiento
- Gestión de Dispositivos
- Controladores, Interrupciones y Operaciones de E/S
Módulo 3: Concurrencia
- Conceptos de Concurrencia
- Hilos y Procesos
- Comunicación entre Procesos (IPC)
- Sincronización y Exclusión Mutua
- Problemas Clásicos de Concurrencia
- Interbloqueos: Prevención, Detección y Recuperación
Módulo 4: Estructuras de Archivos
- Sistemas de Archivos
- Estructuras de Directorios
- Particiones, Montaje y Sistema de Archivos Virtual
- Gestión de Archivos
- Asignación de Espacio, Journaling e Integridad
- Seguridad y Permisos de Archivos
Módulo 5: Protección y Seguridad del Sistema
- Principios de Protección y Control de Acceso
- Usuarios, Autenticación y Escalada de Privilegios
- Amenazas Comunes y Endurecimiento del Sistema
- Auditoría, Registros y Respuesta a Incidentes
Módulo 6: Virtualización y Contenedores
- Virtualización: Hipervisores y Máquinas Virtuales
- Contenedores: Namespaces y cgroups
- El Sistema Operativo en la Nube
- Sistemas Operativos Móviles y de Tiempo Real
