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

  1. La tesis: tres mecanismos y ningún objeto
  2. Los siete namespaces
  3. El mismo proceso, dos PID distintos
  4. /proc/<pid>/ns/, lsns y comparar contenedores
  5. Entrar a mano con nsenter
  6. Crear un namespace desde cero con unshare
  7. El user namespace y el remapeo de UID
  8. Cgroups v2: la jerarquía
  9. Los límites de aurora-db, leídos del kernel
  10. El OOM killer desde memory.events
  11. OverlayFS: el montaje real
  12. Copy-on-write en el upperdir
  13. Capabilities y seccomp a nivel de kernel
  14. Qué hace runc exactamente
  15. Docker Desktop: todo esto ocurre en una VM

  1. 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.

  1. 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.

  1. 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     node

Es 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.

sudo grep -E '^(Name|NSpid|Uid)' /proc/$pid/status
Name:   node
NSpid:  48122  1
Uid:    1000    1000    1000    1000

NSpid lo dice todo: 48122 en el namespace del host, 1 en el suyo. Un mismo proceso con dos identidades simultáneas.

  1. /proc/<pid>/ns/, lsns y comparar contenedores

Cada namespace es un fichero especial cuyo inodo lo identifica:

sudo ls -l /proc/$pid/ns/ | awk '{print $9, $10, $11}'
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)"
net: distinto
pid: distinto
mnt: distinto
user: COMPARTIDO
host user ns: user:[4026531837]

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

  1. Entrar a mano con nsenter

docker exec no es magia: es entrar en los namespaces del proceso y lanzar un comando. nsenter hace lo mismo, y sin pasar por Docker.

sudo nsenter -t "$pid" -m -u -i -n -p -- sh -c 'hostname; ls /; ip -brief addr; ps -eo pid,comm'
a91f3c8d2e10
app  bin  dev  etc  home  lib  proc  root  sys  tmp  usr  var
eth0   UP   172.21.0.5/16
  PID COMMAND
    1 node

Las 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.

sudo nsenter -t "$pid" -n ss -tnp state established | head -3

  1. Crear un namespace desde cero con unshare

Para 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'
  PID COMMAND
    1 sh
    5 ps
lo     DOWN
aurora-manual

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.

  1. 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 -u
0
         0       1000          1
cat: /etc/shadow: Permission denied
1000

Dentro 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.

  1. 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
Cgroup Driver: systemd | Version: 2
docker-a91f3c8d2e10....scope
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.

  1. Los límites de aurora-db, leídos del kernel

Ahora 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)"
done
memory.max       2147483648
memory.current   432791552
memory.high      max
pids.max         200
pids.current     14
cpu.max          200000 100000

Traducció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 / 2GiB

Coincidencia 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.

  1. El OOM killer desde memory.events

docker 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 victima
low 0      high 0      max 412      oom 1      oom_kill 1
exit=137 oom=1

Los 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.

  1. OverlayFS: el montaje real

docker compose exec aurora-api sh -c 'mount | grep " / "'
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
# 3

Tres 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.

  1. Copy-on-write en el upperdir

up=$(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.js

Las tres operaciones, con sus tres efectos distintos en el upperdir:

  • Fichero nuevo: aparece tal cual. Es la A de docker diff.
  • Fichero modificado: el kernel hace copy-up —copia el original completo del lowerdir— y escribe encima. Es la C, 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.

  1. 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: 0000000000000000
CapBnd: 00000000a80425fb
Seccomp:        2
NoNewPrivs:     1
0x0000000000000000=

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 porDefecto
0x00000000a80425fb=cap_chown
cap_dac_override
cap_fowner
cap_kill

Docker 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.

  1. Qué hace runc exactamente

La cadena de la lección 01-03, ahora vista por dentro: dockerdcontainerdshimrunc → 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-shim

Que 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.

  1. 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 -3

Y 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 -2
PID 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 node

NSpid: 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)"
memory.max 2147483648 | cpu.max 200000 100000 | pids.max 200
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-1
memory.max 1073741824 | cpu.max 150000 100000
412.7MiB / 1GiB

El 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.js

Tres 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

Módulo 2: Trabajando con Imágenes Docker

Módulo 3: Contenedores Docker

Módulo 4: Docker Compose

Módulo 5: Conceptos Avanzados de Docker

Módulo 6: Docker en Producción

Módulo 7: Ecosistema y Herramientas de Docker

© Copyright 2026. Todos los derechos reservados