La lliçó anterior et va donar aïllament fort a un preu alt: cada màquina virtual porta el seu propi nucli, triga mig minut a arrencar, ocupa gigabytes al disc i reserva memòria només per existir. Per a un entorn de proves és raonable. Per desplegar una aplicació i poder-la recrear en segons, és caríssim.

Aquesta lliçó construeix l'alternativa, i comença per la idea que cal tenir clara abans d'escriure el primer docker run, perquè adquirir-la malament condiciona tot el que ve després:

Un contenidor no és una màquina petita. És un procés aïllat.

No hi ha un sistema operatiu a dins. No hi ha un nucli. No hi ha una arrencada. Hi ha un procés normal de l'amfitrió, executant-se amb una vista restringida del sistema de fitxers, de la xarxa i de la taula de processos, i amb un límit de recursos. Tot el que veuràs —les imatges, els volums, les xarxes, Compose— és maquinària al voltant d'aquella idea.

I les tres peces del nucli que la fan possible ja les coneixes per separat: els grups de control (cgroups) són literalment els del MemoryMax=512M que vas posar a 05-05, les capacitats (capabilities) són les de 05-02, i els espais de noms de muntatge són l'evolució del chroot amb què vas reparar GRUB a 07-01.

Contingut

  1. Les tres primitives: espais de noms, grups de control i capacitats
  2. Espais de noms d'usuari i el sysctl que vas deixar comentat
  3. Instal·lar Docker, i per què el grup docker equival a root
  4. Imatges, contenidors i dipòsits d'imatges
  5. docker run i les opcions que importen
  6. Dockerfile: construir la imatge de Tramontana
  7. Construcció en diverses etapes i elecció d'imatge base
  8. Dades: volums, muntatges d'enllaç i tmpfs
  9. Xarxa i publicació de ports
  10. Docker Compose
  11. Seguretat de contenidors
  12. Alternatives, i quan no fer servir contenidors

Les tres primitives: espais de noms, grups de control i capacitats

Un contenidor no és una funció del nucli. No existeix una crida al sistema crear_contenidor(). És una convenció: un procés llançat amb certes restriccions activades alhora. Veure-ho directament, sense Docker pel mig, és el que fa que tota la resta encaixi.

Espais de noms: aïllar el que un procés veu

Un namespace aïlla una classe de recurs global del nucli, de manera que els processos que hi són a dins en vegin la seva pròpia instància. N'hi ha set:

Espai de noms Aïlla Efecte visible
mnt Punts de muntatge El seu propi arbre de fitxers
pid Identificadors de procés El seu procés principal és el PID 1
net Interfícies, rutes, ports, tallafocs La seva pròpia pila de xarxa
uts Nom de màquina i domini hostname propi
ipc Memòria compartida, cues de missatges Sense comunicació amb l'exterior
user Correspondència d'UID i GID Ser root a dins sense ser-ho a fora
cgroup Vista de la jerarquia de grups de control No veu la de l'amfitrió
$ lsns
        NS TYPE   NPROCS   PID USER   COMMAND
4026531834 time      184     1 root   /sbin/init
4026531835 cgroup    184     1 root   /sbin/init
4026531836 pid       184     1 root   /sbin/init
4026531837 user      184     1 root   /sbin/init
4026531838 uts       181     1 root   /sbin/init
4026531839 ipc       184     1 root   /sbin/init
4026531840 net       184     1 root   /sbin/init
4026531841 mnt       172     1 root   /sbin/init

Tots els processos comparteixen els mateixos espais de noms, perquè no hi ha cap contenidor en marxa. Anem a crear-ne un a mà, sense Docker:

# Un proces amb el seu propi espai de noms de PID, UTS i muntatge
$ sudo unshare --pid --fork --mount-proc --uts --mount bash

root@srv-tramontana:/# hostname contenidor-manual
root@contenidor-manual:/# hostname
contenidor-manual

root@contenidor-manual:/# ps aux
USER  PID %CPU %MEM    VSZ   RSS TTY  STAT START   TIME COMMAND
root    1  0.0  0.1   9788  5124 pts/0 S   19:04   0:00 bash
root   12  0.0  0.0  11492  3684 pts/0 R+  19:04   0:00 ps aux

Aquí està l'efecte: bash és el PID 1 i només veu dos processos. Des de fora, el mateix procés té un PID normal:

# En un altre terminal de l amfitrio
$ pgrep -a -f 'unshare --pid'
8412 sudo unshare --pid --fork --mount-proc --uts --mount bash
$ ps -ef | grep -c bash
14

El procés és el mateix. L'única cosa que canvia és el que veu. I això explica per què un contenidor arrenca en mil·lisegons: no hi ha res per arrencar, només un clone() amb unes quantes banderes.

# Cada proces exposa els seus espais de noms com a enllacos a /proc
$ ls -l /proc/1/ns/
lrwxrwxrwx 1 root root 0 ago 18 19:06 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 ago 18 19:06 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0 ago 18 19:06 mnt -> 'mnt:[4026531841]'
lrwxrwxrwx 1 root root 0 ago 18 19:06 net -> 'net:[4026531840]'
lrwxrwxrwx 1 root root 0 ago 18 19:06 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 ago 18 19:06 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 ago 18 19:06 uts -> 'uts:[4026531838]'

# Comparar dos processos: si els numeros coincideixen, comparteixen espai de noms
$ sudo readlink /proc/8412/ns/pid /proc/1/ns/pid
pid:[4026532198]
pid:[4026531836]

L'espai de noms de xarxa és especialment il·lustratiu, perquè l'aïllament és total:

$ sudo unshare --net bash
root@srv-tramontana:/# ip addr
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
root@srv-tramontana:/# ss -tulpn
# (buit)
root@srv-tramontana:/# exit

Ni enp0s3, ni IP, ni rutes, ni un sol sòcol. I lo està DOWN. Aquell és el punt de partida de qualsevol contenidor de Docker abans que se li connecti una interfície virtual.

El chroot de 07-01, i per què no n'hi ha prou

A 07-01 vas fer servir chroot per entrar en un sistema instal·lat i reparar GRUB. chroot canvia l'arrel del sistema de fitxers per a un procés — és a dir, fa una part del que fa un espai de noms de muntatge.

Però només aquella part, i per això chroot no és un mecanisme de seguretat:

$ sudo chroot /mnt /bin/bash
bash-5.2# ls /
bin boot dev etc home ...          # una altra arrel: aixo si que funciona

bash-5.2# ps aux | wc -l           # PERO veu tots els processos de l amfitrio
185
bash-5.2# ip addr | grep enp0s3    # i tota la xarxa de l amfitrio
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> ...
bash-5.2# hostname                 # i el seu nom
srv-tramontana

Un procés amb privilegis dins d'un chroot en pot sortir amb relativa facilitat —hi ha tècniques conegudes i documentades— i, encara que no en sortís, té accés complet a la xarxa, als processos i als dispositius de l'amfitrió. L'espai de noms de muntatge resol l'escapada; els altres sis espais de noms resolen la resta.

Grups de control: limitar el que un procés consumeix

Els espais de noms controlen el que un procés veu; els grups de control controlen el que consumeix. I això ja ho has fet servir:

$ systemctl show tramontana.service -p MemoryMax -p TasksMax -p CPUQuotaPerSecUSec
MemoryMax=536870912
TasksMax=64
CPUQuotaPerSecUSec=infinity

# I per sota, la jerarquia de cgroups v2 a /sys/fs/cgroup
$ cat /sys/fs/cgroup/system.slice/tramontana.service/memory.max
536870912
$ cat /sys/fs/cgroup/system.slice/tramontana.service/memory.current
187432960
$ cat /sys/fs/cgroup/system.slice/tramontana.service/pids.max
64

És exactament la mateixa tecnologia. Quan a 05-05 vas posar MemoryMax=512M a la unitat de systemd, estaves creant un grup de control amb memory.max. Docker fa el mateix amb --memory 512m. La diferència és l'embolcall, no el mecanisme.

