Tutorial de Docker Compose. Parte 3

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
¿Qué es Docker Compose?

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.

Estructura Básica del archivo docker-compose.yml

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.

Secciones del archivo docker-compose.yml

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.

name:

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
services:

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:

1. 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
2. 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
3. environment:

Define las variables de entorno que se pasarán al contenedor. Estas variables pueden declararse de dos maneras:

  • Lista de pares clave-valor (en la documentación oficial se refieren a esto como un "map" o mapa de claves-valor):
    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.
  • Array (lista de cadenas):
    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.

4. 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.

5. 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"
6. 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:

  • Montajes bind (bind mounts): Utilizan rutas del host y son visibles solo para el contenedor que los monte.
  • Volúmenes nombrados: Se definen en la sección 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.

7. 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
8. 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.

9. 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.

10. 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.

networks:

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:

  • driver: Define el tipo de red, como bridge (red predeterminada) o overlay (para servicios en un clúster).
  • ipam: Configura opciones avanzadas, como rangos de direcciones IP.
  • external: Permite usar una red existente en lugar de crear una nueva.

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" 
volumes:

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:
configs:

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
secrets:

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:

  • file: El secret se crea con el contenido de un archivo en la ruta especificada.
    services:
      web:
        image: nginx:latest
        secrets:
          - server-certificate
    
      secrets:
        server-certificate:
          file: ./secrets/server.cert # referencia al archivo con secreto alojado en el host
  • environment: El secret se crea con el valor de una variable de entorno en el host en el momento de crear el contenedor.
    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
Lista de comandos más utilizados

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.

docker compose up

Crea e inicia los contenedores definidos en el archivo docker-compose.yml

Opciones comunes:

  • -d: Ejecuta los contenedores en segundo plano (modo "detached").
  • --build: Reconstruye las imágenes antes de iniciar los contenedores.

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
docker compose down

Detiene y elimina los contenedores, redes y volúmenes creados por docker compose up

Uso común:

  • Elimina contenedores y redes.
    docker compose down
  • Eliminar volúmenes anónimos también(volúmenes nombrados no se eliminan):
    docker compose down --volumes
  • El más completo: elimina las imágenes--rmi all y los volúmenes anónimos-v:
    docker compose down --rmi all -v
docker compose rm

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:

  • -f, --force Elimina los contenedores sin solicitar confirmación.
  • -s, --stop Detiene los contenedores para poder eliminarlos.
  • -v Elimina también los volúmenes anónimos adjuntos a los contenedores (volúmenes específicos definidos en el archivo docker-compose.yml no se eliminan).

Ejemplo:

docker compose rm
docker compose ps

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:

  • -a Para listar todos los contenedores, incluidos los detenidos.
    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.
docker compose start

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.
docker compose stop

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
docker compose restart

Reinicia los contenedores definidos en el archivo docker compose.yml.

Ejemplo:

docker compose restart
docker compose logs

Muestra los registros (logs) de los contenedores.

Opciones comunes:

  • Mostrar los logs de un servicio específico:
    docker compose logs nombre_del_servicio
  • Seguir los logs en tiempo real:
    docker compose logs -f
docker compose build

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
docker compose exec

Ejecuta un comando en un contenedor en ejecución.

  • Forma de uso:
    docker compose exec <nombre_del_servicio> <comando>
  • Ejemplo:
    docker compose exec mi_servicio bash
docker compose scale

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
docker compose config

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
docker compose pull

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

  • Cuando trabajas con imágenes oficiales de un registro de imágenes y deseas asegurarte de que estás utilizando las versiones más recientes de dichas imágenes en tus servicios.
  • Cuando trabajas en equipo: Si varios desarrolladores comparten un mismo archivo docker-compose.yml, cada uno debe ejecutar docker compose pull para asegurarse de que todos usan las mismas imágenes.

Ejemplo:

docker compose pull
docker compose images

Lista las imágenes definidas en el archivo docker-compose.yml

Ejemplo:

docker compose images
docker compose version

Muestra la versión de Docker Compose

Ejemplo:

docker compose version
Ejercicio: Proyecto Multiservicio con PHP y MariaDB

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.

Preparando el entorno de trabajo

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.

Creación del Servidor Web

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.

  • FROM: Elegimos la imagen base oficial de PHP y Ubuntu de esta versión basada en Debian
  • RUN docker-php-ext-install Observa que se instalan las librerías mysqli y pdo_mysql utilizando la herramienta específica para instalar y habilitar extensiones basadas en imágenes oficiales de PHP 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
  • WORKDIR El directorio de trabajo lo fijamos en el directorio web de Apache. De esta forma, si iniciamos sesión en el contenedor o realizamos alguna operación de copia de archivos, partimos de la base de que, por defecto, estamos en el directorio web. De no hacerlo, el directorio de trabajo será la raíz del contenedor /.
  • EXPOSE La exposición de puertos en un archivo 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:

  • services: Elemento de primer nivel donde se difinen los servicios y sus características.
    • miservidorphp: Nombre del primer servicio(y único de momento). Debajo están los atributos con las características del servicio.
      • build: web El contexto para construir la imagen. En este caso en el directorio "web" se encuentra el archivo Dockerfile.
      • networks: Apartado donde se indica en red estará integrado el contenedor
        • - mired_01 Nombre de la red. Aquí la red no se crea, solamente se vincula.
      • ports: Sección para indicar los puertos expuestos.
        • - "8080:80" El puerto 8080 del Host se mapea al puerto 80 del contenedor
      • volumes: Sección para montar volumenes
        • - ./web/www:/var/www/html Se crea un bind mount donde el directorio local del host "./web/www" se refleja en el contenedor en "/var/www/html".
      • restart: unless-stopped El contenedor se reinicia automáticamente a menos que se detenga manualmente.

Y la segunda parte, networks, que define las redes utilizadas:

  • networks: Elemento de primer nivel donde se configuran las redes que estarán disponibles para los contenedores.
    • mired_01: Nombre de la red definida dentro del archivo. Aquí se establecen sus características.
      • driver: bridge Define el tipo de red. En este caso, bridge indica que se trata de una red interna de Docker, que permite la comunicación entre contenedores dentro del mismo entorno.

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

Creación del Servidor MariaDB

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:

  • FROM mariadb:11.7.2 Define la imagen base como esta versión de MariaDB.
  • COPY init.sql /docker-entrypoint-initdb.d/ Copia el archivo de inicialización de la base de datos para configurar su estructura y datos predeterminados.
  • EXPOSE 3306 Expone el puerto 3306 para la comunicación con la base de datos.

 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.

  • MYSQL_DATABASE: mibasededatos_pruebas01 – Variable que contiene el nombre de la base de datos. Se declara directamente sin ocultarla en archivos.
  • MYSQL_ROOT_PASSWORD_FILE: /run/secrets/mysql_root_password – Obligatoria. Define la contraseña del superusuario.
  • MYSQL_USER_FILE: /run/secrets/mysql_user – Para mejorar la seguridad, se recomienda crear un usuario administrador en lugar de utilizar el superusuario.
  • MYSQL_PASSWORD_FILE: /run/secrets/mysql_password – Contraseña del usuario administrador.

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.

Creación de la vista en PHP

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.

Levantando los servicios

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.

Servicios saludables: depends_on y service_healthy

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ñ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:

Awesome Compose. Selección de ejemplos de Docker Compose

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.