Fa sis mòduls que fas servir contenidors. Aquesta lliçó explica per què funcionen. La tesi és incòmoda i alliberadora alhora: un contenidor no existeix. No hi ha cap entitat "contenidor" al kernel de Linux. Hi ha processos normals amb tres mecanismes aplicats a sobre, i tots es poden tocar a mà.

Contingut

  1. La tesi: tres mecanismes i cap objecte
  2. Els set namespaces
  3. El mateix procés, dos PID diferents
  4. /proc/<pid>/ns/, lsns i comparar contenidors
  5. Entrar a mà amb nsenter
  6. Crear un namespace des de zero amb unshare
  7. El user namespace i el remapatge d'UID
  8. Cgroups v2: la jerarquia
  9. Els límits d'aurora-db, llegits del kernel
  10. L'OOM killer des de memory.events
  11. OverlayFS: el muntatge real
  12. Copy-on-write a l'upperdir
  13. Capabilities i seccomp a nivell de kernel
  14. Què fa runc exactament
  15. Docker Desktop: tot això passa dins d'una VM

  1. La tesi: tres mecanismes i cap objecte

Quan executes docker run, no es crea cap estructura màgica. Es llança un procés corrent i se li apliquen tres coses:

Mecanisme Respon a El que veus a Docker
Namespaces Què veu el procés? Aïllament de processos, xarxa, fitxers i usuaris
Cgroups Quant pot consumir? --memory, --cpus, --pids-limit
Sistema de fitxers de capes Quin sistema de fitxers té? Imatges, capes, copy-on-write
flowchart TB
  P["Procés 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(("El que anomenem<br/>«contenidor»"))
  P --> CG --> C
  P --> FS --> C
  K["Kernel del host: UN DE SOL, compartit per tothom"] --- C

Tota la resta —imatges, registres d'imatges, Compose, healthchecks— és maquinària construïda al voltant d'aquelles tres primitives del kernel.

  1. Els set namespaces

Un namespace és una vista parcial d'un recurs global del sistema. Els processos d'un namespace veuen la seva versió del recurs i no la dels altres.

Namespace Aïlla Opció de Docker
pid Arbre de processos --pid=host el desactiva; --pid=container:X el comparteix
net Interfícies, rutes, iptables, ports --network (lliçó 05-01)
mnt Punts de muntatge L'arrel del contenidor i tots els -v
uts Nom de host i de domini --hostname
ipc Memòria compartida, cues de missatges --ipc=host, --shm-size
user Correspondència d'UID i GID --userns-remap, mode rootless (lliçó 05-03)
cgroup Vista de la jerarquia de cgroups --cgroupns

Un vuitè, time (rellotge monotònic), existeix al kernel des de 5.6 però Docker encara no el fa servir.

  1. El mateix procés, dos PID diferents

Aquesta és la demostració que convenç més ràpid.

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          <- des de DINS del contenidor
    1 node
   28 ps

  PID  PPID USER     COMMAND     <- des del HOST
48122  4791 1000     node

És el mateix procés. A dins és el PID 1 —d'aquí tot el que vas aprendre a la lliçó 03-02 sobre senyals i PID 1—; al host és el 48122, fill del shim de containerd i executant-se com l'usuari 1000. No hi ha cap màquina virtual: hi ha un node a la teva llista de processos que resulta que veu un arbre diferent del teu.

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

NSpid ho diu tot: 48122 al namespace del host, 1 al seu. Un mateix procés amb dues identitats simultànies.

  1. /proc/<pid>/ns/, lsns i comparar contenidors

Cada namespace és un fitxer especial l'inode del qual l'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 contenidors és comparar aquells 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: COMPARTIT" || echo "$ns: diferent"
done
echo "host user ns: $(sudo readlink /proc/1/ns/user)"
net: diferent
pid: diferent
mnt: diferent
user: COMPARTIT
host user ns: user:[4026531837]

El resultat més revelador és l'últim: aurora-api i aurora-db comparteixen el namespace d'usuari, i a més és el del host. Sense --userns-remap ni mode rootless, l'UID 0 de dins és literalment l'UID 0 del host. Aquí la tens, mesurada, l'afirmació de la lliçó 05-03 sobre per què root en un contenidor és perillós.

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 mà amb nsenter

docker exec no és màgia: és entrar als namespaces del procés i llançar-hi una comanda. nsenter fa el mateix, i sense passar per 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

Les banderes són exactament els namespaces: -m (mnt), -u (uts), -i (ipc), -n (net), -p (pid). Pots entrar només en alguns, que és el que fa útil la tècnica: -n tot sol et dona la xarxa del contenidor amb les eines del host —just el que feies a la lliçó 05-01— i funciona fins i tot amb imatges distroless que no tenen ni sh a dins.

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

  1. Crear un namespace des de zero amb unshare

Per veure que no hi ha màgia, construïm-ne un a mà.

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

Sense Docker, sense imatges i sense daemon: un shell que es creu PID 1, amb el seu propi nom de host i una pila de xarxa buida. Això és l'aïllament d'un contenidor. El que Docker hi afegeix a sobre és tota la resta: el sistema de fitxers de la imatge, la configuració de xarxa amb el seu veth i el seu pont, els cgroups, les capacitats, seccomp i una API per gestionar-ho.

  1. El user namespace i el remapatge d'UID

El namespace d'usuari permet que un mateix UID signifiqui coses diferents dins i fora. És la base del mode rootless (lliçó 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

A dins ets root (id -u = 0) sense haver fet servir sudo; el mapa diu que l'UID 0 de dins correspon al 1000 de fora, amb longitud 1. A fora continues sent el 1000.

Root a dins, però el kernel comprova els permisos del fitxer contra l'UID real del host. Aquest és el mecanisme exacte que fa que en mode rootless l'escalada de la lliçó 05-03 deixi de funcionar: no és una comprovació afegida per Docker, és aritmètica d'UID al kernel.

  1. Cgroups v2: la jerarquia

Els control groups limiten i comptabilitzen recursos. La versió 2, unificada, és l'estàndard des del 2022 i s'exposa com un arbre de directoris a /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 Fitxers clau Què governa
memory memory.max, memory.current, memory.high, memory.events RAM i activació de l'OOM killer
cpu cpu.max, cpu.stat, cpu.weight Quota de CPU i estrangulament
io io.max, io.stat Amplada de banda i IOPS de disc
pids pids.max, pids.current Nombre de processos (defensa davant de fork bomb)

La diferència essencial amb la v1: a la v2 un procés pertany a un únic cgroup i tots els controladors hi actuen a sobre, en lloc d'una jerarquia diferent per recurs. Això simplifica enormement el raonament.

  1. Els límits d'aurora-db, llegits del kernel

Ara la comprovació que tanca el cercle amb la lliçó 03-07 i amb Compose: el que vas declarar en YAML, llegit directament 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ó, línia a línia: memory.max són 2 147 483 648 bytes = 2 GiB, el limits: { memory: 2G } del compose.prod.yaml; memory.current és el que mostra docker stats; pids.max és el pids_limit: 200; i cpu.max amb 200000 100000 significa 200 ms de CPU per cada 100 ms de període, és a dir, cpus: "2.0".

docker stats --no-stream --format '{{.Name}} {{.MemUsage}}' aurora-libros-aurora-db-1
# aurora-libros-aurora-db-1 412.7MiB / 2GiB

Coincidència exacta. docker stats no calcula res: llegeix aquests fitxers. I cpu.max explica per fi la semàntica de --cpus: no és un nombre de nuclis assignats, és una quota de temps per període; amb 2.0, el contenidor pot consumir 200 ms de CPU cada 100 ms, repartits entre els nuclis que hi hagi.

  1. L'OOM killer des de 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 ja eliminat)"
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

Els comptadors expliquen la història completa: max 412 són les vegades que el procés va topar amb el límit i el kernel va haver de reclamar memòria; oom 1 indica que va arribar un punt en què no en va poder reclamar més; oom_kill 1 és l'execució. El resultat, el codi de sortida 137 que coneixes des de la lliçó 03-02, és simplement 128 + 9: acabat per SIGKILL.

Un max alt amb oom_kill 0 és un senyal valuosíssim i gairebé mai vigilat: el contenidor no ha mort, però està lluitant contra el seu límit i pagant-ho en latència. És exactament el que l'alerta MemoriaAPropDelLimit de la lliçó 05-06 detecta abans que sigui tard.

  1. OverlayFS: el muntatge 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)

