Tutorial de docker-compose.yml para ejecutar contenedores

Tutorial de docker-compose yml para ejecutar contenedores
Inicio » Informática » Varios » Tutorial de docker-compose.yml para ejecutar contenedores

Tabla de contenido

Antes de proceder con este post es necesario repasar lo que hemos visto hasta ahora en los anteriores tutoriales de este curso de Docker:

  • Teoría de Docker con los conceptos más importantes de éste. Necesario para establecer una buena base y después pasar a la práctica con todo claro y sin dudas.
  • Comandos de Docker en la práctica con ejemplos reales. Aunque no se utilizan mucho los comandos los hemos visto para comprender y conocer cómo funciona el sistema en realidad.
  • Ejemplos reales de creación de imágenes con Dockerfile. El primer paso necesario antes de pasar a utilizar el fichero docker-compose.yml es crear imágenes optimizadas, correctas y seguras con Dockerfile.

El fichero docker-compose.yml podemos verlo como un orquestador u organizador de todos los puntos anteriores, algo similar al comando run que vimos en episodios pasados pero mucho más completo y centralizado.

¿Qué es y para qué sirve el fichero docker-compose.yml?

Como hemos dicho, docker-compose.yml es un orquestador para crear imágenes e iniciar contenedores. Permite definir un conjunto de contenedores, cómo se iniciarán estos y que imagen o fichero Dockerfile utilizarán.

La forma en la que trabajábamos hasta ahora consistía en crear un fichero de Dockerfile en nuestro proyecto, con el comando build creábamos la imagen y con el run montábamos la imagen en el contenedor y lo iniciábamos mapeando puertos pero…

¿Qué ocurre cuando tenemos una aplicación compuesta por varios proyectos y, por lo tanto, varias imágenes y contenedores?, es inviable y bastante aburrido lanzar uno a uno manualmente.

Dado lo anterior podemos decir que un fichero docker-compose.yml sirve para orquestar varias imágenes y contenedores, permitiendo, a través de un único fichero y un comando, los siguientes puntos:

  • Creación de varias imágenes a través de sus respectivos ficheros Dockerfile o la url donde esté publicada.
  • Creación e inicio de varios contenedores, cada uno con sus respectivas imágenes.
  • Creación, si fuese necesario, de almacenamiento persistente para que los contenedores puedan acceder y compartir los datos.
  • Comunicación entre contenedores (interna) y hacia el exterior (externa) a través de redes y puertos.
  • Sincronización del inicio de contenedores, puesto que algunos debemos lanzarlos antes que otros.
  • Establecimiento de condiciones de inicio o healthchecks para cada uno de los contenedores de tal forma que, si uno de ellos no está en un estado correcto, el resto que dependen de él no se iniciarán.

Ejemplo real de Docker y docker-compose.yml

Antes de comenzar a crear nuestro fichero docker-compose.yml es necesario que tengas clara la arquitectura del ejemplo real de Docker a implementar:

  • Aplicación MVC de .NET que contiene el front y las llamadas al API, será la única accesible desde fuera.
  • API de .NET que realiza llamadas a base de datos. Solo puede acceder al API la aplicación MVC, para lo cual crearemos la Red 1.
  • Base de datos SQL Server solo accesible desde el API, para lo cual crearemos la Red 2.
  • Almacenamiento persistente para nuestra base de datos SQL Server, para lo cual crearemos un volúmen.
  • Orden de ejecución, primero debe ejecutarse SQL Server, luego el API y, finalmente, cuando ésta esté lista, la aplicación cliente.
  • Variables de configuración para modificar el comportamiento de los contenedores una vez estén ejecutándose.
  • Ficheros Dockerfile que definen cómo se crearán las imágenes de cada uno de los contenedores.

Además de lo anterior te dejo como tarea el crear los ficheros Dockerfile y .dockerignore del API, para ello puedes basarte en los ficheros de la aplicación MVC, lo único que debes tener en cuenta es que si quieres probarla a través de Swagger tendrás que comentar en el “Program.cs” las siguientes líneas:

//if (app.Environment.IsDevelopment())
//{
    app.UseSwagger();
    app.UseSwaggerUI();
//}

Primeros pasos y ejemplo sencillo de docker-compose.yml

En este apartado crearemos un fichero docker-compose.yml para automatizar los siguientes pasos a través de un único comando y un fichero:

  • Ejecutar los ficheros Dockerfile del proyecto del API y de la APP MVC de .NET.
  • Generar la imagen de cada uno de ellos. Con ello nos evitaremos utilizar varias veces el comando docker build -t api-image:1.0.0 ..
  • Crear e iniciar un contenedor para cada uno de ellos mapeando los puertos correspondientes para que sean accesibles desde fuera y podamos probarlos. Con ello evitamos utilizar varias veces el comando docker run --name api-container -p 8080:8080 -it api-image:1.0.0.

Dejaremos para la próxima sección la base de datos por tratarse de un caso un tanto especial.

Partiremos del siguiente fichero Dockerfile para el APP:

FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS sdk
RUN addgroup my-app-group \
&& adduser my-app-user --disabled-password --gecos "" --ingroup my-app-group
USER my-app-user
WORKDIR /home/my-app-user/app/src/
COPY --chown=my-app-user ./App.csproj .
RUN dotnet restore "App.csproj"
COPY --chown=my-app-user . .
RUN dotnet publish "App.csproj" --no-restore -c Release -o /home/my-app-user/app/publish
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime
RUN addgroup my-app-group \
&& adduser my-app-user --disabled-password --gecos "" --ingroup my-app-group
USER my-app-user
COPY --chown=my-app-user --from=sdk /home/my-app-user/app/publish /home/my-app-user/app/publish
WORKDIR /home/my-app-user/app/publish
EXPOSE 8080
CMD ["dotnet","App.dll"]

Y del siguiente fichero Dockerfile para el API:

FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS sdk
RUN addgroup my-app-group \
&& adduser my-app-user --disabled-password --gecos "" --ingroup my-app-group
USER my-app-user
WORKDIR /home/my-app-user/app/src/
COPY --chown=my-app-user ./Api.csproj .
RUN dotnet restore "Api.csproj"
COPY --chown=my-app-user . .
RUN dotnet publish "Api.csproj" --no-restore -c Release -o /home/my-app-user/app/publish
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime
RUN addgroup my-app-group \
&& adduser my-app-user --disabled-password --gecos "" --ingroup my-app-group
USER my-app-user
COPY --chown=my-app-user --from=sdk /home/my-app-user/app/publish /home/my-app-user/app/publish
WORKDIR /home/my-app-user/app/publish
CMD ["dotnet","Api.dll"]

El primer paso consiste en crear un fichero docker-compose.yml en la raíz de la carpeta que contiene los proyectos, es decir, en la carpeta “Docker”. El código es el siguiente:

services:
    app:
        build: ./App/App
        ports:
            - 8080:8080
    api:
        build: ./Api/Api
        ports:
            - 50000:8080

Los comandos utilizados dentro del docker-compose.yml son:

  • services, indica los proyectos que tenemos, en nuestro caso son solo dos, APP y API, dejamos la base de datos para más adelante.
  • build, indica la ruta donde se encuentran los ficheros Dockerfile correspondientes, la ruta debe ser relativa al lugar donde se encuentra el fichero docker-compose.yml.
  • ports, al igual que el comando docker create -p, se utiliza para indicar el mapeo de puertos entre la máquina anfitrión y el contenedor.

Ahora utilizaremos el comando docker compose up -d para ejecutar el fichero docker-compose.yml. Debemos ejecutarlo dentro de la ruta donde se encuentra el fichero. El parámetro -d reduce logs y nos devuelve el control de la terminal.

Una vez ejecutado también podemos usar los comandos que hemos visto hasta ahora como, por ejemplo, docker images para ver las imágenes generadas y docker compose ps para ver el listado de contenedores y su estado.

