Accélérer l'IC avec Docker

4 janvier 2017

L'équipe de développement de Driftrock a pour objectif de développer et de livrer de nouvelles valeurs et des correctifs à nos clients aussi rapidement et fréquemment que possible. L'élément important qui nous permet de le faire est l'intégration continue.

Récemment, nous avons commencé à expérimenter des temps de test de CI assez longs - environ 10 minutes. Dans notre équipe, nous procédons à des révisions de Pull Request et cela signifie qu'à chaque fois que nous avons fait une petite mise à jour pendant la discussion PR, nous avons dû attendre 10 minutes pour pouvoir fusionner les changements ou demander au réviseur d'accepter les changements et de nous débloquer effectivement pour faire d'autres travaux. Si nous mettons cela en perspective d'une journée de travail avec 10 demandes d'extraction, cela représente 20% du temps de travail (1:40 heure sur une journée de travail de 8 heures).

Le problème

En regardant le journal de sortie de la construction, nous pouvons identifier que le temps le plus long est pris par l'installation des paquets Gems et Node :

original-test-times.png

Dans ce cas, il s'agit d'une application mixte Ruby on Rails et Javascript (Node), qui nécessite l'installation des paquets Gems et NPM. Cette opération est exécutée à chaque fois bien que les paquets ne changent pas la plupart du temps.

Il y a aussi le temps que Snap prend pour initialiser l'environnement de construction - nous ne pouvons rien y faire. Et il y a le temps des tests proprement dits, qui peut peut-être être amélioré, mais le temps d'empaquetage est le problème le plus important actuellement.

Le fichier Docker

Si vous ne connaissez pas l'écosystème Docker, permettez-moi de m'écarter un peu de l'intrigue principale. Dans Docker, tout ce que vous faites commence par une image Docker. L'image Docker est un instantané d'un système d'exploitation - toute sa structure de système de fichiers, ses outils CLI, son shell et les programmes qui y sont installés avec toutes leurs dépendances - moins le noyau.

Il s'agit d'un concept incroyablement puissant qui vous permet de télécharger et d'exécuter n'importe quel logiciel, du shell Ruby au serveur Postgresql, à l'aide d'une seule commande. Plus besoin de se préoccuper de l'installation de toutes les bibliothèques, de la résolution des conflits, etc. auxquelles nous sommes habitués lorsque nous exécutons des logiciels directement sur nos systèmes d'exploitation.

Pour créer - ou construire - une image Docker, Docker utilise des fichiers appelés Dockerfile. Ce fichier contient des étapes - des commandes - qui sont exécutées dans l'image pour créer une nouvelle image pour l'étape suivante et finalement créer une image finale à laquelle vous pouvez donner un nom et que vous pouvez réutiliser pour exécuter vos applications.

FROM ruby:latest

# Réexécuter l'installation du bundle uniquement si le Gemfile change
COPY Gemfile Gemfile.lock ./
RUN bundle install -j20

# Copier le reste du source
COPY . ./ L'exemple ci-dessus crée une image contenant une application Ruby avec un bundle Gems installé basé sur le Gemfile.

L'intérêt des Dockerfiles est que chaque étape crée une image intermédiaire que Docker stocke et met en cache automatiquement. Comme chaque étape est définie de manière déterministe par sa commande, Docker n'a pas besoin de l'exécuter à nouveau tant que l'étape ne change pas.

Pour les étapes RUN, il suppose que tant que le texte de la commande est le même, sa sortie et tous les effets secondaires qu'il produit sont les mêmes. Plus intéressant encore, pour les étapes COPY, il lit le contenu des fichiers spécifiés, calcule leur empreinte de hachage et, tant que ces fichiers ne changent pas de contenu, il utilise l'image mise en cache.

Dans l'exemple ci-dessus, ce comportement est exploité pour exécuter l'étape d'installation du bundle uniquement si les fichiers Gemfile et Gemfile.lock changent réellement. S'ils ne changent pas, nous pouvons utiliser la version mise en cache et ainsi accélérer la construction. Parce qu'il utilise le hachage du contenu du fichier - et non le temps de modification du fichier, il ne fait aucune supposition sur le contenu, etc. - il invalide toujours le cache de manière fiable lorsque c'est nécessaire, ce qui élimine l'inquiétude liée à l'utilisation d'un état invalide du cache, que l'on craint souvent lorsqu'on entend parler de mise en cache.

La solution

Snap CI a récemment introduit un support beta pour Docker dans leur pile de construction. Connaissant les avantages de la mise en cache des images par Docker, nous avons décidé d'essayer.

Le plan est le suivant :

  • Construire une image Docker contenant les paquets Gems et Node et les fichiers sources de construction actuels en utilisant Dockerfile avec une étape copiant uniquement le Gemfile et le package.json avant d'exécuter bundle install && npm install.
  • Tirer parti de la mise en cache pour que chaque compilation n'ajoute que les nouveaux fichiers sources et ne réexécute pas le bundle si les dépendances ne changent pas.
  • Exécuter les tests en utilisant l'image construite

Nous avons donc créé ce fichier Docker et l'avons utilisé pour construire l'image dans le script de construction :

FROM ruby:2.3.1

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

COPY . /usr/src/app