L'arrel / del contenidor és un muntatge overlay, amb els quatre directoris de la lliçó 05-02. I els lowerdir es corresponen un a un amb les capes de la imatge:

docker image inspect auroralibros/aurora-api:1.3.0 --format '{{len .RootFS.Layers}} capes'
docker compose exec aurora-api sh -c 'mount|grep " / "' | grep -o 'lowerdir=[^,]*' | tr ':' '\n' | wc -l
# 3 capes
# 3

Tres capes al manifest de la imatge, tres lowerdir al muntatge. La correspondència és literal: cada capa de la imatge és un directori al disc del host, i OverlayFS les apila per ordre.

  1. Copy-on-write a l'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 "// tocat" >> /app/package.json'  # 2) modificar
docker compose exec aurora-api sh -c 'rm -f /app/src/util.js'               # 3) esborrar
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

Les tres operacions, amb els seus tres efectes diferents a l'upperdir:

  • Fitxer nou: apareix tal qual. És l'A de docker diff.
  • Fitxer modificat: el kernel fa copy-up —copia l'original sencer del lowerdir— i hi escriu a sobre. És la C, i és l'operació cara que vas mesurar a la lliçó 05-02.
  • Fitxer esborrat: apareix un dispositiu de caràcters 0/0, el famós whiteout. No esborra res: amaga el fitxer de la capa inferior, que continua ocupant espai. És la D, i és la raó que esborrar en una capa posterior no aprimi una imatge (lliçó 05-04).