A continuación puedes probar que tanto la APP como el API están funcionando accediendo a “http://localhost:8080/” y “http://localhost:50000/swagger/index.html” respectivamente.

Una vez realizadas las pruebas recuerda eliminar el apartado ports del API puesto que no debe ser accesible desde fuera, simplemente lo hemos puesto para poder comprobar que realmente funciona.

Finalmente, si así lo deseas, puedes utilizar el comando docker compose down para parar y eliminar los contenedores creados, aunque las imágenes seguirán existiendo por lo que también puedes utilizar docker image prune -a para eliminarlas.

Configuración contenedor de base de datos SQL Server

He dejado esta parte de la aplicación para otro apartado ya que es un tanto peculiar. Lo que la diferencia de los otros dos proyectos es que en este caso no tenemos Dockerfile ni un proyecto para ella.

Es por este motivo que utilizaremos image vs build en docker-compose.yml:

  • services build crea una imagen a partir de un Dockerfile. En nuestro ejemplo lo utilizamos para el API y la APP.
  • services image utiliza una imagen dada una url, pero no la crea, solo la descarga. En nuestro ejemplo lo utilizaremos para descargar la versión correspondiente de SQL Server.

Si recordáis, en artículos anteriores utilizamos la imagen “mcr.microsoft.com/mssql/server:2022-latest”, esta vez también la usaremos pero indicaremos una versión concreta para seguir buenas prácticas y evitar “latest”.

El código de ejemplo de docker-compose.yml que utilizaremos para crear el contenedor de SQL Server es el siguiente:

services:
    app:
        build: ./App/App
        ports:
            - 8080:8080
    api:
        build: ./Api/Api
        # ports:
        #     - 50000:8080
    db:
        environment:
          ACCEPT_EULA: "Y"
          SA_PASSWORD: myStrongP@ssword
        image: mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04
        ports:
            - 1433:1433

Algunos apuntes sobre el código anterior:

  • Aunque lo veremos más adelante, environment sirve para establecer variables de configuración del contenedor de la misma manera que lo hacíamos con el comando run.
  • Como podéis ver en este servicio o proyecto no tenemos build sino image para indicar desde dónde descargar la imagen, no se construirá.
  • Recuerda comentar el apartado ports de la base de datos una vez que hayas probado que es accesible y funciona correctamente porque ésta tampoco será accesible desde el exterior.

Opcionalmente podemos comprobar que accedemos correctamente a través de Microsoft SQL Server Management Studio:

¿Cómo crear volúmenes para almacenamiento persistente?

Ahora tenemos el mismo problema que cuando vimos los volúmenes con comandos, si el contenedor de la base de datos se elimina y se vuelve a crear, sus datos desaparecerán porque no tenemos almacenamiento persistente.

Pues bien, esto se soluciona añadiendo, al mismo nivel que services, la línea volumes con el listado de volúmenes a crear (en nuestro caso será solo uno). Recuerda que SQL Server almacena la información en “/var/opt/mssql/data/” dentro de Linux.

El código de docker-compose.yml con volúmenes es el siguiente:

services:
    app:
        build: ./App/App
        ports:
            - 8080:8080
    api:
        build: ./Api/Api
        # ports:
        #     - 50000:8080
    db:
        environment:
          ACCEPT_EULA: "Y"
          SA_PASSWORD: myStrongP@ssword
        image: mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04
        ports:
            - 1433:1433
        volumes:
            - db-volume:/var/opt/mssql/data/
        user: "root"
volumes:
    db-volume:

Tal y como puedes ver, hemos añadido dos secciones:

  • Una al final del fichero con la etiqueta volumes que indica simplemente los volúmenes disponibles para el grupo de contenedores que estamos creando. En este ejemplo sólo tendremos uno llamado db-volume.
  • Una dentro del service “db” donde asignamos el volumen “db-volume” a la ruta “/var/opt/mssql/data/” del contenedor a través de la línea db-volume:/var/opt/mssql/data/.

