DevOps, CI/CD y contenedores
Docker Compose: aplicaciones multicontenedor, redes y volúmenes
Intermedio11 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Compose describe una aplicación entera —el servicio, su base de datos, un broker de mensajes, un proxy inverso— en un único archivo que cualquier desarrollador arranca con un solo comando. Esta guía recorre el formato del archivo tal como es en Compose v2 y luego profundiza en las dos áreas que más confusión generan: cómo se encuentran los contenedores en una red de Compose y cómo sobreviven los datos cuando los contenedores no lo hacen.
Lo que aprenderás
- Escribir un compose.yaml que describa varios servicios con build, image, ports y environment
- Explicar cómo la red del proyecto y su DNS integrado permiten que los servicios se localicen por nombre
- Ordenar el arranque correctamente con healthchecks y condiciones de depends_on
- Elegir entre volúmenes con nombre, bind mounts y tmpfs, y hacer copia de seguridad de un volumen
- Superponer la configuración con archivos env, interpolación de variables, perfiles y archivos de override
- Ejecutar un stack de Compose en un job de CI sin que se filtre estado entre builds
Una aplicación moderna rara vez es un solo proceso. Un servicio web necesita una base de datos, probablemente una caché, a menudo un broker de mensajes, quizá un proxy inverso y un capturador de correo para desarrollo. Arrancarlos a mano con docker run implica recordar seis líneas de comando con los flags correctos, en el orden correcto y en la red correcta, que es justo el tipo de conocimiento que vive en el historial de shell de una sola persona y en ningún otro sitio.
Compose sustituye todo eso por un archivo. compose.yaml describe los servicios, las redes que los conectan y el almacenamiento que hay debajo, y docker compose up pone todo en marcha. Esta guía da por hecho que te manejas con imágenes y contenedores —la guía de fundamentos de Docker los cubre— y se centra en el formato del archivo, en el modelo de red que sorprende a los recién llegados y en las decisiones de almacenamiento que determinan si tus datos sobreviven.
El archivo y lo que cambió en la v2
Compose v2 es un plugin escrito en Go que se invoca como docker compose (un subcomando, sin guion). El antiguo binario de Python docker-compose está retirado y, con él, dos costumbres: la clave version: de primer nivel es obsoleta y genera un aviso, y el nombre de archivo preferido es compose.yaml en lugar de docker-compose.yml, aunque las cuatro variantes se siguen detectando.
Este es un stack completo para un servicio Spring Boot con PostgreSQL y Redis:
name: shop
services:
api:
build:
context: .
dockerfile: Dockerfile
args:
JAR_FILE: target/app.jar
image: acme/shop-api:dev
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/shop
SPRING_DATASOURCE_USERNAME: shop
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
SPRING_DATA_REDIS_HOST: cache
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 10s
timeout: 3s
retries: 6
start_period: 40s
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: shop
POSTGRES_USER: shop
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
- ./ops/initdb:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shop -d shop"]
interval: 5s
timeout: 3s
retries: 10
ports:
- "127.0.0.1:5432:5432"
cache:
image: redis:7-alpine
command: ["redis-server", "--save", "60", "1", "--appendonly", "no"]
volumes:
- redisdata:/data
volumes:
pgdata:
redisdata:
Arrancarlo, vigilarlo, pararlo:
docker compose up -d --build
docker compose ps
docker compose logs -f api
docker compose exec db psql -U shop -d shop
docker compose down # containers and networks; volumes survive
docker compose down -v # ...and delete the named volumes too
La clave name: fija el nombre del proyecto, que se antepone a cada contenedor, red y volumen que crea Compose. Sin ella, Compose usa el nombre del directorio, y por eso dos copias del mismo repositorio en carpetas con nombres distintos acaban, sin que nadie lo note, con bases de datos separadas.
Redes: la parte que sorprende
Cuando Compose arranca un proyecto, crea una red bridge definida por el usuario llamada <project>_default y conecta a ella todos los servicios. Docker ejecuta un servidor DNS integrado en esa red, y cada servicio se resuelve por su nombre de servicio. Así, la API llega a PostgreSQL en db:5432 y a Redis en cache:6379, sin una sola dirección IP.
De ahí se derivan tres consecuencias, y cada una responde a una pregunta muy frecuente.
Dentro de un contenedor, localhost es el propio contenedor. Una aplicación configurada con jdbc:postgresql://localhost:5432/shop fallará, porque no hay ningún PostgreSQL en el namespace de red del contenedor de la API. Usa el nombre del servicio.
El tráfico entre contenedores ignora ports. La clave ports publica un puerto del contenedor en el host, para tu navegador y tu cliente de base de datos. Los servicios de una misma red se comunican directamente por el puerto del contenedor, esté publicado o no. Por eso el ejemplo publica PostgreSQL como 127.0.0.1:5432:5432: enlazado solo a la interfaz de loopback, para que un portátil conectado a la wifi de una cafetería no le ofrezca su base de datos de desarrollo a todo el local.
expose no publica nada. Igual que EXPOSE en un Dockerfile, es documentación. Quítalo si confunde a la gente.
Puedes definir redes adicionales para segmentar el tráfico, algo que merece la pena en cuanto el stack tiene una capa pública y un núcleo privado:
services:
proxy:
image: nginx:1.27-alpine
ports: ["80:80", "443:443"]
networks: [edge]
volumes:
- ./ops/nginx.conf:/etc/nginx/conf.d/default.conf:ro
api:
build: .
networks: [edge, backend]
db:
image: postgres:16-alpine
networks: [backend]
networks:
edge:
backend:
internal: true # no route to the outside world
Ahora el proxy no puede llegar a la base de datos en absoluto, y la base de datos no puede abrir conexiones salientes. Es una frontera de seguridad real que impone la pila de red, no una convención.
Hay dos opciones más que aparecen a menudo. aliases da a un servicio nombres DNS adicionales en una red, útil cuando una aplicación espera un hostname concreto como kafka.internal. Y network_mode: host elimina por completo el aislamiento de red y le da al contenedor las interfaces del host; en ocasiones es necesario para herramientas de red, rompe el descubrimiento de servicios y no funciona igual en Docker Desktop, así que evítalo salvo que no te quede otra.
Orden de arranque y healthchecks
En su forma corta, depends_on solo controla el orden en que se crean y arrancan los contenedores. No espera a que nada esté listo dentro de ellos, y por eso tantos stacks fallan en el primer up y funcionan en el segundo.
La forma larga lo resuelve apoyándose en el healthcheck de la dependencia:
depends_on:
db:
condition: service_healthy
migrations:
condition: service_completed_successfully
service_healthy espera a que el HEALTHCHECK del destino informe de que está sano; service_completed_successfully espera a que un contenedor de un solo uso —una migración de esquema, un script de datos iniciales— termine con código cero. Un healthcheck cumple, por tanto, una doble función: te dice el estado de un servicio en ejecución en docker compose ps y permite que otros servicios esperen a algo con sentido.
Escribe healthchecks que comprueben la dependencia real, no el proceso. pg_isready confirma que PostgreSQL acepta conexiones; una comprobación TCP al puerto 5432 pasaría mientras la base de datos aún se está recuperando. Dale a los servicios lentos un start_period generoso, durante el cual los fallos no cuentan para retries; si no, un servicio JVM que tarda 40 segundos en arrancar se marcará como unhealthy y se reiniciará sin fin.
Incluso con todo esto, las aplicaciones deben reintentar su primera conexión. El orden de Compose solo cubre el arranque; en tiempo de ejecución un contenedor de base de datos puede reiniciarse en cualquier momento, y un servicio que muere para siempre porque su dependencia se cayó un instante es frágil en cualquier entorno, no solo en este.
Volúmenes y dónde viven realmente los datos
La capa de escritura de un contenedor desaparece cuando se elimina el contenedor. Todo lo que deba sobrevivir a docker compose down necesita un volumen. Compose admite tres tipos.
Un volumen con nombre es almacenamiento que Docker crea y gestiona en su propio directorio. Es la opción por defecto adecuada para bases de datos y cualquier otro estado: sobrevive a la recreación de los contenedores, se puede respaldar y mover, y sus permisos los gestiona la imagen, no los IDs de usuario de tu host.
volumes:
- pgdata:/var/lib/postgresql/data
Un bind mount monta una ruta del host dentro del contenedor. Su contenido es exactamente lo que hay en tu disco, lo que lo hace ideal para el código fuente que quieres editar en caliente, archivos de configuración y datos iniciales de solo lectura:
volumes:
- ./ops/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./src:/app/src
Añade :ro a todo lo que el contenedor no tenga por qué escribir. Ten en cuenta que los archivos montados con bind mount conservan su propietario del host, así que un contenedor que se ejecuta con un usuario no root puede no tener permiso para escribir en ellos: es, con diferencia, la causa más habitual de errores de permisos en un stack de Compose. En sistemas con SELinux, añade :z (compartido) o :Z (privado) para reetiquetar la ruta.
Un montaje tmpfs coloca un directorio en la memoria del host, nunca en disco. Úsalo como espacio temporal y para bases de datos de pruebas en las que la durabilidad no importa y la velocidad sí:
tmpfs:
- /tmp
- /var/lib/postgresql/data:size=512m
Respaldar un volumen con nombre consiste en lanzar un contenedor que lo monte y escriba un archivo comprimido en un lugar que controles:
docker run --rm \
-v shop_pgdata:/data:ro \
-v "$PWD":/backup \
alpine tar -czf /backup/pgdata-$(date +%F).tar.gz -C /data .
Para una base de datos, es preferible usar su propia herramienta de volcado —docker compose exec db pg_dump -U shop shop > shop.sql—, porque una copia a nivel de archivos de un directorio de datos en uso puede quedar inconsistente.
docker compose down conserva los volúmenes a propósito; -v los elimina. Ese flag ha borrado muchísimos datos locales que alguien daba por desechables, así que ten claro cuál de los dos tecleas por costumbre.
Configuración por capas
Dejar valores fijos en compose.yaml lo deja inservible fuera de una sola máquina. Compose te ofrece varios mecanismos que se combinan entre sí.
La interpolación de variables lee del shell y de un archivo .env situado junto al archivo de Compose. La sintaxis admite valores por defecto y valores obligatorios:
environment:
LOG_LEVEL: ${LOG_LEVEL:-info} # default when unset or empty
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
TAG: ${TAG:-dev}
No confundas el archivo .env (que se interpola en el propio archivo de Compose, al parsearlo) con env_file: en un servicio (una lista de archivos cuyo contenido se convierte en variables de entorno dentro del contenedor). Resuelven problemas distintos y la gente echa mano del equivocado constantemente.
services:
api:
env_file:
- ./config/common.env
- ./config/local.env # later files win
Los archivos de override permiten especializar una definición base. docker compose up fusiona automáticamente compose.yaml con compose.override.yaml si existe. Para cualquier otro caso, indica los archivos explícitamente:
docker compose -f compose.yaml -f compose.prod.yaml up -d
El archivo base describe la aplicación; el override añade comodidades de desarrollo (código fuente con bind mount, puertos de depuración, un capturador de correo) o ajustes de producción (límites de recursos, políticas de reinicio, un tag de imagen fijo en lugar de un build).
Los perfiles activan y desactivan servicios opcionales dentro de un mismo archivo:
services:
mailhog:
image: mailhog/mailhog
profiles: ["dev"]
ports: ["8025:8025"]
loadtest:
image: grafana/k6
profiles: ["perf"]
Los servicios sin clave profiles arrancan siempre; los que tienen perfil, solo cuando se piden con --profile dev o COMPOSE_PROFILES=dev. Así un único archivo sigue siendo la referencia, en lugar de ir multiplicando compose.dev.yaml, compose.demo.yaml y compañía.
Por último, las anclas de YAML y la clave extends eliminan la repetición entre servicios parecidos:
x-service-defaults: &defaults
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
services:
api: { <<: *defaults, image: acme/shop-api:dev }
worker: { <<: *defaults, image: acme/shop-worker:dev }
Merece la pena copiar esas opciones de logging: el driver json-file por defecto no tiene límite de tamaño, y un contenedor muy hablador en un host que lleva mucho tiempo encendido acabará llenando el disco de logs.
Compose en un job de CI
Compose es una forma excelente de levantar las dependencias de los tests de integración, siempre que resuelvas dos problemas: builds que se ejecutan a la vez en el mismo agente y estado residual.
Aísla cada ejecución con un nombre de proyecto único y haz que la limpieza sea incondicional:
export COMPOSE_PROJECT_NAME="ci-${BUILD_NUMBER}"
trap 'docker compose down -v --remove-orphans' EXIT
docker compose up -d --wait --wait-timeout 120
docker compose run --rm tests
--wait bloquea hasta que todos los servicios con healthcheck informan de que están sanos y devuelve un código distinto de cero si alguno no lo consigue, lo que sustituye a los bucles con sleep que antes plagaban los scripts de CI. run --rm arranca un contenedor puntual a partir de una definición de servicio, se conecta a él y devuelve su código de salida, de modo que el resultado del job de CI es el resultado de la suite de tests.
Para una suite definida como servicio, --abort-on-container-exit junto con --exit-code-from consigue lo mismo para todo el stack:
docker compose up --abort-on-container-exit --exit-code-from tests
En CI, evita por completo los puertos publicados en el host: dos builds concurrentes que intenten ocupar el 8080 van a chocar. Deja que el contenedor de tests llegue al servicio por su nombre en la red de Compose. La guía de pipelines de Jenkins muestra cómo se publican después los informes resultantes.
Plantéate también si necesitas Compose en el propio proceso de tests: en proyectos JVM, Testcontainers arranca las mismas imágenes de forma programática desde el código de test, lo que elimina la coordinación entre un script de shell y una suite de tests.
Diagnosticar un stack que no arranca
Una secuencia corta resuelve la mayoría de los problemas:
Validar la configuración fusionada
docker compose configimprime el archivo completamente resuelto, con las variables sustituidas y los overrides fusionados. La mayoría de los avisos de «mi variable está vacía» terminan aquí.Revisar estados y códigos de salida
docker compose ps -amuestra qué servicios han terminado y con qué código. 137 significa que el contenedor fue matado, normalmente por falta de memoria; 1 suele significar que el proceso no consiguió arrancar.Leer los logs del servicio que falla
docker compose logs --tail=100 db. Las imágenes de bases de datos muestran mensajes claros sobre un directorio de datos que se inicializó con otras credenciales, síntoma clásico de un volumen con nombre obsoleto.Probar la conectividad desde dentro
docker compose exec api sh -c 'getent hosts db; nc -zv db 5432'distingue un problema de DNS de uno del listener y de uno de configuración de la aplicación.Recrear desde cero
docker compose down -v && docker compose up --build. Si eso lo arregla, el problema era estado obsoleto, y conviene saber qué volumen lo contenía.
Dónde fallan los equipos
- Suponer que
depends_onespera a que el servicio esté listo. Añade healthchecks y la condición de la forma larga. - Publicar todos los puertos en
0.0.0.0. Enlaza las bases de datos de desarrollo a127.0.0.1y publica solo lo que necesita un navegador. - Editar archivos dentro de un contenedor en ejecución. El cambio desaparece al recrearlo. Cambia la imagen o monta el archivo con bind mount.
- Guardar secretos en
compose.yaml. El archivo se sube al repositorio. Usa.env(ignorado por git) o los secrets de Docker, y no reutilices nunca credenciales de producción en local. - Construir imágenes de forma implícita en producción. Un stack de producción debe referenciar un tag inmutable construido por la CI, no un
build:a partir del código que haya en el servidor en ese momento. - Dejar
latesten el archivo. Fijapostgres:16-alpine, nopostgres. Un salto de versión mayor en undocker compose pullno va a migrar tus datos por ti. - No limpiar nunca.
docker system pruneydocker volume lsmerecen un hueco en tu rutina; los volúmenes huérfanos de nombres de proyecto antiguos se acumulan en silencio.
Ideas clave
- Compose v2 se ejecuta como
docker compose, prefierecompose.yamly ya no quiere la claveversion:; definename:para que la identidad del proyecto no dependa del nombre del directorio. - Cada servicio se une a una red de proyecto con DNS integrado, así que los contenedores se localizan por nombre de servicio y puerto del contenedor;
portssolo sirve para el acceso desde el host yexposeno hace nada. - Segmenta con una segunda red e
internal: truecuando quieras una base de datos a la que de verdad no se pueda llegar desde la capa pública. depends_onpor sí solo ordena el arranque; los healthchecks junto concondition: service_healthyoservice_completed_successfullyhacen que signifique «listo», y aun así las aplicaciones deben reintentar.- Volúmenes con nombre para datos, bind mounts para código y configuración (con
:rosiempre que se pueda), tmpfs para espacio temporal; respalda las bases de datos con sus propias herramientas de volcado. - Superpón la configuración con interpolación
.env,env_file, archivos de override y perfiles en lugar de copiar el archivo entero para cada entorno. - En CI, da a cada ejecución su propio nombre de proyecto, usa
--waityrun --rm, evita enlazar puertos del host y limpia condown -vdentro de un trap.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Por qué mi aplicación no consigue conectarse a la base de datos en localhost:5432?
- Dentro de un contenedor, localhost es el namespace de red de ese mismo contenedor, así que apunta a la propia aplicación. Al resto de servicios de la misma red de Compose se llega por su nombre de servicio y por el puerto en el que escucha el proceso dentro de su contenedor —db:5432—, independientemente de lo que hayas publicado en el host. Los puertos publicados sirven para acceder desde tu portátil, no para el tráfico entre contenedores.
- ¿Basta con depends_on para que la base de datos esté lista antes de que arranque la aplicación?
- No. El depends_on simple solo ordena la creación de los contenedores; el proceso de la base de datos puede seguir inicializándose. Añade un healthcheck al servicio de base de datos y usa depends_on con la condición service_healthy para que Compose espere a que la comprobación pase. Aun así, las aplicaciones deben reintentar su primera conexión, porque los contenedores se reinician y las dependencias también desaparecen en tiempo de ejecución.
- ¿Qué diferencia hay entre un volumen con nombre y un bind mount?
- Un volumen con nombre es almacenamiento que Docker crea y gestiona, portable entre máquinas y fácil de respaldar o de mover entre contenedores. Un bind mount monta una ruta concreta del host dentro del contenedor, así que su contenido y sus permisos vienen de tu sistema de archivos. Usa volúmenes con nombre para datos como los archivos de una base de datos, y bind mounts para el código fuente que quieres editar en caliente durante el desarrollo.
- ¿Se puede usar Compose en producción?
- Para un único host con unos pocos servicios y cierta tolerancia a breves cortes durante las actualizaciones, sí: muchos despliegues pequeños funcionan exactamente así. Lo que no obtienes es planificación entre varias máquinas, rolling updates con rollback basado en la salud de los servicios, autoescalado horizontal ni autorreparación ante la caída de un nodo. Cuando eso importa, las mismas definiciones de servicios se trasladan de forma bastante directa a objetos de Kubernetes.