Aquí la tens, en tres línies d'ls, l'explicació física de les lliçons 01-05, 05-02 i 05-04.

  1. Capabilities i seccomp a nivell de kernel

Les capacitats de la lliçó 05-03 són una màscara de bits al descriptor del procés.

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 zero: aurora-api corre sense cap capacitat efectiva, exactament com vas demanar amb cap_drop: [ALL]. Compara-ho amb un contenidor per defecte:

docker run -d --name perDefecte alpine:3 sleep 60
p2=$(docker inspect perDefecte --format '{{.State.Pid}}')
capsh --decode=$(sudo awk '/^CapEff/{print $2}' /proc/$p2/status) | tr ',' '\n' | head -4
docker rm -f perDefecte
0x00000000a80425fb=cap_chown
cap_dac_override
cap_fowner
cap_kill

Docker concedeix catorze capacitats per defecte. La diferència entre aquella llista i un CapEff de zero és tota la superfície que has eliminat, i ara la veus com el que és: bits a /proc.

Seccomp: 2 significa mode filtre BPF actiu (0 seria desactivat); NoNewPrivs: 1 és el no-new-privileges:true del compose.prod.yaml. Tot el que vas configurar en YAML acaba sent dos números en un fitxer de /proc.

  1. Què fa runc exactament

La cadena de la lliçó 01-03, ara vista per dins: dockerdcontainerdshimrunc → procés.

runc és un binari petit que rep un directori amb dues coses: el sistema de fitxers ja muntat i un config.json amb l'especificació OCI del contenidor.

{
  "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" }
  }
}

Reconeixes cada línia: són les teves opcions de docker run i de Compose traduïdes a l'estàndard. Els passos de runc són: crear els namespaces (clone amb les banderes CLONE_NEW*), escriure els cgroups, pivotar l'arrel al rootfs (pivot_root), aplicar capacitats i seccomp, i finalment execve de la comanda. Aquí acaba la seva feina: runc surt, i el procés queda orfe sota el shim.