Además, al igual que cuando creamos la base de datos con comandos, debemos indicar que el usuario por defecto del contenedor de SQL Server sea root, sino tendremos problemas con los permisos. Esto es algo concreto y específico de SQL Server, seguramente no te lo vayas a encontrar con otras bases de datos como MySQL.

Lo anterior se debe a que SQL Server utiliza por defecto el usuario mssql al iniciar el contenedor y éste no tiene acceso al volumen creado porque pertenece a root.

Iniciar un contenedor en modo root no es una buena solución, lo mejor sería:

  • Que al montar el volumen podamos indicarle que el propietario es mssql en vez de root, esta opción no es viable porque no se puede indicar el propietario al crear un volumen con docker-compose.yml.
  • Que en el sistema anfitrión podamos cambiar y dar permisos a los usuarios. Si, por ejemplo, nuestro sistema anfitrión fuera Linux, crearíamos una carpeta para los volúmenes y le daríamos permiso a mssql. Dado que estamos en Windows simulando Linux tampoco podemos realizarlo.

Finalmente comprobaremos la persistencia de datos en el volúmen siguiendo estos pasos:

  • Elimina los contenedores creados con el comando docker compose down.
  • Vuelve a crearlos con docker compose up -d.
  • Crea la base de datos a través de SSMS.
  • Elimina de nuevo los contenedores con docker compose down.
  • Vuelve a crearlos por segunda vez con docker compose up -d.
  • Refresca la carpeta “Databases” en SSMS y verifica que lo que hemos creado antes sigue existiendo.

Variables de configuración en docker-compose.yml

Aunque ya hemos visto el apartado de variables de configuración en docker-compose.yml, vamos a ver otra forma de crearlas cuando tenemos configuraciones un poco más complejas.

En el caso de nuestro proyecto .NET de ejemplo las configuraciones se almacenan en un archivo llamado “appsettings.json”. En el caso de la APP tenemos la siguiente configuración:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Api": "http://localhost:5270/"
}

Para poder establecer estas variables de configuración a través del fichero docker-compose.yml iremos al servicio de APP y crearemos el siguiente subapartado:

environment:
    - Api=http://api:8080/

Como puedes observar es una sintaxis distinta para establecer el valor de las variables de configuración respecto a lo que tenemos en el service db, simplemente en vez de utilizar el formato “Variable: Valor” utilizamos “- variable=valor”.

Quizás te surja la pregunta de ¿Cómo sabemos por qué puerto se inicia el api?. Sencillo, ejecuta docker compose up sin -d y en los logs encontrarás la línea “api-1 | Now listening on: http://[::]:8080”.

Un punto muy importante a tener en cuenta, hemos cambiado “localhost” por “api”. Dentro de las variables de configuración podemos utilizar el nombre de un service para referirnos a la ip de otro contenedor, de esta manera conseguimos generalizar aún más el código y no estar dependiendo de ip’s que pueden cambiar a futuro.

Ahora le toca el turno al API, debemos configurar la conexión a base de datos, el appsettings es el siguiente:

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=Emplyees;Trusted_Connection=True;"
  },
  … etc
}

Si te das cuenta ahora es necesario establecer el valor de una variable con hijos en docker-compose.yml, esto lo conseguimos con dos “_”, es decir, dentro del service del API añadiremos la siguiente línea:

environment:
            - ConnectionStrings__DefaultConnection=Server=db;Database=Employees;Trusted_Connection=false;User Id=sa;Password=myStrongP@ssword;TrustServerCertificate=true;

De nuevo, acuérdate de sustituir “localdb” o la ip correspondiente por el nombre del servicio “db”.

Finalmente, antes de probar la aplicación asegúrate de eliminar los contenedores con el comando docker compose down y recrear las imágenes evitando la caché con el comando docker compose build --no-cache, como podemos observar la aplicación funciona correctamente:

Además también puedes comprobar que si eliminas los contenedores y los vuelves a crear los datos persisten y no desaparecen.

