En el equipo de desarrollo de Driftrock, nuestro objetivo es desarrollar y ofrecer a nuestros clientes nuevas funciones y correcciones con la mayor rapidez y frecuencia posible. Un elemento clave que nos permite hacerlo es la integración continua.
Últimamente hemos empezado a tener tiempos de ejecución de las pruebas de CI bastante largos: unos 10 minutos. En nuestro equipo revisamos las solicitudes de incorporación de cambios (PR), lo que significa que cada vez que hacíamos una pequeña actualización durante la discusión de la PR, teníamos que esperar 10 minutos para poder fusionar los cambios o pedir al revisor que los aceptara y, de hecho, nos desbloqueara para poder hacer otro trabajo. Si lo ponemos en perspectiva en una jornada laboral con 10 solicitudes de incorporación de cambios, esto supone el 20 % del tiempo de trabajo (1 hora y 40 minutos en una jornada laboral de 8 horas).
El problema
Al examinar el registro de salida de la compilación, podemos constatar que lo que más tiempo lleva es la instalación de las gemas y los paquetes de Node:

La aplicación en este caso es una aplicación mixta de Ruby on Rails y JavaScript (Node), que requiere la instalación tanto de paquetes Gem como de paquetes NPM. Esto se ejecuta cada vez, a pesar de que los paquetes no suelen cambiar.
Además, Snap tarda un tiempo en inicializar el entorno de compilación; no podemos hacer nada al respecto. Y luego está el tiempo que duran las pruebas propiamente dichas, que quizá se pueda mejorar, pero el tiempo de empaquetado es ahora el mayor problema.
El archivo Dockerfile
Si no estás familiarizado con el ecosistema de Docker, permíteme hacer un pequeño desvío de la trama principal para hablar de ello. En Docker, todo lo que haces parte de una imagen de Docker. Una imagen de Docker es una instantánea de un sistema operativo —con toda su estructura de sistema de archivos, herramientas de la interfaz de línea de comandos, shell y los programas instalados en él, junto con todas sus dependencias— sin el núcleo.
Se trata de un concepto increíblemente potente que te permite descargar y ejecutar cualquier cosa, desde un intérprete de Ruby hasta un servidor PostgreSQL, con un solo comando. Ya no hay que preocuparse por instalar todas las bibliotecas, resolver conflictos y demás, como solíamos hacer cuando ejecutábamos el software directamente en nuestros sistemas operativos.
Para crear —o compilar— una imagen de Docker, Docker utiliza archivos denominados Dockerfile. Estos archivos contienen pasos —comandos— que se ejecutan dentro de la imagen para generar una nueva imagen para el siguiente paso y, en última instancia, crear la imagen final a la que puedes asignar un nombre y reutilizar para ejecutar tus aplicaciones.
FROM ruby:latest
# Vuelve a ejecutar «bundle install» solo si hay cambios en el Gemfile
COPY Gemfile Gemfile.lock ./
RUN bundle install -j20
# Copia el resto del código fuente
COPY . ./El ejemplo anterior crea una imagen que contiene una aplicación Ruby con las gemas instaladas según el Gemfile.
Lo mejor de los archivos Dockerfile es que cada paso crea una imagen intermedia que Docker almacena y guarda en caché automáticamente. Dado que cada paso está definido de forma determinista por su comando, Docker no necesita volver a ejecutarlo siempre y cuando el paso no cambie.
En el caso de los pasos RUN, se da por sentado que, siempre que el texto del comando sea el mismo, su salida y todos los efectos secundarios que produce serán los mismos. Lo más interesante es que, en el caso de los pasos COPY, lee el contenido de los archivos especificados, calcula su huella hash y, siempre que el contenido de dichos archivos no cambie, utiliza la imagen almacenada en caché.
En el ejemplo anterior, este comportamiento se aprovecha para ejecutar el paso de instalación del paquete solo si los archivos Gemfile y Gemfile.lock realmente cambian. Si no cambia, podemos utilizar la versión almacenada en caché y así acelerar la compilación. Dado que utiliza el hash del contenido del archivo —y no la fecha de modificación del mismo—, no hace ninguna suposición sobre el contenido, etc., por lo que siempre invalida la caché de forma fiable cuando es necesario, eliminando la preocupación por utilizar un estado de caché no válido o similares, algo que a menudo nos preocupa cuando oímos hablar del almacenamiento en caché.
La solución
Snap CI ha incorporado recientemente compatibilidad beta con Docker en su pila de compilación. Conscientes de las ventajas que ofrece el almacenamiento en caché de imágenes de Docker, decidimos probarlo.
El plan es el siguiente:
- Crea una imagen de Docker que contenga las gemas y los paquetes de Node, así como los archivos fuente de la compilación actual, utilizando un Dockerfile en el que, en un paso, solo se copien el Gemfile y el package.json antes de ejecutar «bundle install && npm install».
- Aprovecha el almacenamiento en caché para que cada compilación solo añada los archivos fuente nuevos, pero no vuelva a ejecutar el empaquetado si las dependencias no cambian
- Ejecuta las pruebas utilizando la imagen compilada
Así que hemos creado este Dockerfile y lo utilizamos para compilar la imagen dentro del script de compilación:
FROM ruby:2.3.1
COPY Gemfile Gemfile.lock package.json /usr/src/app
RUN npm install && bundle install
COPY . /usr/src/app
Sin embargo, al hacerlo, descubrimos que, lamentablemente, el comando «docker build» en Snap no utiliza la caché como lo hace en la máquina local. Simplemente vuelve a ejecutar todos los comandos una y otra vez. Quizás sea porque la compilación se ejecuta cada vez en un nodo diferente o porque Snap simplemente borra los archivos de Docker antes de cada compilación.
Generación manual de hash
Si no podemos utilizar la función de almacenamiento en caché integrada en el comando de Docker, nada nos impide imitarla e implementarla nosotros mismos. Así que decidimos hacerlo y cambiar el plan a:
- Compila la imagen base que contenga los paquetes Gems y Node (sin los archivos fuente de compilación) solo una vez
- Guarda esa imagen en el repositorio de imágenes con la etiqueta que contenga el hash de los archivos Gemfile y package.json
- Intenta descargar la imagen en las siguientes compilaciones
- Crear una nueva imagen añadiendo los archivos fuente de la compilación actual
- Ejecuta las pruebas utilizando esa nueva imagen
Este es el guion que acabamos elaborando:
# Hash de los archivos que pueden afectar a las dependencias
PACKAGE_SHA=$( cat Dockerfile-package-base Gemfile Gemfile.lock package.json | sha256sum | cut -d" " -f1 )
BASE_IMAGE=repository.example/driftrock/app:base-$PACKAGE_SHA
# descargar la imagen con las dependencias si existe; si no, compilarla y enviarla al repositorio
docker pull $BASE_IMAGE || ( \
docker build -t $BASE_IMAGE -f Dockerfile-package-base . \
# enviar al repositorio para la próxima vez
&& docker push $BASE_IMAGE
)
# etiquetar localmente con un nombre constante para que se pueda usar en Dockerfile-test
docker tag -f $BASE_IMAGE repository.example/driftrock/app:base-current
# compilar la imagen de prueba a partir de app:base-current: añade los archivos fuente actuales a la imagen base
docker build -f Dockerfile-test -t app-test .
# ejecutar pruebas dentro de la imagen creada
docker run app-test ./scripts/run-tests.sh
Con Dockerfile-package-base, que solo instala las dependencias:
FROM ruby:2.3.1
COPY Gemfile Gemfile.lock package.json /usr/src/app
RUN npm install && bundle install
Y el archivo Dockerfile-test, que solo añade los archivos fuente actuales a la imagen base:
FROM repository.example/driftrock/app:base-current
COPY . /usr/src/app
Inspirándonos en cómo Dockerfile almacena en caché los comandos COPY basándose en el hash del contenido de los archivos, calculamos ese mismo hash manualmente. A continuación, intentamos descargar una imagen cuyo nombre coincida con ese hash. Si no existe —es decir, si el contenido de los archivos correspondientes es nuevo—, compilamos la imagen y la almacenamos en nuestro repositorio privado de imágenes de Docker con un nombre que incluya el hash de los archivos.
A continuación, creamos una imagen «app-test» adicional añadiendo los archivos fuente actuales a la imagen base que acabamos de descargar (o compilar). Esta imagen solo se mantiene localmente para esa compilación concreta, ya que todavía no implementamos imágenes de Docker. Por último, ejecutamos las pruebas dentro de esta imagen.
Estos son los resultados:

En la sección «Gems y paquetes Node», cambiamos el tiempo de instalación propiamente dicho por el tiempo necesario para descargar la imagen almacenada en el repositorio.
Al trasladar nuestras compilaciones de Snap CI para esta aplicación a Docker y aprovechar el repositorio de imágenes para almacenar imágenes con el paquete, hemos conseguido reducir el tiempo de compilación de unos 9 minutos a unos 6 minutos.
Próximos pasos
La descarga de la imagen aún tarda un tiempo. Este tiempo depende principalmente del tamaño de la imagen. Si logramos reducir el tamaño de la imagen eliminando paquetes o optimizando la imagen de Docker al eliminar binarios innecesarios, el proceso se acelerará aún más.
Todavía tenemos margen para optimizar las pruebas en sí mismas y para ejecutar los conjuntos de pruebas independientes en paralelo, lo que nos permitiría ahorrar al menos un minuto y medio más.
Conclusión
Aunque no utilicemos Docker para ejecutar nuestras aplicaciones en producción, Docker ha mejorado considerablemente nuestro proceso de integración continua.
Al trasladar la mayor parte del proceso de compilación al entorno Docker, también hemos eliminado la dependencia del entorno de compilación y las posibles vulnerabilidades derivadas de dependencias defectuosas, etc. Esto nos permite reducir nuestra dependencia de una herramienta concreta de CI/CD, lo que nos permite cambiar a mejores proveedores o sustituir el servicio en caso de interrupción de forma más rápida y económica.
Hemos demostrado que el uso de Docker resulta beneficioso en un entorno de integración continua (CI), tanto desde el punto de vista estratégico como en términos de eficiencia. Vamos a trasladar otras partes del proceso de compilación a Docker para reducir el tiempo de ejecución y la dependencia de las características específicas de la herramienta de compilación que utilizamos —en nuestro caso, Snap CI—.
En general, este cambio supone una mejora significativa en nuestro enfoque de integración continua y lo iremos implementando en el resto de aplicaciones de Driftrock.
Cabe mencionar que nuestro problema era la larga duración de la instalación de las dependencias. Quizás, si tu aplicación tiene menos dependencias o si su instalación lleva un tiempo insignificante en comparación con el resto del tiempo de compilación, es posible que este enfoque no te resulte beneficioso.