Aquest és el paper del shim (containerd-shim-runc-v2): mantenir oberts stdin/stdout/stderr, informar del codi de sortida i —el que és important— permetre que dockerd es reiniciï sense matar els teus contenidors, perquè el pare no és el daemon sinó 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 tot això sigui un estàndard obert (l'OCI Runtime Specification) és el que permet substituir runc per crun, youki o gVisor sense canviar res més. Aquell ecosistema és la lliçó 07-05.

  1. Docker Desktop: tot això passa dins d'una VM

Si ets a macOS o Windows, res de tot l'anterior no passa al teu sistema operatiu: namespaces i cgroups són mecanismes exclusius del kernel de Linux. Docker Desktop executa una màquina virtual Linux lleugera i el daemon viu a dins.

Aspecte Linux macOS / Windows amb Docker Desktop
On corre el daemon Al teu kernel En una VM Linux
/var/lib/docker Ruta del teu disc Dins del disc virtual de la VM
ps aux del host Veus els processos dels contenidors No els veus: són a la VM
Bind mounts Cost zero Creuen la frontera VirtioFS/WSL 2: més lents
nsenter, lsns, docker0, cgroups Directes Només dins de la VM

Per reproduir aquesta lliçó des de macOS o Windows, entra a la VM:

docker run -it --rm --privileged --pid=host justincormack/nsenter1 /bin/sh
# ja dins de la VM:
ls /sys/fs/cgroup/ && lsns -t pid | head -3

I això explica de passada les lliçons anteriors: la lentitud dels bind mounts (05-02 i 04-07) no és de Docker, és la frontera entre dos sistemes de fitxers; i per això les rutes i els processos "no apareixen" on els esperes.

Errors Habituals i Consells

Creure que un contenidor és una màquina lleugera. És un procés amb tres mecanismes a sobre i un sol kernel compartit. D'aquí tota la lliçó 05-03.

Buscar cgroups v1 en un sistema modern. Des del 2022 és v2 unificat: un cgroup per procés i fitxers diferents (memory.max, no memory.limit_in_bytes). I no escriguis directament a /sys/fs/cgroup: Docker ho sobreescriu en reconciliar; fes servir docker update o Compose.

Executar nsenter sense -p esperant veure l'arbre de processos aïllat. A cada namespace s'entra per separat; sense -p veuràs els processos del host.

Suposar que un oom_kill 0 significa que tot va bé. Un max alt indica pressió de memòria constant i latència, encara que no hi hagi mort ningú.

Intentar lsns o /sys/fs/cgroup des de macOS. No existeixen fora de Linux. Entra a la VM de Docker Desktop.

Consell: quan alguna cosa no quadri, baixa un nivell. Un límit que sembla que no s'aplica es comprova a memory.max; un problema de xarxa, a /proc/<pid>/ns/net i nsenter -n; un fitxer que apareix o desapareix, a l'upperdir. El kernel no menteix i sempre es pot consultar.

Exercicis

Exercici 1. Demostra que un contenidor és un procés del host: troba el PID real d'aurora-api, comprova que a dins és el PID 1, verifica amb NSpid que és el mateix procés i entra al seu namespace de xarxa amb nsenter sense fer servir docker exec.

Exercici 2. Comprova que els límits de Compose són exactament els fitxers de cgroups: llegeix memory.max, cpu.max i pids.max d'aurora-db, tradueix-los a les opcions que els van produir, canvia'ls amb docker update i verifica que el fitxer canvia en calent.

Exercici 3. Provoca les tres operacions de copy-on-write (crear, modificar i esborrar) a aurora-api i localitza'n els tres efectes diferents a l'upperdir. Explica quina de les tres explica que esborrar fitxers no aprimi una imatge.

Solucions

Solució 1.

pid=$(docker inspect aurora-libros-aurora-api-1 --format '{{.State.Pid}}')
echo "PID al host: $pid"
docker compose exec aurora-api sh -c 'echo "PID a dins: $$"'
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 al host: 48122
PID a dins: 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 és la prova definitiva: un únic procés amb dues identitats, la del namespace del host i la del seu. No hi ha còpia, ni emulació, ni màquina virtual; hi ha una taula de correspondències al kernel.

I nsenter demostra la segona cosa: has obtingut exactament el que dona docker exec sense parlar amb el daemon en cap moment. Això té dues conseqüències pràctiques. La primera, de diagnòstic: si el daemon està penjat o la imatge és distroless i no té sh, nsenter continua funcionant perquè fa servir els binaris del host sobre els namespaces del contenidor. La segona, de seguretat: qui tingui root al host entra a qualsevol contenidor sense deixar rastre als logs de Docker, cosa que reforça per què l'accés root al host és la frontera que de debò importa.

Solució 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
Fitxer del kernel Valor Opció que el va produir
memory.max 2 147 483 648 B = 2 GiB deploy.resources.limits.memory: 2G
cpu.max 200000 / 100000 cpus: "2.0" (200 ms per 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 canvi és instantani i sense reiniciar el contenidor: cgroups v2 permet reescriure el límit en calent, i el procés ni se n'assabenta fins que intenti superar-lo. docker stats reflecteix el nou valor perquè, literalment, llegeix aquell fitxer.

La lectura de fons és que docker run --memory i deploy.resources no són abstraccions de Docker: són una manera còmoda d'escriure un número a /sys/fs/cgroup. I la interpretació de cpu.max resol la confusió més habitual del curs: --cpus 1.5 no reserva un nucli i mig, concedeix 150 ms de temps de CPU cada 100 ms de període, que es poden repartir entre els vuit nuclis del host.

Solució 3.

up=$(docker inspect aurora-libros-aurora-api-1 --format '{{.GraphDriver.Data.UpperDir}}')
docker compose exec aurora-api sh -c 'echo hola > /tmp/nou.txt
  echo "//x" >> /app/package.json
  rm -f /app/src/util.js'
sudo stat -c '%n -> %F (%s bytes)' "$up/tmp/nou.txt" "$up/app/package.json" "$up/app/src/util.js"
docker diff aurora-libros-aurora-api-1 | grep -E 'nou|package.json|util.js'
.../diff/tmp/nou.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/nou.txt
C /app/package.json
D /app/src/util.js

Tres operacions, tres representacions físicament diferents a l'upperdir:

Operació A l'upperdir docker diff
Crear Fitxer normal de 5 bytes A
Modificar Fitxer normal de 612 bytes: la còpia completa, no el delta C
Esborrar Dispositiu de caràcters 0/0 de 0 bytes: un whiteout D

La tercera és la que explica el fenomen de la lliçó 05-04. L'esborrat no elimina res del lowerdir —és de només lectura, no es pot—: crea un marcador especial que fa que OverlayFS amagui el fitxer en resoldre la vista unificada. Dins del contenidor, ls respon No such file or directory; al disc, el fitxer original continua íntegre a la seva capa, es descarrega a cada docker pull i s'emmagatzema a cada node.

D'aquí la regla que vas aplicar amb l'&& i que ara té explicació completa: si crees i esborres dins del mateix RUN, totes dues operacions passen abans de consolidar la capa, i el fitxer no arriba mai a existir al lowerdir de ningú. I per això una credencial escrita i esborrada en instruccions diferents continua sent extraïble amb docker save: no és una fallada de Docker, és com funciona un sistema de fitxers d'unió.

I fixa't en la segona fila, que també té conseqüències: modificar un byte desa el fitxer sencer a l'upperdir. Amb package.json són 612 bytes; amb un fitxer de dades de PostgreSQL serien centenars de megabytes. Aquest és, mesurat a la lliçó 05-02 i explicat aquí, el motiu físic que les bases de dades facin servir volums.

Conclusió

Un contenidor no existeix. El que existeix és un procés normal de Linux —l'has vist amb el seu PID real al ps del host i el seu NSpid amb dues identitats— al qual el kernel aplica tres mecanismes: namespaces per al que veu, cgroups per al que consumeix i OverlayFS per al sistema de fitxers que té. Coneixes els set namespaces i quina opció de Docker es recolza en cadascun, saps llegir-los a /proc/<pid>/ns/, comparar-los amb lsns, entrar-hi a mà amb nsenter sense passar pel daemon i crear-ne un des de zero amb unshare, comprovant que l'aïllament no té res de màgic. I has descobert, comparant inodes, que sense rootless ni --userns-remap tots els teus contenidors comparteixen el namespace d'usuari del host: l'explicació exacta de per què root a dins és root a fora.

Has llegit els teus propis límits al kernel: memory.max amb els 2 GiB del compose.prod.yaml, pids.max amb els 200, i cpu.max amb 200000 100000, que revela que --cpus 2.0 és una quota de temps per període i no una reserva de nuclis. Els has canviat en calent amb docker update i has vist el fitxer moure's. Has vist l'OOM killer des de dins, a memory.events, amb el seu oom_kill 1 i el codi 137 que ja coneixies. I has tocat el copy-on-write amb les mans: un fitxer nou, una còpia completa per modificar un byte i un dispositiu de caràcters 0/0 per esborrar, que és l'explicació física que esborrar fitxers no aprimi una imatge ni elimini un secret. Tanques el cercle de la seguretat veient CapEff a zero descodificat amb capsh, Seccomp: 2 i NoNewPrivs: 1; i saps què fa runc amb el seu config.json de l'especificació OCI i per què el shim permet reiniciar dockerd sense matar res. Amb la nota imprescindible per a qui treballa a macOS o Windows: tot això passa dins d'una VM Linux, i d'aquí venen les rutes i els rendiments que no quadraven.

I amb això es tanca el mòdul 5. Hi vas entrar sabent fer servir Docker molt bé i en surts sabent què passa exactament quan l'executes. Has obert les xarxes fins als ponts, els parells veth i les regles d'iptables, amb l'avís que publicar un port salta el tallafocs del host; l'emmagatzematge fins a overlay2 i una estratègia de còpies amb restauració verificada; la seguretat fins al model d'amenaces, el mode rootless, les capacitats, seccomp, l'escaneig de CVE i la signatura amb Cosign, amb Aurora Libros endurida en un compose.prod.yaml comentat; l'optimització, que va portar l'API de 142 MB a 102 MB amb multietapa i mesurament constant; BuildKit i Buildx, amb muntatges de memòria cau, secrets sense rastre, memòria cau remota i multiarquitectura; l'observabilitat, amb logs estructurats, Loki, Prometheus, alertes per proporció i un /salut que diu la veritat; i, per fi, el kernel que ho sosté tot.

Al mòdul 6, Aurora Libros surt de la teva màquina. Prepararàs la imatge definitiva per a producció, muntaràs un pipeline de CI/CD que construeixi, escanegi, signi i publiqui amb la memòria cau remota que ja saps fer servir, i portaràs la plataforma a un clúster: primer amb Docker Swarm i les seves xarxes overlay, després amb Kubernetes —els seus objectes, el desplegament real dels quatre serveis, l'escalat i el balanceig de càrrega— i acabaràs amb les estratègies de desplegament i rollback que permeten publicar una versió nova sense que ningú deixi de poder comprar un llibre.

Docker: De Principiant a Avançat

Mòdul 1: Introducció a Docker

Mòdul 2: Treballant amb Imatges Docker

Mòdul 3: Contenidors Docker

Mòdul 4: Docker Compose

Mòdul 5: Conceptes Avançats de Docker

Mòdul 6: Docker en Producció

Mòdul 7: Ecosistema i Eines de Docker

© Copyright 2026. Tots els drets reservats