Finalmente, de manera específica para nuestro proyecto, me gustaría comentar algunos errores que podemos encontrarnos en .NET con SQLServer y Alpine:

  • Al leer del appconfig debemos utilizar el método AddEnvironmentVariables para que se sustituya por lo del docker-compose.yml. Esto en realidad no es un error sino un comportamiento esperado.
  • Si te aparece el error “System.Globalization.CultureNotFoundException” debes cambiar el Api.csproj e introducir la siguiente línea dentro de “PropertyGroup”:
    <InvariantGlobalization>false</InvariantGlobalization>
  • Y después modificar el Dockerfile del API para instalar los paquetes icu-libs e icu-data-full solo en el caso de que utilices el runtime de Alpine.
    Este error solo lo encontramos con versiones determinadas de .NET Core con SQL Server y Alpine, es posible que a ti no te aparezca.
    FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime
    RUN apk add icu-libs icu-data-full

Redes, puertos y comunicación en contenedores

Antes de comenzar con este apartado es necesario recordar dos puntos importantes en las redes en Docker:

  • La línea EXPOSE dentro de un Dockerfile no expone el contenedor al exterior, es meramente informativa.
  • Lo que realmente expone y mapea puertos de un contenedor hacia el exterior es el comando run -p XX:YY o bien la sección ports: - XX:YY del docker-compose.yml.

Además, lo anterior se refiere a la comunicación externa, es decir, la capacidad de acceder desde fuera a un contenedor para que, por ejemplo, un usuario pueda utilizar nuestra aplicación.

De lo que vamos a hablar en este apartado es de la comunicación interna, es decir, la capacidad que tienen los contenedores de comunicarse entre sí.

Si ejecutamos el siguiente docker-compose.yml con docker compose up -d podemos comprobar que la aplicación funciona:

services:
    app:
        build: ./App/App
        ports:
            - 8080:8080
        environment:
            - Api=http://api:8080/
            - ASPNETCORE_ENVIRONMENT=Production
    api:
        build: ./Api/Api
        # ports:
        #     - 50000:8080
        environment:
            - ConnectionStrings__DefaultConnection=Server=db;Database=Employees;Trusted_Connection=false;User Id=sa;Password=myStrongP@ssword;TrustServerCertificate=true;
            - ASPNETCORE_ENVIRONMENT=Production
    db:
        environment:
          ACCEPT_EULA: "Y"
          SA_PASSWORD: myStrongP@ssword
        image: mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04
        #ports:
        #     - 1433:1433
        volumes:
            - db-volume:/var/opt/mssql/data/
        user: "root"
volumes:
    db-volume:  

Pero… ¿Por qué la aplicación funciona si no hemos creado ninguna red para que los contenedores se comuniquen internamente, entre sí? Pues porque, si no indicamos nada de redes en el fichero, por defecto los contenedores se pueden comunicar entre ellos porque se crea una red que los contiene a todos ellos, podemos observarlo con el comando docker network ls:

Además, antes de proceder con la práctica habrás visto la columna driver y quizás te preguntes cuáles son los tipos de redes de Docker. Pues bien, aquí tienes un resumen de los tipos de redes en Docker:

  • Bridge, tipo de red por defecto si no especificamos nada, sirve para comunicar contenedores de una aplicación o grupo entre ellos, es nuestro caso. Éstos están aislados en su propia red y no son accesibles desde el host ni desde otro grupo de contenedores.
  • Host, los contenedores utilizan la red del host en el que se encuentran en vez de la red virtual de Docker. Al estar en la red del host que los contiene son accesibles tanto desde el propio host (máquina anfitrión) como desde otro grupo de contenedores con algo de configuración extra.
  • Overlay, similar a host con la diferencia de que no se utiliza la red del host que los contiene, sino una red virtual que permite la comunicación entre contenedores que están en distintos host y distintos grupos de contenedores. Muy utilizado en Kubernetes.
  • None, el contenedor queda completamente aislado.

