Las aplicaciones reales rara vez viven en un solo contenedor. Una web moderna típica combina varias piezas que colaboran: el servicio que atiende las peticiones, una base de datos que guarda la información y, muy a menudo, una caché que acelera las respuestas. En esta lección reuniremos todo lo aprendido en el módulo para construir, paso a paso, una aplicación multi-contenedor completa con Docker Compose: una API web, una base de datos PostgreSQL y una caché Redis. Verás por qué conviene separar responsabilidades en distintos contenedores, cómo se comunican los servicios entre sí simplemente por su nombre, cómo orquestar su orden de arranque y cómo levantar y probar el conjunto de principio a fin.
Objetivos de Aprendizaje
- Entender por qué las aplicaciones se dividen en varios contenedores.
- Construir una aplicación con web, base de datos y caché.
- Comprender cómo se comunican los servicios usando su nombre como nombre de host.
- Controlar el orden de arranque con
depends_ony entender sus límites. - Levantar, verificar y probar la aplicación completa de extremo a extremo.
¿Por Qué Aplicaciones Multi-Contenedor?
Podrías sentir la tentación de meter todo (servidor web, base de datos y caché) en un único contenedor. Es una mala idea. La filosofía de Docker recomienda un proceso principal por contenedor, y separar los servicios aporta ventajas claras:
- Separación de responsabilidades: cada contenedor hace una sola cosa y la hace bien. Más fácil de entender y mantener.
- Escalabilidad independiente: si necesitas más capacidad web, puedes ejecutar varias réplicas de la API sin duplicar la base de datos.
- Actualizaciones aisladas: puedes actualizar la versión de Redis sin tocar la base de datos ni la web.
- Reutilización: la misma imagen oficial de PostgreSQL o Redis sirve para muchos proyectos.
- Resiliencia: si un servicio falla, los demás pueden seguir funcionando y reiniciarse de forma independiente.
| Enfoque | Ventajas | Inconvenientes |
|---|---|---|
| Todo en un contenedor | Aparentemente simple | Difícil de mantener, escalar y actualizar; frágil |
| Multi-contenedor | Modular, escalable, mantenible | Requiere orquestación (y para eso está Compose) |
La Arquitectura del Ejemplo
Vamos a construir una aplicación con tres servicios que colaboran:
web: una API que atiende las peticiones de los usuarios.db: una base de datos PostgreSQL donde la API guarda y consulta datos.cache: una caché Redis que la API usa para acelerar respuestas frecuentes.
El flujo es: el usuario llama a web, que a su vez habla con db y cache por la red interna que Compose crea automáticamente.
Comunicación entre Servicios por Nombre
Este es uno de los conceptos más potentes y, a la vez, más mágicos para quien empieza. Cuando defines varios servicios en un mismo docker-compose.yml, Compose los conecta a una red común y habilita un DNS interno. Gracias a ello, cada servicio puede encontrar a los demás usando simplemente su nombre de servicio como nombre de host.
- La API no necesita conocer la dirección IP de la base de datos.
- Basta con que se conecte al host
dby al puerto de PostgreSQL (5432). - Compose resuelve
dba la IP interna del contenedor correspondiente automáticamente.
# Dentro del contenedor "web", estas conexiones funcionan:
# Base de datos: host = db puerto = 5432
# Caché: host = cache puerto = 6379Explicación:
dbycacheson exactamente los nombres que les diste a los servicios en el YAML.- No hace falta publicar (
ports) los puertos dedbni decachehacia el host para queweblos use: la comunicación ocurre dentro de la red de Docker. Solo publicamos hacia fuera lo que el usuario debe ver (la API).
Idea clave: el nombre del servicio es el nombre de host. Si llamas a tu base de datos
db, tu aplicación se conecta adb; si la llamaspostgres, se conecta apostgres. Mantén nombres claros y coherentes.
Orden de Arranque con depends_on
La API no debería intentar usar la base de datos antes de que esta exista. Con depends_on indicamos a Compose que arranque primero db y cache, y luego web.
depends_on: - db - cache: Compose arrancarádbycacheantes queweb, y al apagar, pararáwebprimero.
Recuerda el matiz importante que ya vimos: depends_on garantiza el orden de arranque del contenedor, pero no que la base de datos esté lista para aceptar conexiones. Postgres puede tardar unos segundos en inicializarse. Hay dos formas de gestionarlo:
- Reintentos en la aplicación (recomendado y robusto): que tu código reintente la conexión a la base de datos durante unos segundos al arrancar.
- Healthchecks con
condition: service_healthy: hacer quewebespere a quedbinforme de que está sana. Esta técnica avanzada la profundizaremos más adelante.
Ejemplo Completo End-to-End
Construyamos la aplicación entera. La estructura del proyecto será:
Paso 1: El docker-compose.yml
services:
web:
build: ./web # construye la API desde ./web/Dockerfile
ports:
- "8080:8080" # expone la API en http://localhost:8080
environment:
DB_HOST: db # nombre de host de la base de datos
DB_PORT: "5432"
DB_NAME: app_db
DB_USER: app_user
DB_PASSWORD: changeme_in_real_env
CACHE_HOST: cache # nombre de host de la caché
CACHE_PORT: "6379"
depends_on:
- db
- cache
restart: on-failure
db:
image: postgres:16
environment:
POSTGRES_DB: app_db
POSTGRES_USER: app_user
POSTGRES_PASSWORD: changeme_in_real_env
volumes:
- datos_db:/var/lib/postgresql/data
restart: unless-stopped
cache:
image: redis:7-alpine
restart: unless-stopped
volumes:
datos_db:Explicación detallada:
web:build: ./webconstruye la imagen desde elDockerfilede la carpetaweb.ports: "8080:8080"publica la API hacia tu máquina; es el único servicio visible desde fuera.environmentpasa a la aplicación los datos de conexión. Fíjate en queDB_HOSTvaledbyCACHE_HOSTvalecache: los nombres de los servicios. Así la API sabe a qué hosts conectarse dentro de la red.depends_onasegura quedbycachearranquen antes.restart: on-failurela reinicia si falla (por ejemplo, si la base de datos aún no estaba lista y la app no reintentó).
db: PostgreSQL con su configuración por variables de entorno y un volumendatos_dbpara no perder los datos. No publica puertos: solowebla necesita, y por la red interna.cache: Redis ligero, sin puertos publicados, accesible solo desde dentro.volumes: datos_db: declara el volumen con nombre que persiste los datos de la base.
Advertencia de seguridad:
changeme_in_real_enves un valor ficticio solo para el ejemplo. En un proyecto real, gestiona las contraseñas con variables de entorno y un gestor de secretos, nunca en texto plano dentro del YAML (lo verás en detalle en la lección de Variables de Entorno).
Paso 2: El Dockerfile de la API
Para que el ejemplo sea autocontenido, usamos una API mínima. Crea web/Dockerfile:
Explicación:
FROM python:3.12-slim: imagen base ligera de Python.WORKDIR /app: directorio de trabajo dentro del contenedor.COPY app.py .: copia el código de la aplicación.EXPOSE 8080: documenta que la app escucha en el puerto 8080.CMD ["python", "app.py"]: comando que arranca la API.
(El fichero app.py contendría tu API; aquí nos centramos en la orquestación con Compose, no en el código de la aplicación.)
Paso 3: Levantar la Aplicación
Desde la carpeta miapp/, construye y arranca todo en segundo plano:
--buildconstruye la imagen deweba partir de suDockerfile.-ddeja la aplicación corriendo en segundo plano.- Compose crea la red interna, el volumen
datos_dby arranca los tres servicios en el orden correcto.
Paso 4: Verificar el Estado
Comprueba que los tres servicios están en marcha:
- Deberías ver
web,dbycachecon estadorunning. - Solo
webmostrará un puerto publicado (8080), porquedbycachese comunican únicamente por la red interna.
Revisa los registros para detectar errores de arranque o de conexión:
Paso 5: Probar la Comunicación entre Servicios
Puedes confirmar que web resuelve y alcanza a db y cache por su nombre. Entra al contenedor web y prueba la conectividad:
docker compose exec web sh
# Dentro del contenedor:
# ping -c 2 db # responde la IP interna de la base de datos
# ping -c 2 cache # responde la IP interna de la caché- Si
ping dbyping cacheresuelven a una IP, el DNS interno de Compose funciona y la comunicación por nombre está operativa. - (Algunas imágenes mínimas no incluyen
ping; en ese caso, la propia conexión de la aplicación adb:5432ya confirma que la resolución por nombre funciona.)
Paso 6: Apagar la Aplicación
Cuando termines, detén y limpia el entorno:
- Elimina los contenedores y la red, pero conserva el volumen
datos_db, de modo que los datos seguirán ahí la próxima vez que ejecutesup.
Errores Comunes y Consejos
- Usar
localhostpara conectar entre contenedores: dentro de un contenedor,localhostse refiere a ese mismo contenedor, no a los demás. Para llegar a la base de datos usa el nombre del servicio (db), nolocalhost. - Publicar puertos innecesarios: no hace falta
portsendbni encachesi solo se usan internamente. Publica solo lo que el usuario debe ver. - Confiar ciegamente en
depends_on: garantiza el orden de arranque, no la disponibilidad. Implementa reintentos de conexión en tu aplicación o usa healthchecks. - Olvidar
--buildtras cambiar el código de la API: sin reconstruir, seguirás ejecutando la versión antigua. - No persistir la base de datos: sin un volumen con nombre para
db, perderás los datos al hacerdown. Declara siempre el volumen. - Nombres de servicio confusos: como el nombre es también el host, elige nombres claros (
db,cache,web) y mantenlos coherentes con la configuración de tu aplicación.
Ejercicios
Ejercicio 1
Explica por qué, dentro del contenedor web, hay que conectar a la base de datos usando el host db en lugar de localhost.
Ejercicio 2
Añade a la aplicación del ejemplo un cuarto servicio llamado adminer (un cliente web para administrar bases de datos) que use la imagen adminer, publique el puerto 8081 del host hacia el 8080 del contenedor y dependa de db.
Ejercicio 3
Tu servicio web falla al arrancar con un error de conexión a la base de datos, aunque depends_on apunta a db. Explica qué está ocurriendo y propón dos formas de solucionarlo.
Soluciones
Solución al Ejercicio 1
Dentro de un contenedor, localhost apunta al propio contenedor, no al host ni a los demás servicios. Como la base de datos corre en otro contenedor distinto, web no la encontraría en su propio localhost. Compose crea una red interna con DNS, de modo que el nombre del servicio (db) se resuelve a la IP del contenedor de la base de datos. Por eso la conexión correcta es al host db (puerto 5432), no a localhost.
Solución al Ejercicio 2
image: adminerusa la imagen oficial."8081:8080"publica el puerto 8081 del host hacia el 8080 del contenedor de Adminer.depends_on: - dbasegura que la base de datos arranque antes.- Una vez levantado, accederás a Adminer en
http://localhost:8081y, dentro de él, te conectarás al servidor usando el hostdb.
Solución al Ejercicio 3
Lo que ocurre es que depends_on solo garantiza que el contenedor de db haya arrancado, pero no que PostgreSQL ya esté listo para aceptar conexiones. Postgres tarda unos segundos en inicializarse, y durante ese tiempo web intenta conectarse y falla.
Dos formas de solucionarlo:
- Reintentos en la aplicación: que el código de
webreintente la conexión adbdurante unos segundos antes de rendirse. Es la solución más robusta y portable. - Healthcheck con
condition: service_healthy: definir un healthcheck endby hacer quewebdependa de ese estado, de modo que Compose no arranquewebhasta que la base de datos esté realmente sana.
Como medida adicional, restart: on-failure en web permite que, si falla por este motivo, el contenedor se reinicie y vuelva a intentarlo cuando la base de datos ya esté disponible.
Conclusión
En esta lección has construido una aplicación multi-contenedor completa de extremo a extremo: una API web, una base de datos PostgreSQL y una caché Redis, orquestadas con un único docker-compose.yml. Has entendido por qué conviene separar los servicios en contenedores distintos, cómo se comunican simplemente por su nombre gracias al DNS interno de Compose, cómo controlar el orden de arranque con depends_on (y sus límites), y cómo levantar, verificar, probar y apagar todo el conjunto.
Con esto cierras los fundamentos prácticos del módulo. En la siguiente lección, Variables de Entorno en Docker Compose, aprenderás a parametrizar esta misma aplicación para que un único archivo sirva en desarrollo, pruebas y producción sin reescribir nada, y a manejar los secretos de forma segura en lugar de dejarlos en texto plano como hemos hecho aquí con fines didácticos.
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
Módulo 2: Trabajando con Imágenes Docker
- Docker Hub y Repositorios
- Construyendo Imágenes Docker
- Conceptos Básicos de 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
- Redes en Docker
- Persistencia de Datos con Volúmenes
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
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
- Registro y Monitoreo en Docker
Módulo 6: Docker en 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
