La lliçó anterior va acabar amb una objecció de pes: una màquina virtual aïlla molt bé, però per aconseguir-ho carrega amb un nucli sencer, un init, un sistema de fitxers complet, entre 200 i 500 MB de RAM només per existir i desenes de segons d'arrencada. Si el que vols és executar la teva aplicació amb les seves dependències, sense que vegi la resta del sistema i sense que es mengi la màquina, estàs pagant per un sistema operatiu que no necessites.
Els contenidors hi responen amb un canvi de perspectiva radical, i aquesta és la frase que has d'endur-te de tota la lliçó: un contenidor no és una màquina virtual petita; és un procés normal del nucli amfitrió al qual se li ha limitat el que veu i el que consumeix. No hi ha hipervisor, no hi ha nucli convidat, no hi ha emulació de maquinari. Hi ha un fork i un exec com els de 02-01, amb tres ingredients afegits: espais de noms que li menteixen sobre què existeix, grups de control que li acoten quant pot fer servir, i un conjunt de restriccions de seguretat —capabilities, seccomp, AppArmor— que ja coneixes del mòdul 5.
Aquí veuràs els tres ingredients per dins, un a un i amb demostracions que pots executar. Després entendràs de debò overlayfs, que és el que converteix una imatge en una cosa compartible entre cent contenidors, i tancarem empaquetant meteo-api en una imatge amb un Dockerfile comentat línia a línia. L'orquestració i el núvol són la lliçó següent; aquí ens quedem a la màquina.
Contingut
- La idea central: processos limitats, no màquines petites
- Espais de noms, un a un
- Inspecció real dels espais de noms a
/proc - cgroups v2: la jerarquia unificada
- Els controladors a la pràctica:
cpu,memory,ioipids - Com fa servir systemd els cgroups
- El tercer ingredient: seguretat dins del contenidor
- Sistemes de fitxers en capes:
overlayfsde debò - Què és una imatge i què és un contenidor
- Runtimes, estàndards OCI i la pila real
- Pràctica: empaquetar
meteo-api - Contenidors davant de màquines virtuals
- Riscos concrets i bones pràctiques
La idea central: processos limitats, no màquines petites
Comencem per una comprovació que desmunta la intuïció equivocada. Si en un amfitrió Linux arrenques un contenidor i després mires els processos des de fora:
docker run -d --name prova alpine sleep 3600 # arrencar un contenidor
ps -eo pid,user,comm | grep sleep # buscar-lo DES DE L'AMFITRIÓ
# 8123 root sleepQuè demostra. És aquí: sleep és un procés de l'amfitrió, amb PID 8123 a la taula de processos de l'amfitrió, planificat pel CFS-EEVDF de l'amfitrió, amb les seves pàgines gestionades pel gestor de memòria de l'amfitrió. No hi ha cap nucli intermedi. Si fas kill 8123 des de fora, el contenidor mor. Si l'amfitrió entra en pànic, el contenidor mor. Compara-ho amb una VM, on des de fora només veuries un qemu-system-x86 i mai els processos de dins.
La diferència amb un procés normal és només en el que aquell procés percep:
| Pregunta que es fa el procés | Procés normal | Procés "contingut" |
|---|---|---|
| Quins fitxers existeixen? | L'arbre de l'amfitrió | Només la seva pròpia arrel (mnt) |
| Quins altres processos hi ha? | Tots | Només els seus, i ell és el PID 1 (pid) |
| Quina xarxa i quin nom tinc? | Els de l'amfitrió | Els seus propis (net, uts) |
| Quanta RAM i CPU hi ha? | Tota la màquina | El que li deixi el seu cgroup |
D'aquí es dedueixen les tres conseqüències que governen tota la resta: arrencada instantània, perquè no cal arrencar un nucli sinó només fer clone() i execve() (mil·lisegons, no segons); densitat altíssima, perquè sense nucli convidat ni init el cost base són uns pocs megabytes; i aïllament més feble, perquè es comparteix el nucli i la frontera passa a ser la taula de crides al sistema —de 300 a 400 punts d'entrada— davant del grapat de gestors de VM exit de 06-01.
Espais de noms, un a un
Un espai de noms (namespace) embolcalla un recurs global del sistema de manera que els processos que hi són a dins veuen la seva pròpia instància d'aquest recurs. Linux en té vuit. Ja els vam citar a 03-02 com els flags CLONE_NEW* de clone(); ara els obrim.
Hi ha tres crides al sistema implicades: clone() amb els flags CLONE_NEW* crea un procés ja dins d'espais nous, unshare() mou el procés actual a espais nous, i setns() fica un procés en un espai existent (és el que fa docker exec). L'eina unshare embolcalla les dues primeres, i tots els exemples que segueixen es poden executar.
mnt: espai de noms de muntatge
Va ser el primer (Linux 2.4.19, any 2002) i és el que dona al contenidor el seu propi sistema de fitxers. Reprèn directament 04-03: la taula de muntatges no és global, cada procés veu la seva, i /proc/<pid>/mountinfo diu quina.
sudo unshare --mount bash
mount -t tmpfs tmpfs /mnt # dins: un tmpfs propi
echo "només jo veig això" > /mnt/secret.txt
ls /mnt # secret.txt
# En UNA ALTRA terminal de l'amfitrió: ls /mnt → buitQuè ha passat. El muntatge existeix de debò, però només dins d'aquell espai de noms. En sortir de la shell, l'espai desapareix i el muntatge amb ell, sense necessitat de desmuntar res. Aquest és el mecanisme que permet que un contenidor munti el que vulgui sense embrutar l'amfitrió.
Ara bé, tenir la teva pròpia taula de muntatges no n'hi ha prou: cal canviar l'arrel. Aquí convé distingir tres coses que es confonen:
chroot() canvia el / d'aquell procés, però el sistema de fitxers anterior continua muntat i accessible: no és seguretat. pivot_root() canvia l'arrel de l'espai de noms de muntatge i permet desmuntar l'antiga: això sí que ho és, combinat amb mnt. I el muntatge bind exposa un subarbre en un altre punt amb opcions diferents: és una eina, no una barrera.
Sobre chroot cal ser taxatiu, perquè és un error clàssic: chroot mai no va ser un mecanisme de seguretat, i la seva pròpia pàgina de manual ho diu. Un procés amb CAP_SYS_CHROOT s'escapa amb una recepta coneguda des dels anys 90 —obrir un directori, fer chroot a un subdirectori i pujar després amb chdir("..") des del descriptor obert, perquè el nucli comprova l'arrel només a les rutes absolutes—, i també creant un node de dispositiu amb mknod per llegir el disc cru, o amb ptrace sobre un procés de fora.
Per això els contenidors fan servir pivot_root dins d'un espai de noms de muntatge propi, i després desmunten l'arrel antiga: llavors no queda cap referència al sistema de fitxers de l'amfitrió, no pas perquè estigui prohibida, sinó perquè ja no està muntat en aquell espai. Aquesta és la diferència entre amagar una cosa i treure-la.
pid: espai de noms de processos
Dona al contenidor la seva pròpia taula de PID. El primer procés de dins és el PID 1.
sudo unshare --pid --fork --mount-proc bash
ps aux
# USER PID ... COMMAND
# root 1 ... bash
# root 12 ... ps aux ← només dos processos en tot el "sistema"
echo $$ # 1Per què calen les tres opcions, que és la pregunta que tothom es fa: --pid crea l'espai nou; --fork és obligatori perquè qui crida unshare() no hi entra, només els seus fills, de manera que sense --fork la shell continuaria a l'espai antic; i --mount-proc remunta /proc —cosa que implica també un espai de muntatge nou—, perquè sense això ps continuaria llegint el /proc de l'amfitrió i mostraria tots els processos: ps no consulta el nucli, llegeix fitxers.
Aquest detall de --mount-proc és molt instructiu: demostra que l'aïllament de PID el dona el nucli, però la vista que tens depèn de quin /proc estiguis llegint. Un contenidor mal construït que munti el /proc de l'amfitrió ensenya tots els processos de la màquina.
I aquí reapareix un tema de 02-01: els zombis. El nucli imposa al PID 1 dues responsabilitats especials. Primer, adoptar els orfes i fer-los wait() per alliberar-ne l'entrada a la taula de processos: si el PID 1 del contenidor és la teva aplicació i no fa wait(), els zombis s'acumulen fins a esgotar el límit de PID. I segon, gestionar les senyals, perquè al PID 1 no se li apliquen les accions per defecte: si no instal·la un gestor per a SIGTERM, la senyal s'ignora. La conseqüència pràctica és que docker stop envia SIGTERM, el procés l'ignora, i al cap de 10 segons arriba un SIGKILL: el contenidor triga sempre 10 segons a aturar-se i mai no tanca endreçadament els seus fitxers.
La solució estàndard és fer servir un init mínim com a PID 1 (tini, dumb-init, o --init a Docker), que reenvia senyals i cull zombis, deixant l'aplicació com a PID 2.
net: espai de noms de xarxa
Dona al contenidor la seva pròpia pila de xarxa: interfícies, adreces, taules de rutes, regles d'nftables i ports. És la raó que cent contenidors puguin escoltar tots al port 443 sense col·lidir.
sudo ip netns add meteo-ns # crear un espai de xarxa
sudo ip netns exec meteo-ns ip link # només hi ha 'lo', i caiguda
sudo ip link add veth-host type veth peer name veth-cont # cable virtual
sudo ip link set veth-cont netns meteo-ns # un extrem, a dins
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è fa, pas a pas. Un parell veth és un cable virtual amb dos extrems: el que entra per un surt per l'altre. Es creen tots dos a l'amfitrió i després se'n mou un a l'espai de noms del contenidor. A partir d'aquí són dues màquines connectades per un cable: cada extrem té la seva IP i es parlen. Per connectar molts contenidors, l'extrem de l'amfitrió s'endolla a un pont (br0, o docker0), que actua com a commutador exactament igual que el pont de les VM a 06-01. I per donar sortida a internet s'hi afegeix NAT amb nftables, que és literalment el que fa Docker per tu.
Fixa't en el paral·lelisme: el mecanisme de xarxa és conceptualment idèntic al d'una màquina virtual (interfície virtual + pont + NAT). El que canvia és que aquí la pila TCP/IP continua sent la de l'amfitrió, només que instanciada diverses vegades.
uts: nom de màquina i de domini
El més simple. UTS ve de UNIX Time-Sharing System, per l'estructura que retorna uname().
sudo unshare --uts bash
hostname meteo-api-c1 ; hostname # meteo-api-c1
exit ; hostname # meteo-01 ← l'amfitrió, intacteSembla cosmètic, però no ho és: moltíssim programari (registres, clústers, llicències, mètriques) s'identifica pel nom de màquina, i sense aquest espai de noms tots els contenidors dirien que es diuen igual que l'amfitrió.
ipc: comunicació entre processos
Aïlla els objectes IPC de System V —memòria compartida, cues de missatges, semàfors— i les cues POSIX, és a dir, bona part del que vam estudiar a 03-03.
ipcmk -M 1024 ; ipcs -m | tail -2 # crear un segment i veure'l
sudo unshare --ipc bash
ipcs -m # buit! no veu el de l'amfitrióPer què importa. Els identificadors IPC de System V són un espai de noms pla i global: dues aplicacions que triïn la mateixa clau xoquen. Sense aquest aïllament, dos contenidors amb la mateixa aplicació es trepitjarien els segments. Un matís important per a Meteora: la memòria cau de /dev/shm/meteora-cache no és System V, és memòria compartida POSIX, que s'implementa com a fitxers a /dev/shm i per tant l'aïlla l'espai de noms de muntatge, no l'ipc. Són dos mecanismes diferents amb el mateix nom col·loquial, i confondre'ls porta a errors reals.
user: la peça clau de seguretat
És el més recent dels importants (Linux 3.8) i, sense discussió, la millora de seguretat més important de la història dels contenidors. Permet mapar rangs d'UID i GID: un procés pot ser root (UID 0) dins de l'espai de noms i correspondre a un usuari sense privilegis fora.
unshare --user --map-root-user bash # sense sudo!
id -u # 0 ← sóc root aquí dins
cat /proc/self/uid_map
# 0 1000 1
touch /etc/prova # Permission deniedQuè acaba de passar, que és l'important. El uid_map diu: "l'UID 0 de dins és l'UID 1000 de fora, amb un rang de longitud 1". Dins de l'espai de noms tinc totes les capabilities: puc crear altres namespaces, muntar sistemes de fitxers, canviar el nom de màquina. Però quan toco un objecte de l'amfitrió —/etc, que pertany a l'UID 0 real— el nucli avalua els permisos amb l'UID real, el 1000, i me'l denega.
Les conseqüències són enormes. Habilita els contenidors sense privilegis (rootless), on un usuari normal crea contenidors complets sense sudo i sense un dimoni privilegiat, que és el model de Podman. Acota el dany d'una escapada: qui trenqui l'aïllament aterra a l'amfitrió com l'UID 1000, no com a root, cosa que converteix una catàstrofe en un incident. I compleix el mínim privilegi de 05-01 en la seva forma més pura: l'aplicació es pensa que té root i no en té.
El preu és una certa complexitat: els fitxers creats a dins pertanyen a UID mapats (d'aquí newuidmap, /etc/subuid i els rangs de 65.536 UID per usuari), i algunes operacions —muntar certs sistemes de fitxers, fer servir ports per sota del 1024— continuen sense estar permeses.
cgroup i time
cgroup (Linux 4.6) amaga la posició real del procés a la jerarquia de grups de control: dins del contenidor, el seu cgroup sembla ser l'arrel, i sense ell un procés llegiria a /proc/self/cgroup la ruta completa de l'amfitrió, filtrant la topologia del sistema. time (Linux 5.6) permet desplaçar els rellotges CLOCK_MONOTONIC i CLOCK_BOOTTIME; el seu cas d'ús real és la migració de contenidors amb CRIU, on en restaurar en una altra màquina el temps d'arrencada ha de continuar sent coherent. No afecta el rellotge de paret, que continua sent el de l'amfitrió.
Resum
| Namespace | Flag | Què aïlla | Des de |
|---|---|---|---|
mnt |
CLONE_NEWNS |
Taula de muntatges, sistema de fitxers | 2.4.19 |
uts |
CLONE_NEWUTS |
Nom de màquina i de domini | 2.6.19 |
ipc |
CLONE_NEWIPC |
IPC System V i cues POSIX | 2.6.19 |
pid |
CLONE_NEWPID |
Numeració de processos | 2.6.24 |
net |
CLONE_NEWNET |
Interfícies, rutes, ports, nftables |
2.6.29 |
user |
CLONE_NEWUSER |
Mapatge d'UID/GID i capabilities | 3.8 |
cgroup |
CLONE_NEWCGROUP |
Vista de la jerarquia de cgroups | 4.6 |
time |
CLONE_NEWTIME |
Rellotges monòton i d'arrencada | 5.6 |
El que no aïllen, i convé tenir molt clar: el rellotge de paret, els sysctl que no estan namespaced, els mòduls del nucli, l'estat del maquinari, dmesg i —sobretot— el nucli mateix. Una fallada del nucli ho és per a tothom.
Inspecció real dels espais de noms a /proc
Els espais de noms es veuen a /proc/<pid>/ns/, on cadascun és un enllaç simbòlic la destinació del qual inclou un número d'inode. Dos processos amb el mateix inode comparteixen aquell espai de noms.
ls -l /proc/self/ns/ # mnt -> 'mnt:[4026531841]', pid -> 'pid:[4026531836]', ...
# Comparació directa amb el procés d'un contenidor
PID=$(docker inspect -f '{{.State.Pid}}' prova)
for ns in mnt pid net uts ipc user cgroup; do
printf "%-7s amfitrio=%-22s cont=%s\n" "$ns" \
"$(readlink /proc/self/ns/$ns)" "$(sudo readlink /proc/$PID/ns/$ns)"
doneCom llegir la sortida. Allà on els dos inodes coincideixin, el contenidor comparteix aquell espai amb l'amfitrió; allà on difereixin, està aïllat. En un Docker per defecte hi veuràs mnt, pid, net, uts i ipc diferents, i user i de vegades cgroup iguals: això significa que aquell contenidor no fa servir espai d'usuari, i per tant el root de dins és el root de fora. És la comprovació més útil de tota la lliçó per auditar un desplegament.
Dues ordres complementàries: lsns -t pid -t net resumeix tots els espais del sistema amb el seu procés arrel, i nsenter -t $PID -m -u -n -p -i bash entra als espais d'un altre procés. nsenter fa servir setns() i és en essència el que fa docker exec; també és l'eina de depuració definitiva, perquè permet entrar a la xarxa d'un contenidor que no té ni ping ni ss instal·lats, emportant-s'hi les de l'amfitrió.
cgroups v2: la jerarquia unificada
Els namespaces controlen el que es veu. Els grups de control controlen el que es consumeix. Són mecanismes ortogonals: es poden fer servir per separat, i de fet systemd fa anys que fa servir cgroups sense namespaces per a tots els teus serveis.
La versió 1 tenia una jerarquia per controlador: un arbre per a cpu, un altre per a memory, un altre per a blkio, i un procés podia ser en llocs incoherents de cadascun. Va ser una font inesgotable de confusió. cgroups v2 (per defecte a Debian 11+, Fedora 31+, RHEL 9+) imposa una jerarquia unificada: un sol arbre muntat a /sys/fs/cgroup, on cada procés és en un únic node i els controladors s'activen per branca.
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 jerarquia unificada (a v1 hi hauria diverses línies numerades). La ruta diu exactament a quin node de l'arbre és la teva shell, i reflecteix l'organització que fa systemd.
Dues regles de v2 provoquen errors desconcertants i cal conèixer-les. La regla "no processos interns" diu que només els nodes fulla poden contenir processos, de manera que moure'n un a un node intermedi retorna EBUSY sense més explicació. I la delegació explícita: perquè un fill pugui fer servir un controlador, el pare l'hi ha d'habilitar escrivint +cpu +memory al seu cgroup.subtree_control; oblidar-ho és la causa número u de "he escrit el límit i no passa res".
Els controladors a la pràctica: cpu, memory, io i pids
Crearem un grup per a l'agregador, que és la càrrega de Meteora que es pot desbocar en processar els 17.280.000 bytes del fitxer diari, i li aplicarem límits reals.
echo "+cpu +memory +io +pids" | sudo tee /sys/fs/cgroup/cgroup.subtree_control
sudo mkdir -p /sys/fs/cgroup/meteora-agregador # crear el grup: n'hi ha prou amb 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 passar. Un simple mkdir ha creat un grup de control amb tots els seus fitxers de configuració i d'estadístiques. És el sistema de fitxers virtual cgroup2 que vam esmentar a la taula de 04-03: els fitxers no existeixen al disc, els genera el nucli en llegir-los. Tota l'API de cgroups és llegir i escriure fitxers de text.
cpu: quota i pes
# Límit dur: 50.000 µs de CPU cada 100.000 µs → mig nucli
echo "50000 100000" | sudo tee cpu.max
# Pes relatiu en cas de contenció (per defecte 100, rang 1-10000)
echo "50" | sudo tee cpu.weightQuè fan i en què es diferencien, que és la distinció que més es confon. cpu.max és una quota absoluta: en cada període de 100 ms el grup pot consumir 50 ms, i quan els esgota les seves tasques s'estrangulen (throttling) fins al període següent, encara que la màquina estigui completament ociosa; és un sostre. cpu.weight és la versió cgroup del nice de 02-02: només importa quan hi ha contenció i reparteix proporcionalment —amb pesos 50 i 100, un rep un terç i l'altre dos terços si tots dos volen CPU; si un està ociós, l'altre fa servir el 100 %—.
El problema de l'estrangulament amb quotes mal posades mereix un paràgraf a part, perquè és una de les patologies més freqüents i pitjor diagnosticades del món dels contenidors. Imagina't meteo-api amb cpu.max = 100000 100000 (un nucli) i una aplicació amb 4 fils. Els 4 fils es poden executar en paral·lel en 4 nuclis físics: consumeixen els 100 ms de quota en 25 ms de temps real, i després queden 75 ms completament aturats. El resultat és una latència de cua espantosa —peticions que triguen 80 ms quan haurien de trigar-ne 2— amb la CPU mitjana del contenidor en un tranquil·litzador 100 %, és a dir, sense cap senyal òbvia de problema.
El diagnòstic és en un fitxer concret:
cat cpu.stat
# usage_usec 4820193
# nr_periods 2500
# nr_throttled 1840 ← estrangulat en el 73 % dels períodes
# throttled_usec 138200000 ← 138 segons aturat a la forçaSi nr_throttled és una fracció alta de nr_periods, tens un problema de quota, no de codi. Les solucions: apujar la quota, reduir el nombre de fils de l'aplicació perquè coincideixi amb la quota, o fer servir cpu.weight en lloc de quota si l'objectiu era prioritat i no sostre.
memory: el límit i l'OOM per grup
echo "512M" | sudo tee memory.max # sostre dur: en superar-lo, OOM del grup
echo "400M" | sudo tee memory.high # llindar tou: pressió i reclamació agressiva
echo "0" | sudo tee memory.swap.max # prohibir swap per a aquest grupmemory.max és el sostre absolut: quan el grup el supera i no es pot reclamar res, s'invoca l'OOM killer acotat al grup, que mata un procés de dins i no del sistema. Aquesta és la diferència crucial amb 02-04, on l'agregador desbocat podia provocar la mort de meteo-api: amb cgroups, una fallada global es converteix en una fallada local. memory.high no mata; quan se supera, el nucli estrangula el procés i reclama memòria agressivament. És un fre progressiu, i la bona pràctica és posar-lo un 20-25 % per sota de memory.max perquè l'aplicació tingui ocasió de comportar-se abans de morir.
Verificació de l'efecte:
echo $$ | sudo tee cgroup.procs # ficar la shell dins del grup
python3 -c "d=bytearray(600*1024*1024)" # demanar 600 MB amb un sostre de 512
# Killed
cat memory.events
# low 0 / high 128 / max 47 / oom 1 / oom_kill 1Com llegir-ho. memory.events és el millor diagnòstic de memòria en contenidors: high 128 diu que es va creuar el llindar tou 128 vegades (hi va haver pressió), max 47 que es va tocar el sostre 47 vegades, i oom_kill 1 que es va haver de matar un procés. Un contenidor que reinicia amb codi 137 (128 + 9, és a dir SIGKILL) i té oom_kill diferent de zero està morint per memòria, no per una fallada de l'aplicació.
io: limitar el disc
Sobre el que vam veure a 02-05, el controlador io acota l'amplada de banda i les IOPS per dispositiu, identificat pel seu parell major:menor.
lsblk -o NAME,MAJ:MIN /dev/md0 # p. ex. 9:0
# Màxim 20 MB/s de lectura, 10 MB/s d'escriptura, 200 IOPS d'escriptura
echo "9:0 rbps=20971520 wbps=10485760 wiops=200" | sudo tee io.maxPer a què serveix a Meteora. El cas real és la tasca nocturna que comprimeix els fitxers diaris: sense límit satura el RAID 1 i les consultes de meteo-api pateixen latències de centenars de mil·lisegons per espera de disc; amb io.max la tasca triga més però no fa mal al servei. És el raonament de l'ionice de 02-05, amb un sostre garantit en lloc d'una prioritat.
Un matís important: io.max regula bé les lectures i les escriptures directes, però les que passen per la memòria cau de pàgina es comptabilitzen quan les aboca el fil d'escriptura diferida, no quan les fa l'aplicació; per a aquestes és més efectiu io.latency o el control combinat amb memory.
pids: el fre contra les bombes de bifurcació
És un límit d'una sola línia que evita que un contenidor amb un bucle de fork() —accidental o maliciós— esgoti la taula de processos de l'amfitrió sencer i impedeixi fins i tot iniciar sessió. No costa res posar-lo i és dels pocs límits que no tenen contrapartida. Posa'l sempre.
Com fa servir systemd els cgroups
Aquí ve una revelació útil: ja fa cinc mòduls que fas servir cgroups sense saber-ho. systemd organitza tot el sistema en una jerarquia de cgroups amb tres tipus d'unitat: els slices (system.slice, user.slice) són branques de l'arbre per repartir recursos, els services (meteo-api.service) agrupen els processos d'un servei, i els scopes (session-3.scope) agrupen processos creats externament.
systemd-cgls # arbre complet de cgroups del sistema
systemd-cgtop # com 'top', però per cgroup
systemctl show meteo-api.service -p ControlGroup
# ControlGroup=/system.slice/meteo-api.service
# En calent, sense reiniciar (amb --runtime, sense persistir):
sudo systemctl set-property meteo-api.service MemoryMax=768MI els límits es declaren amb directives d'unitat, que systemd tradueix als fitxers que acabem d'escriure a mà:
[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.maxPer què això importa. Recorda l'avís de 02-02: nice no limita el consum. Aquí hi ha el mecanisme que sí que ho fa, i està disponible sense contenidors: afegir MemoryMax i TasksMax a la unitat enfortida de 05-03 aporta la meitat del benefici de contenidoritzar amb una desena part del canvi. I quan executes un contenidor, Docker o Podman escriuen en aquests mateixos fitxers.
El tercer ingredient: seguretat dins del contenidor
Namespaces i cgroups no són mecanismes de seguretat complets. Un procés root dins d'un contenidor sense espai d'usuari és root a l'amfitrió, i li sobren camins per escapar-se si té capabilities suficients. Per això tot runtime seriós aplica, a més, les eines de 05-01:
| Capa | Què aporta | Com s'aplica |
|---|---|---|
| Espai d'usuari | El root de dins no és el de fora | --userns-remap, Podman rootless |
| Capabilities retallades | S'elimina gairebé tot el poder de root | --cap-drop=ALL --cap-add=NET_BIND_SERVICE |
| seccomp i AppArmor/SELinux | Es bloquegen syscalls perilloses i s'hi afegeix MAC | Perfils per defecte: ~44 syscalls de ~350 bloquejades |
no-new-privileges |
Cap execve no pot guanyar privilegi |
--security-opt=no-new-privileges |
El perfil seccomp per defecte de Docker és superfície mínima ben aplicada: bloqueja mount, reboot, kexec_load, init_module, bpf i keyctl, entre altres, i ha neutralitzat ell sol diverses vulnerabilitats del nucli abans que existís el pedaç; desactivar-lo "perquè així funciona" és una de les pitjors decisions habituals. I no-new-privileges activa el bit PR_SET_NO_NEW_PRIVS de la unitat enfortida de 05-03, de manera que cap execve posterior no pugui augmentar privilegis: neutralitza d'un cop qualsevol setuid residual a la imatge, en una línia i sense contrapartides.
La conclusió honesta: un contenidor és segur quan es combinen les tres coses. Namespaces sense capabilities retallades ni espai d'usuari és aïllament de joguina.
Sistemes de fitxers en capes: overlayfs de debò
Ens falta l'ingredient que explica per què els contenidors es distribueixen tan bé. Si cada contenidor necessités la seva còpia completa d'un sistema de fitxers Debian (uns 120 MB), cent contenidors serien 12 GB de disc duplicat i cent còpies diferents a la memòria cau de pàgina.
overlayfs ho resol superposant directoris. Ja va aparèixer a la taula de 04-03; ara l'obrim. Té quatre peces:
| Directori | Paper |
|---|---|
lowerdir |
Una o diverses capes de només lectura, apilades (la primera de la llista és la de dalt) |
upperdir |
La capa escrivible. Aquí hi van tots els canvis |
workdir / merged |
Espai de treball intern del nucli (al costat d'upperdir) i punt de muntatge amb la vista unificada |
Les regles de resolució són senzilles i mereixen memoritzar-se. Per llegir, es busca de dalt a baix i guanya la primera capa que tingui el fitxer. Per escriure un fitxer que és a lower es fa copy-up: es copia sencer a upperdir i s'hi modifica allà, sense tocar mai la capa inferior. I per esborrar un fitxer de lower, com que no es pot esborrar el que és de només lectura, es crea a upperdir un whiteout —un dispositiu de caràcters 0:0 amb aquell nom— que amaga el de sota.
Demostració manual, que és la millor manera d'entendre-ho:
cd /tmp && mkdir -p ovl/{base,extra,upper,work,merged}
echo "config de la imatge base" > ovl/base/meteora.conf
echo "binari base" > ovl/base/meteo-api
echo "pedaç 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 # "pedaç de la capa 2" ← guanya la capa superiorQuè demostra. A lowerdir=ovl/extra:ovl/base, extra és damunt de base. Tots dos tenen meteo-api, i guanya el d'extra. meteora.conf només és a base i es veu igualment. Així es construeix una imatge: cada instrucció del Dockerfile afegeix una capa a sobre.
Ara el copy-up i el whiteout:
echo "modificat pel contenidor" >> ovl/merged/meteora.conf
ls -l ovl/upper/ # ara meteora.conf és aquí, copiat sencer!
cat ovl/base/meteora.conf # la capa base, intacta
rm ovl/merged/meteo-api
ls ovl/merged # ja no hi és
ls -l ovl/upper/meteo-api # c--------- 1 root root 0, 0 ... meteo-api
# ← un whiteout: dispositiu de caràcters 0:0
sudo umount ovl/mergedLa conseqüència pràctica més important. El copy-up copia el fitxer complet la primera vegada que s'hi escriu, encara que en canviïs un sol byte: obrir per escriptura un fitxer de 2 GB que ve de la imatge copia 2 GB abans que la teva escriptura passi. Per això les dades que canvien van en un volum muntat i mai a la capa escrivible, i per això els fitxers diaris de 17,3 MB de /var/lib/meteora/lectures/ no han de ser dins de la imatge. I la raó que la tècnica funcioni tan bé: cent contenidors de la mateixa imatge comparteixen les mateixes capes inferiors, al disc i a la memòria cau de pàgina, de manera que ocupen 120 MB i no 12 GB. És el clonatge enllaçat de 06-01 portat a l'extrem.
Què és una imatge i què és un contenidor
Amb overlayfs explicat, la distinció es torna trivial i deixa de ser una font de confusió:
| Imatge | Contenidor | |
|---|---|---|
| Què és | Capes de només lectura + metadades (JSON) | Una imatge + una capa escrivible + namespaces + cgroups |
| Estat | Immutable, identificada per un digest SHA-256 | Efímer, amb estat propi |
| Analogia | El binari al disc | El procés en execució |
| Quants | Una | Molts contenidors per imatge |
L'analogia binari/procés és exacta i resol gairebé tots els dubtes de principiant: igual que un /usr/bin/python3 dona lloc a molts processos independents, una imatge dona lloc a molts contenidors independents, cadascun amb la seva capa escrivible. D'aquí el corol·lari que més disgustos evita: tot el que un contenidor escriu a la seva capa escrivible desapareix quan el contenidor s'esborra. Les dades van en volums.
Runtimes, estàndards OCI i la pila real
La paraula "Docker" designa col·loquialment diverses capes que convé separar, perquè en producció poques vegades es fan servir totes:
La pila va de dalt a baix així: CLI (docker, podman, nerdctl) → runtime d'alt nivell (containerd o CRI-O: imatges, xarxa, emmagatzematge, cicle de vida) → shim (containerd-shim, conmon) → runtime de baix nivell (runc, crun: crea namespaces, escriu cgroups, fa pivot_root i executa) → nucli Linux.
runc és el runtime de baix nivell de referència, i fa exactament el que hem vist a mà en aquesta lliçó: llegeix un JSON de configuració, crea els namespaces, escriu els cgroups, fa pivot_root, aplica capabilities i seccomp, i executa el procés; la seva feina dura mil·lisegons i després desapareix (crun és una reimplementació en C, més ràpida). containerd gestiona el d'alt nivell: descarregar imatges, muntar els overlays, configurar xarxes i supervisar el cicle de vida. Docker hi afegeix a sobre l'experiència d'usuari, la construcció d'imatges i una API, amb un dimoni que s'executa com a root que és la seva debilitat principal. I Podman fa el mateix sense dimoni —cada contenidor és un fill directe de l'usuari— i sense privilegis, recolzant-se en l'espai de noms d'usuari, amb una CLI compatible i integració amb systemd.
Els estàndards OCI (Open Container Initiative, 2015) fan que tot això sigui intercanviable: la image-spec defineix el format de les imatges, la runtime-spec com s'executa un contenidor a partir d'un directori i un JSON, i la distribution-spec el protocol dels registres. Gràcies a elles, una imatge construïda amb Docker s'executa amb Podman o CRI-O sense canvis, i Kubernetes parla amb containerd o CRI-O sense necessitar Docker en absolut.
Pràctica: empaquetar meteo-api
Construirem la imatge de meteo-api, respectant totes les convencions del curs: UID 990, usuari sense shell, port 443, configuració a /etc/meteora/meteora.conf i dades a /var/lib/meteora.
# ---------- Etapa 1: construcció ----------
FROM python:3.12-slim AS constructor
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/installacio -r requirements.txt
# ---------- Etapa 2: imatge final ----------
FROM python:3.12-slim
# Usuari de servei amb el MATEIX UID que a meteo-01, sense 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
# Només el que s'ha instal·lat a l'etapa anterior: ni compiladors ni capçaleres
COPY --from=constructor /installacio /usr/local
WORKDIR /app
COPY --chown=990:990 app/ /app/
# Directoris de dades i registres, amb la propietat correcta
RUN mkdir -p /var/lib/meteora/lectures /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/salut',timeout=2).status==200 else 1)"
ENTRYPOINT ["python3", "-m", "meteo_api"]
CMD ["--port", "8443"]Línia a línia, i el perquè de cada decisió:
FROM python:3.12-slim: base mínima oficial. La variantslimpesa ~130 MB davant d'~1 GB de la completa, i cada megabyte de més és superfície d'atac i CVE per pedaçar. Es fixa la versió menor (3.12) per reproductibilitat; en producció es fixa fins i tot el digest SHA-256.- Construcció multietapa: l'etapa
constructorinstal·la les dependències, que poden requerir compilador i capçaleres. La imatge final només copia el resultat. Així elgcci les eines de desenvolupament no viatgen a producció: ni pesen ni serveixen a un atacant per compilar un exploit a dins. groupadd/useraddamb UID 990: el mateix UID que ameteo-01. Això no és cosmètic: quan muntis/var/lib/meteorade l'amfitrió com a volum, el nucli compara números, no noms. Un UID diferent dins i fora donaPermission deniedincomprensibles.--shell /usr/sbin/nologincompleix la regla de 05-02 per a comptes de servei.COPY --chown=990:990ichmod 750: es fixa la propietat en copiar, perquè unchown -Rposterior sobre fitxers ja copiats duplicaria aquella capa a l'overlay pel copy-up que acabem d'estudiar; i els permisos són coherents amb l'umask 027del curs.USER 990:990: la línia més important de tot el fitxer. Sense ella el procés s'executa com a root dins del contenidor, i sense espai de noms d'usuari això és root a l'amfitrió. Amb ella, un compromís de l'aplicació no dona ni tan sols root de contenidor.EXPOSE 8443: es fa servir 8443 i no 443 a propòsit, perquè un procés sense privilegis no pot obrir ports per sota de 1024 senseCAP_NET_BIND_SERVICE(05-01); en lloc de concedir la capability, s'escolta alt i es publica al 443 des de fora.HEALTHCHECK: el runtime executa la comprovació periòdicament i marca el contenidor com aunhealthysi falla tres vegades, que és el que permet a un orquestrador reemplaçar-lo;--start-perioddona marge a l'arrencada.ENTRYPOINTen forma exec (llista JSON, no cadena): en forma de cadena el procés es llança sota/bin/sh -c, la shell és el PID 1, no reenviaSIGTERMi tornes al problema de les senyals.
Construcció i execució amb tots els límits que hem estudiat:
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-dades:/var/lib/meteora \
-p 443:8443 \
--init \
meteora/meteo-api:1.4.0Cada opció i a quin mecanisme d'aquesta lliçó correspon:
| Opció | Mecanisme | Efecte |
|---|---|---|
--read-only + --tmpfs /tmp |
Muntatges (mnt) |
Sistema de fitxers immutable; equival a ProtectSystem=strict de 05-03 |
--cap-drop=ALL --cap-add=... + no-new-privileges |
Capabilities (05-01) i PR_SET_NO_NEW_PRIVS |
De ~14 capabilities a una, i cap setuid residual no serveix |
--pids-limit=100 i --cpus=1.5 |
cgroups pids.max i cpu.max |
Bomba de bifurcació continguda i sostre de CPU |
--memory=512m --memory-reservation=400m |
memory.max i memory.high |
OOM acotat al contenidor |
-v ...meteora.conf:ro |
Muntatge bind (04-03) | La configuració amb secrets entra de fora i en només lectura |
-v meteora-dades:/var/lib/meteora |
Volum | Les dades sobreviuen al contenidor i eviten el copy-up |
-p 443:8443 |
net + NAT |
L'amfitrió publica al 443 el que el contenidor serveix al 8443 |
--init |
PID 1 | tini com a PID 1: reenvia senyals i cull zombis |
I la verificació, que és on es tanca el cercle amb tota la lliçó:
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 (només CAP_NET_BIND_SERVICE)
# NoNewPrivs: 1 / Seccomp: 2 (mode filtre actiu)
cat /sys/fs/cgroup/system.slice/docker-*.scope/{memory.max,cpu.stat}Contenidors davant de màquines virtuals
La comparació honesta, sense les exageracions habituals de cada bàndol:
| Aspecte | Màquina virtual | Contenidor |
|---|---|---|
| Què aïlla | Maquinari complet, nucli propi | Vista i consum, nucli compartit |
| Arrencada | 20-60 s (BIOS, nucli, init, serveis) |
20-200 ms |
| Sobrecàrrega de memòria | 200-500 MB per instància | 1-10 MB |
| Sobrecàrrega de CPU | 1-3 % | ~0 % (és un procés normal) |
| Mida d'imatge | 1-20 GB | 20-300 MB, amb capes compartides |
| Densitat per amfitrió | Desenes | Centenars o milers |
| Superfície d'atac exposada | Gestors de VM exit + virtio |
300-400 crides al sistema |
| Aïllament de fallades | Un pànic afecta una VM | Un pànic afecta tot |
SO, nuclis o sysctl diferents |
Sí (Windows sobre Linux) | No: mateix nucli, mateixa família |
La conclusió pràctica no és "un guanya", sinó quina frontera necessites. Si és una frontera de seguretat entre parts que no es fien —inquilins diferents, codi de clients, compliment normatiu estricte—, màquina virtual. Si el que busques és empaquetatge, densitat i velocitat de desplegament dins d'un àmbit de confiança, contenidor. I el que és habitual a la indústria són tots dos: contenidors dins de màquines virtuals, tal com vam anticipar al final de 06-01.
Riscos concrets i bones pràctiques
| Pràctica perillosa | Per què és greu | Què cal fer |
|---|---|---|
--privileged |
Totes les capabilities, sense seccomp ni AppArmor, tots els dispositius: escapar-se és tan trivial com muntar el disc de l'amfitrió | Si alguna cosa ho "necessita", el problema és el disseny |
Muntar /var/run/docker.sock |
Equival a root a l'amfitrió sense matisos: qui parla amb aquell socket llança un contenidor privilegiat amb / a dins |
Evitar-ho; si és imprescindible, un proxy que filtri l'API |
| Executar com a root a dins | És el comportament per defecte sense USER, i sense espai d'usuari aquell root és el de l'amfitrió |
USER 990:990 + --userns-remap o Podman rootless |
| Imatges sense verificar ni actualitzar | latest no és una versió, és una loteria; la base acumula CVE encara que el teu codi no canviï |
Digests fixats, registre propi, trivy/grype, reconstrucció periòdica |
| Secrets a la imatge | Un COPY de meteora.conf o un ENV TOKEN=... queden a la capa per sempre; docker history els recupera |
Secrets en temps d'execució: bind de només lectura o gestor de secrets |
| Límits absents | Sense --memory es dispara l'OOM killer de l'amfitrió, amb l'efecte de 02-04 sobre tota la resta |
Límits de memòria i de PID sempre |
Errors Habituals i Consells
Dir "màquina virtual lleugera" a un contenidor, o oblidar --fork amb unshare --pid. El primer porta a decisions dolentes: instal·lar-hi systemd i sshd a dins, executar-hi diversos processos, tractar-lo com un servidor amb estat; un contenidor és un procés empaquetat, sense estat local i reemplaçable. El segon és la causa del clàssic "he creat el namespace i no ha passat res": el procés que crida unshare() no entra al nou espai de PID, només els seus fills.
No habilitar els controladors a cgroup.subtree_control. Escrius a memory.max i no passa res, o el fitxer ni tan sols existeix: el pare ha de delegar el controlador abans.
Posar una quota de CPU sense ajustar els fils, o desar dades a la capa escrivible. El primer és la patologia descrita més amunt —amb --cpus=1 i 8 fils es consumeix la quota en una vuitena part del període i la resta es passa estrangulat, amb latències de cua horribles i sense símptomes evidents; revisa nr_throttled—. El segon fa que les dades desapareguin en esborrar el contenidor, i a més dispara un copy-up complet a cada modificació d'un fitxer gran.
Fer servir ENTRYPOINT en forma de cadena i construir imatges gegants. El primer converteix /bin/sh en PID 1, que ignora SIGTERM i no reenvia senyals: aturada bruta i sempre lenta; fes servir la forma exec (["prog","arg"]) i --init si el teu procés genera fills. El segon —un FROM ubuntu amb apt install build-essential— dona 1,5 GB, desenes de CVE i un arsenal d'eines per a un atacant: multietapa, bases slim o distroless, i .dockerignore per no ficar-hi el .git sencer.
Consell: audita amb /proc/<pid>/status. Els camps Uid, CapEff, NoNewPrivs i Seccomp del procés d'un contenidor diuen en quatre línies si el desplegament està ben fet. És més fiable que llegir la documentació del runtime.
Consell: comença per systemd, i prova primer sense privilegis. Si el teu problema és limitar recursos i aïllar un servei en un servidor concret, MemoryMax, TasksMax i les directives de 05-03 donen gairebé tot el benefici sense canviar el model de desplegament; contenidoritzar té sentit quan a més necessites empaquetatge reproduïble. I quan ho facis, arrenca amb Podman rootless o --userns-remap: si funciona, has guanyat la millora de seguretat més gran disponible, i si no, la fallada et dirà exactament quin privilegi demana la teva aplicació i per què.
Exercicis
Exercici 1: construir un contenidor a mà, sense runtime
Sense fer servir Docker ni Podman, construeix un "contenidor" mínim per a l'agregador amb només unshare, mount i els fitxers de /sys/fs/cgroup. Ha de complir: (a) espais propis de PID, muntatge, UTS i xarxa; (b) /proc propi, de manera que ps aux només mostri els seus processos; (c) nom de màquina agregador-c1; (d) límit de 256 MB de memòria i mig nucli de CPU; (e) màxim de 50 processos. Escriu les ordres, verifica cada requisit, i indica què li falta per considerar-se segur.
Exercici 2: diagnosticar dos contenidors malalts
Dos contenidors de Meteora donen problemes. Per a cadascun, digues quina és la causa exacta, amb quina ordre ho confirmaries i com ho arreglaries.
Contenidor A — meteo-api, límit --cpus=1, aplicació amb 4 fils de treball. Els usuaris diuen que "de vegades va lentíssim". CPU mitjana del contenidor 98 %, memòria estable en 210 MB, latència p50 de 3 ms i p99 de 340 ms. cpu.stat: nr_periods 6000, nr_throttled 4380, throttled_usec 271000000. memory.events: tot a 0.
Contenidor B — agregador, límit --memory=512m. Es reinicia cada poques hores amb codi de sortida 137 i sense cap missatge al seu registre. Processa el fitxer diari de 17.280.000 bytes carregant-lo sencer a memòria. memory.events: high 0, max 312, oom_kill 5; memory.high és a max.
Exercici 3: revisar un Dockerfile i una execució
Troba tots els problemes de seguretat i d'eficiència en el que segueix, explica el risc concret de cadascun i escriu la versió 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:latestSolucions
Solució 1
# 1. cgroup amb els límits, ABANS d'arrencar
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 nucli
echo "50" | sudo tee $G/pids.max
# 2. Namespaces
sudo unshare --pid --fork --mount-proc --mount --uts --net bash
# 3. A dins: identitat i entrada al grup
hostname agregador-c1 ; ip link set lo up
echo $$ > /sys/fs/cgroup/agregador-c1/cgroup.procsExplicació. El cgroup es crea primer perquè cal habilitar els controladors al pare abans que existeixin els fitxers de límit al fill; --fork és obligatori perquè qui crida unshare() no entra al nou espai de PID; --mount-proc implica --mount i remunta /proc perquè ps llegeixi la taula correcta; i ficar el PID a cgroup.procs hi inclou el procés i tots els seus futurs fills, que hereten el grup.
Verificacions:
echo $$ ; ps aux | wc -l # (a,b) 1, i 3-4 línies en lloc de centenars
hostname ; ip link # (c) agregador-c1 ; (a) només '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 en arribar a 50
sudo readlink /proc/$PID_DE_DINS/ns/pid # diferent de /proc/self/ns/pidQuè li falta per ser segur, i és molt: no hi ha espai de noms d'usuari, així que el root de dins és el root de fora —el defecte més greu—; no hi ha pivot_root, de manera que continua veient tot el sistema de fitxers de l'amfitrió, /etc/shadow inclòs; no s'han retallat capabilities, així que conserva CAP_SYS_ADMIN i CAP_SYS_MODULE; no hi ha seccomp ni perfil AppArmor ni no-new-privileges; i /dev i /sys són els de l'amfitrió, amb accés a dispositius de blocs crus. La conclusió pedagògica és la de la lliçó: namespaces i cgroups per si sols no són seguretat, són la meitat visible del mecanisme, i l'altra meitat són les restriccions del mòdul 5.
Solució 2
Contenidor A: estrangulament de CPU per quota mal dimensionada.
Causa. nr_throttled 4380 sobre nr_periods 6000 significa estrangulament en el 73 % dels períodes, amb 271 segons d'aturada forçada acumulada. Amb --cpus=1 la quota és de 100 ms per període de 100 ms, però els 4 fils s'executen en paral·lel i l'esgoten en ~25 ms de temps real; en els 75 ms restants tots els fils estan aturats, i una petició que arriba en aquell buit espera al període següent: d'aquí un p99 de 340 ms amb un p50 de 3 ms. La "CPU mitjana del 98 %" enganya perquè es mesura contra la quota, no contra la màquina.
Confirmació. Comparar nr_throttled amb nr_periods a cpu.stat: qualsevol quocient per damunt del 5 % ja és sospitós. I nproc dins del contenidor retornarà els nuclis de l'amfitrió, que és justament el que enganya les biblioteques que dimensionen els seus pools de fils.
Arranjament, en ordre de preferència: ajustar els fils a la quota, perquè amb 1 CPU de quota no hi ha paral·lelisme real que aprofitar amb 4 fils; apujar la quota a --cpus=2 o 4 si la càrrega ho justifica; o, si el que es volia era prioritat i no sostre, substituir la quota per cpu.weight, que no estrangula mai quan hi ha CPU lliure. Com a mesura general, exposar la quota a l'aplicació perquè dimensioni els seus pools correctament.
Contenidor B: OOM del cgroup per pic de memòria previsible.
Causa. El codi de sortida 137 = 128 + 9 indica SIGKILL, i oom_kill 5 confirma que va ser l'OOM killer del cgroup, no una fallada de l'aplicació: per això el registre és buit, el procés mor sense oportunitat d'escriure. El max 312 diu que es va tocar el sostre 312 vegades. La causa concreta és carregar els 17.280.000 bytes del fitxer diari de cop: amb la sobrecàrrega de Python (objectes i còpies intermèdies) el pic multiplica diverses vegades els 17 MB crus, i si s'acumulen resultats de diversos dies se sobrepassen els 512 MB.
Confirmació. docker inspect -f '{{.State.OOMKilled}}' retorna true, memory.events mostra oom_kill, i journalctl -k | grep -i "memory cgroup out of memory" a l'amfitrió dona la traça del nucli amb el procés escollit.
Arranjament en dos fronts. Immediat: fixar memory.high —que era a max, és a dir, desactivat— un 20-25 % per sota del sostre (--memory-reservation=400m) perquè el nucli apliqui pressió abans de matar, i apujar --memory si el consum legítim ho requereix, mesurant abans el pic real amb memory.peak. De fons, que és l'arranjament bo: processar el fitxer per blocs. Com que les lectures són registres de mida fixa de 24 bytes, es pot llegir en trossos de 64 KB (2.730 lectures) o mapar el fitxer amb mmap (02-04) perquè el nucli gestioni i reclami les pàgines. El consum passaria de centenars de megabytes a uns pocs, i el sistema quedaria a més independent de la mida del fitxer diari.
Solució 3
Problemes del Dockerfile:
| # | Problema | Risc concret |
|---|---|---|
| 1 | ubuntu:latest |
No reproduïble: la mateixa construcció dona imatges diferents segons el dia |
| 2 | build-essential, git, curl a la imatge final |
~500 MB extra, desenes de CVE i eines d'atac a punt |
| 3 | COPY . /app |
Hi fica .git (historial i possibles secrets), .env i fitxers locals; a més invalida la memòria cau de pip install a cada canvi de codi |
| 4 | COPY de meteora.conf i ENV METEORA_TOKEN=... |
Els secrets queden a la capa per sempre, recuperables amb docker history o desempaquetant |
| 5 | Sense USER i chmod 777 -R |
S'executa com a root i qualsevol pot escriure al codi i a les dades: viola tot 04-06 |
| 6 | EXPOSE 443 |
Port privilegiat: obliga a root o a CAP_NET_BIND_SERVICE |
| 7 | CMD en forma de cadena, sense HEALTHCHECK |
/bin/sh com a PID 1 ignora SIGTERM; i ningú no detecta un procés viu però inservible |
Problemes de l'execució:
| # | Problema | Risc concret |
|---|---|---|
| 8 | --privileged |
Totes les capabilities, sense seccomp ni AppArmor, tots els dispositius: escapada trivial |
| 9 | -v /:/host |
El sistema de fitxers sencer de l'amfitrió, escrivible: s'edita /etc/shadow o /etc/sudoers |
| 10 | -v /var/run/docker.sock |
Equival a root a l'amfitrió: es llança un altre contenidor privilegiat |
| 11 | --net=host, sense límits, etiqueta latest |
Es veuen tots els ports locals; una fallada esgota memòria o PID de l'amfitrió; i no se sap quina versió és en producció |
Versió corregida:
FROM python:3.12-slim@sha256:<digest> AS constructor
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/installacio -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 /installacio /usr/local
WORKDIR /app
COPY --chown=990:990 app/ /app/
USER 990:990
EXPOSE 8443
HEALTHCHECK --interval=30s --retries=3 CMD ["python3", "/app/salut.py"]
ENTRYPOINT ["python3", "/app/meteo_api.py"]Amb un .dockerignore que exclogui .git, .env, *.conf, tests/ i __pycache__/, i amb l'execució exactament igual a la de l'apartat 11 (--user 990:990, --read-only, --cap-drop=ALL, no-new-privileges, --pids-limit, --cpus, --memory, volums, -p 443:8443 i --init), sobre l'etiqueta 1.4.0 i no latest.
Els canvis clau: la construcció multietapa deixa fora compiladors i eines; el digest fixat la fa reproduïble; el COPY de la configuració desapareix i el secret entra en temps d'execució com a bind de només lectura; USER 990:990 i --cap-drop=ALL implanten el mínim privilegi; el port passa a 8443 i es publica al 443, evitant tota capability de xarxa; ENTRYPOINT en forma exec més --init arreglen senyals i zombis; i --privileged, -v /:/host, el socket del dimoni i --net=host s'eliminen sense substitut, perquè no hi havia cap raó legítima per a cap dels quatre.
Conclusió
La frase que resumeix la lliçó és la que la va obrir: un contenidor no és una màquina virtual petita, és un procés normal del nucli amfitrió al qual se li ha limitat el que veu i el que consumeix. Ho demostra un ps des de fora: el procés hi és, a la taula de l'amfitrió, planificat pel mateix CFS-EEVDF de 02-02 i amb les seves pàgines gestionades pel mateix gestor de 02-04. D'aquí en surten les tres conseqüències que ho governen tot: arrencada en mil·lisegons, densitat de centenars per màquina, i un aïllament més feble perquè la frontera són les 300-400 crides al sistema del nucli compartit.
Els espais de noms són el primer ingredient i controlen el que es veu: mnt dona un sistema de fitxers propi —amb pivot_root com a mecanisme correcte i chroot com el que mai no va ser seguretat—, pid dona una taula de processos pròpia amb les dues trampes del PID 1 (adoptar zombis i no ignorar SIGTERM), net dona una pila de xarxa pròpia mitjançant parells veth i un pont, exactament com una VM, uts dona el nom de màquina, ipc aïlla System V —però no /dev/shm, que l'aïlla mnt—, cgroup amaga la posició a la jerarquia, time habilita la migració, i user és la millora de seguretat més important de totes, perquè mapa el root de dins a un usuari sense privilegis de fora i converteix una escapada catastròfica en un incident acotat. Tot això s'audita a /proc/<pid>/ns/ comparant inodes, i es recorre amb lsns i nsenter.
Els cgroups v2 són el segon ingredient i controlen el que es consumeix, amb una jerarquia unificada on tota l'API és escriure fitxers de text a /sys/fs/cgroup: cpu.max com a sostre absolut —amb l'estrangulament com a patologia estrella quan els fils no encaixen amb la quota, diagnosticable a nr_throttled— davant de cpu.weight com a prioritat relativa; memory.max amb OOM acotat al grup, que converteix el desastre global de 02-04 en una fallada local, i memory.high com a fre progressiu; io.max perquè la tasca nocturna no ofegui el servei; i pids.max, una línia que conté qualsevol bomba de bifurcació. I la revelació pràctica: systemd ja fa servir cgroups per a tots els teus serveis, així que MemoryMax i TasksMax a la unitat enfortida de 05-03 donen bona part del benefici sense contenidoritzar res.
El tercer ingredient és la seguretat, i sense ell els dos anteriors són aïllament de joguina: capabilities retallades, el perfil seccomp per defecte que ha neutralitzat vulnerabilitats del nucli abans que existís el pedaç, AppArmor o SELinux, i no-new-privileges. overlayfs completa el quadre explicant l'economia del model: lowerdir de només lectura, upperdir escrivible, copy-up que copia el fitxer sencer al primer canvi i whiteouts per esborrar el que no es pot esborrar; d'aquí que cent contenidors de Debian ocupin 120 MB i no 12 GB, que la imatge sigui al contenidor el que el binari al procés, i que les dades vagin en volums i mai a la capa escrivible. A sobre s'hi apila la pila real —runc fent en mil·lisegons el que aquí hem fet a mà, containerd, Docker amb el seu dimoni root davant de Podman sense dimoni i sense privilegis— unificada pels estàndards OCI.
I la pràctica va tancar el cercle: Dockerfile multietapa amb base mínima, usuari UID 990 perquè el volum de /var/lib/meteora tingui els permisos correctes, port 8443 per no necessitar capability de xarxa, HEALTHCHECK i ENTRYPOINT en forma exec; i una execució on cada opció correspon a un mecanisme estudiat, verificable en quatre línies de /proc/<pid>/status. Davant de les màquines virtuals, la conclusió no és que un guanyi, sinó quina frontera necessites: VM per separar el que no es fia, contenidor per empaquetar i densificar dins d'un àmbit de confiança, i a la indústria, gairebé sempre, tots dos alhora.
Ara bé: tot el que hem fet ha estat sobre una màquina. Hem limitat, aïllat i empaquetat meteo-api, però continua havent-hi un meteo-01 concret, amb un disc concret, que algú va instal·lar i que si s'avaria deixa el servei caigut. Qui decideix en quina màquina s'executa cada contenidor quan hi ha trenta màquines? Què passa quan una es mor a les tres de la matinada? Com es comparteix un fitxer de dades entre contenidors que ni tan sols són al mateix servidor? I què queda del sistema operatiu quan la màquina deixa de ser un objecte físic i passa a ser una crida a una API que retorna una instància en quaranta segons —i que pot desaparèixer amb el mateix avís—?
Allà les peticions i els límits que acabem d'escriure a mà a /sys/fs/cgroup es converteixen en declaracions d'un fitxer YAML, i el planificador de 02-02 reapareix un nivell més amunt, repartint contenidors entre nodes en lloc de processos entre nuclis. És El Sistema Operatiu al Núvol.
Fonaments de Sistemes Operatius
Mòdul 1: Introducció als Sistemes Operatius
- Conceptes Bàsics de Sistemes Operatius
- Història i Evolució dels Sistemes Operatius
- Tipus de Sistemes Operatius
- Funcions Principals d'un Sistema Operatiu
- Arquitectura del Nucli: Monolític, Microkernel i Híbrid
- Mode Usuari, Mode Nucli i Crides al Sistema
Mòdul 2: Gestió de Recursos
- Gestió de Processos
- Planificació de la CPU
- Gestió de Memòria
- Memòria Virtual i Paginació
- Gestió d'Emmagatzematge
- Gestió de Dispositius
- Controladors, Interrupcions i Operacions d'E/S
Mòdul 3: Concurrència
- Conceptes de Concurrència
- Fils i Processos
- Comunicació entre Processos (IPC)
- Sincronització i Exclusió Mútua
- Problemes Clàssics de Concurrència
- Interbloquejos: Prevenció, Detecció i Recuperació
Mòdul 4: Estructures de Fitxers
- Sistemes de Fitxers
- Estructures de Directoris
- Particions, Muntatge i Sistema de Fitxers Virtual
- Gestió de Fitxers
- Assignació d'Espai, Journaling i Integritat
- Seguretat i Permisos de Fitxers
Mòdul 5: Protecció i Seguretat del Sistema
- Principis de Protecció i Control d'Accés
- Usuaris, Autenticació i Escalada de Privilegis
- Amenaces Habituals i Enfortiment del Sistema
- Auditoria, Registres i Resposta a Incidents
Mòdul 6: Virtualització i Contenidors
- Virtualització: Hipervisors i Màquines Virtuals
- Contenidors: Namespaces i cgroups
- El Sistema Operatiu al Núvol
- Sistemes Operatius Mòbils i de Temps Real
