Container Docker che non girano da root: perché conta e come si fa

Se in un Dockerfile non c’è un’istruzione USER, il container parte da root. Non è un caso limite: è il comportamento predefinito, e la documentazione di Compose lo dice senza giri di parole — user «overrides the user used to run the container process. The default is set by the image, for example Dockerfile USER. If it’s not set, then root».
Vale anche per le immagini ufficiali. Il Dockerfile di php:8.4-fpm non contiene nessuna istruzione USER: PHP-FPM viene compilato con --with-fpm-user=www-data, quindi i worker che eseguono il codice scendono a www-data, ma il processo master resta root. Nginx si comporta allo stesso modo: master root, worker non privilegiati.
Perché conta
L’obiezione ragionevole è: il container è isolato, che problema c’è. Il problema è che l’isolamento è un solo strato, e root dentro il container è un buon punto di partenza per uscirne. Nella pratica quotidiana, senza scomodare le fughe dal kernel, root nel container significa tre cose molto concrete:
- I volumi montati. Se fai il bind mount di una cartella dell’host, i permessi che contano sono quelli dell’host. Un processo root nel container scrive e sovrascrive quei file come root. Il classico file di upload che compare sull’host con owner
0:0nasce esattamente da lì. - Il filesystem del container. Root può riscrivere i binari dell’immagine,
/etc/passwd, la configurazione di nginx. Chi trova un’esecuzione di codice arbitrario nella tua applicazione si ritrova un ambiente in cui può installare quello che vuole. - Le capability. Docker non dà tutto, ma il set predefinito non è vuoto:
CHOWN,DAC_OVERRIDE,FOWNER,SETUID,SETGID,MKNOD,NET_RAW,SYS_CHROOTe altre.DAC_OVERRIDEda solo ignora i permessi dei file. Il 99% delle applicazioni web non ne usa nemmeno una.
Il livello base: USER
L’istruzione USER «sets the user name (or UID) and optionally the user group (or GID) to use as the default user and group for the remainder of the current stage». Vale per le RUN successive, per l’ENTRYPOINT e per il CMD.
FROM php:8.4-fpm
# quello che serve root lo si fa prima
RUN docker-php-ext-install pdo_mysql opcache
# UID e GID espliciti: sono quelli che si vedono sull'host
RUN chown -R 1000:1000 /var/www/html
USER 1000:1000
Il consiglio è di usare UID e GID numerici, non il nome. Il nome viene risolto in /etc/passwd dentro il container; il numero è ciò che vede il kernel dell’host, ed è l’unica cosa che conta quando confronti i permessi di un volume.
Se non puoi ricostruire l’immagine, la stessa cosa si fa al volo — -u/--user accetta il formato <name|uid>[:<group|gid>]:
services:
app:
image: php:8.4-fpm
user: "1000:1000"
Nginx senza root
Nginx è il caso che fa sbattere la testa, perché di default scrive in posti che un utente non privilegiato non può toccare. La documentazione dell’immagine ufficiale elenca cosa va spostato: il pid file (pid /tmp/nginx.pid;) e i percorsi temporanei nel contesto http — client_body_temp_path, proxy_temp_path, fastcgi_temp_path, uwsgi_temp_path, scgi_temp_path.
Esiste anche la scorciatoia, ed è indicata dalla documentazione stessa: l’immagine nginxinc/nginx-unprivileged, già configurata per girare come utente non privilegiato. Due differenze da tenere a mente: «the default NGINX listen port is now 8080 instead of 80» e «the default NGINX user directive in /etc/nginx/nginx.conf has been removed».
E la porta 80? È l’obiezione che si sente sempre, e da qualche anno non è più un problema. Docker imposta di default il sysctl net.ipv4.ip_unprivileged_port_start=0 dentro il container (moby/moby#41030, milestone 20.10.0): un processo non root può mettersi in ascolto sotto la 1024 senza CAP_NET_BIND_SERVICE. Attenzione a un’eccezione: in network_mode: host quel sysctl non viene applicato.
Gli altri tre interruttori
Utente non privilegiato è metà del lavoro. Gli altri tre si attivano con una riga ciascuno.
Filesystem in sola lettura. read_only «configures the service container to be created with a read-only filesystem». Le poche cartelle che devono restare scrivibili le dai come tmpfs, che vive in RAM e sparisce con il container.
Capability azzerate. cap_drop: [ALL] e poi, solo se serve, riaggiungi. Nella maggior parte dei casi non serve niente.
Niente escalation. no-new-privileges «disable container processes from gaining new privileges»: neutralizza i binari SUID, che è la strada più semplice per risalire da www-data a root dentro il container.
Messo insieme:
services:
web:
image: nginxinc/nginx-unprivileged # in produzione fissa una versione
ports:
- "80:8080"
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
pids_limit: 128
app:
image: myapp:php-8.4-fpm
user: "1000:1000"
read_only: true
tmpfs:
- /tmp
- /var/run
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
volumes:
- ./storage:/var/www/html/storage
pids_limit «tunes a container’s PIDs limit»: è un tetto al numero di processi, e trasforma una fork bomb dentro il container in un container che si ferma invece che in un server che si ferma.
Cosa si rompe, e come accorgersene
Non aspettarti che funzioni al primo colpo. I due errori che vedrai sono sempre gli stessi:
Permission deniedin scrittura. Conread_only: trueesce ovunque l’applicazione scriva: cache, log, sessioni, upload. Ognuna di quelle cartelle è una decisione da prendere:tmpfsse è roba effimera, volume se deve sopravvivere. Il vantaggio è proprio questo — ti costringe a sapere dove scrive la tua applicazione.- File sull’host con owner sbagliato. Se il volume era stato creato da un container root, i file esistenti restano suoi. Un
chown -R 1000:1000sulla cartella dell’host, una volta, e poi non succede più.
Per verificare che sia davvero applicato, dentro il container id deve rispondere con l’UID che ti aspetti, e touch /qualcosa deve fallire. Se rispondono uid=0(root) e un file creato, la configurazione non sta facendo quello che credi.
L’ordine in cui farlo
Su un sistema già in produzione non si accende tutto insieme, altrimenti quando qualcosa si rompe non sai cosa è stato. Un servizio alla volta, e in quest’ordine: prima cap_drop: [ALL] e no-new-privileges (raramente rompono qualcosa), poi user, e per ultimo read_only, che è quello che tira fuori tutti i percorsi di scrittura che non sapevi di avere.
Fonti: docker run reference · Dockerfile reference — USER · Compose file — services · Runtime privilege and Linux capabilities · immagine ufficiale nginx · immagine ufficiale php · moby/moby#41030
