Aceleración de CI con Docker

4 de enero de 2017

En el equipo de desarrollo de Driftrock nuestro objetivo es desarrollar y enviar nuevos valores y correcciones a nuestros clientes tan rápido y frecuentemente como podamos. Algo importante que nos permite hacerlo es la Integración Continua.

Recientemente hemos empezado a experimentar tiempos de prueba CI bastante largos - alrededor de 10 minutos. En nuestro equipo hacemos revisiones de Pull Request y eso significa que cada vez que hacíamos una pequeña actualización durante la discusión del PR teníamos que esperar 10 minutos para poder fusionar los cambios o pedir al revisor que aceptara los cambios y efectivamente nos desbloqueara para hacer otro trabajo. Si lo ponemos en perspectiva de un día de trabajo con 10 pull requests, supone el 20% del tiempo de trabajo (1:40 hora en una jornada laboral de 8 horas).

El problema

Observando el registro de salida de la compilación podemos identificar que el tiempo más largo lo lleva la instalación de los paquetes Gems y Node:

tiempos-prueba-originales.png

La aplicación en este caso es una aplicación mixta Ruby on Rails y Javascript (Node), que requiere la instalación tanto del paquete Gems como de los paquetes NPM. Esto se ejecuta cada vez a pesar de que los paquetes no cambian la mayoría de las veces.

También hay un tiempo que Snap tarda en inicializar el entorno de compilación - no podemos hacer nada al respecto. Y está el tiempo real de las pruebas, que quizá pueda mejorarse, pero el tiempo de empaquetado es ahora el mayor problema.

El archivo Dockerfile

Si no estás familiarizado con el ecosistema Docker, permíteme desviarme un poco de la trama principal hacia él. En Docker, todo lo que haces, empiezas con una imagen Docker. La imagen Docker es una instantánea de un sistema operativo - toda su estructura de sistema de archivos, herramientas CLI, shell, y los programas instalados en él junto con todas sus dependencias - menos el kernel.

Se trata de un concepto increíblemente potente que permite descargar y ejecutar cualquier cosa, desde el shell Ruby hasta el servidor Postgresql, utilizando un único comando. Sin necesidad de preocuparse por instalar todas las librerías, resolver conflictos y demás a lo que estábamos acostumbrados cuando ejecutábamos software directamente en nuestros SOs.

Para crear - o construir - una imagen Docker, Docker utiliza archivos llamados Dockerfile. Estos archivos contienen pasos - comandos - que se ejecutan dentro de la imagen para hacer una nueva imagen para el siguiente paso y, finalmente, la creación de la imagen final que se puede dar un nombre y reutilizar para ejecutar sus aplicaciones.

FROM ruby:latest

# Vuelva a ejecutar la instalación del paquete sólo si cambia el Gemfile
COPY Gemfile Gemfile.lock ./
RUN bundle install -j20

# Copie el resto del código fuente
COPY . ./El ejemplo anterior crea una imagen que contiene una aplicación Ruby con el paquete Gems instalado basado en el Gemfile.

Lo mejor de Dockerfiles es que cada paso crea una imagen intermedia que Docker almacena y guarda automáticamente. Como cada paso está definido de forma determinista por su comando, Docker no necesita volver a ejecutarlo mientras el paso no cambie.

Para los pasos RUN asume que mientras el texto del comando sea el mismo, su salida y todos los efectos secundarios que hace son los mismos. Más interesante aún, para los pasos COPIAR lee el contenido de los archivos especificados, calcula la huella digital hash de los mismos y mientras estos archivos no cambien su contenido, utiliza la imagen almacenada en caché.

En el ejemplo anterior este comportamiento se aprovecha para ejecutar el paso de instalación del paquete sólo si el archivo Gemfile y Gemfile.lock cambian realmente. Si no cambia, podemos utilizar la versión en caché y así acelerar la compilación. Debido a que utiliza el hash del contenido del archivo - no el tiempo de modificación del archivo, no hace ninguna suposición sobre el contenido, etc. - siempre invalida la caché de forma fiable cuando es necesario, eliminando la preocupación sobre el uso de estado no válido de la caché o así que a menudo tememos cuando se oye hablar de almacenamiento en caché.

La solución

Snap CI recientemente introdujo soporte beta para Docker en su pila de construcción. Conociendo las ventajas del almacenamiento en caché de imágenes de Docker, decidimos probarlo.

El plan es:

  • Construir imagen Docker conteniendo paquetes Gems y Node y archivos fuente de construcción actuales usando Dockerfile con paso copiando sólo el Gemfile y package.json antes de ejecutar bundle install && npm install.
  • Aprovechar el almacenamiento en caché para que cada compilación sólo añada nuevos archivos fuente y no vuelva a ejecutar el paquete si las dependencias no cambian.
  • Ejecute las pruebas con la imagen creada

