DEV Community

Cover image for Definiendo nuestras infraestructuras para desarrollo y testing con Docker
Dailos Rafael Díaz Lara
Dailos Rafael Díaz Lara

Posted on

Definiendo nuestras infraestructuras para desarrollo y testing con Docker

🇬🇧 English version

🎯 Objetivo

Cuando estamos creando una nueva aplicación o funcionalidad, normalmente necesitamos enviar peticiones a recursos independientes como pueden ser una base de datos o servicios con comportamiento controlado pero obviamente, realizar estas tareas contra servidores en la nube tiene un coste.

En este tipo de situaciones es cuando el aislamiento de sistemas que nos proporcionan los contenedores de Docker, es realmente útil.

En este artículo vamos a ver cómo podemos usar Docker para levantar una infraestructura mínima que nos permita ejecutar las tareas de desarrollo y/o testing, localmente.

El principal objetivo de este texto es mostrar cómo utilizar un único archivo docker-compose.yml para ambos entornos, empleando diferentes archivos .env para personalizar cada contenedor específico tanto para desarrollo como para testing.

Además, nos centraremos en cómo arrancar un nuevo contenedor para testing, ejecutar los tests que sean pertinentes y finalmente, apagar dicho contenedor.

💻 Configuración del sistema

Si vamos a hablar sobre Docker, es obvio que necesitamos tenerlo instalado en nuestro sistema. Si aún no lo tienes, puedes seguir las indicaciones dadas en la documentación oficial, para el sistema operativo que corresponda.

Otro elemento que vamos a necesitar tener instalado en nuestro sistema es docker-compose. De nuevo, si aún no lo tienes instalado, puedes seguir las indicaciones de la documentación oficial.

Por último, dado que este ejemplo está orientado a aplicaciones basadas en JavaScript/TypeScript, necesitamos tener instalado NodeJS (documentación oficial).

🏗 Inicialización del proyecto

🔥 Si ya tienes inicializando tu propio proyecto basado en NodeJS, puedes saltarte esta sección 🔥

Vamos a inicializar nuestro proyecto NodeJS abriendo una consola de comandos, en el directorio donde queramos trabajar, y escribimos el siguiente comando:

npm init -y
Enter fullscreen mode Exit fullscreen mode

Esta acción nos creará una único archivo package.json en la raíz de nuestro proyecto, con el siguiente contenido:

Ahora podemos instalar Jest ejecutando la siguiente instrucción en nuestra consola de comandos, para incluir esta librería en nuestro proyecto:

npm i -D jest
Enter fullscreen mode Exit fullscreen mode

El siguiente paso es crear la estructura más básica de directorios para el proyecto.

/
|-- /docker # <= Nuevo directorio.
|-- /node_modules
|-- /src # <= Nuevo directorio.
|-- package-lock.json
|-- package.json
Enter fullscreen mode Exit fullscreen mode

🐳 Definiendo la configuración de Docker

Vamos a tener dos entornos principales (development y test) y la idea es tener un único archivo docker-compose.yml para gestionar los contenedores de ambos entornos.

📄 Definición del archivo docker-compose.yml

Para conseguir nuestro objetivo, dentro del directorio /docker vamos a crear un único archivo llamado docker-compose.yml, el cual contendrá el siguiente código:

Como podemos apreciar, hay varias líneas marcadas como coupling smell. Esto significa que, con la configuración actual, podemos ejecutar un único contenedor de Docker destinado principalmente para tareas de desarrollo. Por lo tanto, está altamente acoplado a su entorno de ejecución.

¿No sería genial si fuésemos capaces de reemplazar esas configuraciones definidas directamente en el código, por referencias las cuales vinieran establecidas por algún tipo de archivo de configuración?

⚙ Archivos .env para contenedores Docker

!Sí! Podemos usar archivos .env de la misma manera que ya los usamos para nuestras aplicaciones, pero para configurar contenedores de Docker.

Lo primero que necesitamos hacer es modificar el archivo docker-compose.yml que acabamos de crear para usar plantillas basadas en llaves, para definir nombres de constantes que reemplazaremos con los valores indicados en nuestros archivos .env. De este modo, el contenido del archivo docker-compose.yml quedará de la siguiente manera:

Como podemos ver, hemos reemplazado los valores directamente escritos en el código por referencias del tipo ${CONSTANT_NAME}. El nombre de las variables escrito entre llaves será el nombre de los valores definidos en nuestros archivos .env. De esta manera, cuando arranquemos el comando docker-compose usando una opción específica de la línea de comandos que veremos más adelante, el contenido del archivo .env será reemplazado en nuestro archivo docker-compose.yml antes de que se cree el contenedor de Docker.

Ahora es el momento de definir nuestros entornos así que modificamos el contenido del directorio /docker para que quede tal que así:

/
|-- /docker
|   |-- /dev # <= Nuevo directorio y archivo.
|   |   |-- .docker.dev.env
|   |-- /test # <= Nuevo directorio y archivo.
|   |   |-- .docker.test.env
|   |-- docker-compose.yml
|-- /node_modules
|-- /src
|-- package-lock.json
|-- package.json
Enter fullscreen mode Exit fullscreen mode

Por cada entorno hemos creado un único subdirectorio: dev y test.

Dentro de cada subdirectorio de entorno hemos creado un archivo .env específico: .docker.dev.env y .docker.test.env.

🙋❓ ¿Sería posible nombrar los archivos de entorno sólo como .env?

Sí, es posible y además, no habría ningún problema en ello pero... un nombre de archivo tan descriptivo es una ayuda para nuestro rol como profesionales del desarrollo. Dado que en un mismo proyecto es muy probable que haya múltiples archivos de configuración, es útil ser capaz de diferenciarlos cuando tenemos varios de ellos abiertos, en el editor de código, al mismo tiempo. Esta es la razón por la que los archivos .env tienen uno nombres tan descriptivos.

Ahora pasaremos a definir el contenido de nuestros archivos de entornos, para que queden de la siguiente manera:

y...

Hay cuatro propiedades a las que debemos prestar atención a la hora de diferenciar entre los dos archivos:

  • CONTAINER_NAME
  • EXTERNAL_PORT
  • VOLUME_NAME
  • CONFIGURATION_PATH

La propiedad CONTAINER_NAME nos permite definir el nombre del contenedor que veremos después de que éste ha sido creado y además, cuando ejecutamos el comando docker ps -a para listar todos los contenedores presentes en nuestro sistema.

EXTERNAL_PORT es una propiedad realmente sensible ya que nos permite definir el puerto que el contenedor tendrán publicado y a través del cual, nuestra aplicación podrá conectarse con él. Es realmente importante tener cuidado con este parámetro porque algunas veces nos interesará tener levantados ambos entornos al mismo tiempo (development y test), pero si hemos definido el mismo puerto de acceso para ambos contenedores, el sistema nos lanzará un error al lanzar el segundo contenedor, ya que el puerto estará ocupado.

La propiedad VOLUME_NAME definirá el nombre del almacenamiento de datos en nuestro sistema.

Finalmente, en caso de que hayamos definido cualquier tipo de conjunto de datos para inicializar nuestra base de datos antes de usarla, la propiedad CONFIGURATION_PATH nos permitirá definir dónde está ubicado ese conjunto de datos.

🙋‍♀️❓ Oye pero, ¿qué pasa con la propiedad COMPOSE_PROJECT_NAME?

Esa es una magnífica pregunta.

Nuestro primer objetivo es crear un contenedor específico por cada entorno, basándonos en el mismo archivo docker-compose.yml.

Ahora mismo, si ejecutamos nuestro docker-compose para development, por ejemplo, crearemos el contenedor con esa definición de entorno y el archivo docker-compose.yml quedará enlazado a dicho contenedor.

De este modo, si intentamos ahora arrancar el mismo archivo pero utilizando la configuración para testing, el resultado final será que hemos actualizado el contenedor previo de development, sin la configuración para el entorno de testing. ¿Por qué? Pues porque el archivo de composición está enlazado al contenedor que arrancamos inicialmente.

Para conseguir nuestro objetivo satisfactoriamente, empleamos la propiedad COMPOSE_PROJECT_NAME dentro de cada archivo .env y le asignamos valores diferentes dependiendo del entorno al que pertenezca.

De esta manera, cada vez que ejecutemos el archivo de composición, dado que el nombre de proyecto es diferente para cada archivo .env, las modificaciones que se apliquen sólo afectarán al contenedor que corresponda con dicho nombre de proyecto.

🙋❓ Vale, bien, pero hemos usado la propiedad COMPOSE_PROJECT_NAME sólo dentro de nuestros archivos .env y no en el archivo docker-compose.yml. ¿Cómo es posible que afecte al resultado final?

Es posible porque esa propiedad es leída directamente por el comando docker-compose y no es necesario que esté incluida dentro del archivo docker-compose.yml.

En este enlace puedes encontrar toda la documentación oficial about COMPOSE_PROJECT_NAME.

🤹‍♂️ Inicializando la base de datos

🔥 Advertencia: El proceso que se expone a continuación está dirigido a inicializar el contenido de una base de datos MongoDB. Si quieres usar un motor diferente, necesitarás adaptar este proceso así como la configuración del docker-compose.yml para ello. 🔥

El concepto más básico que debemos saber, si es que no lo sabemos ya, es que cuando un contenedor basado en MongoDB se ejecuta por primera vez, todos los archivos con extensión .sh o .js ubicados en el directorio /docker-entrypoint-initdb.d dentro del propio contenedor, son ejecutados.

