Llevas seis módulos usando contenedores. Esta lección explica por qué funcionan. La tesis es incómoda y liberadora a la vez: un contenedor no existe. No hay ninguna entidad "contenedor" en el kernel de Linux. Hay procesos normales con tres mecanismos aplicados encima, y todos se pueden tocar a mano.
Contenido
- La tesis: tres mecanismos y ningún objeto
- Los siete namespaces
- El mismo proceso, dos PID distintos
/proc/<pid>/ns/,lsnsy comparar contenedores- Entrar a mano con
nsenter - Crear un namespace desde cero con
unshare - El user namespace y el remapeo de UID
- Cgroups v2: la jerarquía
- Los límites de
aurora-db, leídos del kernel - El OOM killer desde
memory.events - OverlayFS: el montaje real
- Copy-on-write en el
upperdir - Capabilities y seccomp a nivel de kernel
- Qué hace
runcexactamente - Docker Desktop: todo esto ocurre en una VM
- La tesis: tres mecanismos y ningún objeto
Cuando ejecutas docker run, no se crea ninguna estructura mágica. Se lanza un proceso corriente y se le aplican tres cosas:
| Mecanismo | Responde a | Lo que ves en Docker |
|---|---|---|
| Namespaces | ¿Qué ve el proceso? | Aislamiento de procesos, red, ficheros y usuarios |
| Cgroups | ¿Cuánto puede consumir? | --memory, --cpus, --pids-limit |
| Sistema de ficheros de capas | ¿Qué sistema de ficheros tiene? | Imágenes, capas, copy-on-write |
flowchart TB
P["Proceso normal de Linux<br/>(node src/server.js)"]
NS["NAMESPACES<br/>pid · net · mnt · uts<br/>ipc · user · cgroup"]
CG["CGROUPS v2<br/>memory.max · cpu.max<br/>pids.max · io.max"]
FS["OVERLAYFS<br/>lowerdir + upperdir<br/>= merged"]
P --> NS --> C(("Lo que llamamos<br/>«contenedor»"))
P --> CG --> C
P --> FS --> C
K["Kernel del host: UNO SOLO, compartido por todos"] --- C
Todo lo demás —imágenes, registros, Compose, healthchecks— es maquinaria construida alrededor de esas tres primitivas del kernel.
- Los siete namespaces
Un namespace es una vista parcial de un recurso global del sistema. Los procesos de un namespace ven su versión del recurso y no la de los demás.
| Namespace | Aísla | Opción de Docker |
|---|---|---|
pid |
Árbol de procesos | --pid=host lo desactiva; --pid=container:X lo comparte |
net |
Interfaces, rutas, iptables, puertos |
--network (lección 05-01) |
mnt |
Puntos de montaje | La raíz del contenedor y todos los -v |
uts |
Nombre de host y de dominio | --hostname |
ipc |
Memoria compartida, colas de mensajes | --ipc=host, --shm-size |
user |
Correspondencia de UID y GID | --userns-remap, modo rootless (lección 05-03) |
cgroup |
Vista de la jerarquía de cgroups | --cgroupns |
Un octavo, time (reloj monotónico), existe en el kernel desde 5.6 pero Docker aún no lo usa.
- El mismo proceso, dos PID distintos
Esta es la demostración que más rápido convence.
docker compose exec aurora-api ps -eo pid,comm
pid=$(docker inspect aurora-libros-aurora-api-1 --format '{{.State.Pid}}')
ps -o pid,ppid,user,comm -p "$pid"PID COMMAND <- desde DENTRO del contenedor
1 node
28 ps
PID PPID USER COMMAND <- desde el HOST
48122 4791 1000 nodeEs el mismo proceso. Dentro es el PID 1 —de ahí todo lo que aprendiste en la lección 03-02 sobre señales y PID 1—; en el host es el 48122, hijo del shim de containerd y ejecutándose como el usuario 1000. No hay ninguna máquina virtual: hay un node en tu lista de procesos que resulta que ve un árbol distinto al tuyo.
NSpid lo dice todo: 48122 en el namespace del host, 1 en el suyo. Un mismo proceso con dos identidades simultáneas.
/proc/<pid>/ns/, lsns y comparar contenedores
/proc/<pid>/ns/, lsns y comparar contenedoresCada namespace es un fichero especial cuyo inodo lo identifica:
cgroup -> cgroup:[4026532897]
ipc -> ipc:[4026532835]
mnt -> mnt:[4026532833]
net -> net:[4026532838]
pid -> pid:[4026532836]
user -> user:[4026531837]
uts -> uts:[4026532834]Comparar dos contenedores es comparar esos números:
pdb=$(docker inspect aurora-libros-aurora-db-1 --format '{{.State.Pid}}')
for ns in net pid mnt user; do
a=$(sudo readlink /proc/$pid/ns/$ns); b=$(sudo readlink /proc/$pdb/ns/$ns)
[ "$a" = "$b" ] && echo "$ns: COMPARTIDO" || echo "$ns: distinto"
done
echo "host user ns: $(sudo readlink /proc/1/ns/user)"El resultado más revelador es el último: aurora-api y aurora-db comparten el namespace de usuario, y además es el del host. Sin --userns-remap ni modo rootless, el UID 0 de dentro es literalmente el UID 0 del host. Ahí está, medida, la afirmación de la lección 05-03 sobre por qué root en un contenedor es peligroso.
sudo lsns -t net -o NS,PID,COMMAND | head -4
# 4026532838 48122 node src/server.js
# 4026532901 48310 postgres
# 4026532955 48502 nginx: master process
- Entrar a mano con
nsenter
nsenterdocker exec no es magia: es entrar en los namespaces del proceso y lanzar un comando. nsenter hace lo mismo, y sin pasar por Docker.
a91f3c8d2e10
app bin dev etc home lib proc root sys tmp usr var
eth0 UP 172.21.0.5/16
PID COMMAND
1 nodeLas banderas son exactamente los namespaces: -m (mnt), -u (uts), -i (ipc), -n (net), -p (pid). Puedes entrar solo en algunos, que es lo que hace útil la técnica: -n a secas te da la red del contenedor con las herramientas del host —justo lo que hacías en la lección 05-01— y funciona incluso con imágenes distroless que no tienen ni sh dentro.
- Crear un namespace desde cero con
unshare
unsharePara ver que no hay magia, construyamos uno a mano.
sudo unshare --pid --fork --mount-proc --uts --net --mount \
sh -c 'hostname aurora-manual; ps -eo pid,comm; ip -brief addr; hostname'Sin Docker, sin imágenes y sin daemon: un shell que se cree PID 1, con su propio nombre de host y una pila de red vacía. Eso es el aislamiento de un contenedor. Lo que Docker añade encima es todo lo demás: el sistema de ficheros de la imagen, la configuración de red con su veth y su puente, los cgroups, las capacidades, seccomp y una API para gestionarlo.
- El user namespace y el remapeo de UID
El namespace de usuario permite que un mismo UID signifique cosas distintas dentro y fuera. Es la base del modo rootless (lección 05-03).
unshare --user --map-root-user sh -c 'id -u; cat /proc/self/uid_map; cat /etc/shadow' 2>&1 | tail -3
id -uDentro eres root (id -u = 0) sin haber usado sudo; el mapa dice que el UID 0 de dentro corresponde al 1000 de fuera, con longitud 1. Fuera sigues siendo el 1000.
Root dentro, pero el kernel comprueba los permisos del fichero contra el UID real del host. Ese es el mecanismo exacto que hace que en modo rootless la escalada de la lección 05-03 deje de funcionar: no es una comprobación añadida por Docker, es aritmética de UID en el kernel.
- Cgroups v2: la jerarquía
Los control groups limitan y contabilizan recursos. La versión 2, unificada, es el estándar desde 2022 y se expone como un árbol de directorios en /sys/fs/cgroup.
docker info --format 'Cgroup Driver: {{.CgroupDriver}} | Version: {{.CgroupVersion}}'
ls /sys/fs/cgroup/system.slice/ | grep docker | head -1| Controlador | Ficheros clave | Qué gobierna |
|---|---|---|
memory |
memory.max, memory.current, memory.high, memory.events |
RAM y disparo del OOM killer |
cpu |
cpu.max, cpu.stat, cpu.weight |
Cuota de CPU y estrangulamiento |
io |
io.max, io.stat |
Ancho de banda y IOPS de disco |
pids |
pids.max, pids.current |
Número de procesos (defensa ante fork bomb) |
La diferencia esencial con la v1: en v2 un proceso pertenece a un único cgroup y todos los controladores actúan sobre él, en lugar de una jerarquía distinta por recurso. Eso simplifica enormemente el razonamiento.
- Los límites de
aurora-db, leídos del kernel
aurora-db, leídos del kernelAhora la comprobación que cierra el círculo con la lección 03-07 y con Compose: lo que declaraste en YAML, leído directamente del kernel.
id=$(docker inspect aurora-libros-aurora-db-1 --format '{{.Id}}')
cg=/sys/fs/cgroup/system.slice/docker-$id.scope
for f in memory.max memory.current memory.high pids.max pids.current cpu.max; do
printf '%-16s %s\n' "$f" "$(cat $cg/$f)"
donememory.max 2147483648
memory.current 432791552
memory.high max
pids.max 200
pids.current 14
cpu.max 200000 100000Traducción, línea a línea: memory.max son 2 147 483 648 bytes = 2 GiB, el limits: { memory: 2G } del compose.prod.yaml; memory.current es lo que muestra docker stats; pids.max es el pids_limit: 200; y cpu.max con 200000 100000 significa 200 ms de CPU por cada 100 ms de periodo, es decir, cpus: "2.0".
docker stats --no-stream --format '{{.Name}} {{.MemUsage}}' aurora-libros-aurora-db-1
# aurora-libros-aurora-db-1 412.7MiB / 2GiBCoincidencia exacta. docker stats no calcula nada: lee estos ficheros. Y cpu.max explica por fin la semántica de --cpus: no es un número de núcleos asignados, es una cuota de tiempo por periodo; con 2.0, el contenedor puede consumir 200 ms de CPU cada 100 ms, repartidos entre los núcleos que haya.
- El OOM killer desde
memory.events
memory.eventsdocker run -d --name victima --memory 64m --memory-swap 64m alpine:3 \
sh -c 'x=""; while true; do x="$x$(head -c 1048576 /dev/zero | tr "\0" "a")"; done'
sleep 6
id=$(docker inspect victima --format '{{.Id}}')
cat /sys/fs/cgroup/system.slice/docker-$id.scope/memory.events 2>/dev/null || echo "(cgroup ya eliminado)"
docker inspect victima --format 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}}'
docker rm -f victimaLos contadores cuentan la historia completa: max 412 son las veces que el proceso topó con el límite y el kernel tuvo que reclamar memoria; oom 1 indica que llegó un punto en que no pudo reclamar más; oom_kill 1 es la ejecución. El resultado, el código de salida 137 que conoces desde la lección 03-02, es simplemente 128 + 9: terminado por SIGKILL.
Un max alto con oom_kill 0 es una señal valiosísima y casi nunca vigilada: el contenedor no ha muerto, pero está luchando contra su límite y pagándolo en latencia. Es exactamente lo que la alerta MemoriaCercaDelLimite de la lección 05-06 detecta antes de que sea tarde.
- OverlayFS: el montaje real
overlay on / type overlay (rw,relatime,lowerdir=/var/lib/docker/overlay2/l/QW3F:/var/lib/docker/overlay2/l/K7RT:/var/lib/docker/overlay2/l/P2MN,upperdir=/var/lib/docker/overlay2/9be21c/diff,workdir=/var/lib/docker/overlay2/9be21c/work)La raíz / del contenedor es un montaje overlay, con los cuatro directorios de la lección 05-02. Y los lowerdir se corresponden uno a uno con las capas de la imagen:
docker image inspect auroralibros/aurora-api:1.3.0 --format '{{len .RootFS.Layers}} capas'
docker compose exec aurora-api sh -c 'mount|grep " / "' | grep -o 'lowerdir=[^,]*' | tr ':' '\n' | wc -l
# 3 capas
# 3Tres capas en el manifiesto de la imagen, tres lowerdir en el montaje. La correspondencia es literal: cada capa de la imagen es un directorio en el disco del host, y OverlayFS las apila en orden.
- Copy-on-write en el
upperdir
upperdirup=$(docker inspect aurora-libros-aurora-api-1 --format '{{.GraphDriver.Data.UpperDir}}')
docker compose exec aurora-api sh -c 'echo "nota" > /tmp/nota.txt' # 1) crear
docker compose exec aurora-api sh -c 'echo "// tocado" >> /app/package.json' # 2) modificar
docker compose exec aurora-api sh -c 'rm -f /app/src/util.js' # 3) borrar
sudo ls -l "$up/tmp/nota.txt" "$up/app/src/util.js"
docker diff aurora-libros-aurora-api-1 | head -3-rw-r--r-- 1 node node 5 ago 5 11:02 .../diff/tmp/nota.txt
c--------- 1 root root 0, 0 ago 5 11:02 .../diff/app/src/util.js
A /tmp/nota.txt
C /app/package.json
D /app/src/util.jsLas tres operaciones, con sus tres efectos distintos en el upperdir:
- Fichero nuevo: aparece tal cual. Es la
Adedocker diff. - Fichero modificado: el kernel hace copy-up —copia el original completo del
lowerdir— y escribe encima. Es laC, y es la operación cara que mediste en la lección 05-02. - Fichero borrado: aparece un dispositivo de caracteres 0/0, el famoso whiteout. No borra nada: oculta el fichero de la capa inferior, que sigue ocupando espacio. Es la
D, y es la razón de que borrar en una capa posterior no adelgace una imagen (lección 05-04).
Ahí está, en tres líneas de ls, la explicación física de las lecciones 01-05, 05-02 y 05-04.
- Capabilities y seccomp a nivel de kernel
Las capacidades de la lección 05-03 son una máscara de bits en el descriptor del proceso.
sudo grep -E '^(CapEff|CapBnd|Seccomp|NoNewPrivs)' /proc/$pid/status
capsh --decode=$(sudo awk '/^CapEff/{print $2}' /proc/$pid/status)CapEff a cero: aurora-api corre sin ninguna capacidad efectiva, exactamente como pediste con cap_drop: [ALL]. Compáralo con un contenedor por defecto:
docker run -d --name porDefecto alpine:3 sleep 60
p2=$(docker inspect porDefecto --format '{{.State.Pid}}')
capsh --decode=$(sudo awk '/^CapEff/{print $2}' /proc/$p2/status) | tr ',' '\n' | head -4
docker rm -f porDefectoDocker concede catorce capacidades por defecto. La diferencia entre esa lista y un CapEff de cero es toda la superficie que has eliminado, y ahora la ves como lo que es: bits en /proc.
Seccomp: 2 significa modo filtro BPF activo (0 sería desactivado); NoNewPrivs: 1 es el no-new-privileges:true del compose.prod.yaml. Todo lo que configuraste en YAML acaba siendo dos números en un fichero de /proc.
- Qué hace
runc exactamente
runc exactamenteLa cadena de la lección 01-03, ahora vista por dentro: dockerd → containerd → shim → runc → proceso.
runc es un binario pequeño que recibe un directorio con dos cosas: el sistema de ficheros ya montado y un config.json con la especificación OCI del contenedor.
{
"ociVersion": "1.2.0",
"process": {
"user": { "uid": 1000, "gid": 1000 },
"args": ["node", "src/server.js"],
"capabilities": { "bounding": [], "effective": [], "permitted": [] },
"noNewPrivileges": true
},
"root": { "path": "rootfs", "readonly": true },
"linux": {
"namespaces": [ { "type": "pid" }, { "type": "network" }, { "type": "ipc" },
{ "type": "uts" }, { "type": "mount" }, { "type": "cgroup" } ],
"resources": {
"memory": { "limit": 536870912 },
"cpu": { "quota": 200000, "period": 100000 },
"pids": { "limit": 200 }
},
"seccomp": { "defaultAction": "SCMP_ACT_ERRNO" }
}
}Reconoces cada línea: son tus opciones de docker run y de Compose traducidas al estándar. Los pasos de runc son: crear los namespaces (clone con las banderas CLONE_NEW*), escribir los cgroups, pivotar la raíz al rootfs (pivot_root), aplicar capacidades y seccomp, y finalmente execve del comando. Ahí termina su trabajo: runc sale, y el proceso queda huérfano bajo el shim.
Ese es el papel del shim (containerd-shim-runc-v2): mantener abiertos stdin/stdout/stderr, informar del código de salida y —lo importante— permitir que dockerd se reinicie sin matar tus contenedores, porque el padre no es el daemon sino el shim.
ps -o pid,ppid,comm -p "$pid"; ps -o pid,comm -p "$(ps -o ppid= -p $pid | tr -d ' ')"
# 48122 4791 node
# 4791 containerd-shimQue todo esto sea un estándar abierto (la OCI Runtime Specification) es lo que permite sustituir runc por crun, youki o gVisor sin cambiar nada más. Ese ecosistema es la lección 07-05.
- Docker Desktop: todo esto ocurre en una VM
Si estás en macOS o Windows, nada de lo anterior sucede en tu sistema operativo: namespaces y cgroups son mecanismos exclusivos del kernel de Linux. Docker Desktop ejecuta una máquina virtual Linux ligera y el daemon vive dentro.
| Aspecto | Linux | macOS / Windows con Docker Desktop |
|---|---|---|
| Dónde corre el daemon | En tu kernel | En una VM Linux |
/var/lib/docker |
Ruta de tu disco | Dentro del disco virtual de la VM |
ps aux del host |
Ves los procesos de los contenedores | No los ves: están en la VM |
| Bind mounts | Coste cero | Cruzan la frontera VirtioFS/WSL 2: más lentos |
nsenter, lsns, docker0, cgroups |
Directos | Solo dentro de la VM |
Para reproducir esta lección desde macOS o Windows, entra en la VM:
docker run -it --rm --privileged --pid=host justincormack/nsenter1 /bin/sh
# ya dentro de la VM:
ls /sys/fs/cgroup/ && lsns -t pid | head -3Y esto explica de paso las lecciones anteriores: la lentitud de los bind mounts (05-02 y 04-07) no es de Docker, es la frontera entre dos sistemas de ficheros; y por eso las rutas y los procesos "no aparecen" donde los esperas.
Errores Comunes y Consejos
Creer que un contenedor es una máquina ligera. Es un proceso con tres mecanismos encima y un solo kernel compartido. De ahí toda la lección 05-03.
Buscar cgroups v1 en un sistema moderno. Desde 2022 es v2 unificado: un cgroup por proceso y ficheros distintos (memory.max, no memory.limit_in_bytes). Y no escribas directamente en /sys/fs/cgroup: Docker lo sobrescribe al reconciliar; usa docker update o Compose.
Ejecutar nsenter sin -p esperando ver el árbol de procesos aislado. Cada namespace se entra por separado; sin -p verás los procesos del host.
Suponer que un oom_kill 0 significa que todo va bien. Un max alto indica presión de memoria constante y latencia, aunque nadie haya muerto.
Intentar lsns o /sys/fs/cgroup desde macOS. No existen fuera de Linux. Entra en la VM de Docker Desktop.
Consejo: cuando algo no cuadre, baja un nivel. Un límite que parece no aplicarse se comprueba en memory.max; un problema de red, en /proc/<pid>/ns/net y nsenter -n; un fichero que aparece o desaparece, en el upperdir. El kernel no miente y siempre se puede consultar.
Ejercicios
Ejercicio 1. Demuestra que un contenedor es un proceso del host: encuentra el PID real de aurora-api, comprueba que dentro es el PID 1, verifica con NSpid que es el mismo proceso y entra en su namespace de red con nsenter sin usar docker exec.
Ejercicio 2. Comprueba que los límites de Compose son exactamente los ficheros de cgroups: lee memory.max, cpu.max y pids.max de aurora-db, tradúcelos a las opciones que los produjeron, cámbialos con docker update y verifica que el fichero cambia en caliente.
Ejercicio 3. Provoca las tres operaciones de copy-on-write (crear, modificar y borrar) en aurora-api y localiza sus tres efectos distintos en el upperdir. Explica cuál de las tres explica que borrar ficheros no adelgace una imagen.
Soluciones
Solución 1.
pid=$(docker inspect aurora-libros-aurora-api-1 --format '{{.State.Pid}}')
echo "PID en el host: $pid"
docker compose exec aurora-api sh -c 'echo "PID dentro: $$"'
sudo grep NSpid /proc/$pid/status
sudo nsenter -t "$pid" -n ip -brief addr
sudo nsenter -t "$pid" -n -p -m ps -eo pid,comm | head -2PID en el host: 48122
PID dentro: 1
NSpid: 48122 1
eth0 UP 172.21.0.5/16
eth1 UP 172.22.0.4/16
PID COMMAND
1 nodeNSpid: 48122 1 es la prueba definitiva: un único proceso con dos identidades, la del namespace del host y la del suyo. No hay copia, ni emulación, ni máquina virtual; hay una tabla de correspondencias en el kernel.
Y nsenter demuestra lo segundo: has obtenido exactamente lo que da docker exec sin hablar con el daemon en ningún momento. Eso tiene dos consecuencias prácticas. La primera, de diagnóstico: si el daemon está colgado o la imagen es distroless y no tiene sh, nsenter sigue funcionando porque usa los binarios del host sobre los namespaces del contenedor. La segunda, de seguridad: quien tenga root en el host entra en cualquier contenedor sin dejar rastro en los logs de Docker, lo que refuerza por qué el acceso root al host es la frontera que de verdad importa.
Solución 2.
id=$(docker inspect aurora-libros-aurora-db-1 --format '{{.Id}}')
cg=/sys/fs/cgroup/system.slice/docker-$id.scope
printf 'memory.max %s | cpu.max %s | pids.max %s\n' \
"$(cat $cg/memory.max)" "$(cat $cg/cpu.max)" "$(cat $cg/pids.max)"| Fichero del kernel | Valor | Opción que lo produjo |
|---|---|---|
memory.max |
2 147 483 648 B = 2 GiB | deploy.resources.limits.memory: 2G |
cpu.max |
200000 / 100000 | cpus: "2.0" (200 ms por cada 100 ms) |
pids.max |
200 | pids_limit: 200 |
docker update --memory 1g --memory-swap 1g --cpus 1.5 aurora-libros-aurora-db-1
printf 'memory.max %s | cpu.max %s\n' "$(cat $cg/memory.max)" "$(cat $cg/cpu.max)"
docker stats --no-stream --format '{{.MemUsage}}' aurora-libros-aurora-db-1El cambio es instantáneo y sin reiniciar el contenedor: cgroups v2 permite reescribir el límite en caliente, y el proceso ni se entera hasta que intente superarlo. docker stats refleja el nuevo valor porque, literalmente, lee ese fichero.
La lectura de fondo es que docker run --memory y deploy.resources no son abstracciones de Docker: son una forma cómoda de escribir un número en /sys/fs/cgroup. Y la interpretación de cpu.max resuelve la confusión más común del curso: --cpus 1.5 no reserva un núcleo y medio, concede 150 ms de tiempo de CPU cada 100 ms de periodo, que pueden repartirse entre los ocho núcleos del host.
Solución 3.
up=$(docker inspect aurora-libros-aurora-api-1 --format '{{.GraphDriver.Data.UpperDir}}')
docker compose exec aurora-api sh -c 'echo hola > /tmp/nuevo.txt
echo "//x" >> /app/package.json
rm -f /app/src/util.js'
sudo stat -c '%n -> %F (%s bytes)' "$up/tmp/nuevo.txt" "$up/app/package.json" "$up/app/src/util.js"
docker diff aurora-libros-aurora-api-1 | grep -E 'nuevo|package.json|util.js'.../diff/tmp/nuevo.txt -> regular file (5 bytes)
.../diff/app/package.json -> regular file (612 bytes)
.../diff/app/src/util.js -> character special file (0 bytes)
A /tmp/nuevo.txt
C /app/package.json
D /app/src/util.jsTres operaciones, tres representaciones físicamente distintas en el upperdir:
| Operación | En el upperdir |
docker diff |
|---|---|---|
| Crear | Fichero normal de 5 bytes | A |
| Modificar | Fichero normal de 612 bytes: la copia completa, no el delta | C |
| Borrar | Dispositivo de caracteres 0/0 de 0 bytes: un whiteout | D |
La tercera es la que explica el fenómeno de la lección 05-04. El borrado no elimina nada del lowerdir —es de solo lectura, no se puede—: crea un marcador especial que hace que OverlayFS oculte el fichero al resolver la vista unificada. Dentro del contenedor, ls responde No such file or directory; en el disco, el fichero original sigue íntegro en su capa, se descarga en cada docker pull y se almacena en cada nodo.
De ahí la regla que aplicaste con el && y que ahora tiene explicación completa: si creas y borras dentro del mismo RUN, ambas operaciones ocurren antes de consolidar la capa, y el fichero jamás llega a existir en el lowerdir de nadie. Y por eso una credencial escrita y borrada en instrucciones distintas sigue siendo extraíble con docker save: no es un fallo de Docker, es cómo funciona un sistema de ficheros de unión.
Y fíjate en la segunda fila, que también tiene consecuencias: modificar un byte guarda el fichero entero en el upperdir. Con package.json son 612 bytes; con un fichero de datos de PostgreSQL serían cientos de megabytes. Ese es, medido en la lección 05-02 y explicado aquí, el motivo físico de que las bases de datos usen volúmenes.
Conclusión
Un contenedor no existe. Lo que existe es un proceso normal de Linux —lo has visto con su PID real en ps del host y su NSpid con dos identidades— al que el kernel aplica tres mecanismos: namespaces para lo que ve, cgroups para lo que consume y OverlayFS para el sistema de ficheros que tiene. Conoces los siete namespaces y qué opción de Docker se apoya en cada uno, sabes leerlos en /proc/<pid>/ns/, compararlos con lsns, entrar a mano con nsenter sin pasar por el daemon y crear uno desde cero con unshare, comprobando que el aislamiento no tiene nada de mágico. Y has descubierto, comparando inodos, que sin rootless ni --userns-remap todos tus contenedores comparten el namespace de usuario del host: la explicación exacta de por qué root dentro es root fuera.
Has leído tus propios límites en el kernel: memory.max con los 2 GiB del compose.prod.yaml, pids.max con los 200, y cpu.max con 200000 100000, que revela que --cpus 2.0 es una cuota de tiempo por periodo y no una reserva de núcleos. Los has cambiado en caliente con docker update y has visto el fichero moverse. Has visto el OOM killer desde dentro, en memory.events, con su oom_kill 1 y el código 137 que ya conocías. Y has tocado el copy-on-write con las manos: un fichero nuevo, una copia completa por modificar un byte y un dispositivo de caracteres 0/0 por borrar, que es la explicación física de que borrar ficheros no adelgace una imagen ni elimine un secreto. Cierras el círculo de la seguridad viendo CapEff a cero descodificado con capsh, Seccomp: 2 y NoNewPrivs: 1; y sabes qué hace runc con su config.json de la especificación OCI y por qué el shim permite reiniciar dockerd sin matar nada. Con la nota imprescindible para quien trabaja en macOS o Windows: todo esto ocurre dentro de una VM Linux, y de ahí vienen las rutas y los rendimientos que no cuadraban.
Y con esto se cierra el módulo 5. Entraste sabiendo usar Docker muy bien y sales sabiendo qué ocurre exactamente cuando lo ejecutas. Has abierto las redes hasta los puentes, los pares veth y las reglas de iptables, con el aviso de que publicar un puerto salta el cortafuegos del host; el almacenamiento hasta overlay2 y una estrategia de copias con restauración verificada; la seguridad hasta el modelo de amenazas, el modo rootless, las capacidades, seccomp, el escaneo de CVE y la firma con Cosign, con Aurora Libros endurecida en un compose.prod.yaml comentado; la optimización, que llevó la API de 142 MB a 102 MB con multietapa y medición constante; BuildKit y Buildx, con montajes de caché, secretos sin rastro, caché remota y multiarquitectura; la observabilidad, con logs estructurados, Loki, Prometheus, alertas por proporción y un /salud que dice la verdad; y, por fin, el kernel que lo sostiene todo.
En el módulo 6, Aurora Libros sale de tu máquina. Prepararás la imagen definitiva para producción, montarás un pipeline de CI/CD que construya, escanee, firme y publique con la caché remota que ya sabes usar, y llevarás la plataforma a un clúster: primero con Docker Swarm y sus redes overlay, después con Kubernetes —sus objetos, el despliegue real de los cuatro servicios, el escalado y el balanceo de carga— y terminarás con las estrategias de despliegue y rollback que permiten publicar una versión nueva sin que nadie deje de poder comprar un libro.
Docker: De Principiante a Avanzado
Módulo 1: Introducción a Docker
- ¿Qué es Docker?
- Instalando Docker
- Arquitectura de Docker
- Comandos Básicos de Docker
- Entendiendo las Imágenes de Docker
- Creando tu Primer Contenedor Docker
- El Proyecto del Curso: la Plataforma Aurora Libros
Módulo 2: Trabajando con Imágenes Docker
- Docker Hub y Repositorios
- Construyendo Imágenes Docker
- Conceptos Básicos de Dockerfile
- Instrucciones Avanzadas del Dockerfile
- Gestionando Imágenes Docker
- Etiquetado y Publicación de Imágenes
Módulo 3: Contenedores Docker
- Ejecutando Contenedores
- Ciclo de Vida del Contenedor
- Gestionando Contenedores
- Inspección y Depuración de Contenedores
- Redes en Docker
- Persistencia de Datos con Volúmenes
- Límites de Recursos y Políticas de Reinicio
Módulo 4: Docker Compose
- Introducción a Docker Compose
- Definiendo Servicios en Docker Compose
- Comandos de Docker Compose
- Aplicaciones Multi-Contenedor
- Variables de Entorno en Docker Compose
- Perfiles, Overrides y Múltiples Entornos
- Desarrollo Local con Docker Compose
Módulo 5: Conceptos Avanzados de Docker
- Profundización en Redes Docker
- Opciones de Almacenamiento Docker
- Mejores Prácticas de Seguridad en Docker
- Optimizando Imágenes Docker
- Builds Avanzadas con BuildKit y Buildx
- Registro y Monitoreo en Docker
- El Runtime por Dentro: Namespaces, Cgroups y Capas
Módulo 6: Docker en Producción
- Preparar una Imagen para Producción
- CI/CD con Docker
- Orquestando Contenedores con Docker Swarm
- Introducción a Kubernetes
- Desplegando Contenedores Docker en Kubernetes
- Escalado y Balanceo de Carga
- Estrategias de Despliegue y Rollback