Así que hicimos este Dockerfile y lo usamos para construir la imagen dentro del script de construcción:

FROM ruby:2.3.1

COPIAR Gemfile Gemfile.lock package.json /usr/src/app
EJECUTAR npm install && bundle install

COPIAR . /usr/src/app

Cuando hicimos esto, sin embargo, encontramos que el comando docker build en Snap desafortunadamente no utiliza la caché como lo hace en la máquina local. Simplemente ejecuta todos los comandos una y otra vez. Quizás porque la compilación se ejecuta cada vez en un nodo diferente o porque Snap simplemente limpia los archivos de Docker antes de ejecutar cada compilación.

Hashing manual

Si no podemos utilizar la construcción de caché en el comando Docker, nada nos impide imitarlo e implementarlo nosotros mismos. Así que decidimos hacerlo y cambiar el plan a:

  • Construir la imagen base que contiene los paquetes Gems y Node (sin los archivos fuente de construcción) sólo una vez.
  • Almacenar esa imagen en el repositorio de imágenes etiquetado con el hash de los archivos Gemfile y package.json.
  • Intenta descargar la imagen en compilaciones posteriores
  • Crear una nueva imagen añadiendo los archivos fuente de la compilación actual
  • Ejecute las pruebas utilizando esa nueva imagen

Este es el guión con el que terminamos:

# hash de ficheros que pueden influir en 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 imagen con dependencias si existe, si no construir y empujar a repo
docker pull $BASE_IMAGE || ( \
docker build -t $BASE_IMAGE -f Dockerfile-package-base . \
# push to repository for next time
&& docker push $BASE_IMAGE
)

# tag locally to constant name so it can be used in Dockerfile-test
docker tag -f $BASE_IMAGE repository.example/driftrock/app:base-current

# construir imagen de prueba desde 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 sólo instalar dependencias:

FROM ruby:2.3.1

COPY Gemfile Gemfile.lock package.json /usr/src/app
RUN npm install && bundle install

Y el Dockerfile-test sólo añadir los archivos de origen actual en la parte superior de 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 el mismo hash manualmente. A continuación, intentamos descargar la imagen cuyo nombre coincida con este hash. Si no existe - es decir, si el contenido de los respectivos archivos es nuevo - construimos la imagen y la almacenamos en nuestro repositorio privado de imágenes Docker con un nombre que contiene el hash de los archivos.

Entonces construimos la imagen extra app-test añadiendo los archivos fuente actuales a la imagen base que acabamos de descargar (o construir). Esto sólo se queda localmente para la construcción en particular - ya que no desplegar imágenes Docker todavía. Finalmente ejecutamos las pruebas dentro de esta imagen.

Aquí están los resultados:

después-prueba-tiempos.png

En la parte "Paquetes Gems y Node" cambiamos el tiempo de instalación real por el tiempo de descarga de la imagen almacenada en el repositorio.

Trasladando nuestras compilaciones de Snap CI para esta aplicación a Docker y aprovechando el repositorio de imágenes para almacenar imágenes con bundle pudimos reducir el tiempo de compilación de ~9 minutos a ~6 minutos.

Próximos pasos

La descarga de la imagen todavía tarda algún tiempo. Este tiempo depende principalmente del tamaño de la imagen. Si somos capaces de reducir el tamaño de la imagen mediante la limpieza de la cantidad de paquetes o la optimización de la imagen Docker mediante la eliminación de binarios innecesarios, esto acelerará aún más.

Aún tenemos margen para optimizar las propias pruebas y ejecutar los trajes de prueba independientes en paralelo, lo que puede ahorrar al menos 1:30 minutos más.

Conclusión

Aunque no utilicemos Docker para ejecutar nuestras aplicaciones en producción, Docker ha mejorado significativamente nuestro CI.

Al trasladar la mayor parte de la ejecución de la compilación al entorno Docker, también eliminamos la dependencia del entorno de compilación y las posibles vulnerabilidades a dependencias rotas, etc. Esto nos permite ser menos dependientes de una herramienta CI/CD en particular, lo que nos permite cambiar a mejores proveedores o cambiar en caso de interrupción de forma más rápida y menos costosa.

Hemos demostrado que el uso de Docker es beneficioso en el entorno de CI, tanto desde el punto de vista estratégico, así como la eficiencia. Vamos a mover otras partes de la tubería de construcción para Docker para reducir el tiempo o la dependencia de las características específicas de la herramienta de construcción que utilizamos - en nuestro caso Snap CI.

En general, este cambio supone una mejora significativa de nuestro enfoque de CI y lo extenderemos al resto de aplicaciones de Driftrock.

Vale la pena mencionar que nuestro problema era la larga instalación de dependencias. Quizás si tu aplicación tiene menos dependencias o su instalación lleva un tiempo insignificante comparado con el resto del tiempo de compilación, este enfoque puede no beneficiarte.