Redes y volúmenes
Cómo se comunican los contenedores y qué sobrevive a su eliminación.
Redes y volúmenes en Docker
Dos preguntas aparecen invariablemente en cuanto se sale del contenedor aislado, y por sí solas producen la mayoría de los mensajes de error incomprensibles de los primeros meses.
La primera: «¿por qué mi aplicación no consigue conectarse a su base de datos?». Se han lanzado los dos contenedores, cada uno funciona, docker ps aparece en verde, y la API responde obstinadamente connection refused en localhost:5432. El reflejo es buscar una dirección IP, escribirla a mano en el código, comprobar que funciona… hasta el siguiente reinicio, cuando la IP ha cambiado. El problema no era la dirección: es que localhost, dentro de un contenedor, no designa la misma máquina que en el host.
La segunda: «¿por qué han desaparecido mis datos?». Una base Postgres llena tras dos días de pruebas, un docker compose down -v escrito sin pensar, y todo se ha esfumado. No es un accidente: es el comportamiento documentado. Un contenedor es efímero por diseño, y su capa de escritura muere con él. La persistencia nunca es implícita, se declara.
Estas dos preguntas tienen la misma raíz: un contenedor está aislado por defecto, tanto en la red como en el almacenamiento. Ese aislamiento es lo que hace que Docker sea reproducible; las redes y los volúmenes son los dos mecanismos explícitos que lo perforan, exactamente donde usted decide.
Los controladores de red y cuál elegir
| Controlador | Uso | Observación |
|---|---|---|
bridge | El predeterminado. Red privada virtual en el host. | El único necesario en el 95 % de los casos. |
host | El contenedor comparte la pila de red del host. | Sin aislamiento ni mapeo. Solo Linux. |
none | Sin red, salvo la interfaz de loopback. | Procesamiento puramente local. |
macvlan | Dirección MAC propia en la red física. | Integración de red particular. |
overlay | Red distribuida entre varios hosts. | Reservado a clústeres Swarm. |
# Los reseaux existants. bridge, host et none sont crees a l'installation.
docker network ls
# Le detail : plage d'adresses, passerelle, conteneurs connectes
docker network inspect bridge
El controlador host ahorra unos microsegundos de latencia al eliminar la traducción de direcciones, a costa de todo el aislamiento: el contenedor ocupa directamente los puertos del host, con las colisiones que eso implica. Es un recurso, no una optimización por defecto. Y solo tiene sentido en Linux: en macOS y Windows, la pila «host» sería la de la máquina virtual intermedia.
El punto que lo desbloquea todo: bridge predeterminado frente a bridge personalizado
Esta es la diferencia más útil de esta lección, y casi siempre se ignora.
Todo contenedor lanzado sin más indicaciones se une a la red bridge predeterminada. En esa red, la resolución DNS por nombre de contenedor no funciona.
docker run -d --name serveur nginx:1.27-alpine
docker run --rm alpine:3.20 ping -c 2 serveur
# ping: bad address 'serveur' -> echec attendu
Cree usted mismo una red y el comportamiento cambia por completo: Docker le asocia un servidor DNS interno que resuelve los nombres de los contenedores.
docker network create app-net
docker run -d --name serveur --network app-net nginx:1.27-alpine
docker run --rm --network app-net alpine:3.20 ping -c 2 serveur
# 2 packets transmitted, 2 packets received -> succes
# La requete HTTP passe sur le port INTERNE (80), sans qu'aucun
# port n'ait ete publie sur l'hote.
docker run --rm --network app-net alpine:3.20 \
sh -c "apk add --no-cache curl >/dev/null && curl -s -o /dev/null -w '%{http_code}\n' http://serveur"
La regla que hay que recordar: cree siempre una red con nombre para sus aplicaciones. Es lo que hace Docker Compose automáticamente —la red <proyecto>_default**— y por eso, en la lección anterior, la API alcanzaba su base de datos en la dirección db:5432` sin configuración. Eso resuelve de paso el problema de las direcciones IP: el DNS interno sigue a los contenedores, y un servicio recreado con una nueva IP sigue siendo accesible con el mismo nombre, de inmediato.
Un contenedor puede conectarse a varias redes, en caliente:
docker network create back-net
docker network connect back-net serveur
docker network disconnect back-net serveur
docker inspect serveur --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}'
Publicar un puerto: qué hace exactamente -p
-p 8080:80 no abre el puerto 80 del contenedor: ya estaba abierto dentro de su red. Lo que hace -p es instalar una regla de traducción de direcciones en el host. El orden host:contenedor se recuerda por el sentido de la circulación: se viene del host y se va hacia el contenedor.
# Port 8080 de l'hote -> port 80 du conteneur
docker run -d -p 8080:80 nginx:1.27-alpine
# Se lier uniquement a l'interface de bouclage : accessible depuis
# la machine, INVISIBLE depuis le reseau local.
docker run -d -p 127.0.0.1:8080:80 nginx:1.27-alpine
# Laisser Docker choisir un port libre sur l'hote
docker run -d -p 80 nginx:1.27-alpine
# Publier tous les ports declares par EXPOSE
docker run -d -P nginx:1.27-alpine
# Voir les correspondances effectives
docker port <conteneur>
La segunda forma merece una insistencia particular, porque corrige un problema de seguridad que muchos descubren demasiado tarde. -p 8080:80 se enlaza por defecto a 0.0.0.0, es decir, a todas las interfaces, incluida la dirección pública. Y en Linux, Docker escribe sus reglas de traducción en una cadena de iptables que se evalúa antes que las de un cortafuegos de aplicación como ufw: un puerto publicado por Docker puede seguir siendo accesible desde Internet aunque ufw status afirme que lo bloquea. No es un bug, es el orden de las cadenas de filtrado, pero la sorpresa es desagradable cuando la base de datos afectada no tenía contraseña.
La buena práctica se resume en una frase: publique solo lo que deba ser accesible desde el exterior, y prefije con 127.0.0.1 todo lo que solo necesite ser accesible localmente.
services:
web:
ports:
- "80:8080" # public : c'est voulu
db:
ports:
- "127.0.0.1:5432:5432" # local uniquement, pour un client graphique
cache:
expose:
- "6379" # documentation seulement, rien de publie
Una observación sobre localhost, origen del primer problema planteado en la introducción. Dentro de un contenedor, localhost designa al propio contenedor. Una aplicación que busca su base de datos en localhost:5432 está buscando, por tanto, un Postgres dentro de sí misma; la dirección correcta es el nombre del servicio, db:5432. Y si un contenedor necesita realmente alcanzar un servicio que se ejecuta en el host, existe un nombre dedicado:
# Sur Docker Desktop (macOS, Windows), ce nom est fourni d'office
docker run --rm alpine:3.20 ping -c 1 host.docker.internal
# Sur Linux, il faut le declarer explicitement
docker run --rm --add-host=host.docker.internal:host-gateway \
alpine:3.20 ping -c 1 host.docker.internal
Segmentar la pila mediante la red
Dado que un contenedor solo alcanza las redes a las que pertenece, las redes son una verdadera herramienta de compartimentación. El patrón clásico separa una capa expuesta de una capa interna.
services:
proxy:
image: nginx:1.27-alpine
ports:
- "80:80"
networks:
- frontend # seul service joignable de l'exterieur
api:
build: .
networks:
- frontend # joignable par le proxy
- backend # peut joindre la base
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- db-data:/var/lib/postgresql/data
networks:
- backend # UNIQUEMENT le reseau interne
networks:
frontend:
backend:
internal: true # aucune route vers l'exterieur
volumes:
db-data:
La ventaja no es teórica. El proxy no pertenece a backend: incluso comprometido, no puede abrir una conexión TCP hacia db, porque no existe ninguna ruta. La opción internal: true va más allá al suprimir la ruta por defecto hacia el exterior, lo que limita considerablemente la exfiltración de datos desde esa red.
docker compose exec proxy sh -c "nc -zv db 5432" # doit echouer
docker compose exec api sh -c "nc -zv db 5432" # doit reussir
Tres formas de persistir y cuándo usar cada una
| Tipo | Gestionado por | Ubicación | Uso principal |
|---|---|---|---|
| Volumen con nombre | Docker | /var/lib/docker/volumes/... | Datos de producción: bases de datos, archivos subidos |
| Bind mount | Usted | Cualquier lugar del host | Desarrollo: código fuente, configuración |
| tmpfs | Docker | Solo en memoria RAM | Datos sensibles o temporales |
El volumen con nombre es un espacio que Docker crea y gestiona. Usted no conoce su ruta real, y ese es precisamente el interés: es portable, independiente del árbol de directorios del host, se puede respaldar con herramientas de Docker y es idéntico en los tres sistemas.
El bind mount monta una carpeta existente del host en el contenedor. Perfecto para el desarrollo —modifica un archivo y el contenedor lo ve al instante— e inadecuado para producción, ya que acopla la aplicación al árbol de directorios concreto de una máquina determinada.
# Volume nomme : Docker gere l'emplacement
docker run -d --name pg1 \
-e POSTGRES_PASSWORD=secret \
-v pg-data:/var/lib/postgresql/data \
postgres:16-alpine
# Bind mount : chemin ABSOLU de l'hote, obligatoirement
docker run -d --name web1 \
-v "$(pwd)/site:/usr/share/nginx/html:ro" \
-p 8080:80 nginx:1.27-alpine
# tmpfs : en memoire, disparait a l'arret
docker run -d --name calcul --tmpfs /tmp:rw,size=64m alpine:3.20 sleep 3600
La distinción se juega en un carácter: -v pg-data:/ruta sin barra inicial designa un volumen con nombre, y -v /ruta/host:/ruta un bind mount. Un error tipográfico hace, por tanto, pasar silenciosamente de un mecanismo a otro. La sintaxis --mount, más verbosa, elimina la ambigüedad y falla limpiamente si el origen no existe:
docker run -d \
--mount type=volume,source=pg-data,target=/var/lib/postgresql/data \
--mount type=bind,source="$(pwd)/conf",target=/etc/app,readonly \
--mount type=tmpfs,target=/tmp,tmpfs-size=64m \
-e POSTGRES_PASSWORD=secret \
postgres:16-alpine
Hay un comportamiento diferencial que conviene conocer, porque explica situaciones desconcertantes. Cuando un volumen con nombre vacío se monta sobre una carpeta no vacía de la imagen, Docker copia el contenido de la imagen en el volumen la primera vez que se usa. Un bind mount, en cambio, no copia nada: oculta pura y simplemente el contenido de la imagen.
# Le volume est pre-rempli avec la page par defaut de nginx
docker run -d --name v1 -v html-vol:/usr/share/nginx/html nginx:1.27-alpine
docker run --rm -v html-vol:/data alpine:3.20 ls /data # index.html present
# Le bind mount d'un dossier vide masque tout : nginx renvoie 403
mkdir -p ./vide
docker run -d --name v2 -p 8081:80 \
-v "$(pwd)/vide:/usr/share/nginx/html" nginx:1.27-alpine
Es exactamente por esta razón por lo que el archivo de sobrescritura de la lección anterior añadía un volumen anónimo - /app/node_modules: el bind mount de ./src no debía ocultar las dependencias instaladas en la imagen.
Gestionar los volúmenes en el día a día
# Creer explicitement (facultatif : un volume inconnu est cree a la volee)
docker volume create pg-data
# Lister. Les noms en hexadecimal sont des volumes ANONYMES.
docker volume ls
# Ou vivent reellement les donnees, et qui les utilise
docker volume inspect pg-data
# Supprimer. Refuse si un conteneur l'utilise.
docker volume rm pg-data
# Supprimer tous les volumes inutilises. COMMANDE DESTRUCTRICE :
# listez d'abord, decidez ensuite.
docker volume ls -f dangling=true
docker volume prune
Dos puntos de vigilancia. Los volúmenes sobreviven a docker rm: eliminar un contenedor no elimina sus datos, lo cual es intencionado, pero explica la acumulación silenciosa de volúmenes huérfanos. Y los volúmenes anónimos se amontonan con identificadores ilegibles: ponga nombre sistemáticamente a los suyos.
Para inspeccionar un volumen sin arrancar el servicio que lo utiliza, el contenedor desechable es la herramienta ideal:
docker run --rm -v pg-data:/data alpine:3.20 ls -la /data
docker run --rm -v pg-data:/data alpine:3.20 du -sh /data
El problema de los permisos en los bind mounts
En Linux, hay una trampa que acecha a todo el mundo una vez. Los permisos se basan en identificadores numéricos (UID, GID), y esos números no se traducen entre el host y el contenedor. Si el contenedor escribe como usuario 1001 en una carpeta montada desde el host, los archivos creados pertenecen al UID 1001 del host, una cuenta que quizá no exista. El síntoma habitual: aparecen en su carpeta de trabajo archivos que pertenecen a root, o la aplicación se queja de permission denied en una carpeta que usted ve perfectamente accesible.
# Reproduction : le conteneur ecrit en root
docker run --rm -v "$(pwd):/work" -w /work alpine:3.20 touch fichier-root
ls -l fichier-root # appartient a root
# Solution : imposer l'UID et le GID de l'utilisateur courant
docker run --rm --user "$(id -u):$(id -g)" \
-v "$(pwd):/work" -w /work alpine:3.20 touch fichier-moi
ls -l fichier-moi # vous appartient
# Dans Compose, avec une variable pour rester portable
services:
api:
build: .
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./src:/app/src
Este problema no existe en macOS ni en Windows, donde la capa de uso compartido de archivos de Docker Desktop reescribe los propietarios. A cambio, estas plataformas sufren un coste de rendimiento en los archivos compartidos: una carpeta node_modules o .git en bind mount puede ralentizar un proyecto en un orden de magnitud. La solución es mantener esas carpetas en un volumen de Docker y montar únicamente el código fuente.
Un último reflejo útil: monte en solo lectura todo lo que el contenedor no tenga que modificar —configuración, conjuntos de datos, certificados—. Es gratis y suprime toda una clase de accidentes.
docker run -d -v "$(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro" nginx:1.27-alpine
Respaldar y restaurar un volumen
Un volumen con nombre no se copia con cp: su ubicación pertenece a Docker, y en macOS o Windows se encuentra dentro de la máquina virtual. El método portable consiste en montarlo en un contenedor desechable que escribe un archivo comprimido en una carpeta del host.
# --- Sauvegarde : le volume en lecture, un dossier de l'hote en ecriture
docker run --rm \
-v pg-data:/data:ro \
-v "$(pwd):/sauvegarde" \
alpine:3.20 \
tar czf /sauvegarde/pg-data-$(date +%F).tar.gz -C /data .
# --- Restauration
docker volume create pg-data-restaure
docker run --rm \
-v pg-data-restaure:/data \
-v "$(pwd):/sauvegarde" \
alpine:3.20 \
tar xzf /sauvegarde/pg-data-2026-01-15.tar.gz -C /data
Una salvedad importante para las bases de datos: copiar los archivos de un motor mientras está escribiendo puede producir un archivo incoherente. Detenga el contenedor antes de la copia o, mucho mejor, utilice la herramienta de copia de seguridad lógica del motor, que garantiza un estado coherente:
# Copia lógica de Postgres, con el motor en funcionamiento
docker compose exec -T db pg_dump -U app -d appdb | gzip > appdb-$(date +%F).sql.gz
# Restauración
gunzip -c appdb-2026-01-15.sql.gz | docker compose exec -T db psql -U app -d appdb
La opción -T desactiva la asignación de un pseudoterminal, imprescindible en cuanto se redirige un flujo: sin ella, el archivo generado se corrompe por los caracteres de control del terminal.
Redes y volúmenes en Compose
Compose declara ambos en el nivel superior del archivo, lo que ofrece una visión completa de la topología en un único lugar. La forma larga es más verbosa, pero mucho más legible en las revisiones.
services:
api:
build: .
networks:
- backend
volumes:
- uploads:/app/uploads # forma corta : volume nomme
- type: bind
source: ./config
target: /app/config
read_only: true
- type: tmpfs
target: /tmp
tmpfs:
size: 67108864 # 64 MB, en bytes
networks:
backend:
driver: bridge
volumes:
uploads:
Recordatorio de la trampa de nomenclatura vista en la lección anterior: Compose antepone el nombre del proyecto a los volúmenes, por lo que uploads se convierte en monprojet_uploads. Renombrar la carpeta del proyecto crea un volumen nuevo y vacío, y da la impresión de que los datos han desaparecido, cuando en realidad siguen ahí, bajo el prefijo antiguo.
docker volume ls | grep uploads # retrouver un volume derriere son prefixe
docker compose -p monprojet up -d # fixer le prefixe pour ne plus en dependre
Conclusiones
- En la red
bridgepredeterminada, la resolución por nombre no funciona. Cree siempre una red con nombre —lo que Compose hace por usted— y conecte sus servicios por su nombre, nunca por una dirección IP que cambiará. - Dentro de un contenedor,
localhostdesigna al propio contenedor. Para llegar a otro servicio, utilice su nombre; para llegar al host,host.docker.internal(con--add-host=host.docker.internal:host-gatewayen Linux). -p 8080:80se enlaza a todas las interfaces y esquivaufwen Linux. Anteponga127.0.0.1a todo lo que no tenga que ser público y no publique una base de datos por costumbre.- Volumen con nombre para los datos que deben perdurar, bind mount para el código durante el desarrollo, tmpfs para lo que nunca debe tocar el disco. Un volumen con nombre vacío hereda el contenido de la imagen; un bind mount lo oculta.
- Los volúmenes sobreviven a la eliminación de los contenedores.
docker compose downlos conserva,docker compose down -vlos destruye: la diferencia entre ambos son sus datos. - En Linux, los UID no se traducen a través de un bind mount.
--user "$(id -u):$(id -g)"evita los archivos propiedad derooten su carpeta de trabajo. - Haga la copia de seguridad de un volumen mediante un contenedor desechable que genere un archivo; para una base de datos, prefiera siempre una exportación lógica (
pg_dump) a una copia de archivos en caliente.
Consejo: ante cualquier duda sobre una conexión entre contenedores,
docker network inspect <reseau>muestra la lista exacta de los contenedores conectados. Nueve de cada diez veces, el servicio que «no responde» simplemente no está en la misma red.
Comentarios
Los comentarios funcionan con GitHub Discussions
Inicia sesión con GitHub para participar en la discusión