La plataforma funciona. curl http://localhost:3000/libros devuelve los ocho títulos y la web carga en el navegador. Pero hay una grieta en los cimientos que ya has tropezado dos veces sin darle importancia: cada vez que recreas aurora-db, el catálogo desaparece y tienes que volver a cargar init.sql a mano con docker cp. En una máquina de desarrollo eso es una molestia. En el servidor donde vive el catálogo real de una librería, es una catástrofe.
Esta lección resuelve el segundo gran problema de los contenedores, después de la red: los datos. Vas a demostrar la pérdida con un experimento que da un poco de miedo, vas a conocer los tres tipos de montaje que ofrece Docker y cuándo usar cada uno, vas a entender dónde viven de verdad los volúmenes y por qué no debes tocarlos a mano, y vas a dar a PostgreSQL un volumen con nombre para que el catálogo de Aurora Libros sobreviva a la destrucción de su contenedor. Al final repetirás el experimento del principio y el resultado será el contrario.
Contenido
- El problema, demostrado
- Los tres tipos de montaje
- Volúmenes gestionados por Docker
- Volúmenes anónimos y el problema de los huérfanos
- Bind mounts: ficheros de tu máquina dentro del contenedor
-vfrente a--mount- Montajes de solo lectura
tmpfs: datos en memoria- Práctica:
aurora-datosy la prueba de destrucción - Copias de seguridad y restauración
- Buenas prácticas
- El problema, demostrado
Empecemos por mirar el desastre a la cara. Añade un libro nuevo al catálogo:
docker exec aurora-db psql -U aurora -d aurora_libros -c \
"INSERT INTO libros (titulo, autor, isbn, precio) VALUES
('El Aleph', 'Jorge Luis Borges', '978-84-206-3311-4', 15.20);"
docker exec aurora-db psql -U aurora -d aurora_libros -t -c "SELECT COUNT(*) FROM libros;"
curl -s http://localhost:3000/libros | jq -r '.libros | length'Nueve libros. La API los sirve. Ahora simula lo que ocurre en cualquier operación normal: actualizar la imagen de PostgreSQL, cambiar una variable de entorno, añadir un límite de memoria... todo eso obliga a recrear el contenedor, como sabes desde la lección 03-01.
docker rm -f aurora-db
docker run -d --name aurora-db --network aurora-net \
--label proyecto=aurora-libros --label componente=base-de-datos \
-e POSTGRES_USER=aurora -e POSTGRES_PASSWORD=aurora_secreta -e POSTGRES_DB=aurora_libros \
-p 127.0.0.1:5432:5432 \
postgres:16-alpine
sleep 6
docker exec aurora-db psql -U aurora -d aurora_libros -c "SELECT COUNT(*) FROM libros;"No es que falte El Aleph: es que ya no existe la tabla. Todo el catálogo de Aurora Libros se ha evaporado. Y la API, coherente:
Esto no es un fallo de Docker: es su diseño, y ya lo conocías desde la lección 01-05. Un contenedor escribe en una capa de escritura que nace con él y muere con él. Las nueve filas estaban en un volumen anónimo que Docker creó automáticamente —lo descubriste en el ejercicio 2 de la lección 03-03— y que al recrear el contenedor fue sustituido por otro nuevo y vacío.
flowchart TB
subgraph SIN["Sin volumen: los datos mueren con el contenedor"]
direction TB
S1["docker run postgres"] --> S2["Capa de escritura<br/>+ volumen anónimo nuevo"]
S2 --> S3["INSERT ... 9 libros"]
S3 --> S4["docker rm -f"]
S4 --> S5["Todo destruido<br/>o huérfano e inalcanzable"]
end
subgraph CON["Con volumen con nombre: los datos son independientes"]
direction TB
V1["docker volume create aurora-datos"] --> V2["docker run -v aurora-datos:/var/lib/postgresql/data"]
V2 --> V3["INSERT ... 9 libros"]
V3 --> V4["docker rm -f"]
V4 --> V5["El volumen SIGUE ahí"]
V5 --> V6["docker run ... mismo volumen<br/>→ los 9 libros vuelven"]
end
La solución es sacar los datos del contenedor. Vamos a ello.
- Los tres tipos de montaje
Docker ofrece tres formas de que una ruta dentro del contenedor apunte a algo que no está en su capa de escritura:
| Volumen | Bind mount | tmpfs | |
|---|---|---|---|
| Dónde viven los datos | En un área gestionada por Docker (/var/lib/docker/volumes/) |
En una ruta que tú eliges de tu máquina | En la memoria RAM del host |
| Quién lo gestiona | Docker | Tú | El kernel |
¿Sobrevive a docker rm? |
Sí | Sí (es tu fichero) | No, se evapora |
| ¿Sobrevive a reiniciar el host? | Sí | Sí | No |
| Portabilidad entre máquinas | Alta (mismo comando en todas partes) | Baja (depende de las rutas del host) | Alta |
| Rendimiento en Docker Desktop | Bueno | Peor (cruza la frontera con la VM) | El mejor |
| Copias de seguridad | Con docker run + tar, o docker volume |
Con las herramientas del host | No aplica |
| Caso de uso natural | Datos de producción: bases de datos, subidas de usuarios | Desarrollo y ficheros de configuración | Secretos y ficheros temporales sensibles |
Sintaxis --mount |
type=volume |
type=bind |
type=tmpfs |
flowchart LR
subgraph HOST["Host"]
VOL["/var/lib/docker/volumes/aurora-datos/_data<br/>(gestionado por Docker)"]
DIR["~/aurora-libros/db/init.sql<br/>(fichero tuyo)"]
RAM["Memoria RAM"]
end
subgraph CONT["Contenedor aurora-db"]
M1["/var/lib/postgresql/data"]
M2["/docker-entrypoint-initdb.d/init.sql"]
M3["/tmp/sensible"]
end
VOL -- "volumen" --> M1
DIR -- "bind mount :ro" --> M2
RAM -- "tmpfs" --> M3
La regla de decisión, en una frase: si los datos importan y los gestiona la aplicación, un volumen; si el fichero es tuyo y quieres editarlo desde tu editor, un bind mount; si no debe tocar el disco jamás, tmpfs.
- Volúmenes gestionados por Docker
aurora-datos
DRIVER VOLUME NAME
local aurora-datos
local b41f7c9e2a8d05f31c6e8b4a2d97f0c5e3a1b8d6f4c2e0a9b7d5f3c1e8a6b4d2Ese segundo nombre ilegible es un volumen anónimo huérfano, resto de alguno de los aurora-db que has borrado. Volveremos a él.
| Comando | Qué hace |
|---|---|
docker volume create NOMBRE |
Crea un volumen |
docker volume ls |
Lista todos |
docker volume ls -f dangling=true |
Solo los que no usa ningún contenedor |
docker volume inspect NOMBRE |
Muestra su ruta real y sus metadatos |
docker volume rm NOMBRE |
Lo borra (falla si está en uso) |
docker volume prune |
Borra todos los volúmenes sin usar |
¿Dónde viven realmente?
[
{
"CreatedAt": "2026-08-04T22:03:11+02:00",
"Driver": "local",
"Labels": {},
"Mountpoint": "/var/lib/docker/volumes/aurora-datos/_data",
"Name": "aurora-datos",
"Options": {},
"Scope": "local"
}
]Mountpoint es la ruta en el host. Y aquí va el aviso más importante del apartado:
No entres ahí a tocar ficheros. Esa ruta es propiedad del demonio de Docker: pertenece a root, sus permisos y su contexto de seguridad los gestiona Docker, y en Docker Desktop (macOS y Windows) ni siquiera existe en tu máquina, sino dentro de una máquina virtual Linux a la que no tienes acceso directo. Escribir ahí a mano es una forma excelente de corromper una base de datos.
La forma correcta de mirar dentro de un volumen es con un contenedor:
Vacío, recién creado. Este patrón —un contenedor efímero de Alpine que monta el volumen— es la navaja suiza para inspeccionar, copiar y restaurar volúmenes, y lo usarás en el apartado 10.
Etiquetas en volúmenes
docker volume create --label proyecto=aurora-libros aurora-copias
docker volume ls --filter label=proyecto=aurora-librosIgual que con los contenedores, etiquetar permite limpiar de forma selectiva:
- Volúmenes anónimos y el problema de los huérfanos
Un volumen anónimo es el que Docker crea por su cuenta, sin que se lo pidas, en dos situaciones:
- Cuando la imagen declara
VOLUME /rutaen su Dockerfile (lo hacenpostgres,redis,mysql,mongo…). - Cuando montas con
-v /rutaindicando solo el destino, sin origen.
docker run -d --name demo-anonimo -v /datos alpine:3.20 sleep 60
docker inspect -f '{{range .Mounts}}{{.Type}} | {{.Name}} | {{.Destination}}{{end}}' demo-anonimoEse nombre de 64 caracteres es el volumen anónimo. Y aquí está su problema:
demo-anonimo
b41f7c9e2a8d05f31c6e8b4a2d97f0c5e3a1b8d6f4c2e0a9b7d5f3c1e8a6b4d2
e83f1c7a92b40d651fe8a3c9d7b2f4e6a0c8b1d5f3a7c9e1b8d6f4a2c0e8b6d4El volumen sigue ahí, huérfano. Sin contenedor que lo use, con un nombre que nadie puede recordar, y ocupando disco indefinidamente. Cada postgres que has borrado en este módulo dejó atrás uno de estos, con sus datos dentro, irrecuperables en la práctica.
| Volumen anónimo | Volumen con nombre | |
|---|---|---|
| Cómo se crea | Automáticamente (VOLUME del Dockerfile o -v /destino) |
docker volume create o -v nombre:/destino |
| Nombre | Un hash de 64 caracteres | El que tú elijas |
| Se reutiliza al recrear el contenedor | No, se crea otro vacío | Sí, es el mismo |
Lo borra docker rm -v |
Sí | No |
Lo borra docker volume prune |
Sí si está huérfano | Sí si está huérfano |
| Recomendación | Evitarlos para datos que importen | Siempre para datos que importen |
Limpia los huérfanos ahora, comprobando antes qué te vas a llevar por delante:
Deleted Volumes:
b41f7c9e2a8d05f31c6e8b4a2d97f0c5e3a1b8d6f4c2e0a9b7d5f3c1e8a6b4d2
e83f1c7a92b40d651fe8a3c9d7b2f4e6a0c8b1d5f3a7c9e1b8d6f4a2c0e8b6d4
Total reclaimed space: 47.83MBCuidado con este comando: docker volume prune borra datos de verdad y no hay papelera. Un volumen "sin usar" puede ser el de una base de datos cuyo contenedor está simplemente parado. Desde Docker 23, prune solo toca los anónimos por defecto; para incluir los que tienen nombre hace falta --all, lo cual es una red de seguridad que conviene no desactivar a la ligera.
- Bind mounts: ficheros de tu máquina dentro del contenedor
Un bind mount conecta una ruta concreta de tu host con una ruta del contenedor. Ya lo usaste en la lección 01-06 con tu index.html.
mkdir -p ~/aurora-libros/pruebas
echo "Contenido desde el host" > ~/aurora-libros/pruebas/nota.txt
docker run --rm -v ~/aurora-libros/pruebas:/datos alpine:3.20 cat /datos/nota.txt
docker run --rm -v ~/aurora-libros/pruebas:/datos alpine:3.20 \
sh -c 'echo "Escrito desde el contenedor" >> /datos/nota.txt'
cat ~/aurora-libros/pruebas/nota.txtEs el mismo fichero visto desde dos sitios: los cambios van en las dos direcciones y en tiempo real. Ese es su superpoder para desarrollo —editas en tu IDE y el contenedor lo ve al instante— y su peligro en producción.
Reglas y trampas:
| Regla | Detalle |
|---|---|
| Ruta absoluta obligatoria | -v ./web:/html no funcionaba en versiones antiguas. Usa -v "$(pwd)/web":/html o ~/... (el shell expande la virgulilla) |
| Si la ruta del host no existe, Docker la crea | Y la crea como directorio vacío y propiedad de root. Es la causa del clásico "monté mi fichero y aparece una carpeta" |
| El montaje oculta lo que hubiera en el destino | Montar un directorio vacío sobre /usr/share/nginx/html deja Nginx sirviendo un 403 |
| Los permisos son los del host | Y ahí empieza el problema de los UID |
El choque de UID
Este es el dolor de cabeza clásico de los bind mounts:
docker run --rm -u node --entrypoint sh -v ~/aurora-libros/pruebas:/datos \
auroralibros/aurora-api:1.2.0 -c 'id; ls -ln /datos'En este caso funciona por casualidad: tu usuario del host tiene UID 1000 y el usuario node de la imagen también. Pero prueba con un usuario distinto:
docker run --rm -u 1500 -v ~/aurora-libros/pruebas:/datos alpine:3.20 \
sh -c 'touch /datos/prueba.txt || echo "SIN PERMISO"'El kernel compara números, no nombres. Dentro del contenedor no existe ningún "usuario del host": solo hay UID y GID, y los permisos del fichero son los que tenga en tu máquina. Las tres soluciones habituales:
| Solución | Cómo | Cuándo |
|---|---|---|
| Ajustar el UID del contenedor | -u $(id -u):$(id -g) |
Desarrollo, la más práctica |
| Ajustar los permisos en el host | chmod 644 fichero / chown |
Ficheros de configuración de solo lectura |
| Usar un volumen en vez de un bind mount | -v aurora-datos:/ruta |
Datos de producción: Docker gestiona los permisos |
Y de ahí sale una regla que conviene memorizar: los bind mounts son para desarrollo y para ficheros de configuración; los datos de producción van en volúmenes.
-v frente a --mount
-v frente a --mountDocker ofrece dos sintaxis para lo mismo:
# Sintaxis -v: compacta, posicional, ambigua
docker run -v aurora-datos:/var/lib/postgresql/data postgres:16-alpine
# Sintaxis --mount: verbosa, explícita, con clave=valor
docker run --mount type=volume,source=aurora-datos,target=/var/lib/postgresql/data postgres:16-alpine-v / --volume |
--mount |
|
|---|---|---|
| Legibilidad | Compacta pero críptica | Verbosa y autoexplicativa |
| Si el origen no existe | Lo crea (un directorio vacío en bind mounts) | Falla con error |
| Tipo de montaje | Se deduce de la sintaxis | Explícito con type= |
| Opciones avanzadas (tmpfs, propagación, drivers) | Limitadas | Completas |
| Uso en Docker Compose y Swarm | Soportada | La forma nativa |
| Recomendación | Correcta para ejemplos y comandos rápidos | Preferida, sobre todo en scripts |
Ese "si el origen no existe" es la diferencia práctica más importante. Con -v, un error de tecleo en la ruta te deja un directorio vacío y un contenedor que arranca sin sus datos; con --mount, obtienes un error claro y el contenedor no arranca.
docker: Error response from daemon: invalid mount config for type "bind":
bind source path does not exist: /ruta/que/no/existeTabla de equivalencias para tenerla a mano:
| Objetivo | Con -v |
Con --mount |
|---|---|---|
| Volumen con nombre | -v datos:/var/lib/data |
--mount type=volume,src=datos,dst=/var/lib/data |
| Volumen anónimo | -v /var/lib/data |
--mount type=volume,dst=/var/lib/data |
| Bind mount | -v /host/ruta:/cont/ruta |
--mount type=bind,src=/host/ruta,dst=/cont/ruta |
| Bind mount de solo lectura | -v /host/f:/cont/f:ro |
--mount type=bind,src=/host/f,dst=/cont/f,readonly |
| tmpfs | --tmpfs /tmp |
--mount type=tmpfs,dst=/tmp |
src y source son sinónimos, igual que dst, destination y target.
- Montajes de solo lectura
Un fichero de configuración no tiene por qué ser escribible por el contenedor. Marcarlo como solo lectura es una defensa barata y eficaz:
docker run --rm -v ~/aurora-libros/pruebas/nota.txt:/config/nota.txt:ro alpine:3.20 \
sh -c 'cat /config/nota.txt; echo "intento escribir" >> /config/nota.txt || echo "BLOQUEADO"'Contenido desde el host
Escrito desde el contenedor
sh: can't create /config/nota.txt: Read-only file system
BLOQUEADOEl kernel lo impide, no Docker. Es el mismo mecanismo que usaste con index.html y nginx.conf en la lección anterior. Úsalo siempre que montes:
- Ficheros de configuración (
nginx.conf,redis.conf,postgresql.conf). - Scripts de inicialización (
init.sql). - Certificados y claves públicas.
- Contenido estático que la aplicación no debe modificar.
tmpfs: datos en memoria
tmpfs: datos en memoriaUn montaje tmpfs crea un sistema de ficheros en la RAM del host. Nada toca el disco y todo desaparece al parar el contenedor:
docker run --rm --mount type=tmpfs,dst=/sensible,tmpfs-size=16m alpine:3.20 \
sh -c 'echo "token-temporal-abc123" > /sensible/token; df -h /sensible; cat /sensible/token'Filesystem Size Used Available Use% Mounted on
tmpfs 16.0M 4.0K 16.0M 0% /sensible
token-temporal-abc123| Opción | Para qué |
|---|---|
tmpfs-size |
Tamaño máximo. Sin límite, puede llenar la RAM del host |
tmpfs-mode |
Permisos del directorio, p. ej. 0700 |
Casos de uso reales: secretos desencriptados en tiempo de ejecución, ficheros de sesión, directorios temporales de aplicaciones que se ejecutan con el sistema de ficheros raíz en solo lectura, y cualquier dato que por normativa no deba quedar escrito en disco. Solo funciona en Linux.
- Práctica:
aurora-datos y la prueba de destrucción
aurora-datos y la prueba de destrucciónEs el momento de arreglar aurora-db de una vez.
El plan
| Ruta en el contenedor | Tipo de montaje | Origen | Por qué |
|---|---|---|---|
/var/lib/postgresql/data |
Volumen aurora-datos |
Gestionado por Docker | Son datos de producción: deben sobrevivir al contenedor |
/docker-entrypoint-initdb.d/init.sql |
Bind mount :ro |
~/aurora-libros/db/init.sql |
Es un fichero tuyo, versionado en Git, que el contenedor solo lee |
La segunda fila usa una convención de la imagen oficial de PostgreSQL: todo lo que haya en /docker-entrypoint-initdb.d/ (ficheros .sql, .sql.gz o .sh) se ejecuta automáticamente, en orden alfabético, la primera vez que se inicializa la base de datos. Es exactamente el trabajo que llevas dos lecciones haciendo a mano con docker cp.
Recrear aurora-db como debe ser
docker rm -f aurora-db
chmod 644 ~/aurora-libros/db/init.sql
docker run -d \
--name aurora-db \
--network aurora-net \
--label proyecto=aurora-libros --label componente=base-de-datos \
-e POSTGRES_USER=aurora \
-e POSTGRES_PASSWORD=aurora_secreta \
-e POSTGRES_DB=aurora_libros \
-p 127.0.0.1:5432:5432 \
--mount type=volume,src=aurora-datos,dst=/var/lib/postgresql/data \
--mount type=bind,src=$HOME/aurora-libros/db/init.sql,dst=/docker-entrypoint-initdb.d/init.sql,readonly \
postgres:16-alpineEl chmod 644 no es decorativo: el proceso de PostgreSQL corre como el usuario postgres (UID 70), y si el fichero solo fuera legible por tu usuario, el script no podría ejecutarse. Es el choque de UID del apartado 5, aplicado.
Comprueba qué ha pasado en el arranque:
sleep 8
docker logs aurora-db 2>&1 | grep -A2 "initdb.d"
docker exec aurora-db psql -U aurora -d aurora_libros -t -c "SELECT COUNT(*) FROM libros;"/usr/local/bin/docker-entrypoint.sh: running /docker-entrypoint-initdb.d/init.sql
CREATE TABLE
CREATE INDEX
INSERT 0 8
8Los ocho libros se han cargado solos. Sin docker cp, sin psql -f, sin acordarse de nada.
Verifica los montajes:
docker inspect -f '{{range .Mounts}}{{.Type}} | {{if .Name}}{{.Name}}{{else}}{{.Source}}{{end}} → {{.Destination}} | rw={{.RW}}{{println}}{{end}}' aurora-dbvolume | aurora-datos → /var/lib/postgresql/data | rw=true
bind | /home/junior/aurora-libros/db/init.sql → /docker-entrypoint-initdb.d/init.sql | rw=falseUn volumen escribible para los datos y un bind mount de solo lectura para el script. Exactamente el plan.
La prueba de destrucción
Añade otra vez El Aleph:
docker exec aurora-db psql -U aurora -d aurora_libros -c \
"INSERT INTO libros (titulo, autor, isbn, precio) VALUES
('El Aleph', 'Jorge Luis Borges', '978-84-206-3311-4', 15.20);"
curl -s http://localhost:3000/libros | jq -r '.libros | length'Y ahora, destruye el contenedor. Sin miedo:
docker stop --time 30 aurora-db
docker rm aurora-db
docker ps -a --filter name=aurora-db -q
docker volume ls --filter name=aurora-datosEl contenedor ya no existe. El volumen sigue ahí. Recréalo con exactamente el mismo comando de antes:
docker run -d \
--name aurora-db --network aurora-net \
--label proyecto=aurora-libros --label componente=base-de-datos \
-e POSTGRES_USER=aurora -e POSTGRES_PASSWORD=aurora_secreta -e POSTGRES_DB=aurora_libros \
-p 127.0.0.1:5432:5432 \
--mount type=volume,src=aurora-datos,dst=/var/lib/postgresql/data \
--mount type=bind,src=$HOME/aurora-libros/db/init.sql,dst=/docker-entrypoint-initdb.d/init.sql,readonly \
postgres:16-alpine
sleep 6
docker exec aurora-db psql -U aurora -d aurora_libros -c \
"SELECT id, titulo FROM libros WHERE titulo = 'El Aleph';"
curl -s http://localhost:3000/libros | jq -r '.libros | length'El Aleph sigue ahí. Has borrado el contenedor de la base de datos por completo y el catálogo ha sobrevivido intacto, con sus nueve libros. Compara con el apartado 1 de esta misma lección, donde el mismo experimento destruyó hasta la tabla.
Y hay un detalle en los logs que merece una mirada:
El init.sql no se ha vuelto a ejecutar. La imagen detecta que el directorio de datos ya contiene una base de datos y se salta la inicialización. Eso es exactamente lo que quieres: si se ejecutara siempre, el CREATE TABLE IF NOT EXISTS no haría daño, pero un script menos cuidadoso podría borrar datos reales en cada arranque. Y la consecuencia práctica: si cambias init.sql, no bastará con reiniciar; tendrás que borrar el volumen para que vuelva a inicializarse desde cero.
# Así se empieza de cero, cuando de verdad quieres perder los datos
docker rm -f aurora-db && docker volume rm aurora-datos(No lo ejecutes ahora: lo necesitas para la siguiente lección.)
aurora-web, ya montado
El contenedor de Nginx que creaste en la lección anterior ya usa bind mounts; ahora puedes leer aquel comando con otros ojos:
docker rm -f aurora-web
docker run -d \
--name aurora-web --network aurora-net \
--label proyecto=aurora-libros --label componente=web \
-p 8080:80 \
--mount type=bind,src=$HOME/aurora-libros/web/index.html,dst=/usr/share/nginx/html/index.html,readonly \
--mount type=bind,src=$HOME/aurora-libros/web/nginx.conf,dst=/etc/nginx/conf.d/default.conf,readonly \
--stop-signal SIGQUIT \
nginx:alpine
curl -s http://localhost:8080/api/libros | jq -r '.libros | length'Bind mounts de solo lectura para dos ficheros que viven en tu repositorio de Git. Edita index.html en tu editor, recarga el navegador, y el cambio está ahí sin reconstruir ninguna imagen: ese es el flujo de desarrollo que se convierte en el protagonista de la lección 04-07.
- Copias de seguridad y restauración
Un volumen que no se copia es un volumen que se perderá. Hay dos estrategias y sirven para cosas distintas.
Copia a nivel de ficheros con un contenedor auxiliar
El patrón universal: un contenedor efímero que monta el volumen y un directorio del host, y ejecuta tar.
mkdir -p ~/aurora-libros/copias
# 1) Parar la base de datos para que la copia sea COHERENTE
docker stop --time 30 aurora-db
# 2) Empaquetar el volumen
docker run --rm \
--mount type=volume,src=aurora-datos,dst=/datos,readonly \
--mount type=bind,src=$HOME/aurora-libros/copias,dst=/copia \
alpine:3.20 \
tar czf /copia/aurora-datos-2026-08-04.tar.gz -C /datos .
# 3) Volver a arrancar
docker start aurora-db
ls -lh ~/aurora-libros/copias/Qué hace cada parte:
| Fragmento | Motivo |
|---|---|
docker stop antes de copiar |
PostgreSQL mantiene datos en búfer. Copiar en caliente puede dar una copia corrupta |
--rm |
El contenedor auxiliar se autodestruye |
readonly en el volumen de origen |
El proceso de copia no puede estropear el original |
-C /datos . |
Empaqueta el contenido, no el directorio, para que la restauración sea directa |
alpine:3.20 |
8 MB con tar dentro; no hace falta nada más |
La restauración es la operación inversa:
docker stop aurora-db
docker run --rm \
--mount type=volume,src=aurora-datos,dst=/datos \
--mount type=bind,src=$HOME/aurora-libros/copias,dst=/copia,readonly \
alpine:3.20 \
sh -c 'rm -rf /datos/* /datos/..?* && tar xzf /copia/aurora-datos-2026-08-04.tar.gz -C /datos'
docker start aurora-db
sleep 6
docker exec aurora-db psql -U aurora -d aurora_libros -t -c "SELECT COUNT(*) FROM libros;"El ..?* del rm es para incluir los ficheros ocultos; sin él quedarían restos de la instalación anterior mezclados con la copia.
Copia lógica con pg_dump
Para una base de datos, casi siempre es mejor una copia lógica: un fichero SQL con el esquema y los datos.
docker exec aurora-db pg_dump -U aurora -d aurora_libros --clean --if-exists \
> ~/aurora-libros/copias/aurora_libros-2026-08-04.sql
ls -lh ~/aurora-libros/copias/*.sql
head -20 ~/aurora-libros/copias/aurora_libros-2026-08-04.sql | tail -4-rw-r--r-- 1 junior junior 4.2K Aug 4 22:45 aurora_libros-2026-08-04.sql
DROP TABLE IF EXISTS public.libros;
CREATE TABLE public.libros (
id integer NOT NULL,Y la restauración, con -i para que psql lea de la entrada estándar:
docker exec -i aurora-db psql -U aurora -d aurora_libros \
< ~/aurora-libros/copias/aurora_libros-2026-08-04.sql
docker exec aurora-db psql -U aurora -d aurora_libros -t -c "SELECT COUNT(*) FROM libros;"Comparación de las dos estrategias:
Copia con tar del volumen |
Copia lógica con pg_dump |
|
|---|---|---|
| Qué copia | Todos los bytes del directorio de datos | Sentencias SQL que reconstruyen los datos |
| Requiere parar el servicio | Sí, para que sea coherente | No, pg_dump es transaccional |
| Tamaño | Grande (8,4 MB aquí) | Pequeño (4,2 kB) |
| Portable entre versiones de PostgreSQL | No: el formato de datos es específico de la versión mayor | Sí |
| Legible e inspeccionable | No | Sí, es texto |
| Sirve para cualquier volumen | Sí, es genérico | No, solo bases de datos |
| Cuándo usarla | Volúmenes de aplicaciones sin herramienta propia | Bases de datos, siempre que sea posible |
La regla: usa la herramienta nativa del servicio si la tiene (pg_dump, mysqldump, redis-cli BGSAVE), y el truco del contenedor con tar para todo lo demás.
- Buenas prácticas
| Práctica | Motivo |
|---|---|
| Un volumen con nombre por cada estado que importe | aurora-datos, aurora-subidas, aurora-copias. Nunca anónimos para datos reales |
| Nombres descriptivos con prefijo de proyecto | aurora-datos dice qué es; data no dice nada en una máquina con veinte volúmenes |
| Etiqueta los volúmenes | --label proyecto=aurora-libros permite prune selectivo |
| Bind mounts solo para configuración y desarrollo | Dependen de rutas del host: no son portables |
Todo lo que el contenedor no deba escribir, en :ro |
Configuración, scripts, certificados, contenido estático |
Prefiere --mount en scripts |
Falla ruidosamente si el origen no existe, en vez de crear un directorio vacío |
Nunca escribas dentro de /var/lib/docker/volumes |
Es territorio del demonio, y en Docker Desktop ni siquiera está en tu máquina |
Copia de seguridad antes de cualquier prune |
docker volume prune no tiene deshacer |
| Prueba la restauración, no solo la copia | Una copia que nunca se ha restaurado no es una copia, es una esperanza |
Estado actual del almacenamiento de Aurora Libros:
docker volume ls --format "table {{.Name}}\t{{.Driver}}"
docker system df --format "table {{.Type}}\t{{.TotalCount}}\t{{.Size}}\t{{.Reclaimable}}"NAME DRIVER
aurora-datos local
TYPE TOTAL SIZE RECLAIMABLE
Images 11 1.021GB 612.4MB (59%)
Containers 4 3.87MB 0B (0%)
Local Volumes 1 48.2MB 0B (0%)
Build Cache 52 1.847GB 1.847GB (100%)Un solo volumen, con nombre, y 0 B recuperables: ni un huérfano. Ese es el objetivo.
Lo que esta lección no cubre y dónde se ve: los storage drivers del demonio (overlay2 y compañía), los drivers de volumen para NFS, SMB o almacenamiento en la nube, y las opciones avanzadas de rendimiento se estudian en la lección 05-02. Cómo se declaran los volúmenes en un fichero compose.yaml, en la lección 04-02.
Errores Comunes y Consejos
- Confiar en la capa de escritura para datos que importan. Muere con el contenedor, y recrear un contenedor es una operación rutinaria.
- Usar volúmenes anónimos sin saberlo. Toda imagen con
VOLUMEen su Dockerfile crea uno. Si no le pones nombre, tus datos dependen de un hash que nadie recuerda. - Ejecutar
docker volume prunesin mirar. Borra datos reales sin confirmación por segunda vez y sin papelera. Lista antes condocker volume ls -f dangling=true. - Escribir a mano en
/var/lib/docker/volumes/.... Permisos incorrectos, corrupción, y en Docker Desktop ni siquiera existe esa ruta. - Rutas relativas en un bind mount. Usa rutas absolutas o
$(pwd); con-v, una ruta mal escrita se convierte en un directorio vacío creado como root. - Montar un directorio vacío sobre uno que tenía contenido. El montaje oculta lo de la imagen: Nginx devolverá 403 y PostgreSQL se reinicializará.
- Esperar que
init.sqlse ejecute en cada arranque. Solo corre cuando el directorio de datos está vacío. Si lo cambias, borra el volumen. - Copiar un volumen de base de datos en caliente con
tar. Puede salir corrupto. Para el contenedor, o usapg_dump. - Consejo: escribe el
docker runde tus servicios con datos en un fichero de notas. Es largo y hay que reproducirlo exactamente para no perder el volumen... hasta el módulo 4, donde eso se resuelve del todo. - Consejo: nombra las copias con la fecha y automatiza su rotación. Y restaura al menos una vez, para saber que funciona.
Ejercicios
Ejercicio 1: comprueba las tres persistencias
Crea un contenedor de alpine:3.20 llamado tres-montajes con sleep 600 y tres montajes a la vez: un volumen con nombre prueba-vol en /vol, un bind mount de ~/aurora-libros/pruebas en /bind, y un tmpfs de 8 MB en /mem. Escribe un fichero distinto en cada ruta. Después:
- Reinicia el contenedor con
docker restarty comprueba cuáles de los tres ficheros siguen. - Bórralo con
docker rm -f(sin-v), crea uno nuevo con los mismos montajes y comprueba de nuevo los tres. - Explica los resultados y di qué montaje usarías para: el catálogo de libros, el
nginx.conf, y un token de sesión que no debe tocar el disco.
Ejercicio 2: migra aurora-cache a un volumen con nombre
Redis guarda su instantánea en /data, que la imagen oficial declara como VOLUME y por tanto hoy es un volumen anónimo. Arregla eso:
- Comprueba con
docker inspectque elaurora-cacheactual usa un volumen anónimo. - Recréalo con un volumen con nombre
aurora-cache-datosy activando la persistencia con el comandoredis-server --appendonly yes. - Escribe una clave, fuerza un guardado, destruye el contenedor y recréalo. Comprueba si la clave sobrevive.
- Responde: ¿tiene sentido persistir una caché? Argumenta a favor y en contra.
Ejercicio 3: simula un desastre y recupérate
Con la plataforma funcionando y los nueve libros en su sitio:
- Haz una copia de seguridad lógica con
pg_dumpy otra física contar, anotando el tamaño de cada una. - Provoca el desastre: borra el contenedor
aurora-dby el volumenaurora-datos. - Comprueba qué responde la API en ese momento.
- Recupérate por el camino más rápido y explica cuál elegiste y por qué. Verifica que los nueve libros —incluido El Aleph, que no está en
init.sql— vuelven a estar disponibles a través decurl http://localhost:8080/api/libros.
Soluciones
Solución al ejercicio 1
docker volume create prueba-vol
docker run -d --name tres-montajes \
--mount type=volume,src=prueba-vol,dst=/vol \
--mount type=bind,src=$HOME/aurora-libros/pruebas,dst=/bind \
--mount type=tmpfs,dst=/mem,tmpfs-size=8m \
alpine:3.20 sleep 600
docker exec tres-montajes sh -c 'echo volumen > /vol/f.txt; echo bind > /bind/f.txt; echo memoria > /mem/f.txt'
docker exec tres-montajes sh -c 'cat /vol/f.txt /bind/f.txt /mem/f.txt'1. Tras docker restart:
docker restart tres-montajes && sleep 1
docker exec tres-montajes sh -c 'cat /vol/f.txt 2>&1; cat /bind/f.txt 2>&1; cat /mem/f.txt 2>&1'El tmpfs ya se ha perdido con el simple reinicio: vivía en RAM y el montaje se recrea vacío en cada arranque.
2. Tras borrar y recrear:
docker rm -f tres-montajes
docker run -d --name tres-montajes \
--mount type=volume,src=prueba-vol,dst=/vol \
--mount type=bind,src=$HOME/aurora-libros/pruebas,dst=/bind \
--mount type=tmpfs,dst=/mem,tmpfs-size=8m \
alpine:3.20 sleep 600
docker exec tres-montajes sh -c 'cat /vol/f.txt 2>&1; cat /bind/f.txt 2>&1; cat /mem/f.txt 2>&1'
docker rm -f tres-montajes && docker volume rm prueba-vol3. Resultados y decisiones:
| Montaje | ¿Sobrevive a restart? |
¿Sobrevive a rm + recrear? |
Por qué |
|---|---|---|---|
Volumen prueba-vol |
Sí | Sí | Es un objeto independiente del contenedor, gestionado por Docker |
Bind ~/aurora-libros/pruebas |
Sí | Sí | Es un directorio de tu máquina; Docker solo lo enseña |
tmpfs /mem |
No | No | Vive en RAM y se descarta con el proceso |
Y las tres decisiones: el catálogo de libros → volumen con nombre (dato de producción que debe sobrevivir y del que Docker gestiona los permisos); el nginx.conf → bind mount de solo lectura (fichero versionado en Git que quieres editar en tu IDE y que el contenedor solo lee); el token de sesión → tmpfs (no debe quedar escrito en disco ni siquiera un instante, y debe evaporarse al parar el contenedor).
Solución al ejercicio 2
docker inspect -f '{{range .Mounts}}{{.Type}} | nombre={{.Name}} | {{.Destination}}{{end}}' aurora-cacheVolumen anónimo confirmado: nombre de 64 caracteres, creado por el VOLUME /data del Dockerfile oficial de Redis.
docker volume create aurora-cache-datos
docker rm -f aurora-cache
docker run -d --name aurora-cache --network aurora-net \
--label proyecto=aurora-libros --label componente=cache \
--mount type=volume,src=aurora-cache-datos,dst=/data \
redis:7-alpine redis-server --appendonly yesdocker exec aurora-cache redis-cli SET libro:destacado "Rayuela"
docker exec aurora-cache redis-cli BGREWRITEAOF
sleep 2
docker exec aurora-cache ls /data
docker rm -f aurora-cache
docker run -d --name aurora-cache --network aurora-net \
--label proyecto=aurora-libros --label componente=cache \
--mount type=volume,src=aurora-cache-datos,dst=/data \
redis:7-alpine redis-server --appendonly yes
sleep 2
docker exec aurora-cache redis-cli GET libro:destacadoLa clave sobrevive. Nota que hicieron falta dos cosas: el volumen con nombre (para que los ficheros persistan) y --appendonly yes (para que Redis escriba en ellos). Un volumen sin persistencia activada guardaría un fichero que nunca se actualiza.
4. ¿Tiene sentido persistir una caché?
A favor:
- Arranque en caliente. Tras un reinicio, la caché ya tiene datos y no hay una avalancha de peticiones contra PostgreSQL (el fenómeno conocido como thundering herd).
- Si Redis se usa también para sesiones de usuario o colas de trabajo, ahí ya no es una caché: son datos que perder tiene consecuencias visibles.
En contra:
- Una caché, por definición, debe poder perderse sin consecuencias. Si tu sistema no funciona sin ella, no es una caché: es una base de datos disfrazada, y debería tratarse como tal.
- La persistencia cuesta: E/S de disco en cada escritura y un arranque más lento.
- Los datos persistidos pueden quedar obsoletos: al recuperarse, Redis sirve un catálogo antiguo hasta que expire el TTL.
Para Aurora Libros, con un TTL de 60 segundos y un catálogo pequeño, no compensa: es más limpio dejar aurora-cache sin persistencia y que se rellene sola en la primera petición. Ese es el criterio que se aplicará en el compose.yaml del módulo 4.
docker rm -f aurora-cache && docker volume rm aurora-cache-datos
docker run -d --name aurora-cache --network aurora-net \
--label proyecto=aurora-libros --label componente=cache redis:7-alpineSolución al ejercicio 3
1. Las dos copias:
mkdir -p ~/aurora-libros/copias
docker exec aurora-db pg_dump -U aurora -d aurora_libros --clean --if-exists \
> ~/aurora-libros/copias/desastre.sql
docker stop --time 30 aurora-db
docker run --rm \
--mount type=volume,src=aurora-datos,dst=/datos,readonly \
--mount type=bind,src=$HOME/aurora-libros/copias,dst=/copia \
alpine:3.20 tar czf /copia/desastre.tar.gz -C /datos .
docker start aurora-db
ls -lh ~/aurora-libros/copias/desastre.*-rw-r--r-- 1 junior junior 4.3K Aug 4 23:02 desastre.sql
-rw-r--r-- 1 junior junior 8.4M Aug 4 23:03 desastre.tar.gzDos mil veces más pequeña la lógica que la física, para exactamente los mismos nueve libros.
2 y 3. El desastre:
docker rm -f aurora-db
docker volume rm aurora-datos
curl -s http://localhost:8080/api/libros | jq -cInteresante: el error no es de base de datos, sino de DNS. Al no existir el contenedor, el nombre aurora-db no se resuelve en aurora-net. Es el mismo ENOTFOUND de la lección 03-04, ahora con una causa distinta: allí faltaba la red, aquí falta el contenedor.
4. La recuperación:
# Recrear el contenedor: el volumen se crea vacío y init.sql se ejecuta solo
docker run -d --name aurora-db --network aurora-net \
--label proyecto=aurora-libros --label componente=base-de-datos \
-e POSTGRES_USER=aurora -e POSTGRES_PASSWORD=aurora_secreta -e POSTGRES_DB=aurora_libros \
-p 127.0.0.1:5432:5432 \
--mount type=volume,src=aurora-datos,dst=/var/lib/postgresql/data \
--mount type=bind,src=$HOME/aurora-libros/db/init.sql,dst=/docker-entrypoint-initdb.d/init.sql,readonly \
postgres:16-alpine
sleep 8
docker exec aurora-db psql -U aurora -d aurora_libros -t -c "SELECT COUNT(*) FROM libros;"Ocho, no nueve. init.sql restauró el catálogo original, pero El Aleph no está ahí: se insertó después. Aquí es donde la copia de seguridad deja de ser un trámite y pasa a ser lo único que te salva:
docker exec -i aurora-db psql -U aurora -d aurora_libros < ~/aurora-libros/copias/desastre.sql
docker exec aurora-db psql -U aurora -d aurora_libros -t -c "SELECT COUNT(*) FROM libros;"
curl -s http://localhost:8080/api/libros | jq -r '.libros[] | select(.titulo=="El Aleph") | .titulo'Elegí la copia lógica, y por tres motivos:
- Es más rápida de aplicar: un
psqlde 4 kB frente a parar el servicio, vaciar el volumen y desempaquetar 8,4 MB. - No exige parar nada: se restaura sobre la base de datos en marcha, mientras la copia física obliga a detener el contenedor.
- Es portable: si al recuperarme hubiera aprovechado para subir a PostgreSQL 17, el
tardel volumen no habría servido —el formato del directorio de datos es específico de cada versión mayor— y el.sqlsí.
La copia física sigue teniendo su papel: es la única opción para volúmenes de aplicaciones que no tienen una herramienta de volcado propia, y es una copia bit a bit que incluye configuración y estado que pg_dump no recoge.
Conclusión
El segundo gran problema está resuelto. Has visto con tus propios ojos que un contenedor destruido se lleva sus datos —la primera vez ni siquiera quedaba la tabla libros— y sabes por qué: la capa de escritura nace y muere con el contenedor, y los volúmenes anónimos que crean por su cuenta imágenes como postgres o redis no se reutilizan, sino que quedan huérfanos con sus datos dentro, inalcanzables en la práctica.
Conoces los tres tipos de montaje y cuándo usar cada uno: volúmenes gestionados por Docker para los datos que importan, bind mounts para configuración y desarrollo, y tmpfs para lo que no debe tocar el disco. Sabes dónde viven realmente los volúmenes —/var/lib/docker/volumes/…, territorio del demonio, invisible en Docker Desktop— y que la forma correcta de mirar dentro es un contenedor efímero de Alpine, nunca tu editor. Entiendes el choque de UID de los bind mounts, porque el kernel compara números y no nombres, y tienes las tres formas de resolverlo. Y prefieres --mount a -v en scripts por una razón muy concreta: falla ruidosamente en vez de crear un directorio vacío que arruina el arranque en silencio.
Aurora Libros ha cambiado de categoría. aurora-db guarda su estado en el volumen con nombre aurora-datos y recibe init.sql como bind mount de solo lectura en /docker-entrypoint-initdb.d/, así que el catálogo se carga solo la primera vez y nunca más se pisa. La prueba definitiva salió como debía: insertaste El Aleph, destruiste el contenedor entero, lo recreaste con el mismo comando y los nueve libros seguían ahí. Y aurora-web sirve index.html y nginx.conf desde tu repositorio en :ro, editables desde tu IDE sin reconstruir nada. Además sabes copiar y restaurar: con un contenedor auxiliar y tar para cualquier volumen, y con pg_dump —más pequeña, en caliente y portable entre versiones— para la base de datos.
Quedan dos riesgos en pie, y son los últimos de este módulo. El primero lo viste en docker stats en la lección 03-04: ninguno de tus contenedores tiene límite de memoria, así que una consulta desbocada en PostgreSQL o una fuga en Node pueden dejar sin RAM a toda la máquina. El segundo es que si un contenedor muere de madrugada, nadie lo levanta. En la siguiente lección, Límites de Recursos y Políticas de Reinicio, pondrás cerco a ambos: límites de memoria y CPU con sus tablas de combinaciones, el OOM killer provocado a propósito para verlo con OOMKilled: true y su código 137, --pids-limit como defensa ante una fork bomb, y las cuatro políticas de reinicio con la diferencia sutil entre always y unless-stopped. Y al terminar, tendrás delante la lista completa de comandos que hace falta para levantar Aurora Libros desde cero... y entenderás por qué existe Docker Compose.
Docker: De Principiante a Avanzado
Módulo 1: Introducción a Docker
- ¿Qué es Docker?
- Instalando Docker
- Arquitectura de Docker
- Comandos Básicos de Docker
- Entendiendo las Imágenes de Docker
- Creando tu Primer Contenedor Docker
- El Proyecto del Curso: la Plataforma Aurora Libros
Módulo 2: Trabajando con Imágenes Docker
- Docker Hub y Repositorios
- Construyendo Imágenes Docker
- Conceptos Básicos de Dockerfile
- Instrucciones Avanzadas del Dockerfile
- Gestionando Imágenes Docker
- Etiquetado y Publicación de Imágenes
Módulo 3: Contenedores Docker
- Ejecutando Contenedores
- Ciclo de Vida del Contenedor
- Gestionando Contenedores
- Inspección y Depuración de Contenedores
- Redes en Docker
- Persistencia de Datos con Volúmenes
- Límites de Recursos y Políticas de Reinicio
Módulo 4: Docker Compose
- Introducción a Docker Compose
- Definiendo Servicios en Docker Compose
- Comandos de Docker Compose
- Aplicaciones Multi-Contenedor
- Variables de Entorno en Docker Compose
- Perfiles, Overrides y Múltiples Entornos
- Desarrollo Local con Docker Compose
Módulo 5: Conceptos Avanzados de Docker
- Profundización en Redes Docker
- Opciones de Almacenamiento Docker
- Mejores Prácticas de Seguridad en Docker
- Optimizando Imágenes Docker
- Builds Avanzadas con BuildKit y Buildx
- Registro y Monitoreo en Docker
- El Runtime por Dentro: Namespaces, Cgroups y Capas
Módulo 6: Docker en Producción
- Preparar una Imagen para Producción
- CI/CD con Docker
- Orquestando Contenedores con Docker Swarm
- Introducción a Kubernetes
- Desplegando Contenedores Docker en Kubernetes
- Escalado y Balanceo de Carga
- Estrategias de Despliegue y Rollback