Esto nos proporciona una manera para inicializar nuestra base de datos.

Si quieres profundizar en esta propiedad, puedes consultar la documentación de la imagen oficial de MongoDB en Docker.

🧪 Configuración del entorno de testing

Para ver cómo podemos hacer esto, vamos a empezar por el entorno de testing así que antes de nada, tenemos que crear la siguiente estructura de archivos dentro del directorio /docker/test de nuestro proyecto:

/
|-- /docker
|   |-- /dev
|   |   |-- .docker.dev.env
|   |-- /test
|   |   |-- /configureDatabase # <= Nuevo directorio y archivo.
|   |   |   |-- initDatabase.js
|   |   |-- .docker.test.env
|   |-- docker-compose.yml
|-- /node_modules
|-- /src
|-- package-lock.json
|-- package.json
Enter fullscreen mode Exit fullscreen mode

El contenido del archivo initDatabase.js será el siguiente:

Este script está dividido en tres elementos diferentes.

La constante apiDatabases contiene todas las definiciones de bases de datos que queremos crear para nuestro contenedor.

Cada definición de base de datos contendrá su nombre (dbName), un array de usuarios (dbUsers) los cuales estarán autorizados para operar con la base de datos (incluyendo la definición de sus privilegios de acceso) y el conjunto de datos con los que inicializaremos la base de datos.

La función createDatabaseUser está destinada a gestionar la información contenida en cada bloque del apiDatabases, procesar los datos de usuarios y crearlos dentro de la base de datos indicada.

Finalmente, el bloque try/catch contiene la magia porque en este bloque iteramos sobre la constante apiDatabase, conmutamos entre bases de datos y procesamos la información.

Una vez que hemos analizado este código, si recordamos el contenido de nuestro archivo docker-compose.yml, dentro de la sección volumes definimos la siguiente línea:

- ${CONFIGURATION_PATH}:/docker-entrypoint-initdb.d:rw

Además, para el entorno de testing, dentro del archivo .docker.test.env, configuramos lo siguiente:

CONFIGURATION_PATH="./test/configureDatabase"

Con esta acción, el proceso docker-compose está copiando el contenido de la ruta indicada por CONFIGURATION_PATH dentro del directorio del contenedor /docker-entrypoint-initdb.d:rw antes de que éste se arranque por primera vez. Así es como estamos definiendo el script de configuración de nuestra base de datos, para que sea ejecutado al iniciarse el contenedor.

🙋‍♀️❓ Para esta configuración no estás usando ningún conjunto de datos iniciales. ¿Por qué?

Porque esta será la base de datos de testing y la intención es que se almacenen y eliminen datos ad-hoc en base a los tests que estén ejecutándose en un momento concreto. Por esta razón no tiene sentido que inicialicemos la base de datos con información que vamos a crear/editar/eliminar dinámicamente.

🛠 Configuración del entorno de desarrollo

Esta configuración es muy similar a la de testing.

Lo primero que tenemos que hacer es modificar el subdirectorio /docker/dev de nuestro proyecto, para que quede tal que así:

/
|-- /docker
|   |-- /dev
|   |   |-- /configureDatabase # <= Nuevo directorio y archivos.
|   |   |   |-- initDatabase.js
|   |   |   |-- postsDataToBePersisted.js
|   |   |   |-- usersDataToBePersisted.js
|   |   |-- .docker.dev.env
|   |-- /test
|   |   |-- /configureDatabase
|   |   |   |-- initDatabase.js
|   |   |-- .docker.test.env
|   |-- docker-compose.yml
|-- /node_modules
|-- /src
|-- package-lock.json
|-- package.json
Enter fullscreen mode Exit fullscreen mode

Los archivos postsDataToBePersisted.js y usersDataToBePersisted.js sólo contienen información estática definida dentro de constantes independientes. Esta información será almacenad en la base de datos indicada, dentro de la colección especificada.

La estructura de dichos contenidos será la siguiente:

Por otro lado, el contenido del archivo initDatabase.js es bastante similar al del entorno de testing pero un poco más complejo ya que ahora tenemos que gestionar colecciones y datos. De este modo, el resultado final es este:

En este script hay varias partes que necesitamos analizar.

En la cabecera tenemos un bloque compuesto por dos llamadas a la función load() encaminadas a importar los datos preparados y almacenados en las constantes que declaramos en los otros archivos JavaScript.

🔥 Hay que prestar atención a que la ruta indicada para hacer referencia a los archivos de datos, es relativa al interior de la estructura de ficheros del contenedor de Docker y no a la de nuestro sistema. 🔥

ℹ️ Si quieres aprender más acerca de cómo ejecutar MongoDB archivos JavaScript en su consola de comandos, echa un vistazo a su documentación oficial.