Lorsque nous avons fait cela, nous avons constaté que la commande docker build sur Snap n'utilise malheureusement pas le cache comme elle le fait sur la machine locale. Elle exécute toujours toutes les commandes encore et encore. Peut-être parce que le build est exécuté à chaque fois dans un nœud différent ou que Snap nettoie simplement les fichiers Docker avant chaque exécution du build.

Hachage manuel

Si nous ne pouvons pas utiliser le caching build dans la commande Docker, rien ne nous empêche de l'imiter et de l'implémenter nous-mêmes. Nous avons donc décidé de le faire et de changer le plan en :

  • Construire une seule fois l'image de base contenant les paquets Gems et Node (sans les fichiers sources de construction)
  • Stocker cette image dans un référentiel d'images étiqueté avec le hash des fichiers Gemfile et package.json
  • Essayez de télécharger l'image dans les versions suivantes
  • Créer une nouvelle image en ajoutant les fichiers sources de la compilation actuelle
  • Exécuter les tests en utilisant cette nouvelle image

Voici le scénario auquel nous avons abouti :

# hash des fichiers qui peuvent influencer les dépendances
PACKAGE_SHA=$( cat Dockerfile-package-base Gemfile Gemfile.lock package.json | sha256sum | cut -d" " -f1 )

BASE_IMAGE=repository.example/driftrock/app:base-$PACKAGE_SHA

# download image with dependencies if exists, if not build it and push to repo
docker pull $BASE_IMAGE || ( \
docker build -t $BASE_IMAGE -f Dockerfile-package-base . \N-
# pousser vers le dépôt pour la prochaine fois
&& docker push $BASE_IMAGE
)

# taguer localement vers un nom constant pour qu'il puisse être utilisé dans Dockerfile-test
docker tag -f $BASE_IMAGE dépôt.example/driftrock/app:base-current

# construire l'image de test à partir de app:base-current - il ajoute les fichiers sources actuels à l'image de base
docker build -f Dockerfile-test -t app-test .

# exécuter les tests dans l'image créée
docker run app-test ./scripts/run-tests.sh

Avec Dockerfile-package-base, il suffit d'installer les dépendances :

FROM ruby:2.3.1

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

Et le test Dockerfile ne fait qu'ajouter les fichiers source actuels à l'image de base :

FROM repository.example/driftrock/app:base-current

COPY . /usr/src/app

Inspiré par la façon dont Dockerfile met en cache les commandes COPY en se basant sur le hachage du contenu des fichiers, nous calculons le même hachage manuellement. Ensuite, nous essayons de télécharger l'image nommée avec ce hachage. Si elle n'existe pas - c'est-à-dire si le contenu des fichiers respectifs est nouveau - nous construisons l'image et la stockons dans notre dépôt d'images Docker privé avec un nom contenant le hachage des fichiers.

Nous construisons ensuite l'image supplémentaire app-test en ajoutant les fichiers sources actuels à l'image de base que nous venons de télécharger (ou de construire). Ceci ne reste localement que pour cette construction particulière - car nous ne déployons pas encore d'images Docker. Enfin, nous exécutons les tests dans cette image.

Voici les résultats :

après les temps d'essai.png

Dans la partie "Paquets Gems et Node", nous avons échangé le temps d'installation réel contre le temps de téléchargement de l'image stockée dans le référentiel.

En transférant nos builds Snap CI pour cette application vers Docker et en exploitant le dépôt d'images pour stocker les images avec le bundle, nous avons pu réduire le temps de build de ~9 minutes à ~6 minutes.

Prochaines étapes

Le téléchargement de l'image prend encore un certain temps. Ce temps dépend principalement de la taille de l'image. Si nous sommes en mesure de réduire la taille de l'image en nettoyant la quantité de paquets ou en optimisant l'image Docker en supprimant les binaires inutiles, le téléchargement sera encore plus rapide.

Il est encore possible d'optimiser les tests eux-mêmes et d'exécuter les combinaisons de tests indépendantes en parallèle, ce qui permet d'économiser au moins 1 minute 30 de plus.

Conclusion

Même si nous n'utilisons pas Docker pour exécuter nos applications en production, Docker a considérablement amélioré notre CI.

En déplaçant la majeure partie de l'exécution de la construction vers l'environnement Docker, nous avons également supprimé la dépendance à l'environnement de construction et les vulnérabilités possibles aux dépendances brisées, etc. Cela nous permet d'être moins dépendants d'un outil CI/CD particulier, ce qui nous permet de passer plus rapidement et à moindre coût à de meilleurs fournisseurs ou de changer en cas de panne.

Nous avons prouvé que l'utilisation de Docker est bénéfique dans un environnement CI, à la fois d'un point de vue stratégique et en termes d'efficacité. Nous allons déplacer d'autres parties du pipeline de construction vers Docker afin de réduire le temps ou la dépendance aux spécificités de l'outil de construction que nous utilisons - dans notre cas Snap CI.

Dans l'ensemble, ce changement est une amélioration significative de notre approche CI et nous allons l'étendre au reste des applications de Driftrock.

Il est utile de mentionner que notre problème était une longue installation de dépendances. Si votre application a moins de dépendances ou si leur installation prend un temps insignifiant par rapport au reste du temps de construction, cette approche ne vous sera peut-être pas utile.