$ systemd-cgtop --iterations=1 -m | head -6
Control Group                    Tasks   %CPU   Memory  Input/s Output/s
/                                  184    2.1     1.2G        -        -
system.slice                       102    1.4   842.1M        -        -
system.slice/postgresql@16-main.…    18    0.8   412.4M        -        -
system.slice/tramontana.service      12    0.4   178.7M        -        -

Capacitats: fragmentar els privilegis de root

De 05-02: en lloc de «root o no root», el nucli divideix els privilegis en una quarantena de capacitats independents.

$ capsh --print | head -3
Current: =ep
Bounding set: cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,...

Això és el que permet que el procés principal d'un contenidor pugui ser «root» i tot i així no pugui fer gairebé res perillós: se li retiren gairebé totes les capacitats. Docker, per defecte, en deixa unes catorze de les quaranta.

I amb les tres primitives juntes, ja pots definir què és un contenidor sense cap mena de màgia:

Un contenidor és un procés llançat amb espais de noms propis, dins d'un grup de control amb límits, amb un conjunt reduït de capacitats, i amb l'arrel del sistema de fitxers apuntant a la imatge.

Espais de noms d'usuari i el sysctl que vas deixar comentat

L'espai de noms d'usuari és el més recent i el que més conseqüències de seguretat té. Permet que un UID de dins es correspongui amb un UID diferent a fora:

# Com a usuari NORMAL, sense sudo
$ unshare --user --map-root-user bash
root@srv-tramontana:~# id
uid=0(root) gid=0(root) groups=0(root)
root@srv-tramontana:~# cat /proc/self/uid_map
         0       1000          1
root@srv-tramontana:~# touch /etc/prova
touch: cannot touch '/etc/prova': Permission denied
root@srv-tramontana:~# exit

Llegeix-ho a poc a poc, perquè és contraintuïtiu: id diu uid=0(root), però no pot escriure a /etc. L'uid_map explica per què — l'UID 0 de dins és l'UID 1000 de fora. És root del seu propi espai de noms i de ningú més.

Això és el que fa possibles els contenidors sense privilegis (rootless): un usuari normal pot crear espais de noms, muntar-hi sistemes de fitxers a dins i ser root dins del seu contenidor, sense ser root a l'amfitrió.

I ara, el paràmetre que vas deixar comentat a 06-06:

$ grep -A3 unprivileged_userns /etc/sysctl.d/60-enfortiment.conf
# No permetre a usuaris sense privilegis crear espais de noms d usuari.
# ATENCIO: trenca els contenidors sense privilegis. Comentat perque a
# 07-05 el necessitaras; descomentar nomes en servidors sense contenidors.
#kernel.unprivileged_userns_clone = 0

La tensió és real i val la pena entendre-la en tots dos sentits:

A favor de desactivar-lo (= 0) A favor de deixar-lo actiu
Els espais de noms d'usuari han estat la via de nombroses escalades de privilegis Són la base dels contenidors sense privilegis i de l'entorn de proves aïllat
Donen a un usuari normal accés a superfície del nucli que abans era només de root Sense ells, tot contenidor necessita un dimoni amb privilegis
En un servidor que no executa contenidors, no s'hi perd res També els fan servir Flatpak, Snap, bwrap i els navegadors

La decisió per a srv-tramontana: deixar-lo actiu, perquè executarà contenidors. I la nota que s'afegeix al fitxer, perquè el criteri és el que cal documentar:

$ sudo tee -a /etc/sysctl.d/60-enfortiment.conf >/dev/null <<'FI'

# DECISIO 2026-08-18: kernel.unprivileged_userns_clone es deixa ACTIU
# (valor per defecte 1). Motiu: es requisit dels contenidors sense
# privilegis (podman rootless) i de l aillament de Snap. Desactivar-lo
# reduiria superficie d atac, pero impediria la contencio que aporten
# els contenidors, que es una proteccio mes gran.
# Revisar si aquesta maquina deixa d executar contenidors.
FI
$ sysctl kernel.unprivileged_userns_clone
kernel.unprivileged_userns_clone = 1

Un enfortiment descartat amb el seu motiu escrit és coneixement; descartat en silenci és una omissió.

Instal·lar Docker, i per què el grup docker equival a root

Docker no és als dipòsits d'Ubuntu amb la versió actual, així que s'instal·la des del dipòsit oficial aplicant el de 05-03: clau a /etc/apt/keyrings/ i signed-by.

$ sudo apt install ca-certificates curl
$ sudo install -m 0755 -d /etc/apt/keyrings
$ sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
      -o /etc/apt/keyrings/docker.asc
$ sudo chmod a+r /etc/apt/keyrings/docker.asc

$ echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
  | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

$ sudo apt update
$ sudo apt install docker-ce docker-ce-cli containerd.io \
      docker-buildx-plugin docker-compose-plugin

$ sudo docker version --format '{{.Server.Version}}'
27.1.2
$ sudo systemctl is-enabled docker
enabled

Fixa't que afegir un dipòsit de tercers és una decisió de seguretat, com es va dir a 05-03: a partir d'ara, qui controli aquell dipòsit pot instal·lar paquets al teu servidor. signed-by limita la clau a aquell dipòsit concret, que és el mínim exigible.

L'avís del grup docker

$ sudo usermod -aG docker operador   # <- LLEGEIX L AVIS ABANS DE FER AIXO

Aquella ordre apareix a tots els tutorials com una comoditat, i la seva implicació real gairebé mai no s'explica:

Pertànyer al grup docker és equivalent a tenir root sense contrasenya, i sense deixar el rastre que deixa sudo.

No és cap exageració. La demostració cap en una línia:

# Com a membre del grup docker, SENSE sudo:
$ docker run --rm -v /:/amfitrio -it alpine \
      cat /amfitrio/etc/shadow | head -2
root:$y$j9T$K2p...:20318:0:99999:7:::
daemon:*:20318:0:99999:7:::

El dimoni de Docker corre com a root, i -v /:/amfitrio li demana muntar l'arrel de l'amfitrió dins del contenidor. Qualsevol del grup docker pot llegir /etc/shadow, escriure a /etc/sudoers.d/, o modificar /etc/tramontana/secrets/. I ho fa sense aparèixer a auth.log.

Les conseqüències pràctiques, que cal aplicar:

  1. El grup docker es tracta com el grup sudo, amb el mateix control de qui hi entra i per què. A srv-tramontana només operador, i consta a la llista de verificació de 06-06.
  2. Mai no s'afegeix a un compte de servei ni a un usuari d'aplicació. Si svc-tramontana fos a docker, un compromís de l'aplicació seria root immediat.
  3. L'alternativa correcta quan hi ha diverses persones és sudo docker, amb una regla a sudoers.d que registri qui executa què — coherent amb el que vas fer a 05-02 amb desplegar-segur.
  4. O fer servir podman, que no té dimoni amb privilegis i resol el problema d'arrel. Es veu al final de la lliçó.
$ sudo tee /etc/sudoers.d/docker-tramontana >/dev/null <<'FI'
# Docker requereix privilegis efectius de root. Es canalitza per sudo perque
# quedi traca a auth.log de qui executa que.
Cmnd_Alias TRAMO_DOCKER = /usr/bin/docker, /usr/bin/docker compose
operador ALL=(root) TRAMO_DOCKER
FI
$ sudo visudo -c -f /etc/sudoers.d/docker-tramontana
/etc/sudoers.d/docker-tramontana: parsed OK

Imatges, contenidors i dipòsits d'imatges

Tres conceptes que es confonen i que convé separar d'una vegada:

Concepte Què és Analogia
Imatge Plantilla immutable de només lectura, en capes El fitxer executable
Contenidor Una instància en execució, amb una capa d'escriptura a sobre El procés
Dipòsit d'imatges Servidor on es publiquen i es descarreguen imatges El dipòsit de paquets

