Au sein de l'équipe de développement de Driftrock, notre objectif est de développer et de fournir à nos clients de nouvelles fonctionnalités et des corrections aussi rapidement et aussi souvent que possible. L'intégration continue est un élément essentiel qui nous permet d'y parvenir.
Depuis peu, nous constatons que les tests de CI prennent beaucoup de temps, environ 10 minutes. Au sein de notre équipe, nous effectuons des revues de Pull Request, ce qui signifie qu’à chaque fois que nous apportions une petite mise à jour pendant la discussion sur la PR, nous devions attendre 10 minutes pour pouvoir fusionner les modifications ou demander au réviseur d’accepter les modifications et nous permettre ainsi de passer à d’autres tâches. Si l’on considère une journée de travail avec 10 Pull Request, cela représente 20 % du temps de travail (1 h 40 sur une journée de 8 heures).
Le problème
En examinant le journal de compilation, nous constatons que c'est l'installation des gemmes et des paquets Node qui prend le plus de temps :

Dans le cas présent, il s'agit d'une application combinant Ruby on Rails et JavaScript (Node), qui nécessite l'installation à la fois de paquets Gems et de paquets NPM. Cette opération est effectuée à chaque fois, même si, la plupart du temps, les paquets ne changent pas.
Il faut également compter un certain temps pour que Snap initialise l'environnement de compilation — nous ne pouvons rien y faire. Et puis il y a la durée des tests proprement dite, qui pourrait peut-être être optimisée, mais le temps de création des paquets constitue actuellement un problème plus important.
Le fichier Dockerfile
Si vous ne connaissez pas bien l'écosystème Docker, permettez-moi de faire un petit détour par rapport au sujet principal pour vous le présenter. Dans Docker, tout ce que vous faites commence par une image Docker. Une image Docker est un instantané d'un système d'exploitation — comprenant toute la structure de son système de fichiers, ses outils en ligne de commande, son shell et les programmes qui y sont installés, ainsi que toutes leurs dépendances — à l'exception du noyau.
Il s'agit d'un concept extrêmement puissant qui vous permet de télécharger et d'exécuter n'importe quoi, du shell Ruby au serveur PostgreSQL, à l'aide d'une seule commande. Plus besoin de vous soucier de l'installation de toutes les bibliothèques, de la résolution des conflits et de tout ce à quoi nous étions habitués lorsque nous exécutions des logiciels directement sur nos systèmes d'exploitation.
Pour créer – ou construire – une image Docker, Docker utilise des fichiers appelés Dockerfile. Ces fichiers contiennent des étapes – des commandes – qui sont exécutées au sein de l'image afin de générer une nouvelle image pour l'étape suivante et, au final, de créer l'image finale à laquelle vous pouvez attribuer un nom et que vous pouvez réutiliser pour exécuter vos applications.
FROM ruby:latest
# Relancer « bundle install » uniquement si le Gemfile a été modifié
COPY Gemfile Gemfile.lock ./
RUN bundle install -j20
# Copier le reste des sources
COPY . ./L'exemple ci-dessus crée une image contenant une application Ruby avec les gemmes installées via « bundle » en fonction du Gemfile.
Ce qui est formidable avec les fichiers Dockerfile, c'est que chaque étape crée une image intermédiaire que Docker stocke et met automatiquement en cache. Comme chaque étape est définie de manière déterministe par sa commande, Docker n'a pas besoin de la réexécuter tant que l'étape ne change pas.
Pour les étapes RUN, le système part du principe que, tant que le texte de la commande reste identique, sa sortie et tous ses effets secondaires 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 le contenu de ces fichiers ne change pas, il utilise l'image mise en cache.
Dans l'exemple ci-dessus, ce comportement est exploité pour n'exécuter l'étape d'installation du bundle que si les fichiers Gemfile et Gemfile.lock ont effectivement changé. S'ils ne changent pas, nous pouvons utiliser la version mise en cache et ainsi accélérer la compilation. Comme il utilise le hachage du contenu du fichier — et non la date de modification du fichier —, il ne fait aucune supposition quant au contenu, etc. Il invalide donc toujours le cache de manière fiable lorsque cela est nécessaire, éliminant ainsi le risque d'utiliser un état invalide du cache, ce que nous redoutons souvent lorsque l'on parle de mise en cache.
La solution
Snap CI a récemment intégré la prise en charge bêta de Docker dans sa pile de compilation. Conscients des avantages de la mise en cache des images Docker, nous avons décidé de l'essayer.
Voici le programme :
- Créez une image Docker contenant les gemmes, les paquets Node et les fichiers source de la version actuelle à l'aide d'un fichier Dockerfile, en veillant à ne copier que le fichier Gemfile et le fichier package.json avant d'exécuter la commande `bundle install && npm install`.
- Utilisez la mise en cache de manière à ce que chaque compilation n'ajoute que les nouveaux fichiers source, sans relancer la création du bundle si les dépendances ne changent pas
- Exécutez les tests à l'aide de l'image précompilée
Nous avons donc créé ce fichier Dockerfile et nous l'utilisons pour générer l'image dans le script de compilation :
FROM ruby:2.3.1
COPY Gemfile Gemfile.lock package.json /usr/src/app
RUN npm install && bundle install
COPY . /usr/src/app
Cependant, lorsque nous avons testé cela, nous avons constaté que la commande `docker build` sur Snap n'utilisait malheureusement pas le cache comme c'est le cas sur une machine locale. Elle réexécute simplement toutes les commandes à chaque fois. C'est peut-être parce que la compilation est effectuée à chaque fois sur un nœud différent, ou parce que Snap efface tout simplement les fichiers Docker avant chaque exécution de la compilation.
Hachage manuel
Si nous ne pouvons pas utiliser la fonctionnalité de mise en cache intégrée à la commande Docker, rien ne nous empêche de la reproduire et de la mettre en œuvre nous-mêmes. Nous avons donc décidé de le faire et de modifier notre plan comme suit :
- Compiler l'image de base contenant les paquets Gems et Node (sans les fichiers source de compilation) une seule fois
- Enregistrez cette image dans le référentiel d'images en lui attribuant une balise correspondant au hachage des fichiers Gemfile et package.json
- Essayez de télécharger l'image lors des prochaines versions
- Créer une nouvelle image en y ajoutant les fichiers source de la version actuelle
- Exécutez les tests à l'aide de cette nouvelle image
Voici le script que nous avons finalement obtenu :
# Hachage des fichiers pouvant 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
# télécharger l'image avec les dépendances si elles existent, sinon la construire et la pousser vers le dépôt
docker pull $BASE_IMAGE || ( \
docker build -t $BASE_IMAGE -f Dockerfile-package-base . \
# pousser vers le dépôt pour la prochaine fois
&& docker push $BASE_IMAGE
)
# baliser localement avec un nom constant afin de pouvoir l'utiliser dans Dockerfile-test
docker tag -f $BASE_IMAGE repository.example/driftrock/app:base-current
# construire l'image de test à partir de app:base-current - cela ajoute les fichiers source 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, qui installe uniquement les dépendances :
FROM ruby:2.3.1
COPY Gemfile Gemfile.lock package.json /usr/src/app
RUN npm install && bundle install
Et le fichier Dockerfile-test, qui se contente d'ajouter les fichiers source actuels à l'image de base :
FROM repository.example/driftrock/app:base-current
COPY . /usr/src/app
En nous inspirant de la manière dont le fichier Dockerfile met en cache les commandes COPY en fonction du hachage du contenu des fichiers, nous calculons ce hachage manuellement. Nous essayons ensuite de télécharger une image portant ce nom de hachage. Si celle-ci n'existe pas – c'est-à-dire si le contenu des fichiers concernés a été modifié –, nous construisons l'image et la stockons dans notre référentiel privé d'images Docker sous un nom contenant le hachage des fichiers.
Nous créons ensuite une image « app-test » supplémentaire en ajoutant les fichiers source actuels à l'image de base que nous venons de télécharger (ou de créer). Celle-ci ne reste disponible que localement pour cette version spécifique, car nous ne déployons pas encore d'images Docker. Enfin, nous exécutons les tests au sein de cette image.
Voici les résultats :