Ahora sí, vamos a la práctica a comprobar cómo crear redes en docker-compose.yml:

  • Lo primero que haremos será crear dos redes, Red 1 y Red 2 con el driver “Bridge”. Para ello iremos al final de nuestro fichero y crearemos la sección networks:
    networks:
      network-1:
        driver: bridge
      network-2:
        driver: bridge
  • Asignamos las redes a los contenedores. En concreto asignamos a APP la red 1 y a API la red 2 con la instrucción networks en cada uno de los servicios:
    app:        
      networks:
        - network-1
  • Lanzamos la aplicación y, como era de esperar, nos da error porque el APP no se puede comunicar con el API ya que están en redes distintas:

Ahora sí vamos a implementar la arquitectura que pensamos al principio donde APP y API estarán en la red 1 y API y base de datos estarán en la red 2. Comprobamos que la aplicación funciona correctamente. De manera opcional podemos comprobar que el APP puede hacer ping al API pero no a la base de datos:

El código final del fichero docker-compose.yml para este apartado sería el siguiente:

services:
    app:
        build: ./App/App
        ports:
            - 8080:8080
        environment:
            - Api=http://api:8080/
            - ASPNETCORE_ENVIRONMENT=Production
        networks:
            - network-1
    api:
        build: ./Api/Api
        # ports:
        #     - 50000:8080
        environment:
            - ConnectionStrings__DefaultConnection=Server=db;Database=Employees;Trusted_Connection=false;User Id=sa;Password=myStrongP@ssword;TrustServerCertificate=true;
            - ASPNETCORE_ENVIRONMENT=Production
        networks:
            - network-1
            - network-2
    db:
        environment:
          ACCEPT_EULA: "Y"
          SA_PASSWORD: myStrongP@ssword
        image: mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04
        #ports:
        #     - 1433:1433
        volumes:
            - db-volume:/var/opt/mssql/data/
        user: "root"
        networks:
            - network-2
volumes:
    db-volume:
networks:
  network-1:
    driver: bridge
  network-2:
    driver: bridge

¿Qué son las dependencias y los healthchecks en Docker?

Las dependencias en Docker nos permiten ejecutar un contenedor cuando otro se haya ejecutado. En nuestro caso tenemos el siguiente escenario:

  • El APP depende del API.
  • El API depende de la base de datos.
  • La base de datos no depende de nadie.

Ahora bien, ¿Cómo indicamos las dependencias en docker-compose.yml?, sencillo, a través de la línea depends_on, dentro de cada servicio indicaremos el listado de los servicios de los que depende:

services:
    app:
        …
        depends_on:
            - api
    api:
        …
        depends_on:
            - db
    db:
        …

Ahora bien, ten en cuenta que depends_on es un comando incompleto porque lo único que comprueba, para un contenedor, es que el contenedor del que depende se haya iniciado, nada más. No comprueba si ese inicio es correcto o no. Esto, a su vez, significa:

  • No comprueba errores, por ejemplo, es posible que el contenedor de base de datos se inicie pero, por algún error en la configuración, éste no se inicie o no permita hacer consultas. Si tuviéramos solo depends_on el API se iniciaría y, al llamar a base de datos, daría error.
    Otro ejemplo sería que el API no tuviera acceso a la base de datos por una mala configuración de red, con depends_on, como la SQL Server se ha iniciado correctamente, el API se iniciará y dará error.
  • Solo se ejecuta una vez, lo que significa que, si por algún motivo, uno de los contenedores deja de funcionar tras un tiempo, el resto seguirán activos y lanzarán errores.
    Un ejemplo de ello sería que el API se cayese por sobrecarga de tráfico, la APP llamaría a ésta y, obviamente, daría error.

Lo que cubre Docker es el primer punto, pero no el segundo, éste corresponde a Kubernetes y no forma parte del curso.

El primer punto, es decir, la comprobación de errores o el chequeo del estado de salud de otro contenedor lo conseguimos con la instrucción healthcheck. Este comando nos permite definir si un contenedor se ha iniciado correctamente o no, en terminología de Docker, cuando está “saludable” o “healthy”. Esto, junto con depends_on, nos permite establecer un orden de ejecución comprobando errores.