La propietat més important d'una imatge és que està formada per capes, cadascuna un conjunt de canvis sobre l'anterior, identificada per la seva suma criptogràfica:

$ sudo docker pull postgres:16-alpine
16-alpine: Pulling from library/postgres
c6a83fedfae6: Pull complete
a2e5cb2d5c74: Pull complete
...
Status: Downloaded newer image for postgres:16-alpine

$ sudo docker image ls
REPOSITORY   TAG          IMAGE ID       CREATED       SIZE
postgres     16-alpine    8b4c1f2a9e33   2 weeks ago   274MB
alpine       3.20         a606584aa9aa   3 weeks ago   7.8MB

$ sudo docker history postgres:16-alpine --format 'table {{.Size}}\t{{.CreatedBy}}' | head -6
SIZE      CREATED BY
0B        CMD ["postgres"]
0B        EXPOSE map[5432/tcp:{}]
0B        ENTRYPOINT ["docker-entrypoint.sh"]
12.4MB    RUN /bin/sh -c set -eux; ...
188MB     RUN /bin/sh -c set -eux; apk add --no-cache postgresql16 ...
7.8MB     /bin/sh -c #(nop) ADD file:... in /

Les capes es comparteixen entre imatges: si deu imatges es basen en alpine:3.20, aquells 7,8 MB s'emmagatzemen una sola vegada. És la mateixa idea que els discos de suport de qcow2 a 07-04, aplicada al sistema de fitxers.

I la implicació de seguretat de les capes, que cal tenir present en escriure un Dockerfile: una capa no desapareix mai. Si en una capa hi copies un fitxer amb una contrasenya i a la següent l'esborres, el fitxer continua sent a la imatge i se'n pot extreure. Hi tornarem.

# Les etiquetes son MUTABLES: latest avui no es latest dema
$ sudo docker image inspect postgres:16-alpine --format '{{index .RepoDigests 0}}'
postgres@sha256:4f2b8c1e...

# El digest SI que es immutable, i es el que es fixa en produccio
$ sudo docker pull postgres@sha256:4f2b8c1e...

docker run i les opcions que importen

$ sudo docker run --rm alpine:3.20 echo "hola des del contenidor"
hola des del contenidor

$ sudo docker run -d --name prova-nginx -p 8081:80 nginx:1.27-alpine
a3f1c88e2d4b...

$ sudo docker ps
CONTAINER ID   IMAGE               COMMAND                  STATUS         PORTS                  NAMES
a3f1c88e2d4b   nginx:1.27-alpine   "/docker-entrypoint.…"   Up 4 seconds   0.0.0.0:8081->80/tcp   prova-nginx

I ara la comprovació que tanca el primer apartat. Des de l'amfitrió:

$ pgrep -a nginx
9204 nginx: master process nginx -g daemon off;
9251 nginx: worker process

$ sudo ls -l /proc/9204/ns/ | awk '{print $9, $11}'
mnt mnt:[4026532412]
net net:[4026532475]
pid pid:[4026532413]
uts uts:[4026532410]

$ cat /sys/fs/cgroup/system.slice/docker-a3f1c88e2d4b*.scope/memory.current
8912896

El procés de nginx és a la taula de processos de l'amfitrió, amb un PID normal, i pgrep el troba. Només té espais de noms propis i un grup de control. No hi ha cap màquina, cap nucli, cap arrencada. És un procés.

Les opcions de docker run que es fan servir de debò:

Opció Què fa Nota
-d En segon pla
--name Nom en lloc d'un identificador aleatori Necessari per a la resolució per nom
-p 8081:80 Publica el port: amfitrió:contenidor -p 127.0.0.1:8081:80 el limita a local
-v Munta un volum o un directori Vegeu l'apartat de dades
-e Variable d'entorn No per a secrets (06-05)
--rm Esborra el contenidor en acabar Per a proves
--restart unless-stopped Rearrenca després d'una fallada o un reinici Producció
--user 1000:1000 Executa com aquell UID No com a root a dins
--read-only Sistema de fitxers de només lectura Amb --tmpfs per al que sigui escrivible
--cap-drop ALL Retira totes les capacitats I --cap-add només les necessàries
--security-opt no-new-privileges Impedeix escalar per SUID Equival a NoNewPrivileges de systemd
--memory / --cpus Límits del grup de control Els de 05-05, amb un altre nom
--health-cmd Comprovació de salut Igual que revisio_salut.sh

Les de gestió del cicle de vida:

$ sudo docker logs -f --tail 20 prova-nginx
$ sudo docker exec -it prova-nginx sh
$ sudo docker inspect prova-nginx --format '{{.State.Status}} {{.NetworkSettings.IPAddress}}'
running 172.17.0.2
$ sudo docker stats --no-stream
$ sudo docker stop prova-nginx && sudo docker rm prova-nginx
$ sudo docker system df
$ sudo docker system prune -a --volumes   # COMPTE: esborra el que no s usa

docker stop envia SIGTERM, espera 10 segons i després SIGKILL — exactament la disciplina de senyals de 03-06. Si la teva aplicació necessita més temps per tancar netament, --time 30.

Dockerfile: construir la imatge de Tramontana

Un Dockerfile és la recepta d'una imatge. Cada instrucció que modifica el sistema de fitxers crea una capa.

# Dockerfile — Tramontana Reserves
# Construccio en dues etapes: la primera compila, la segona nomes executa.

# ---------- Etapa 1: compilacio ----------
FROM golang:1.23-alpine AS constructor

WORKDIR /origen

# Primer NOMES els fitxers de dependencies. Motiu: aquesta capa es
# reutilitza de la memoria cau mentre no canviin, encara que canvii el codi.
COPY go.mod go.sum ./
RUN go mod download

# I despres el codi, que canvia a cada commit.
COPY . .

# CGO_ENABLED=0 produeix un binari estatic: no necessita biblioteques
# del sistema, aixi que la imatge final pot ser minima.
RUN CGO_ENABLED=0 GOOS=linux go build \
        -ldflags='-s -w -X main.version=3.2.1' \
        -o /tramontana ./cmd/servidor

# ---------- Etapa 2: execucio ----------
FROM alpine:3.20

# Actualitzacions de seguretat i certificats arrel per validar TLS.
# --no-cache evita deixar l index de paquets dins de la imatge.
RUN apk add --no-cache ca-certificates tzdata curl \
    && addgroup -g 1002 tramontana \
    && adduser -u 997 -G tramontana -s /sbin/nologin -D svc-tramontana

WORKDIR /opt/tramontana

# Nomes el binari de l etapa anterior. El compilador de Go, el codi
# font i les dependencies NO arriben a la imatge final.
COPY --from=constructor /tramontana /opt/tramontana/tramontana
COPY --chown=svc-tramontana:tramontana plantilles/ /opt/tramontana/plantilles/

# Els mateixos identificadors numerics que al servidor: els permisos
# dels volums nomes son coherents si coincideixen.
USER svc-tramontana

ENV TRAMONTANA_PORT=8080 \
    TRAMONTANA_LOG_NIVELL=info

EXPOSE 8080

# La comprovacio de salut, equivalent a revisio_salut.sh
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD curl -fsS "http://127.0.0.1:${TRAMONTANA_PORT}/salut" || exit 1

# ENTRYPOINT: l executable, sempre. CMD: arguments per defecte,
# substituibles des de la linia d ordres.
ENTRYPOINT ["/opt/tramontana/tramontana"]
CMD ["--config", "/etc/tramontana/app.conf"]

Les instruccions i el que cal saber de cadascuna:

Instrucció Què fa Compte
FROM Imatge base Fixa la versió, mai latest
WORKDIR Directori de treball Millor que RUN cd, que no persisteix
COPY Copia fitxers Preferible a ADD, que fa màgia amb URL i tar
RUN Executa en construcció Encadena amb &&: cada RUN és una capa
ENV Variable d'entorn Queda a la imatge: mai secrets
USER Usuari d'execució Sense això, el procés corre com a root
EXPOSE Documenta el port No publica res: això és -p
HEALTHCHECK Comprovació periòdica La fa servir depends_on de Compose
ENTRYPOINT L'executable
CMD Arguments per defecte Substituïble en executar

