Docker: volumi e bind mount, dove finiscono davvero i dati

Il bug arriva sempre allo stesso modo. Il container del database gira da settimane, tutto
funziona, poi qualcuno fa docker compose down -v per ripartire pulito — oppure aggiorna
l’immagine e ricrea il servizio — e i dati non ci sono più. Nessun errore, nessun log: un
database vuoto, inizializzato da zero come al primo avvio.
Non è un difetto di Docker. È il comportamento documentato di un container a cui nessuno ha
detto dove scrivere.
Il livello scrivibile, e perché non è una soluzione
La documentazione di Docker lo dice in una riga: «By default all files created inside a
container are stored on a writable container layer that sits on top of the read-only,
immutable image layers». E subito dopo: «Data written to the container layer doesn’t persist
when the container is destroyed».
“Destroyed”, non “stopped”. Uno stop e uno start conservano il livello scrivibile; un
docker compose down, un docker rm, un docker compose up dopo aver cambiato immagine o
variabile d’ambiente ricreano il container, e il livello scrivibile di prima non esiste più.
Per tenere i dati fuori da quel livello Docker documenta cinque tipi di mount: «Volume
mounts, Bind mounts, tmpfs mounts, Image mounts, Named pipes». Nel lavoro di tutti i giorni
contano i primi tre, e la scelta fra i primi due è quasi tutto.
Volume: lo gestisce Docker
Un volume nominato è una directory che il demone Docker crea e amministra: «When you create
a volume, it’s stored within a directory on the Docker host». Su Linux la trovi in
/var/lib/docker/volumes/<nome>/_data — è il campo Mountpoint di docker volume inspect.
La differenza che conta sta in una frase sola: «While bind mounts are dependent on the
directory structure and OS of the host machine, volumes are completely managed by Docker».
services:
db:
image: postgres:18
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db-data:/var/lib/postgresql
volumes:
db-data:
Due cose da sapere su questo blocco. La prima: Compose non chiama quel volume db-data, ma
{project_name}_db-data — il nome del progetto davanti. Se vuoi il nome esatto, l’attributo
name «is used as is and is not scoped with the stack name». La seconda: «Running
docker compose up creates the volume if it doesn’t already exist. Otherwise, the existing
volume is used». È questo che rende un up ripetibile senza perdere niente.
Il percorso del mount, invece, va letto sul README dell’immagine e non tirato a indovinare.
Su postgres:18 il PGDATA è /var/lib/postgresql/18/docker e il VOLUME dichiarato nel
Dockerfile è /var/lib/postgresql; su PostgreSQL 17 e precedenti la regola era l’opposto —
«Mount the data volume at /var/lib/postgresql/data and not at /var/lib/postgresql». Chi
ha copiato un docker-compose.yml del 2024 e ha alzato il tag a 18 ha esattamente il bug
descritto qui. Su MySQL la directory è /var/lib/mysql.
E se sbagli percorso non ottieni un errore, ottieni questo: «If no data volume is mounted at
that path then the container runtime will automatically create an anonymous volume that is
not reused across container re-creations. Data will be written to the anonymous volume rather
than your intended data volume and won’t persist when the container is deleted and
re-created». Il volume anonimo esiste, ha un nome casuale, ed è un volume nuovo a ogni
ricreazione del container.
Bind mount: lo gestisci tu
Un bind mount «create a direct link between a host system path and a container». Nessuna
astrazione: quella directory dell’host, vista da dentro. È lo strumento giusto per il codice
in sviluppo, per un file di configurazione, per un certificato. È lo strumento sbagliato per
la directory dati di un database, e la documentazione dice perché: «Containers with bind
mounts are strongly tied to the host. Bind mounts rely on the host machine’s filesystem
having a specific directory structure available».
C’è anche un tema di sicurezza, scritto esplicitamente: «One side effect of using bind mounts
is that you can change the host filesystem via processes running in a container, including
creating, modifying, or deleting important system files or directories». Un container che
gira da root con /etc montato in scrittura non è un container, è una shell sull’host. Dove
il container deve solo leggere, l’opzione c’è: «You can use the readonly or ro option to
prevent the container from writing to the mount».
Il README di MySQL mette in fila i due lati senza girarci intorno. Volume gestito da Docker:
«This is the default and is easy and fairly transparent to the user», con il rovescio che «the
files may be hard to locate for tools and applications that run directly on the host system».
Directory dell’host: «the user needs to make sure that the directory exists, and that e.g.
directory permissions and other security mechanisms on the host system are set up correctly».
tmpfs: quando i dati non devono restare
Il terzo tipo sta in memoria: «a tmpfs mount is temporary, and only persisted in the host
memory», e «When the container stops, the tmpfs mount is removed, and files written there
won’t be persisted». Serve per cache, file temporanei, dati che non vuoi vedere scritti su
disco. Due limiti dichiarati: «you can’t share tmpfs mounts between containers» e «This
functionality is only available if you’re running Docker on Linux». La dimensione massima, se
non la imposti, è «50% of the host’s total RAM», e il mode di default è «1777 or
world-writable» — cioè scrivibile da tutti, che su un container multiutente va deciso, non
ereditato.
Gli errori da evitare
Montare su una directory che ha già dei file. Le due regole sono diverse e vale la pena
saperle a memoria. Volume vuoto: «If you mount an empty volume into a directory in the
container in which files or directories exist, these files or directories are propagated
(copied) into the volume by default» — è così che il volume del database si popola al primo
avvio. Volume già pieno: «the pre-existing files are obscured by the mount». Bind mount: «the
directory’s existing contents are obscured by the bind mount», sempre, senza copia. Se ti
serve un volume vuoto che resti vuoto, l’opzione è volume-nocopy.
Usare -v per un bind mount e fidarsi del percorso. Con --volume, «if you use
--volume to bind-mount a file or directory that does not yet exist on the Docker host,
Docker automatically creates the directory on the host for you»: un typo nel percorso non dà
errore, dà una directory vuota nuova e un’applicazione che parte senza i suoi dati. Con
--mount no: «By default, --mount does not automatically create a directory if the
specified mount path does not exist on the host». In un docker run scritto a mano, --mount
è la forma che fallisce quando deve.
Dare per scontato cosa fa down -v. Senza flag, docker compose down rimuove container,
le reti della sezione networks e la rete di default; i volumi anonimi «are not removed by
default» e quelli external «are never removed». Con -v rimuove «named volumes declared in
the “volumes” section of the Compose file and anonymous volumes attached to containers»: cioè
proprio il volume del database, quello che ti interessava. Se un volume non deve morire per
sbaglio, dichiaralo external: true — Compose «doesn’t create the volume and returns an error
if the volume doesn’t exist», che è una rete di sicurezza in entrambe le direzioni.
Fare pulizia con prune senza leggere il messaggio. docker volume prune rimuove i
volumi non usati, ma «By default, it only removes anonymous volumes». Il flag che cambia le
cose è --all: «Remove all unused volumes, not just anonymous ones». Un volume di un progetto
spento, in quel momento, è un volume non usato.
La regola in una riga
I dati di un database stanno in un volume nominato, dichiarato nella sezione volumes, montato
al percorso che dice il README dell’immagine. Configurazione e codice in bind mount, in sola
lettura dove basta. Tutto il resto muore con il container, ed è giusto così — a patto di
saperlo prima.
Un dettaglio che nessuna di queste pagine copre: un volume non è un backup. Vive sullo stesso
disco dell’host, e un down -v dato dalla directory sbagliata lo porta via in mezzo secondo.
Se il container tiene dati che non puoi riscrivere a mano, serve una copia fuori da lì.
Se vuoi una revisione della configurazione Docker di un progetto in produzione — cosa è
persistente, cosa non lo è e cosa succede al prossimo aggiornamento di immagine —
scrivimi.
Fonti primarie, tutte consultate mentre scrivevo:
Docker storage,
Volumes,
Bind mounts,
tmpfs mounts,
docker compose down,
docker volume prune,
Compose: volumes,
immagine ufficiale postgres,
immagine ufficiale mysql.