Vamos a ver, en nuestro propio proyecto, un ejemplo completo y práctico de depends_on y healthckeck.

Comenzaremos por la base de datos, la pregunta que debemos hacernos es, ¿Cuándo está lista? Cuando podamos hacer consultas sobre ella. Pues bien, debemos añadir al servicio db las siguientes líneas:

healthcheck:
            test: ["CMD-SHELL",
            "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P myStrongP@ssword -C -b
            -Q \"SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE'\""]
            interval: 10s
            retries: 10
            start_period: 10s
            timeout: 3s

Después debemos asegurarnos que en el API tenemos el apartado depends_on para indicar que depende del healthcheck del servicio db con la condition service_healty:

depends_on:
    db:
        condition: service_healthy

En el caso de SQLServer y, más concretamente, sqlcmd utility, el parámetro -b en la query es el más importante porque devuelve error si la consulta es errónea o no se puede ejecutar.

El resto de parámetros son fácilmente entendibles, interval indica cada cuánto tiempo se realiza la comprobación, retries el número de veces que se repite en caso de que dé error, start_period cuándo deben empezar las comprobaciones y timeout el tiempo máximo de cada una de las ejecuciones.

Una vez tenemos esto en nuestro docker-compose.yml podemos observar que, si hacemos un docker compose up, los demás contenedores no se inician hasta pasado un tiempo:

Otra comprobación que puedes hacer en el caso de que uses SQL Server es eliminar el FROM de la query del apartado test y verás que, pasado un periodo de tiempo, Docker nos avisa que el contenedor de base de datos no está “healthy” y, como consecuencia, ha detenido todos los contenedores del grupo y nos devuelve el error “dependency failed to start: container docker-db-1 is unhealthy”.

Ahora vamos a pasar al API, debemos hacernos la misma pregunta, ¿Cuándo estará lista?, pues cuando sea accesible y se la pueda llamar. Podemos implementar esto a través de un ping o, mejor aún, a través de los health checks de las APIs que no son más que un método o endpoint que al llamarle nos indica si nuestra API está saludable o no.

Aquí tenéis las instrucciones para implementar health checks en .NET, si utilizas otro lenguaje o framework deberás investigar, pero casi todos tienen esta funcionalidad.

Una vez que hemos implementado los health checks en el API debemos añadir lo siguiente a nuestro docker-compose.yml:

  • Dentro del servicio del API le indicaremos que está saludable cuando se pueda ejecutar el comando CURL contra el endpoint del health check:
    api:
    …    
            healthcheck:
                test: ["CMD", "curl", "-f", "http://api:8080/healthcheck"]
                interval: 10s
                retries: 10
                start_period: 10s
                timeout: 3s
  • Ten en cuenta que, dado que estamos utilizando una imagen de Alpine, es posible que tengas que instalar curl en el Dockerfile del API con la siguiente instrucción antes de cambiar al usuario del contenedor:
    RUN apk add curl
  • Dentro del servicio del APP le indicamos que depende de que el API esté en estado saludable de la misma manera que antes:
    depends_on:
        api:
            condition: service_healthy

Una vez realizado todo lo anterior es momento de detener y eliminar los contenedores con el comando docker compose down, reconstruir las imágenes con docker compose build --no-cache y finalmente iniciarlos con docker compose up.

Como podrás observar ahora todos los contenedores se inician en orden y tardan un poquito ya que tienen que esperar a que los contenedores de los que dependen estén listos.

El código final de nuestro fichero docker-compose.yml puedes encontrarlo en el repositorio de nuestro ejemplo de Docker enlazado.

Finalmente me gustaría hablar sobre test unitarios y de integración en Docker, hay defensores de ejecutarlos en Docker y defensores de ejecutarlos fuera de éste. Yo personalmente prefiero ejecutarlos fuera, en un paso previo a la creación de la imagen y ejecución de los contenedores. En cualquier caso, dado que no nos va a aportar mucho a la hora de aprender Docker, este tema lo dejamos aparcado.