La memòria cau de capes i com ordenar les instruccions

Docker reutilitza una capa si la instrucció i el seu context no han canviat. Tan bon punt una capa s'invalida, totes les següents es reconstrueixen. D'aquí la regla:

Ordena les instruccions de menys a més freqüentment canviants.

# MALAMENT: qualsevol canvi al codi invalida la descarrega de dependencies
COPY . .
RUN go mod download
RUN go build -o /tramontana ./cmd/servidor

# BE: les dependencies nomes es tornen a baixar si canvien go.mod o go.sum
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /tramontana ./cmd/servidor

A la primera versió, canviar una línia de codi obliga a tornar a descarregar totes les dependències: minuts a cada construcció. A la segona, segons.

I l'altra regla de capes, la d'encadenar:

# MALAMENT: tres capes, i l index d apk queda dins de la imatge
RUN apk update
RUN apk add curl
RUN rm -rf /var/cache/apk/*

# BE: una capa, i l esborrat passa ABANS que la capa es tanqui
RUN apk add --no-cache curl

L'rm en un RUN posterior no redueix la mida: la capa anterior ja conté els fitxers, i les capes són immutables. És el mateix motiu pel qual un secret copiat i esborrat després continua sent extraïble de la imatge.

# .dockerignore: el que NO s envia al context de construccio
$ cat .dockerignore
.git
.gitignore
*.md
Dockerfile
compose.yaml
/dades
/proves
*.bak-*
.env
secrets/

.dockerignore importa per dos motius: velocitat —el context s'envia sencer al dimoni— i seguretat, perquè sense ell un COPY . . pot ficar a la imatge el directori .git amb tot l'historial, o un .env amb credencials.

$ sudo docker build -t tramontana:3.2.1 .
[+] Building 24.1s (16/16) FINISHED
$ sudo docker image ls tramontana
REPOSITORY   TAG     IMAGE ID       CREATED         SIZE
tramontana   3.2.1   f4a1c88e2d4b   8 seconds ago   19.4MB

Construcció en diverses etapes i elecció d'imatge base

19,4 MB. La comparació amb una construcció d'una sola etapa explica d'on ve:

Construcció Contingut Mida
Una etapa sobre golang:1.23 Compilador, codi font, dependències, binari ~850 MB
Una etapa sobre golang:1.23-alpine Igual, amb base menor ~380 MB
Dues etapes sobre alpine:3.20 Només el binari i els certificats 19,4 MB
Dues etapes sobre scratch Només el binari ~11 MB

I el benefici no és només el disc. La imatge de 850 MB conté el compilador de Go, git, make i desenes de biblioteques: cadascuna és superfície d'atac i una font potencial de CVE. La de 19 MB no té res d'això. És el principi de superfície mínima de 06-06 aplicat a una imatge.

Les bases habituals:

Base Mida Gestor Quan
ubuntu:24.04 ~78 MB apt Quan necessites l'ecosistema complet
debian:12-slim ~29 MB apt Compromís raonable
alpine:3.20 ~7,8 MB apk Binaris estàtics, eines
gcr.io/distroless/static ~2 MB cap Màxima seguretat; sense shell
scratch 0 B cap Binari estàtic pur

Alpine, que es va esmentar a 01-03 en parlar de distribucions, té una peculiaritat que cal conèixer: fa servir musl en lloc de glibc. Un binari compilat contra glibc no funciona a Alpine, i algunes aplicacions —notablement en Python amb extensions natives— tenen problemes subtils de rendiment amb l'assignador de memòria de musl. Amb Go i CGO_ENABLED=0 no hi ha problema, perquè el binari és estàtic.

Les imatges distroless van un pas més enllà: no tenen shell, ni ls, ni gestor de paquets. Un atacant que aconsegueixi execució no té amb què operar. A canvi, depurar exigeix docker debug o una imatge paral·lela amb eines.

Dades: volums, muntatges d'enllaç i tmpfs

El sistema de fitxers d'un contenidor és efímer: en esborrar-lo, la capa d'escriptura desapareix. Les dades que han de sobreviure es munten des de fora, i hi ha tres formes:

Mecanisme On viu Quan
Volum Gestionat per Docker a /var/lib/docker/volumes/ Dades de l'aplicació: bases de dades, fitxers pujats
Muntatge d'enllaç Una ruta concreta de l'amfitrió Configuració, desenvolupament, integrar-se amb el sistema
tmpfs Memòria de l'amfitrió Temporals i secrets: no toquen disc
# Volum: Docker decideix on, i el gestiona
$ sudo docker volume create tramontana-dades-bd
$ sudo docker volume inspect tramontana-dades-bd --format '{{.Mountpoint}}'
/var/lib/docker/volumes/tramontana-dades-bd/_data

$ sudo docker run -d --name bd \
    -v tramontana-dades-bd:/var/lib/postgresql/data \
    -e POSTGRES_PASSWORD_FILE=/run/secrets/bd_pass \
    postgres:16-alpine

# Muntatge d enllac: una ruta concreta, en nomes lectura
$ sudo docker run -d --name app \
    -v /etc/tramontana/app.conf:/etc/tramontana/app.conf:ro \
    -v /opt/tramontana/shared/uploads:/opt/tramontana/shared/uploads \
    tramontana:3.2.1

# tmpfs: en memoria, no persisteix, no toca disc
$ sudo docker run -d --name app-nomes-lectura \
    --read-only \
    --tmpfs /tmp:rw,noexec,nosuid,size=64m \
    tramontana:3.2.1

Aquell noexec,nosuid del tmpfs és el mateix criteri que vas aplicar a /tmp a 06-06.

La preferència pels volums davant dels muntatges d'enllaç per a dades té motius concrets: Docker els gestiona (creació, permisos, còpia amb docker run --volumes-from), són portables entre amfitrions, i no depenen que existeixi una ruta concreta. Els muntatges d'enllaç són millors per a configuració —perquè la vols editar des de l'amfitrió amb les teves eines— i per a desenvolupament.

I l'advertiment de permisos, que és la font número u de frustració amb volums: els UID són numèrics i no es tradueixen. Un fitxer propietat de l'UID 997 a l'amfitrió pertany a l'UID 997 dins del contenidor, es digui com es digui aquell usuari allà. Per això el Dockerfile de Tramontana crea svc-tramontana amb l'UID 997 i el grup amb el GID 1002: perquè coincideixin amb el servidor.

$ sudo docker run --rm -v /opt/tramontana/shared/uploads:/dades alpine:3.20 \
      ls -ln /dades
total 4
drwxrws--- 2 997 1002 4096 Aug 18 17:12 fotos

Xarxa i publicació de ports

$ sudo docker network ls
NETWORK ID     NAME      DRIVER    SCOPE
f2a1c88e2d4b   bridge    bridge    local
a3c1f88e2d4c   host      host      local
b4d1e88e2d4d   none      null      local
Mode Aïllament Resolució per nom Quan
bridge (per defecte) Xarxa virtual pròpia amb NAT No a la xarxa per defecte Poques vegades directament
Xarxa d'usuari Igual, però amb DNS intern Sí El correcte gairebé sempre
host Cap: fa servir la de l'amfitrió N/A Rendiment extrem; perd aïllament
none Total: només lo N/A Processos sense xarxa

La diferència entre la xarxa bridge per defecte i una xarxa d'usuari és la que decideix el disseny:

$ sudo docker network create tramontana-xarxa
$ sudo docker run -d --name bd --network tramontana-xarxa postgres:16-alpine
$ sudo docker run -d --name app --network tramontana-xarxa tramontana:3.2.1

# Resolucio per NOM DE SERVEI: no calen IP a la configuracio
$ sudo docker exec app getent hosts bd
172.19.0.2       bd

Això és el que permet que app.conf digui db_host=bd en lloc d'una IP que canvia a cada arrencada. A la xarxa bridge per defecte no funciona.

I la publicació de ports, amb un detall de seguretat important:

# MALAMENT: 0.0.0.0, accessible des de tota la xarxa
$ sudo docker run -d -p 8080:8080 tramontana:3.2.1

# BE: nomes des del mateix amfitrio, coherent amb escolta=127.0.0.1
$ sudo docker run -d -p 127.0.0.1:8080:8080 tramontana:3.2.1
$ sudo ss -tulpn | grep 8080
tcp LISTEN 127.0.0.1:8080  users:(("docker-proxy",pid=9841,fd=4))

Avís seriós: Docker escriu regles a nftables i se salta ufw. Un -p 8080:8080 insereix una regla a la cadena DOCKER que s'avalua abans que les d'ufw, així que el port queda accessible des d'Internet encara que ufw status digui que està bloquejat.

Es comprova, i cal comprovar-ho:

$ sudo ufw status | grep 8080
# (res: ufw es pensa que esta tancat)
$ sudo nft list chain ip nat DOCKER 2>/dev/null | head -4
$ sudo nmap -p 8080 10.0.2.15 -Pn | grep 8080
8080/tcp open  http-proxy      # <- OBERT malgrat ufw

Les dues solucions: publicar sempre amb 127.0.0.1: al davant —que és el correcte quan hi ha un servidor intermediari invers al davant, com n'hi haurà a 08-01—, o desactivar la manipulació de regles de Docker:

$ sudo tee /etc/docker/daemon.json >/dev/null <<'FI'
{
  "iptables": false,
  "log-driver": "journald",
  "log-opts": { "tag": "{{.Name}}" },
  "live-restore": true
}
FI
$ sudo systemctl restart docker

Compte: amb "iptables": false els contenidors perden la sortida a Internet llevat que escriguis tu les regles de NAT. L'opció pràctica en la majoria dels casos és la primera.

Fixa't també en "log-driver": "journald": envia la sortida dels contenidors al journal, així que journalctl i el logrotate de 05-06 se'ls apliquen també. Per defecte, Docker escriu a fitxers JSON que creixen sense límit i són una causa habitual de discos plens.

Docker Compose

Un compose.yaml descriu el conjunt de serveis, xarxes i volums en un fitxer versionable:

# compose.yaml — Tramontana Reserves
name: tramontana

services:
  bd:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: tramontana_reserves
      POSTGRES_USER: tramontana
      # El secret NO va aqui: es llegeix d un fitxer muntat.
      POSTGRES_PASSWORD_FILE: /run/secrets/bd_pass
    secrets:
      - bd_pass
    volumes:
      - dades-bd:/var/lib/postgresql/data
    networks:
      - interna
    # La comprovacio de salut es el que permet l arrencada ordenada
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U tramontana -d tramontana_reserves"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    # Els mateixos limits que la unitat de systemd de 05-05
    deploy:
      resources:
        limits:
          memory: 512M
    security_opt:
      - no-new-privileges:true

  app:
    build:
      context: .
      dockerfile: Dockerfile
    image: tramontana:3.2.1
    restart: unless-stopped
    # Nomes accessible des de l amfitrio: el proxy invers de 08-01 anira al davant
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      TRAMONTANA_DB_HOST: bd          # resolucio per nom de servei
      TRAMONTANA_DB_PORT: "5432"
      TRAMONTANA_LOG_NIVELL: info
    secrets:
      - bd_pass
    volumes:
      - ./config/app.conf:/etc/tramontana/app.conf:ro
      - pujades:/opt/tramontana/shared/uploads
    networks:
      - interna
    # NO arrenca fins que la base de dades respon de debo.
    # Sense 'condition', depends_on nomes espera que el contenidor existeixi.
    depends_on:
      bd:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/salut"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 10s
    read_only: true
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64m
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    deploy:
      resources:
        limits:
          memory: 512M

volumes:
  dades-bd:
  pujades:

networks:
  interna:
    driver: bridge

secrets:
  bd_pass:
    file: ./secrets/bd_pass    # fitxer 0600, fora de git
$ sudo docker compose config --quiet && echo "sintaxi correcta"
sintaxi correcta
$ sudo docker compose up -d
[+] Running 4/4
 ✔ Network tramontana_interna   Created
 ✔ Volume "tramontana_dades-bd" Created
 ✔ Container tramontana-bd-1    Healthy
 ✔ Container tramontana-app-1   Started

$ sudo docker compose ps
NAME               IMAGE               STATUS                   PORTS
tramontana-app-1   tramontana:3.2.1    Up 12 seconds (healthy)  127.0.0.1:8080->8080/tcp
tramontana-bd-1    postgres:16-alpine  Up 45 seconds (healthy)  5432/tcp

$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/cases
200

depends_on amb condition: service_healthy és la peça que resol un problema real: sense ella, depends_on només espera que el contenidor existeixi, no que el servei de dins estigui llest. L'aplicació arrencaria, no trobaria la base de dades i moriria. Amb la condició, Compose espera el healthcheck.

$ sudo docker compose logs -f app
$ sudo docker compose exec bd psql -U tramontana -d tramontana_reserves -c '\dt'
$ sudo docker compose down            # atura i esborra contenidors i xarxa
$ sudo docker compose down -v         # ...I ELS VOLUMS: destrueix les dades

Aquell -v mereix l'avís: docker compose down -v esborra els volums, és a dir, la base de dades. És un rm -rf amb un altre nom, i la disciplina de 02-04 s'hi aplica igual.

Seguretat de contenidors

L'aïllament d'un contenidor és més feble que el d'una VM, així que les mesures importen més. Les que cal aplicar sempre:

Mesura Per què
No executar com a root a dins USER al Dockerfile o --user. Sense això, una fuga és root a l'amfitrió
--read-only + tmpfs Un atacant no pot escriure la seva eina al sistema de fitxers
--cap-drop=ALL i afegir només el necessari El procés no necessita 14 capacitats
no-new-privileges Impedeix escalar per un binari SUID
Fixar la versió de la imatge latest canvia sense avisar: no hi ha reproductibilitat
Límits de memòria i CPU Un contenidor sense límit pot tombar l'amfitrió
Escanejar les imatges Les bases acumulen CVE amb el temps
No posar secrets a ENV ni a capes Queden a la imatge per sempre

Sobre els secrets, la demostració de per què ENV és mala idea:

# MALAMENT: visible per a qualsevol que pugui inspeccionar la imatge o el contenidor
$ sudo docker inspect tramontana-app-1 --format '{{json .Config.Env}}' | tr ',' '\n'
"TRAMONTANA_DB_PASSWORD=Zx9K2pQ7vLm4RtWn"

# I tambe a /proc, com ja vas veure a 06-05
$ sudo tr '\0' '\n' < /proc/$(sudo docker inspect -f '{{.State.Pid}}' tramontana-app-1)/environ

És exactament el problema que vas resoldre a 06-05 amb LoadCredentialEncrypted. L'equivalent a Docker és la secció secrets del compose.yaml, que munta el fitxer en un tmpfs dins del contenidor:

$ sudo docker compose exec app ls -l /run/secrets/
-r--r----- 1 svc-tramontana tramontana 17 ago 18 19:41 bd_pass

I l'escaneig d'imatges:

$ sudo docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
      aquasec/trivy:latest image --severity HIGH,CRITICAL tramontana:3.2.1

tramontana:3.2.1 (alpine 3.20.2)
Total: 0 (HIGH: 0, CRITICAL: 0)

$ sudo docker run --rm aquasec/trivy:latest image --severity HIGH,CRITICAL \
      postgres:16-alpine | tail -4
Total: 2 (HIGH: 2, CRITICAL: 0)

Fixa't en un detall que il·lustra l'advertiment del grup docker: aquella ordre munta /var/run/docker.sock dins d'un contenidor, cosa que li dona control complet del dimoni de Docker i per tant de l'amfitrió. Es fa amb imatges en què confies, i amb consciència del que implica.

Dues mesures més que tanquen el cercle amb mòduls anteriors:

# AppArmor: Docker aplica un perfil per defecte (06-06)
$ sudo docker inspect tramontana-app-1 --format '{{.AppArmorProfile}}'
docker-default

# seccomp: filtre de crides al sistema, com el SystemCallFilter de 05-05
$ sudo docker inspect tramontana-app-1 --format '{{.HostConfig.SecurityOpt}}'
[no-new-privileges:true]

El perfil docker-default d'AppArmor i el filtre seccomp per defecte bloquegen unes quaranta crides al sistema perilloses. Desactivar-los amb --privileged o --security-opt seccomp=unconfined és el que converteix un contenidor en un procés amb accés gairebé total a l'amfitrió, i --privileged no ha d'aparèixer mai en producció.

Alternatives, i quan no fer servir contenidors

Eina Model Avantatge
Docker Dimoni amb privilegis Ecosistema, documentació, eines
Podman Sense dimoni, rootless Sense procés privilegiat; compatible amb Docker
containerd Motor de baix nivell És el que fa servir Kubernetes per sota
LXC/LXD Contenidors de sistema S'assemblen a una VM lleugera: init complet a dins

Podman mereix atenció pel que resol:

$ sudo apt install podman
$ podman run --rm alpine:3.20 echo "sense dimoni, sense sudo"
sense dimoni, sense sudo

# I sense root: gracies als espais de noms d usuari del segon apartat
$ podman unshare cat /proc/self/uid_map
         0       1000          1
         1     100000      65536

podman no té dimoni: cada contenidor és un procés fill del que el llança. En mode rootless fa servir espais de noms d'usuari, així que no existeix el problema del grup docker. Les seves ordres són compatibles (alias docker=podman funciona gairebé sempre) i s'integra amb systemd generant unitats, cosa que encaixa bé amb tot el del Mòdul 5. Per a un servidor on diverses persones executen contenidors, és l'opció més defensable.

LXC és diferent en concepte: contenidors de sistema, amb init complet a dins, diversos processos i sessions. S'assembla més a una VM lleugera que a un procés aïllat.

Quan NO fer servir contenidors

L'honestedat que falta a la majoria de les guies:

Situació Per què no
Aïllament de seguretat fort entre inquilins Comparteixen nucli. Per a això hi ha les VM de 07-04
Una base de dades amb estat crític Es pot, però l'emmagatzematge persistent afegeix complexitat sense benefici clar en un sol servidor
Aplicacions que necessiten accés profund al nucli Mòduls, systemd complet, maquinari específic
Una aplicació en un únic servidor que ja funciona Contenidoritzar afegeix una capa que cal aprendre i mantenir a canvi de poc
Càrregues amb requisits de latència extrems La capa de xarxa afegeix microsegons
Sistemes que han de durar deu anys sense tocar-se L'ecosistema canvia de pressa

Aquell quart punt s'aplica a Tramontana ara mateix: srv-tramontana funciona, està enfortit, monitorat i documentat. Contenidoritzar-lo avui afegiria complexitat sense resoldre cap problema pendent. Els contenidors brillen quan hi ha diversos serveis, diversos entorns o diversos servidors — que és justament el terreny de les dues lliçons següents.

Errors Comuns i Consells

  • Pensar en un contenidor com en una VM petita. Porta a ficar-hi systemd, sshd i cron a dins, i a tractar el contenidor com una màquina que s'administra. Un contenidor executa un procés i es reemplaça, no s'administra.
  • Afegir algú al grup docker sense entendre el que implica. És root sense contrasenya i sense traça. Tracta'l com el grup sudo.
  • Fer servir latest. La imatge canvia sense avisar, i una reconstrucció de la setmana que ve no produeix el mateix. Fixa la versió, i en producció el digest.
  • Posar secrets a ENV o en una capa. Queden a la imatge per sempre, encara que els esborris després: les capes són immutables. Fes servir secrets o un fitxer muntat.
  • Copiar el codi abans de les dependències al Dockerfile. Invalida la memòria cau a cada canvi i converteix una construcció de segons en una de minuts.
  • rm en un RUN posterior per reduir mida. No funciona: la capa anterior ja conté els fitxers. Encadena amb && dins del mateix RUN.
  • Oblidar .dockerignore. Un COPY . . pot ficar .git sencer, o un .env amb credencials, a la imatge.
  • Executar com a root dins del contenidor. És el valor per defecte i és el primer que cal canviar amb USER.
  • Publicar amb -p 8080:8080 i creure que ufw protegeix. Docker escriu regles que s'avaluen abans. Fes servir -p 127.0.0.1:8080:8080 i verifica-ho des de fora amb nmap.
  • Deixar el controlador de registre per defecte. Els fitxers JSON creixen sense límit. "log-driver": "journald" els integra amb journalctl i logrotate.
  • docker compose down -v sense pensar-hi. Esborra els volums, és a dir, les dades.
  • --privileged perquè «funcioni». Desactiva seccomp, AppArmor i les capacitats de cop. Mai en producció; busca la capacitat concreta que falta.
  • Consell de mètode. Un contenidor ha de poder morir i renéixer sense que es perdi res. Si el teu té estat dins de la seva capa d'escriptura, alguna cosa està mal muntada: tot el que importa va en un volum.

Exercicis

Exercici 1

Demostra, sense fer servir Docker, que un contenidor és un procés aïllat. Crea un «contenidor» a mà amb unshare que tingui el seu propi nom de màquina, la seva pròpia taula de processos i la seva pròpia xarxa, i comprova des de l'amfitrió que continua sent un procés normal. Explica què li falta a la teva construcció per ser un contenidor de debò.

Exercici 2

Revisa aquest Dockerfile que ha escrit el Luis i corregeix-lo, explicant cada problema:

FROM ubuntu:latest
RUN apt-get update
RUN apt-get install -y python3 python3-pip curl git
COPY . /app
WORKDIR /app
RUN pip3 install -r requirements.txt
ENV DB_PASSWORD=Zx9K2pQ7vLm4RtWn
EXPOSE 8080
CMD python3 servidor.py

Exercici 3

La Marta pregunta si convé migrar Tramontana Reserves a contenidors. Redacta l'anàlisi, tenint en compte l'estat real del servidor i el que s'ha vist a les dues últimes lliçons.

Solucions

Solució 1

# --- El "contenidor" a ma ---
# Cada bandera d unshare crea un espai de noms. Juntes donen la major part
# de l aillament d un contenidor.
$ sudo unshare --pid --fork --mount-proc \
               --uts --ipc --mount --net \
               --root=/srv/minicontenidor \
               /bin/bash

Abans cal una arrel mínima, que és justament el que fa una imatge:

$ sudo mkdir -p /srv/minicontenidor
$ sudo docker export $(sudo docker create alpine:3.20) \
    | sudo tar -x -C /srv/minicontenidor
$ ls /srv/minicontenidor
bin  dev  etc  home  lib  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

I ara, a dins:

$ sudo unshare --pid --fork --mount-proc --uts --ipc --mount --net \
      chroot /srv/minicontenidor /bin/sh

/ # hostname minicontenidor
/ # hostname
minicontenidor

/ # ps aux
PID   USER     TIME  COMMAND
    1 root      0:00 /bin/sh
    8 root      0:00 ps aux

/ # ip addr
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

/ # cat /etc/os-release | head -2
NAME="Alpine Linux"
ID=alpine

Nom propi, PID 1, dos processos visibles, sense xarxa, i una distribució diferent de la de l'amfitrió — tot sense arrencar cap nucli.

# --- Des de l amfitrio, la comprovacio que demostra la tesi ---
$ pgrep -a -f 'unshare --pid'
10412 sudo unshare --pid --fork --mount-proc --uts --ipc --mount --net chroot ...
$ pid=$(pgrep -f 'chroot /srv/minicontenidor' | head -1)

$ ps -o pid,ppid,user,comm -p "$pid"
    PID    PPID USER     COMMAND
  10415   10412 root     sh

$ sudo cat /proc/$pid/status | grep -E '^Name|^Pid|^NSpid'
Name:	sh
Pid:	10415
NSpid:	10415	1

$ sudo readlink /proc/$pid/ns/pid /proc/1/ns/pid
pid:[4026532398]
pid:[4026531836]

$ sudo readlink /proc/$pid/root
/srv/minicontenidor

La línia NSpid: 10415 1 és la demostració exacta: el mateix procés té el PID 10415 a l'amfitrió i el PID 1 dins del seu espai de noms. I uname -r dona el mateix nucli als dos costats:

$ uname -r
6.8.0-41-generic
$ sudo nsenter -t "$pid" -a uname -r
6.8.0-41-generic

Què li falta per ser un contenidor de debò:

Falta Per què importa Com ho fa Docker
Grups de control Sense límits, el procés pot consumir tota la memòria i la CPU de l'amfitrió Crea un grup de control amb memory.max, cpu.max, pids.max
Capacitats reduïdes Aquí el procés conserva les de root: podria carregar mòduls o muntar qualsevol cosa Retira ~26 de les 40 capacitats
seccomp Sense filtre, pot invocar qualsevol crida al sistema, incloses les vulnerables Aplica un perfil que bloqueja ~44 crides
AppArmor / MAC Sense perfil, el DAC és l'única barrera Aplica docker-default
pivot_root en lloc de chroot chroot és evadible; pivot_root desmunta l'arrel antiga Fa servir pivot_root
Xarxa utilitzable L'espai de noms de xarxa és buit: només lo i caigut Crea un veth, el connecta a un pont i configura NAT
Sistema de fitxers en capes Aquí l'arrel és un directori normal, i cada «contenidor» necessita la seva còpia completa Fa servir overlayfs: capes de només lectura més una d'escriptura
Espai de noms d'usuari El root de dins és el root de fora Opcional a Docker; per defecte a podman rootless
Gestió del cicle de vida No hi ha imatges, ni versions, ni forma de reproduir això Dipòsit d'imatges, etiquetes, digests

La conclusió que es demana: Docker no aporta cap primitiva nova. L'aïllament ja és al nucli des de fa més d'una dècada. El que aporta és l'empaquetat: un format d'imatge amb capes compartides, un dipòsit on publicar-les, una forma reproduïble de construir-les, i l'aplicació coherent de les vuit restriccions de la taula, que a mà serien un guió llarg i fàcil d'equivocar. Entendre això explica per què existeixen alternatives compatibles com podman: si les primitives són del nucli, qualsevol les pot orquestrar.

Solució 2

Onze problemes. La versió corregida, i després el perquè de cadascun:

# --- Etapa 1: dependencies ---
# 1. Versio FIXA, no latest. 2. Base slim en lloc de completa.
FROM python:3.12-slim-bookworm AS constructor

WORKDIR /app

# 3. Les dependencies ABANS del codi: la memoria cau es reutilitza mentre
#    requirements.txt no canvii.
COPY requirements.txt .
# 4. Un sol RUN encadenat, sense deixar la memoria cau de pip a la capa.
RUN pip install --no-cache-dir --prefix=/instalat -r requirements.txt

# --- Etapa 2: execucio ---
FROM python:3.12-slim-bookworm

# 5. Un sol RUN, amb neteja DINS de la mateixa capa.
#    Sense git ni pip: son de construccio, no d execucio.
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/* \
    && groupadd -g 1002 tramontana \
    && useradd -u 997 -g tramontana -s /usr/sbin/nologin -M svc-tramontana

WORKDIR /app

# 6. Nomes les dependencies instal·lades, sense l arbre de construccio
COPY --from=constructor /instalat /usr/local
# 7. Propietat correcta des del principi
COPY --chown=svc-tramontana:tramontana . /app

# 8. NO executar com a root
USER svc-tramontana

# 9. Sense secrets. Nomes configuracio no sensible.
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    TRAMONTANA_PORT=8080

EXPOSE 8080

# 10. Comprovacio de salut, que a mes habilita depends_on a Compose
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD curl -fsS "http://127.0.0.1:${TRAMONTANA_PORT}/salut" || exit 1

# 11. Forma exec, no shell: el proces es PID 1 i rep els senyals
ENTRYPOINT ["python3", "servidor.py"]

I el .dockerignore que faltava del tot:

.git
.gitignore
.env
secrets/
*.bak-*
__pycache__/
*.pyc
Dockerfile
compose.yaml
README.md

Els onze problemes, en ordre de gravetat:

# Problema Conseqüència
1 ENV DB_PASSWORD=... El més greu. La contrasenya queda a la imatge per sempre, visible amb docker history o docker inspect, i a qualsevol dipòsit on es publiqui. I encara que s'esborri en una capa posterior, la capa original la conserva
2 FROM ubuntu:latest Sense reproductibilitat: la construcció d'avui i la d'aquí a un mes produeixen imatges diferents. I ubuntu complet són 78 MB davant dels 29 d'slim
3 Executa com a root Sense USER, el procés és root a dins. Combinat amb una fuga de contenidor, és root a l'amfitrió
4 git a la imatge final Eina de construcció que queda en producció: superfície d'atac i CVE. pip també hi sobra
5 COPY . /app abans d'instal·lar dependències Qualsevol canvi de codi invalida la memòria cau i obliga a reinstal·lar-ho tot: minuts a cada construcció
6 Tres RUN separats Tres capes, i l'índex d'apt (~40 MB) queda dins de la imatge perquè no s'esborra mai a la mateixa capa
7 apt-get update sense install al mateix RUN La memòria cau pot reutilitzar l'update vell i l'install portar paquets desactualitzats. És el problema del cache busting
8 Sense --no-install-recommends Arrossega desenes de paquets que ningú no ha demanat
9 CMD python3 servidor.py en forma shell S'executa com a /bin/sh -c "python3 servidor.py", així que el PID 1 és el shell i no propaga SIGTERM a Python: docker stop acaba matant el procés als 10 segons amb SIGKILL, sense tancament net
10 Sense HEALTHCHECK Docker no sap si el servei funciona, i depends_on: condition: service_healthy no es pot fer servir
11 Sense .dockerignore COPY . /app hi fica .git sencer —amb tot l'historial, inclosos secrets ja esborrats— i qualsevol .env present

El resultat mesurat:

$ sudo docker build -t tramontana-luis:dolent -f Dockerfile.original .
$ sudo docker build -t tramontana:bo .
$ sudo docker image ls | grep -E 'tramontana'
tramontana         bo       f4a1c88e2d4b   14 seconds ago   142MB
tramontana-luis    dolent   a3c1f88e2d4c   2 minutes ago    684MB

$ sudo docker history tramontana-luis:dolent | grep -i password
<missing>  2 minutes ago  ENV DB_PASSWORD=Zx9K2pQ7vLm4RtWn   0B

684 MB davant de 142, i la contrasenya recuperable amb una sola ordre per qualsevol que tingui accés a la imatge.

La nota que cal donar-li al Luis: la contrasenya que apareix en aquell Dockerfile és la de producció, així que —seguint la regla de 06-05— es considera compromesa des del moment en què es va escriure, i cal rotar-la amb el procediment crear-aplicar-verificar-retirar. No n'hi ha prou de corregir el fitxer.

Solució 3

Anàlisi: migrar Tramontana Reserves a contenidors Per a: Marta Vidal · De: Operacions de sistemes · 18 d'agost de 2026

Recomanació breu: encara no, però sí preparar-ho. Contenidoritzar avui srv-tramontana afegiria complexitat sense resoldre cap problema pendent. Proposo un camí intermedi amb benefici immediat i sense risc.


El punt de partida importa. No som davant d'una aplicació desplegada de qualsevol manera. srv-tramontana té desplegament atòmic amb reversió automàtica, servei de systemd enfortit amb la millor puntuació de seguretat que dona l'eina, secrets xifrats, registres centralitzats al journal amb rotació, còpies amb restauració provada, detecció de canvis i un tallafocs de llista blanca. Funciona, està documentat i està mesurat. Aquella és la referència contra la qual cal comparar.

Què hi guanyaríem

  1. Desplegaments reproduïbles bit a bit. Avui desplegar.sh mou un enllaç simbòlic a un directori nou, i l'entorn —biblioteques del sistema, versions— és el que hi hagi a la màquina. Amb una imatge, el que es prova és exactament el que es desplega. Recorda que el release 3.3.0 va fallar al juliol i no vam saber per què fins aquesta setmana: una fallada d'aquell tipus és menys probable amb imatges, perquè l'entorn viatja amb l'aplicació.
  2. Reversió encara més ràpida. Tornar a la imatge anterior és canviar una etiqueta.
  3. Paritat entre entorns. La mateixa imatge en proves i en producció, sense la deriva que apareix quan es configuren dues màquines per separat.
  4. Aïllament addicional de l'aplicació. Un compromís quedaria més contingut: sense shell útil, sense accés a la resta del sistema de fitxers, sense capacitats.
  5. Preparació per créixer. Si en algun moment calen dues o tres instàncies darrere d'un balancejador, amb contenidors és qüestió de minuts.

Què costaria

  1. Refer feina que ja està feta i validada. L'enfortiment de systemd, el perfil d'AppArmor, la gestió de secrets i la integració amb el journal s'haurien de reconstruir amb l'equivalent de contenidors. No és impossible, però són setmanes de feina per arribar on ja som.
  2. Una capa més per aprendre i mantenir. Imatges, dipòsits, xarxes virtuals, volums, i els seus modes de fallada propis — que són diferents i menys coneguts que els del sistema.
  3. Un risc de seguretat nou i concret. El dimoni de Docker corre com a root, i pertànyer al grup docker equival a tenir root sense deixar traça. A més, Docker escriu regles de tallafocs que se salten ufw: un port publicat queda obert encara que el tallafocs digui el contrari. Ho he verificat al laboratori. S'hauria de gestionar explícitament.
  4. La base de dades és el punt difícil. PostgreSQL en contenidor és possible, però l'emmagatzematge persistent afegeix complexitat i no aporta res en un únic servidor. La meva recomanació seria deixar-la fora en qualsevol cas.
  5. No resol el nostre problema real. El nostre problema obert és que srv-tramontana és un únic punt de fallada. Els contenidors no el resolen: si la màquina cau, cauen els contenidors. Això és redundància, i és el tema del qual et portaré una proposta.

El que proposo, en tres passos

Pas 1 — Ara: contenidoritzar només l'entorn de proves. Benefici immediat, risc nul. Permet aixecar l'aplicació i una base de dades neta en segons per provar un canvi, i aprendre l'eina sense exposar producció. Ja està funcionant a srv-tramontana-proves.

Pas 2 — Després: construir la imatge a cada release, sense desplegar-la. Cada versió produeix a més una imatge versionada i escanejada. Guanyem reproductibilitat i detecció de vulnerabilitats a les dependències, sense canviar res en producció. És un pas reversible.

Pas 3 — Quan es compleixi alguna condició: migrar. Les condicions que ho justificarien:

Condició Per què canvia la decisió
Calen dues o més instàncies de l'aplicació És on els contenidors comencen a rendir de debò
Apareixen més serveis (una API, un processador de tasques) Gestionar cinc serveis a mà és on Compose guanya
Hi ha més d'un entorn per mantenir sincronitzat La paritat deixa de ser un luxe
Tornem a tenir una fallada per diferències d'entorn Seria la segona vegada; una és casualitat

I una observació sobre l'alternativa. Si fem el pas, proposo avaluar podman en lloc de Docker: no té dimoni amb privilegis, funciona sense root, resol d'arrel el problema del grup docker, i s'integra amb systemd generant unitats — és a dir, encaixa amb tot el que ja tenim muntat en lloc de substituir-ho. Les seves ordres són compatibles, així que el que hem après no es perd.

Resum. Els contenidors són la resposta correcta a un problema d'escala i reproductibilitat entre entorns. Avui tenim un servidor, un entorn i una aplicació que funciona, així que el benefici no compensa el cost. Comencem per proves i per construir les imatges, que és on el benefici és immediat, i ho reavaluem quan canviï alguna de les condicions de la taula.

Conclusió

Tens la idea correcta, que és el que més val d'aquesta lliçó: un contenidor és un procés aïllat, no una màquina petita. Ho has vist sense intermediaris —un bash que és PID 1 a dins i té un PID normal a fora, amb NSpid: 10415 1 a /proc com a prova— i saps que Docker no inventa cap primitiva: els espais de noms, els grups de control i les capacitats són al nucli, i són literalment els mateixos que el MemoryMax=512M de 05-05 i el chroot de 07-01. El que Docker aporta és l'empaquetat: imatges en capes compartides, un dipòsit d'imatges, construcció reproduïble, i l'aplicació coherent de vuit restriccions que a mà serien un guió llarg i fràgil.

Saps construir una imatge que pesi 19 MB en lloc de 850, ordenant les instruccions per aprofitar la memòria cau i fent servir dues etapes perquè el compilador no arribi a producció — que és el principi de superfície mínima de 06-06 aplicat a una imatge. Saps que un secret en una capa no s'esborra mai, que -p 8080:8080 se salta ufw i cal verificar-ho des de fora amb nmap, que USER no és opcional, i que docker compose down -v destrueix les dades. I has resolt el kernel.unprivileged_userns_clone que vas deixar comentat a 06-06: es queda actiu, amb la decisió i el seu motiu escrits, perquè la contenció que aporten els contenidors val més que la superfície que tanca.

Fixa't ara en on ets. Tens srv-tramontana en producció, srv-tramontana-proves recreable amb cloud-init, i contenidors que aixequen l'aplicació i la seva base de dades amb una ordre. Tres formes de crear màquines i serveis. I les tres comparteixen un problema: la configuració de producció continua sent un manual d'operació en prosa i la memòria de l'administrador. Cada usuari, cada regla de sudoers, cada unitat de systemd, cada línia d'ufw, cada sysctl i cada perfil d'AppArmor dels mòduls 5 i 6 es va aplicar a mà. L'RTO de 8 hores que la Marta va aprovar depèn enterament que algú recordi l'ordre correcte, i la regla de 06-04 —«un servidor compromès es reinstal·la, no es neteja»— és fàcil de dir i cara de complir.

A la lliçó 07-06: Automatització amb Ansible això es converteix en codi. Veuràs la diferència entre un script imperatiu com desplegar.sh i un estat desitjat idempotent, entendràs per què Ansible no necessita agent —treballa sobre l'SSH que vas enfortir a 06-02— i escriuràs inventaris, playbooks amb manejadors i etiquetes, plantilles Jinja2 que generen app.conf amb variables per entorn, i rols que ho organitzen tot en peces reutilitzables. Faràs servir --check i --diff, que són el --dry-run del curs portat a la configuració sencera, guardaràs els secrets amb Vault al costat del teu pass, i provaràs cada canvi contra srv-tramontana-proves abans de tocar producció. I al final faràs la mesura que importa: quant triga a reconstruir-se el servidor des de zero. Aquell número —de vuit hores a menys d'una— és el que converteix una regla de seguretat en un procediment que de debò es pot complir.

Curs de Linux: De Principiant a Administrador de Sistemes

Mòdul 1: Introducció a Linux

Mòdul 2: Comandes Bàsiques de Linux

Mòdul 3: Habilitats Avançades en la Línia de Comandes

Mòdul 4: Scripting en Shell

Mòdul 5: Administració del Sistema

Mòdul 6: Xarxes i Seguretat

Mòdul 7: Temes Avançats

Mòdul 8: Projectes Pràctics

© Copyright 2026. Tots els drets reservats