Dans la section « Gems et paquets Node », nous avons troqué le temps d'installation proprement dit contre le temps nécessaire au 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 utilisant un référentiel d'images pour stocker les images avec le bundle, nous avons réussi à réduire le temps de compilation d'environ 9 minutes à environ 6 minutes.
Prochaines étapes
Le téléchargement de l'image prend encore un certain temps. Ce délai dépend principalement de la taille de l'image. Si nous parvenons à réduire la taille de l'image en allégeant les paquets ou en optimisant l'image Docker via la suppression des binaires superflus, cela permettra d'accélérer encore davantage le processus.
Nous pouvons encore optimiser les tests eux-mêmes et exécuter les suites de tests indépendantes en parallèle, ce qui permettrait de gagner au moins une minute et demie supplémentaire.
Conclusion
Même si nous n'utilisons pas Docker pour exécuter nos applications en production, Docker a considérablement amélioré notre intégration continue.
En transférant la majeure partie du processus de compilation vers un environnement Docker, nous avons également éliminé la dépendance vis-à-vis de l'environnement de compilation et les vulnérabilités potentielles liées à des dépendances défaillantes, etc. Cela nous permet d'être moins tributaires d'un outil CI/CD spécifique, ce qui nous donne la possibilité de passer plus rapidement et à moindre coût à de meilleurs fournisseurs ou de changer de prestataire en cas de panne.
Nous avons démontré que l'utilisation de Docker présente des avantages dans un environnement d'intégration continue (CI), tant sur le plan stratégique qu'en termes d'efficacité. Nous allons migrer d'autres parties de notre pipeline de build vers Docker afin de réduire les délais et de limiter notre dépendance vis-à-vis des spécificités de l'outil de build que nous utilisons – dans notre cas, Snap CI.
Dans l'ensemble, ce changement constitue une amélioration notable de notre approche en matière d'intégration continue, et nous allons le déployer dans le reste des applications Driftrock.
Il convient de noter que notre problème résidait dans la longueur du temps nécessaire à l'installation des dépendances. Si votre application comporte moins de dépendances ou si leur installation ne prend qu'un temps négligeable par rapport au reste du temps de compilation, cette approche ne vous sera peut-être pas utile.