Ha llegado el momento de explorar la creación de aplicaciones multicontenedor con Docker a través de la herramienta Compose. En los dos primeros artículos, adquirimos las bases mínimas necesarias de Docker para aventurarnos a un nivel superior. Todo lo visto hasta ahora es válido y funcional, pero tiene el inconveniente de que, si necesitamos crear una aplicación con más contenedores, el proceso no es demasiado cómodo.
Tanto si trabajamos con un solo contenedor como con varios, el uso de Docker Compose resulta mucho más práctico y automatizable. Sin embargo, si necesitamos gestionar un entorno con decenas o cientos de contenedores, deberíamos considerar el uso de una herramienta más robusta como Kubernetes.
Actualmente, Docker Compose
ya viene instalado con Docker. Es posible que encuentres tutoriales antiguos donde
se menciona como "Docker-Compose" con guión. En ese caso, se trata de la versión obsoleta que se instalaba como una aplicación independiente. No te fíes demasiado
de esos tutoriales y verifica siempre con la documentación oficial.
Si sigues todo el artículo, aprenderás las bases de Docker Compose y crearemos varios entornos multicontenedor para diferentes propósitos.
Paso 1: Creación del archivo Dockerfile de MariaDB Paso 2: Creación del archivo init.sql de MariaDB Paso 3: Ampliar el archivo docker-compose.yml para añadir el servicio MariaDB
Docker Compose es una herramienta que permite definir, configurar y gestionar aplicaciones Docker
multi-contenedor mediante un archivo de configuración en formato YAML
.
En lugar de ejecutar múltiples comandos docker run
para cada contenedor,
Docker Compose facilita la definición de todos los servicios necesarios y su ejecución con un solo comando,
simplificando así la gestión de entornos complejos.
Los servicios de cada contenedor se describen de forma clara y estructurada en un único archivo
docker-compose.yml
, lo que facilita su comprensión, mantenimiento y replicación
en distintos entornos. Además, los detalles específicos de cada servicio, como dependencias, variables de entorno o configuraciones avanzadas,
pueden definirse en archivos Dockerfile
y ser referenciados desde el archivo
YAML
.
Entre las principales ventajas de Docker Compose destacan su capacidad para escalar servicios fácilmente, la portabilidad de entornos, y la posibilidad de replicar configuraciones en diferentes máquinas de forma fiable. Compose funciona en todos los entornos: producción, preparación (staging), desarrollo, pruebas, así como en flujos de trabajo de integración continua (CI workflows).
En resumen, Docker Compose simplifica la orquestación de múltiples contenedores, optimizando la eficiencia, productividad y consistencia en el desarrollo, las pruebas y el despliegue de aplicaciones. Además, proporciona comandos para gestionar todo el ciclo de vida de la aplicación.
El archivo docker-compose.yml
es el núcleo de Docker Compose. En él se definen los servicios,
redes y volúmenes necesarios para gestionar una aplicación multi-contenedor. Cada servicio o contenedor puede ser descrito directamente en este archivo,
o bien puede construirse a partir de un archivo externo, como un Dockerfile
.
La estructura del archivo docker-compose.yml
se organiza en una serie de claves y valores, tanto principales como anidados, que permiten configurar detalladamente el entorno de ejecución de los contenedores.
Existen hasta seis secciones principales dentro del archivo, aunque no es necesario configurar todas ellas. En la documentación oficial se explica con todo detalle cada apartado. En este tutorial veremos un breve resumen de lo necesario para crear algunos ejemplos de configuraciones multicontenedor.
A continuación, se presenta una estructura genérica de este archivo que será de gran ayuda para entender qué es un docker-compose.yml
. Con los conocimientos previos sobre Docker y Dockerfile, podemos entender fácilmente la estructura básica del archivo. En este ejemplo, se aplican 3 de los 6 apartados posibles:
services:
service_1:
# Si el servicio se construye a partir de un Dockerfile
build:
context: ./ruta_del_directorio_1
dockerfile: Dockerfile_1
ports:
- "8080:80" # Puertos mapeados, pueden ser definidos en el Dockerfile pero aquí se sobrescriben si es necesario
environment:
- VAR1=valor1 # Variables de entorno, pueden definirse en el Dockerfile, pero aquí se añaden o sobrescriben
volumes:
- volumen_1:/ruta/del/contenedor # Volúmenes, similares a los que pueden definirse en el Dockerfile
networks:
- red_personalizada
service_2:
# Si el servicio usa una imagen existente
image: nombre_de_imagen_2
ports:
- "9090:90"
environment:
- VAR2=valor2 # Variables de entorno, también podrían definirse en la imagen de la imagen
depends_on:
- service_1
networks:
- red_personalizada
volumes:
volumen_1:
networks:
red_personalizada:
driver: bridge
ATENCIÓN: En los archivos YAML, la indentación es muy importante. Para separar cada sección de claves y valores, deben respetarse 2 espacios de margen.
Hasta el momento hemos comentado brevemente las secciones services, volumes y networks. La lista completa es la siguiente:
name | Especifica el nombre del proyecto. |
services | Define los contenedores que se van a desplegar. |
networks | Configura redes personalizadas para la comunicación. |
volumes | Gestiona la persistencia de datos entre contenedores y el host. |
configs | Almacena y gestiona archivos de configuración. |
secrets | Maneja datos sensibles de forma segura. |
Nota: He omitido el uso de la clave "version", ya que se considera obsoleta. Desde la versión 2 de Docker Compose, publicada en julio de 2023, su uso solo es necesario para garantizar la compatibilidad con entornos antiguos.
Esta sección define el nombre del proyecto. Este nombre se utiliza como prefijo para todos los recursos creados por Docker Compose, como contenedores, redes y volúmenes. Si no se especifica, Compose utiliza, por defecto, el nombre del directorio que contiene el archivo. Personalmente, prefiero no hacer uso de este ajuste. Considero muy práctico tener diferentes copias del proyecto en directorios independientes con nombres diferentes como "proyecto1_desarrollo", "proyecto1_producción" o cualquier otro nombre identificativo.
name: nombre_proyecto
En la arquitectura de microservicios, cada servicio es responsable de una única tarea. En el apartado services, definiremos cada uno de estos servicios, que representan un contenedor. Para crear y configurar los servicios, Docker Compose ofrece una amplia variedad de claves que permiten adaptarlo a nuestras necesidades. No es necesario utilizar todas las claves; con un repertorio reducido es posible configurar una gran parte de servicios de cualquier tipo. Para conocerlas en más detalle, la documentación oficial es clara y completa. A continuación, resumimos las más habituales:
image:
Especifica la imagen de Docker que se usará para el servicio. Esta imagen puede ser oficial o personalizada y puede obtenerse de diferentes orígenes: registros de contenedores como el popular Docker Hub, registros privados o desde el entorno local mediante un archivo Dockerfile o una imagen previamente creada.
Si no se especifica la ruta de la imagen, Docker la buscará primero en el entorno local. Si no la encuentra, intentará localizarla en el registro de contenedores predeterminado y la descargará automáticamente.
services:
miservicio_web:
image: nginx:latest
build:
Especifica un contexto (que puede ser un directorio o archivo) para construir una imagen personalizada a partir de un Dockerfile
. La sección build
se usa cuando queremos crear un servicio a partir de una imagen o Dockerfile
que no está disponible para descargar desde los registros de Docker (públicos o privados).
services:
miservicio_local:
build:
context: ./mis_imagenes # ubicación del Dockerfile
dockerfile: Dockerfile
environment:
Define las variables de entorno que se pasarán al contenedor. Estas variables pueden declararse de dos maneras:
services:
mi_servicio1:
image: mi_imagen
environment:
VAR1: 15
VAR2: "true"
En este formato, cada variable se define como una clave seguida de su valor, utilizando dos puntos (:
) para separarlos. Este método es más legible y es el preferido cuando se trabaja con configuraciones más estructuradas.
services:
mi_servicio1:
image: mi_imagen
environment:
- VAR1=15
- VAR2="true"
En este formato, cada variable se define como una cadena en la que la clave y el valor están separados por un signo igual (=
). Este método es útil cuando se necesita una sintaxis más compacta o cuando se importan variables desde un archivo externo.
Nota: no declares variables de entorno que contengan datos sensibles, como usuarios y contraseñas, de esta forma. En su lugar, utiliza siempre secrets
o guárdalas en un archivo como una lista de variables de entorno.
env_file:
Define las variables de entorno que se pasarán al contenedor mediante un archivo con una lista de variables de entorno:
services:
mi_servicio1:
image: mi_imagen
env_file: - .env
El archivo de variables de entorno, por defecto llamado .env
, debe encontrarse en el mismo directorio que el archivo YAML de Docker Compose. Si es necesario, se puede especificar una ruta o un nombre diferente.
El formato de las variables de entorno en este archivo es CLAVE=VALOR
. Ejemplo:
VAR1=valor1
VAR2=valor2
DB_PASSWORD=supersegura123
DEBUG_MODE=true
Si el archivo .env
no existe en la ruta especificada, Docker Compose ignorará silenciosamente esa entrada y continuará sin errores. Sin embargo, si el servicio depende de esas variables, podrían producirse fallos en tiempo de ejecución.
Cuando las variables de entorno están definidas para un servicio tanto en env_file
como en environment
, los valores establecidos en environment
tienen prioridad.
ports:
Se utiliza para mapear puertos entre el contenedor y el host (tu máquina local o servidor). Esto permite que los servicios que se ejecutan dentro del contenedor sean accesibles desde fuera del contenedor, es decir, desde tu red local o incluso desde Internet, dependiendo de la configuración.
services:
mi_servicio1:
image: nginx
ports:
- "8080:80"
El mapeo de puertos en archivos docker-compose.yml
sigue la misma estructura que en Docker y en un Dockerfile
: "<puerto_host>:<puerto_contenedor>". En este ejemplo, si accedemos a http://localhost:8080, nos estaremos conectando al servidor Nginx dentro del contenedor en el puerto 80.
También es posible mapear rangos de puertos y especificar explitamente el protocolo "tcp" o "udp"
services:
mi_servicio1:
image: nginx
ports:
- "8080:80/tcp"
- "53:53/udp"
- "8000-8010:8000-8010"
volumes:
La clave volumes
, dentro del elemento de primer nivel services
, se utiliza para definir los volúmenes que serán montados en los contenedores de los servicios.
Existen varios tipos de volúmenes, siendo los más comunes:
volumes
de nivel superior y pueden ser compartidos entre múltiples contenedores.servicio1:
image: mi_imagen
volumes:
- mi_volumen:/ruta/en/el/contenedor # Volumen nombrado.
- ./data_host:/data_container # Monta el directorio ./data_host del host en /data_container dentro del contenedor.
volumes:
mi_volumen:
En el segundo caso del ejemplo, donde el volumen no es gestionado por Docker porque se especifica manualmente el directorio del host que se mapea en el contenedor, el directorio será creado automáticamente si no existe, siempre que se usen rutas absolutas (completas desde la raíz) o relativas (a partir del directorio actual con "./"
). IMPORTANTE: Si se utiliza ~
para representar el home del usuario, el directorio debe existir previamente, ya que Docker no lo creará automáticamente.
depends_on:
Define la relación de dependencia entre servicios. Garantiza que un servicio se inicie después de otro.
Un ejemplo muy común de la utilidad de este ajuste es asegurarse de que el servicio web no se inicie hasta que la base de datos esté lista.
services:
web:
image: httpd:latest
depends_on:
- db # espera a que mariadb esté listo antes de iniciar Apache
db:
image: mariadb:latest
entrypoint:
Define el comando principal que se ejecutará cuando el contenedor inicie. No puede ser reemplazado fácilmente al ejecutar docker run, ya que está pensado para que el contenedor siempre haga lo mismo.
servicio1:
web:
image: httpd:latest
entrypoint: ["httpd", "-D", "FOREGROUND"]
En este ejemplo, el comando de entrada garantiza que el servidor Apache se mantenga en primer plano al iniciar el contenedor.
Nota: Aunque en la documentación oficial, dentro del apartado Reference que explica este tema, el comando a ejecutar no aparece entre corchetes ni separado por comillas y comas, en la sección Best Practices se recomienda esta forma de escritura.
networks:
El atributo networks
, dentro de services
, define las redes a las que están conectados los contenedores de cada servicio, haciendo referencia a entradas en el elemento de nivel superior networks
.
Las redes a las que tendrá acceso cada servicio se asignan desde aquí. Sin embargo, la definición y configuración avanzada de las redes se realiza en el elemento de nivel superior.
En este ejemplo, el servicio web tiene dos redes: una propia y otra compartida con el servicio de base de datos para poder realizar consultas. Sin embargo, el servicio de base de datos no necesita acceder a la red del servicio web.
services:
web:
image: nginx:latest
networks:
- frontend
- backend
ports:
- "80:80"
db:
image: mariadb:latest
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: mydatabase
MYSQL_USER: user
MYSQL_PASSWORD: password
networks:
- backend
networks:
frontend:
backend:
En el ejemplo anterior, con los atributos de networks
del elemento de primer nivel services
asignamos las redes accesibles desde cada servicio o "contenedor. La definición o creación de cada red se hace desde el elemento de primer nivel networks
que veremos a continuación.
restart:
El atributo restart
, dentro de services
, define la política que la plataforma aplica cuando un contenedor termina su ejecución.
no
: La política de reinicio predeterminada. No reinicia el contenedor bajo ninguna circunstancia.always
: La política reinicia el contenedor siempre, hasta que sea eliminado.on-failure[:max-retries]
: La política reinicia el contenedor si el código de salida indica un error.
Opcionalmente, se puede limitar el número de intentos de reinicio que realiza el daemon de Docker.unless-stopped
: La política reinicia el contenedor sin importar el código de salida, pero deja de reiniciarlo cuando el servicio es detenido o eliminado.services:
miservidorphp04:
image: php:apache-bookworm
container_name: miservidorphp4
networks:
- nuevamired_04
volumes:
- .:/var/www/html
ports:
- "8084:80"
restart: unless-stopped
networks:
nuevamired_04: # Docker Compose creará esta red automáticamente
driver: bridge
En este caso, el servicio se reanudará a menos que se detenga manualmente o se elimine. Se reiniciará automáticamente en situaciones como el reinicio del host, detenciones por tareas de mantenimiento o si el servicio se detiene de manera abrupta.
El elemento de nivel superior networks
permite definir redes personalizadas para conectar servicios entre sí o con otros recursos. Las redes ayudan a aislar la comunicación entre los servicios y proporcionan seguridad. Algunas propiedades comunes son:
bridge
(red predeterminada) o overlay
(para servicios en un clúster).Las redes pueden compartirse entre varios servicios para permitir la comunicación o configurarse para restringir el acceso externo.
networks:
custom_bridge:
driver: bridge # tipo de red bridge (valor por defecto)
driver_opts:
# Restringe la conexión únicamente a localhost (127.0.0.1) en lugar de permitir todas las conexiones (0.0.0.0), que es el valor por defecto.
com.docker.network.bridge.host_binding_ipv4: "127.0.0.1"
El elemento de primer nivel volumes
se utiliza para definir volúmenes nombrados que permiten almacenar datos de forma persistente, incluso si los contenedores son eliminados.
Los volúmenes son esenciales para servicios que requieren persistencia, como bases de datos o aplicaciones que manejan grandes volúmenes de datos.
Opcionalmente, es posible configurar características avanzadas del volumen, como el tipo de controlador, un nombre personalizado o el uso de un volumen externo existente. Sin embargo, estos casos no se abordarán en este artículo. Nos centraremos en el caso más sencillo y común: el volumen nombrado.
services:
servicio1:
image: mi_imagen
volumes:
- mi_volumen:/ruta/en/el/contenedor # Volumen nombrado.
volumes:
mi_volumen:
El elemento de primer nivel configs
permite gestionar archivos de configuración.
Su propósito es adaptar el comportamiento de los servicios sin necesidad de reconstruir la imagen Docker. Esto es útil para manejar archivos de configuración que pueden cambiar entre diferentes entornos o despliegues.
El uso de configs
permite inyectar archivos de configuración que no se encuentran en el contenedor.
services:
servicio1:
image: mi_imagen
configs:
- mi_config
configs:
mi_config:
file: ./config/mi_app.conf
El elemento de primer nivel secrets
se utiliza para definir o referenciar datos sensibles que se conceden a los servicios. Su uso es similar al del elemento configs
, pero con un enfoque orientado a datos sensibles, como contraseñas, certificados y tokens.
Fuentes de secrets: Pueden provenir de dos fuentes:
services:
web:
image: nginx:latest
secrets:
- server-certificate
secrets:
server-certificate:
file: ./secrets/server.cert # referencia al archivo con secreto alojado en el host
services:
myapp:
build:
secrets:
- npm_token
context: .
secrets:
npm_token:
environment: NPM_TOKEN
Cuando se define un secreto en Docker Compose, este no se almacena como una variable de entorno en el contenedor (lo cual podría ser menos seguro). En su lugar, Docker lo monta como un archivo dentro del contenedor en la ruta /run/secrets/nombre_del_secreto
.
En los siguientes ejemplos, estos archivos se crean con permisos de solo lectura para el servicio que los ha solicitado, ubicándose en el directorio correspondiente dentro de su contenedor:
/run/secrets/server-certificate
/run/secrets/npm_token
Docker Compose ofrece una lista completa de comandos para las tareas más comunes al levantar, detener o eliminar los servicios,
volúmenes y redes definidos en el archivo docker-compose.yml
. Una vez más, insisto en consultar la documentación oficial para ver la lista completa y actualizada. Aquí mostraremos algunos de los comandos más utilizados.
Crea e inicia los contenedores definidos en el archivo docker-compose.yml
Opciones comunes:
Ejemplo:
docker compose up -d
Por defecto, los comandos de Docker Compose tendrán como objetivo el archivo docker-compose.yml
del directorio en el que nos encontremos.
Sin embargo, podemos especificar un archivo de compose con otro nombre utilizando la opción -f.
Por ejemplo:
docker compose -f mi_archivo_docker_compose.yml up
Detiene y elimina los contenedores, redes y volúmenes creados por docker compose up
Uso común:
docker compose down
docker compose down --volumes
--rmi all
y los volúmenes anónimos-v
:
docker compose down --rmi all -v
El comando docker compose rm
se utiliza para eliminar los contenedores que fueron
creados por un archivo docker-compose.yml
. Este comando no elimina las imágenes ni los
volúmenes asociados a los contenedores, solo se encarga de borrar los contenedores en sí mismos.
IMPORTANTE: antes de ejecutar este comando, los contenedores deben estar detenidos. Si están en ejecución, primero deberás detenerlos
con docker compose stop
o utilizar la opción-s, --stop
que los detendrá .
Opciones comunes:
docker-compose.yml
no se eliminan).
Ejemplo:
docker compose rm
Muestra el estado de los contenedores administrados por un archivo docker-compose.yml. Es útil para verificar qué contenedores están en ejecución, sus nombres, puertos y estados.
Ejemplo:
docker compose ps
Salida típica:
NAME SERVICE STATUS PORTS
miapp_web_1 web running 0.0.0.0:8080->80/tcp
miapp_db_1 db exited (0)
Opción común:
docker compose ps -a
Diferencia con docker ps -a
docker compose ps -a
muestra únicamente información sobre los contenedores definidos en un archivo docker-compose.yml
. Si este archivo no existe en el directorio actual, se producirá un error.docker ps -a
muestra información sobre el estado de todos los contenedores, independientemente de si fueron creados mediante un archivo docker-compose.yml
o no.Inicia los contenedores que ya han sido creados pero que están detenidos. No crea nuevos contenedores, solo pone en marcha los existentes.
Ejemplo:
docker compose start
Diferencia con docker compose up
docker compose up
crea y ejecuta los contenedores si no existen.docker compose start
solo inicia los contenedores ya creados y detenidos.Detiene los contenedores de forma segura enviando una señal SIGTERM y esperando un tiempo antes de forzar su detención, sin eliminarlos.
La señal SIGTERM es la forma correcta de detener un proceso en Linux y Docker. Permite que el proceso tenga la oportunidad de limpiar recursos antes de finalizar (como cerrar archivos, guardar estados, liberar memoria, etc.).
Ejemplo:
docker compose stop
Reinicia los contenedores definidos en el archivo docker compose.yml
.
Ejemplo:
docker compose restart
Muestra los registros (logs) de los contenedores.
Opciones comunes:
docker compose logs nombre_del_servicio
docker compose logs -f
Construye o reconstruye las imágenes de los servicios definidos en el archivo docker-compose.yml
.
Se utiliza cuando se han realizado cambios en el archivo docker-compose.yml
o en las dependencias de construcción.
Ejemplo:
docker compose build
Ejecuta un comando en un contenedor en ejecución.
docker compose exec <nombre_del_servicio> <comando>
docker compose exec mi_servicio bash
Escala los servicios aumentando o disminuyendo el número de instancias de un contenedor.
Ejemplo:
Esto crea 3 instancias del servicio llamado "web"."
docker compose scale web=3
Valida y muestra la configuración del archivo docker-compose.yml
. Útil para detectar errores en el archivo antes de ejecutar los servicios.
Ejemplo:
docker compose config
Se utiliza para descargar (o "pull") las imágenes de Docker necesarias para los servicios definidos en el archivo docker-compose.yml
.
Estas imágenes se obtienen desde un registro remoto, como Docker Hub o cualquier otro repositorio de imágenes configurado.
Este comando es útil cuandl
docker-compose.yml
, cada uno debe ejecutar docker compose pull
para asegurarse de que todos usan las mismas imágenes.Ejemplo:
docker compose pull
Lista las imágenes definidas en el archivo docker-compose.yml
Ejemplo:
docker compose images
Muestra la versión de Docker Compose
Ejemplo:
docker compose version
Después de esta extensa pero necesaria introducción teórica, pasemos a la práctica con ejemplos de proyectos multicontenedor utilizando Docker Compose. Recrearemos nuevamente el proyecto realizado en el Tutorial de Docker Compose. Parte 1, en el que implementamos un servicio PHP y otro con MariaDB.
Como recordarás, la aplicación web realizaba consultas a una base de datos de ordenadores retro con varios registros. En aquella ocasión, configuramos todo manualmente sin utilizar archivos Dockerfile
. Ahora repetiremos el proceso, pero aplicando una metodología más adecuada.
Verás que, de esta manera, será mucho más sencillo desplegar los servicios, ya que todo estará configurado en los archivos
Dockerfile
y docker-compose.yml
. Además, practicaremos el escalado de servicios para simular el comportamiento de una web con alto tráfico.
Partiendo de nuestro directorio de trabajo, crearemos una estructura de directorios como esta:
Como sabes, los directorios en Linux se crean con el comando mkdir
. Para visualizar la estructura de directorios, utilizo el práctico comando tree
. Si no lo tienes instalado en tu sistema, te recomiendo hacerlo.
En el directorio web crearemos el archivo Dockerfile
que defina el servidor PHP con Apache y el directorio www, que será un bind volume y contendrá los archivos de la página web. Cada cambio realizado dentro de este directorio en el HOST se reflejará en el contenedor.
Por otro lado, en el directorio mariadb crearemos el archivo Dockerfile
con la configuración del servicio de base de datos MariaDB. Además, como vamos a suponer que no tenemos ninguna base de datos para importar, también crearemos un archivo inicial que genere una tabla en la base de datos y la pueble con varios registros.
Para no exponer datos sensibles en los archivos de configuración de los contenedores, como docker-compose.yml
o Dockerfile
, crearemos un directorio de secretos. De esta forma, si decidimos publicar nuestro proyecto en GitHub u otro repositorio, podremos excluir este directorio y evitar la exposición accidental de contraseñas.
Para seguir la misma línea y comparar mejor las diferencias, tanto positivas como negativas, tomaremos como hoja de ruta todos
los pasos seguidos en el primer tutorial, pero adaptándolos al uso de Dockerfile
.
En aquella ocasión, primero creamos el contenedor con la imagen php y descubrimos que no trae por defecto algunas librerías
necesarias para trabajar con bases de datos. Hubo que instalarlas y hacer commit
para tener una
imagen completamente equipada por si era necesario desplegar nuevamente el contenedor. Con el uso de Dockerfile
no será necesario este paso. Daremos todas las órdenes necesarias para tener una imagen completa y personalizada, ofreciendo así un servicio web completo con PHP.
Archivo Dockerfile
del servidor web:
# Usamos la imagen oficial de PHP con Apache
FROM php:apache-bookworm
# Instalamos las extensiones necesarias
RUN docker-php-ext-install mysqli pdo_mysql
# Configuramos el directorio de trabajo
WORKDIR /var/www/html
# Exponemos el puerto 80
EXPOSE 80
El archivo casi se explica por sí solo. Puedes repasar el Tutorial de Docker. Parte 2 de esta serie de artículos por si no recuerdas algo.
docker-php-ext-install
. Para otro tipo de extensiones o paquetes, podemos usar el habitual apt-get
. Por ejemplo, si queremos instalar el editor nano
, añadiremos una capa más:
# Instalar 'nano' y otras dependencias del sistema necesarias
RUN apt-get update && apt-get install -y nano
/
.
Dockerfile
es meramente documental o informativa; no tiene repercusión. Los puertos de los contenedores se exponen con parámetros en el archivo docker-compose.yml
. Anteriormente, esto lo hacíamos con parámetros en la instrucción docker run -p 8080:80 ...
No vamos a crear volúmenes en el Dockerfile
porque solo permite crearlos del tipo anónimo, es decir, aquellos que gestiona directamente Docker y que no permiten el uso de "bind mounts" (enlaces a una carpeta local del host desde el contenedor). Para tener mayor flexibilidad, crearemos el volumen desde el archivo docker-compose.yml
, ya que nos ofrece más control para elegir el nombre, la ubicación del directorio en el host o el tipo de volumen.
Por supuesto, si decides usar volúmenes anónimos, es una opción totalmente válida. Cada uno es libre de elegir la solución que mejor se adapte a sus necesidades.
Podemos comenzar a preparar el archivo docker-compose.yml
con la configuración del primer servicio.
Si este funciona correctamente, continuaremos con el servicio de MariaDB.
services:
miservidorphp:
build: web
networks:
- mired_01
ports:
- "8080:80"
volumes:
- ./web/www:/var/www/html # Bind volume, enlaza la carpeta "/web/www" del host al contenedor en "/var/www/html"
restart: unless-stopped
networks:
mired_01:
driver: bridge
Estos archivos son muy estructurados. Vemos claramente que tenemos 2 elementos de primer nivel: "services" y "networks". Comenzamos examinando el primero:
Dockerfile
.Y la segunda parte, networks, que define las redes utilizadas:
Con esta configuración, el contenedor miservidorphp se conecta a la red mired_01, permitiendo la comunicación con otros contenedores que compartan la misma red.
Podemos comprobar que lo realizado hasta ahora funciona levantando el contenedor con
docker compose up -d
. Si nos conectamos desde el navegador
a ip_pública:8080
de la instancia donde se ejecuta Docker Compose, se mostrará el contenido del
archivo index.php
dentro del volumen.
$ docker compose up -d
[+] Running 1/1
✔ Container compose_php_mariadb-miservidorphp-1 Started
Si aún no has agregado contenido, puedes editarlo con
nano ./web/www/index.php
, o si prefieres algo rápido y sencillo,
ejecuta:
$ echo "<p>Hola desde Docker Compose</p>" > ./web/www/index.php
Desde el host, podemos comprobar que el servicio web está en funcionamiento:
$ curl localhost:8080/
Hola desde Docker Compose
La configuración del servicio mibasededatos
requiere varios pasos y la intervención de múltiples componentes, algunos propios de Docker y otros de MariaDB. Desglosaremos cada parte para comprenderlo mejor. ¡Comencemos!
Paso 1: Creación del archivo Dockerfile de MariaDB
En el directorio mariadb, que creamos previamente, creamos el siguiente archivo Dockerfile
:
# Dockerfile para el servicio MariaDB
FROM mariadb:11.7.2
# Copiar el script de inicialización
COPY init.sql /docker-entrypoint-initdb.d/
# Exponer el puerto de MySQL
EXPOSE 3306
Nada nuevo que no hayamos visto en el primer tutorial:
Paso 2: Creación del archivo init.sql de MariaDB
Vamos a recuperar el archivo init.sql del primer tutorial y guardarlo en el mismo directorio que el archivo Dockerfile
de MariaDB.
CREATE DATABASE IF NOT EXISTS mibasededatos_pruebas01
CHARACTER SET utf8mb4
COLLATE utf8mb4_spanish_ci;
USE mibasededatos_pruebas01;
CREATE TABLE IF NOT EXISTS ordenadoresretro (
id INT AUTO_INCREMENT PRIMARY KEY,
ordenador VARCHAR(100) NOT NULL,
año INT NOT NULL
) CHARACTER SET utf8mb4
COLLATE utf8mb4_spanish_ci;
INSERT INTO ordenadoresretro (ordenador, año) VALUES
('Sinclair ZX Spectrum', 1982),
('Commodore 64', 1982),
('Amstrad CPC 464', 1984),
('Apple Macintosh', 1984),
('Atari ST', 1985),
('Commodore Amiga 500', 1987);
El archivo init.sql
es un script SQL que generalmente se utiliza para inicializar una base de datos con datos predeterminados o para configurar su estructura (como crear tablas, insertar registros, definir índices, etc.). Al iniciar el servicio de MariaDB, este script se ejecuta y crea una tabla con seis registros. De esta forma, se automatiza la carga de datos en la base de datos, permitiendo que puedan ser mostrados por la vista PHP.
Paso 3: Creación de los archivos de datos sensibles: secretos
La manera más segura de gestionar datos sensibles es mediante secretos. Para ello, creamos una serie de archivos, cada uno conteniendo un dato, dentro de un directorio específico. Estos archivos serán leídos desde el contenedor durante la ejecución.
Para crear los archivos de "secretos":
mkdir -p secrets
echo "rootpassword" > secrets/mysql_root_password.txt
echo "usuario" > secrets/mysql_user.txt
echo "contrasenya" > secrets/mysql_password.txt
Dentro del archivo docker-compose.yml
, veremos cómo configurar el servicio MariaDB para que utilice los archivos de secretos.
Paso 4: Ampliar el archivo docker-compose.yml para añadir el servicio MariaDB
Partiendo del archivo que ya teníamos, lo ampliamos añadiendo el servicio de MariaDB.
A continuación, se muestra el resultado con el nuevo servicio, el cual emplea variables de entorno almacenadas como secretos:
services:
miservidorphp:
build: web
networks:
- mired_01
ports:
- "8080:80"
volumes:
- ./web/www:/var/www/html # Bind volume, enlaza la carpeta "/web/www" del host al contenedor en "/var/www/html"
depends_on:
- mibasededatos
restart: unless-stopped
mibasededatos:
build: mariadb
networks:
- mired_01
environment:
MYSQL_DATABASE: mibasededatos_pruebas01
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/mysql_root_password
MYSQL_USER_FILE: /run/secrets/mysql_user
MYSQL_PASSWORD_FILE: /run/secrets/mysql_password
volumes:
- mariadb_ordenadoresretro:/var/lib/mysql
secrets:
- mysql_root_password
- mysql_user
- mysql_password
restart: unless-stopped
networks:
mired_01:
driver: bridge
volumes:
mariadb_ordenadoresretro:
secrets:
mysql_root_password:
file: ./secrets/mysql_root_password.txt
mysql_user:
file: ./secrets/mysql_user.txt
mysql_password:
file: ./secrets/mysql_password.txt
El servicio PHP también ha sufrido un cambio. Como no nos interesa que el servicio mibasededatos se inicie hasta que esté en marcha, utilizamos el atributo depends_on
para gestionarlo.
services:
miservidorphp:
.
depends_on:
- mibasededatos
.
Nota previa: Cuando llegue el momento de probarlo, descubriré que esta característica no funcionará debido a la falta de otra configuración.
Entendiendo los secretos y las variables de entorno
Dentro de la configuración del servicio mibasededatos, tengo una lista de variables de entorno.
services:
mibasededatos:
.
environment:
MYSQL_DATABASE: mibasededatos_pruebas01
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/mysql_root_password
MYSQL_USER_FILE: /run/secrets/mysql_user
MYSQL_PASSWORD_FILE: /run/secrets/mysql_password
.
En la documentación oficial se indica que, para que el contenedor de MariaDB funcione correctamente, se debe proporcionar al menos una de las siguientes variables: MARIADB_ROOT_PASSWORD_HASH
, MARIADB_ROOT_PASSWORD
, MARIADB_ALLOW_EMPTY_ROOT_PASSWORD
o MARIADB_RANDOM_ROOT_PASSWORD
(o sus equivalentes, incluyendo las versiones con _FILE
). Las demás variables de entorno, como MARIADB_DATABASE
y MARIADB_USER
, son opcionales y se utilizan para configuraciones adicionales.
He optado por utilizar MYSQL_ROOT_PASSWORD_FILE
para asignar la contraseña de superusuario a través de un archivo mediante secretos.
Para más información, siempre es recomendable consultar la documentación oficial.
La documentación oficial también menciona que, en versiones superiores a la 10.2.38, las variables de entorno con el prefijo MARIADB_*
tienen prioridad y se recomienda su uso en lugar de MYSQL_*
. Sin embargo, ambas formas siguen siendo compatibles. Personalmente, he preferido usar el formato MYSQL_*
por tradición.
Las variables de entorno declaradas tienen nombres bastante descriptivos, por lo que apenas requieren explicación. Aquellas que terminan en *_FILE
apuntan a un archivo seguro dentro del contenedor (/run/secrets/*
). Esto evita que la contraseña se exponga en los registros o en el código fuente.
Para declarar los nombres de los secretos, debemos usar el atributo secrets
dentro del elemento de primer nivel services
.
services:
mibasededatos:
.
secrets:
- mysql_root_password
- mysql_user
- mysql_password
El contenido de cada secreto se define en un elemento de primer nivel, también llamado secrets
.
secrets:
mysql_root_password:
file: ./secrets/mysql_root_password.txt
mysql_user:
file: ./secrets/mysql_user.txt
mysql_password:
file: ./secrets/mysql_password.txt
Cada secreto se almacena en un archivo de texto dentro del host. Al crear el contenedor, los archivos de secretos se instalan dentro de este en el directorio antes mencionado: /run/secrets/*
.
IMPORTANTE: No se deben usar credenciales ni datos sensibles directamente en variables de entorno sin utilizar archivos en entornos de producción, ya que quedarán expuestos en el archivo docker-compose.yml
si este se comparte.
El volumen donde se almacena la base de datos
En el servicio mibasededatos
vemos esto:
mibasededatos:
.
volumes:
- mariadb_ordenadoresretro:/var/lib/mysql
Como ya sabemos, esto significa que le estamos indicando al contenedor de MariaDB que utilice un volumen denominado mariadb_ordenadoresretro
, el cual se monta en el directorio /var/lib/mysql
dentro del contenedor. Al montar el volumen en esta ruta, cualquier dato que MariaDB escriba en /var/lib/mysql
se almacenará en el volumen mariadb_ordenadoresretro
, garantizando que esos datos no se pierdan cuando el contenedor se detenga o reinicie.
Este tipo de volumen se denomina "Gestionado por Docker". Los datos del volumen se guardan en el host en el directorio /var/lib/docker/volumes/
.
También tenemos, como elemento de primer nivel, la siguiente declaración:
volumes:
mariadb_ordenadoresretro:
Este bloque asegura que Docker cree un volumen llamado mariadb_ordenadoresretro
si no existe. Si el volumen ya existe, Docker simplemente lo reutiliza.
¿Exponer o no el puerto 3306?
Es posible que te haya llamado la atención que no hemos expuesto ningún puerto en el servicio mibasededatos y, sin embargo, los servicios pueden comunicarse entre sí. La razón es que todos forman parte de la misma red, y mibasededatos ya está configurado para escuchar el puerto 3306
dentro de la red interna mired_01
, creada específicamente para este entorno.
En cambio, el servicio miservidorphp sí expone y mapea el puerto 8080:80
, lo que permite que el tráfico entrante al host por el puerto 8080
sea dirigido al puerto 80
del contenedor.
Volvemos al primer tutorial para reutilizar el archivo de PHP, que realiza la consulta a la BBDD y la muestra en pantalla. En esta ocasión, he mejorado el diseño incorporando Bootstrap para darle un aspecto más moderno.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contenedor PHP</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body>
<div class="container">
<h1>Mostrar ordenadores retro</h1>
<p>Lectura de datos mediante la extensión "pdo_mysql" desde un contenedor PHP hacia otro contenedor MariaDB.</p>
<?php
// Conexión a la base de datos con PDO_MYSQL
$dsn = "mysql:host=mibasededatos;dbname=mibasededatos_pruebas01;charset=utf8mb4";
$username = "root"; // Cambia esto si es necesario
$password = "rootpassword"; // Cambia esto si es necesario
try {
$pdo = new PDO($dsn, $username, $password);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Consulta para obtener los datos de la tabla ordenadoresretro
$stmt = $pdo->query("SELECT ordenador, año FROM ordenadoresretro");
echo "<table class='table table-dark table-striped'>";
echo "<tr><th>Ordenador</th><th>Año</th></tr>";
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
echo "<tr><td>" . htmlspecialchars($row['ordenador']) . "</td><td>" . htmlspecialchars($row['año']) . "</td></tr>";
}
echo "</table>";
} catch (PDOException $e) {
echo "Error: " . $e->getMessage();
}
?>
<div>
</body>
</html>
Con esto, ya podríamos levantar los servicios, pero antes, déjame explicar cómo funcionan los secretos y las variables de entorno en el servicio mibasededatos.
Ha llegado el momento de comprobar si todo funciona levantando los servicios. La terminal indica que todo se ha creado correctamente, por lo que ya podemos abrir el navegador e ingresar la URL de nuestra IP pública con el puerto 8080.
$ docker compose up -d
[+] Running 4/4
✔ Network compose_php_mariadb_mired_01 Created 0.0s
✔ Volume "compose_php_mariadb_mariadb_ordenadoresretro" Created 0.0s
✔ Container compose_php_mariadb-mibasededatos-1 Created 0.3s
✔ Container compose_php_mariadb-miservidorphp-1 Created 0.4s
Al realizar una consulta en la web, se muestra la tabla de la base de datos, cumpliendo así el objetivo de esta práctica. La vista presenta una tabla con los registros de la base de datos de ordenadores retro. Aparentemente, todo funciona correctamente.
Aunque aún no lo vemos, hay un error. Para evitar que el servicio web se inicie antes que MariaDB, utilizamos en el archivo
docker-compose.yml
el siguiente atributo:
depends_on:
- mibasededatos
Sin embargo, cometí un error al interpretar la documentación. Si bien depends_on garantiza que el contenedor de MariaDB se inicie antes que el servicio web, esto no significa que la base de datos esté lista para aceptar conexiones. De hecho, MariaDB tarda aproximadamente 15 segundos en estar completamente operativo. Si intentamos acceder a la web inmediatamente después de levantar los contenedores, es probable que nos encontremos con un mensaje de Conexión rechazada.
Después de unos 10-15 segundos, si volvemos a intentarlo, debería funcionar. A continuación, explicamos cómo evitar este problema.
La solución para evitar que el servicio PHP se inicie antes de que el servicio MariaDB esté completamente operativo se puede gestionar mediante los atributos de Docker Compose a partir de la versión 2.20.2, lanzada en julio de 2023. Antes, era necesario recurrir a herramientas externas como el conocido script de Bash wait-for-it.sh, Dockerize u otras soluciones publicadas o personalizadas. Estas herramientas permitían verificar la disponibilidad de un servicio antes de continuar con la ejecución de otros servicios, pero añadían complejidad y dependencias adicionales al proyecto.
Con la introducción de nuevas funcionalidades en Docker Compose, ahora es posible gestionar estas dependencias de manera nativa. Para ello, se utiliza el atributo depends_on
en combinación con la condición service_healthy
. Esto permite asegurar que un servicio no se inicie hasta que otro servicio dependiente esté completamente operativo y en un estado saludable.
La corrección requiere 2 modificaciones en el archivo docker-compose-yml
:
Paso 1: Por un lado, modificamos el atributo depends_on
del servicio miservidorphp
añadiendo un nuevo atributo de condición que forzará a esperar un estado saludable del servicio mibasededatos
.
Su funcionalidad es similar a las promesas (*promises*) en lenguajes de programación como Java, JavaScript o Python, ya que comparten el concepto de esperar a que se cumpla una condición antes de continuar. Es decir, un mecanismo para manejar operaciones asíncronas:
miservidorphp:
.
.
depends_on:
mibasededatos:
condition: service_healthy
.
.
Paso 2: Por otro lado, dentro del servicio mibasededatos
añadimos el atributo healthcheck
que permite insertar un script o comando que verifique el estado del servicio en intervalos regulares.
mibasededatos:
.
.
healthcheck:
test: ["CMD", "/usr/local/bin/healthcheck.sh", "--su-mysql", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
.
.
Algunas imágenes oficiales de contenedores (como las de MySQL, PostgreSQL, Redis, etc.) ya incluyen comandos integrados que se pueden usar directamente en el healthcheck
sin necesidad de un script externo. Para saber si la imagen ya incluye un script o cuál es el más recomendable, debemos consultar la documentación de las imágenes.
En el caso concreto de MariaDB, en su documentación oficial encontramos información completa con ejemplos sobre cómo verificar el estado de salud con el script healthcheck.sh
.
test: Especifica el comando o script que se ejecutará para verificar el estado del servicio. En este caso, se utiliza un script llamado healthcheck.sh
que comprueba la conexión a la base de datos.
interval: Define el tiempo entre cada verificación de salud (en este caso, 10 segundos).
timeout: Establece el tiempo máximo que Docker esperará una respuesta del comando de verificación (5 segundos en este ejemplo).
retries: Indica cuántas veces se reintentará la verificación antes de marcar el servicio como no saludable.
El archivo completo modificado queda así:
services:
miservidorphp:
build: web
networks:
- mired_01
ports:
- "8080:80"
volumes:
- ./web/www:/var/www/html # Bind volume, enlaza la carpeta "/web/www" del host al contenedor en "/var/www/html"
depends_on:
mibasededatos:
condition: service_healthy
restart: unless-stopped
mibasededatos:
build: mariadb
networks:
- mired_01
environment:
MYSQL_DATABASE: mibasededatos_pruebas01
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/mysql_root_password
MYSQL_USER_FILE: /run/secrets/mysql_user
MYSQL_PASSWORD_FILE: /run/secrets/mysql_password
volumes:
- mariadb_ordenadoresretro:/var/lib/mysql
- ./mariadb/init.sql:/docker-entrypoint-initdb.d/init.sql
secrets:
- mysql_root_password
- mysql_user
- mysql_password
restart: unless-stopped
healthcheck:
test: ["CMD", "/usr/local/bin/healthcheck.sh", "--su-mysql", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
networks:
mired_01:
driver: bridge
volumes:
mariadb_ordenadoresretro:
secrets:
mysql_root_password:
file: ./secrets/mysql_root_password.txt
mysql_user:
file: ./secrets/mysql_user.txt
mysql_password:
file: ./secrets/mysql_password.txt
Tras este cambio, si detenemos los servicios y los volvemos a iniciar, notaremos un tiempo de espera mayor en la ejecución del comando. Durante aproximadamente 10 a 20 segundos, el servicio MariaDB permanecerá en estado Waiting, impidiendo el inicio del servicio Web.
Después de este tiempo, MariaDB cambiará al estado Healthy y, en ese momento, Docker Compose iniciará el servicio Web.
Añadir el servicio phpMyAdmin a este proyecto es extremadamente sencillo. Se trata de una veterana herramienta de administración de bases de datos MySQL y MariaDB basada en la web, muy útil para realizar todo tipo de gestiones en la base de datos mediante una interfaz visual más amigable.
Simplemente debemos agregar el servicio en el archivo docker-compose.yml
y volver a ejecutar el comando docker compose up -d
:
phpmyadmin:
image: phpmyadmin
restart: unless-stopped
ports:
- "8081:80" # Acceso desde el navegador
environment:
PMA_HOST: mibasededatos
PMA_PORT: 3306
networks:
- mired_01
Luego, accede desde el navegador a la dirección IP pública del host en el puerto 8081
, lo que te redirigirá a la aplicación:
Desde la documentación oficial, nos invitan a consultar "The Awesome Compose samples", que proporcionan un punto de partida para integrar diferentes frameworks y tecnologías utilizando Docker Compose.
Todos los ejemplos están disponibles en el repositorio de GitHub Awesome Compose y están listos para ejecutarse con
docker compose up
.
Te resultará interesante saber que, en muchos casos, los archivos Dockerfile
están configurados para crear contenedores multiplataforma utilizando el parámetro --platform=$BUILDPLATFORM
en la instrucción FROM
. Además, suelen emplear etapas de construcción para definir distintas configuraciones dentro del mismo Dockerfile
, adaptándose a entornos como desarrollo, pruebas o producción.
Para definir etapas de construcción en un Dockerfile
, se usa la opción AS nombre_etapa en la instrucción FROM
.
En el archivo Dockerfile
del ejemplo apache-php, podemos ver dos etapas: la etapa builder
y la etapa dev-envs
.
FROM --platform=$BUILDPLATFORM php:8.0.9-apache as builder
CMD ["apache2-foreground"]
FROM builder as dev-envs
RUN <<EOF
apt-get update
apt-get install -y --no-install-recommends git
EOF
RUN <<EOF
useradd -s /bin/bash -m vscode
groupadd docker
usermod -aG docker vscode
EOF
# install Docker tools (cli, buildx, compose)
COPY --from=gloursdocker/docker / /
CMD ["apache2-foreground"]
Para levantar un contenedor según la etapa, utilizaremos el atributo target
en el archivo docker-compose.yml
. No profundizaremos más en este tema, así que te recomiendo consultar la documentación oficial para obtener más información.