Después de "importar" las definiciones de las constantes usersToBePersisted y postsToBePersisted mediante el uso de la función load(), estas están disponibles globalmente dentro del contexto de nuestro script de inicialización.

El siguiente bloque a analizar es el de la constante apiDatabases donde, además de los campos dbName y dbUsers que ya vimos en la configuración de testing, en este caso el array dbData es un poco más complejo.

Cada objeto declarado dentro del array dbData define el nombre de la colección así como el conjunto de datos que debe ser almacenado en dicha colección.

Ahora nos encontramos con la definición de la constante collections. Es la definición de un mapa de funciones el cual contiene las acciones que se deben ejecutar por cada colección definida en el bloque apiDatabases.dbData.

Como podemos ver, en estas funciones estamos invocando directamente instrucciones nativas de MongoDB.

La siguiente función que nos encontramos es createDatabaseUsers la cual no tiene diferencias con la que definimos para el entorno de testing.

Justo antes de terminar el archivo, encontramos la función populateDatabase.

En esta función es donde vamos a través de las colecciones de bases de datos, insertando los datos asignados y aquí es donde invocamos al mapa de funciones collections.

Finalmente tenemos el bloque try/catch donde ejecutamos las mismas acciones que para el entorno testing pero hemos incluido la llamada a la función populateDatabase.

De esta manera es como hemos podido configurar el script de inicialización para nuestra base de datos del entorno de desarrollo.

🧩 Comando de Docker Compose

Una vez que hemos definido el archivo de composición así como el conjunto de datos que inicializará nuestra base de datos, tenemos que definir los campos mediante los cuales, operaremos nuestros contenedores.

🔥 Hay que prestar especial atención al hecho de que las rutas empleadas están referenciadas a la raíz de nuestro proyecto. 🔥

🌟 Configurando los últimos detalles para NodeJS

El último paso es definir los scripts necesarios dentro de nuestro archivo package.json.

Para proporcionar una mejor modularización de los scripts, es muy recomendable que se dividan en diferentes scripts atómicos y luego, crear otros scripts diferentes para agrupar aquellos que sean más específicos.

Por ejemplo, en este código hemos definido los scripts dev_infra:up, dev_infra:down, test:run, test_infra:up and test_infra:down que son atómicos porque definen una acción simple y serán los encargados de arrancar y para los contenedores para cada entorno, así como de ejecutar la suite de test.

Por el contrario tenemos los scripts build:dev y test que son compuestos ya que cada uno involucra varios scripts atómicos.

🤔 FAQ

¿Qué pasa si la suite de testing se para repentinamente porque alguno de los tests ha fallado?

No hay que preocuparse por esto porque es verdad que la infraestructura de testing continuará ejecutándose pero tenemos dos opciones:

  1. Mantener en ejecución el contenedor ya que la próxima vez que ejecutemos la suite de tests, el comando docker-compose actualizará el contenido del contenedor.
  2. Ejecutar manualmente el script de apagado del contenedor de testing.

¿Qué sucede si en lugar de una base de datos, necesitamos ejecutar algún servicio más complejo como una API?

Sólo necesitamos configurar los contenedores/servicios necesarios dentro del archivo docker-compose.yml, prestando especial atención a la configuración .env para cada entorno.

No importa lo que queramos incluir en nuestros contenedores. Lo importante aquí es que vamos a ser capaces de arrancarlos y detenerlos cuando nuestro proyecto lo necesite.

👋 Conclusiones finales

Con esta configuración podemos incluir la gestión de la infraestructura necesaria para nuestros proyectos con NodeJS.

Este tipo de configuraciones nos proporciona un nivel de desacoplamiento que aumenta nuestra independencia durante la fase de desarrollo, ya que vamos a tratar elementos externos a nuestro código como una caja negra con la cual interactuar.

Otro punto interesante de esta estrategia es que cada vez que arrancamos el contenedor mediante el comando docker-compose, éste es totalmente renovado lo que nos permite asegurar que nuestras suites de tests van a ejecutarse sobre sistemas completamente limpios.

Además, mantendremos limpio nuestro propio sistema ya que no necesitaremos instalar ningún tipo de aplicación auxiliar porque todas ellas, estarán incluidas en diferentes contenedores que compondrán nuestras infraestructura de pruebas.

Sólo una advertencia a este respecto, trata de mantener el contenido de dichos contenedores lo más actualizado posible para, de ese modo, hacer las pruebas contra un entorno lo más parecido posible al que podemos encontrarnos en producción.

Espero que este contenido te sea útil. Si tienes cualquier pregunta, siéntete totalmente libre de contactar conmigo. Aquí están mis perfiles de Twitter, LinkedIn y Github.

🙏 Reconocimientos y agradecimientos

  • Jonatan Ramos por darme la pista del COMPOSE_PROJECT_NAME para crear archivos docker-compose.yml único que se comparten entre diferentes entornos.

Top comments (0)