Partie 1 : Optimiser sa CI

Grinbergs Reinis
8 min readMar 22, 2021

--

La CI (intégration continue) est un outil incontournable dans la plupart des projets de développement moderne et agile. Elle permet d’automatiser les tests de non régressions, de qualité de code, allant du build d’applications jusqu’au déploiement de celles-ci, on parle alors de CD (déploiement continu) dans ce cas.

La CI fait partie intégrante dans la vie d’un développeur, malheureusement elle est souvent mal optimisée : les temps de CI trop long et les tests instables sont source de frustration.

Quel est pour vous le temps d’attente acceptable dès lors que l’on pousse un nouveau commit ?

Formulé autrement : à partir de combien de temps cela devient gênant et vous empêche d’être productif ?

Il n’y a pas de réponse absolue et cela dépend évidemment du contexte, cela peut prendre 10 minutes comme plusieurs heures si cela est justifié par les prérequis, la taille ou l’organisation du projet.

Mais prenons tout de même un cas nominal :

Un projet web classique (un backend en php et un frontend en javascript), de taille moyenne qui a entre 5 à 10 développeurs, en mode agile avec des sprints de 2 semaines et qui délivre à la fin de chaque sprint.

Il y a environ 40 pipelines dans une journée d’une durée de 40 minutes chacune soit 27 heures cumulé.

Personnellement, dans le contexte où on délivre souvent, j’estime que l’on est dans la zone rouge au bout de 30 minutes… après quoi on perd le rythme et le focus sur notre travail, mais cela dépend évidemment de chacun.

10 minutes de moins sur 40 pipelines nous feraient pratiquement gagner 7 heures d’attente dans une journée soit un jour/homme de développement.

Finalement peu importe d’où nous partons, il faut simplement se poser une seule question : améliore-t-on en continu notre CI ?

Cette première partie sera donc consacrée à vous donner des pistes d’optimisation pour la CI en prenant pour exemple gitlab-ci mais cela peut être aussi valable pour d’autres plateformes (CircleCI…), les principes restant les mêmes.

La seconde partie se penchera sur l’écriture des tests automatisés avec behat et selenium.

Pareillement, même si vous n’utilisez pas ces outils, les problèmes énumérés et leurs solutions sont communs et peuvent à minima vous donner des pistes de réflexions sur votre propre stack.

1. L’équilibrage des jobs

L’une des erreurs les plus communes est de mal équilibrer les jobs.

Sur l’image si dessus il y a 3 phases ou “stages” :

  • le build
  • la QA qui regroupe principalement les analyseurs de code statique
  • les tests

En regardant de plus prêt il y a 1 job de build, 12 jobs de QA et 13 jobs de tests. Chaque outil de QA à son propre job tout comme chaque suite de test (correspondant à un module de notre application). Cela nous permet d’avoir l’information directement si celui-ci passe ou échoue, le découpage à première vue est propre.

Cependant on se retrouve avec des écarts de temps : c’est la durée du job le plus long de chaque stage qui décidera du temps final du pipeline :

Il faut donc constamment trouver le juste milieu, regrouper les petits jobs ou au contraire découper les gros jobs même si cela ne reflète pas nos outils ou le découpage en module de nos tests. Il ne faut pas chercher l’élégance mais la performance : économiser les jobs tout en diminuant le temps total.

J’ai tendance à privilégier 2 stages, avec un job de build par application (qui intègre la QA) puis des jobs de tests en parallèle avec une durée équilibrée.

Les scénarii de tests sont tagués de manière arbitraire afin de les répartir équitablement : les tests tagués @front1 seront exécutés dans le job behat_front_1, ceux tagués @front2 dans le job behat_front_2.

S’il s’avère que le premier job dure beaucoup plus longtemps que le second, on transvase des scénarii de premier vers le second en modifiant le tag, on peut aussi ajouter un 3ème job.

2. Cache et artifacts

Sur gitlab-ci ou CircleCI, le cache est partagé entre les différents pipelines et jobs contrairement aux artifacts qui sont passés d’un job à un autre au sein d’un même pipeline, on peut visualiser ces derniers depuis l’interface.

On assume que le cache peut être invalidé à n’importe quel moment, il ne doit avoir aucun impact sur nos jobs et sur le résultat du pipeline. L’artifact quant à lui est le résultat d’un job passé aux jobs suivants qui en dépendent directement (un build d’une application par exemple).

Nous allons voir un premier cas simple puis second un peu plus complexe.

Le cas le plus courant est de mettre en cache les dépendances, ici composer et node, afin d’éviter de les télécharger à chaque fois.

Qu’il y ait du cache ou non, le job de build va à chaque fois lancer un composer install et un yarn install .

S’il n’y a pas de cache, ce qui arrive la première fois, un seul job devra tout télécharger et cela prendra du temps mais tous les jobs suivants bénéficierons du cache et les commandes seront plus rapides.

composer vous dira simplement “Nothing to install or update”.

Le hic c’est que les dépendances peuvent être différentes d’un pipeline à l’autre (un package composer est mis à jour dans une merge/pull request) : ce n’est pas grave car seule cette dépendance sera téléchargée, toutes les autres étant déjà dans le dossier vendor. Ensuite on repasse ce même dossier vendor en artifact aux jobs suivants en étant certain d’avoir la bonne version des packages. Le principe est le même pour les modules node.

Voyons maintenant un cas plus complexe. Apres avoir télécharger les modules node, on build une application react avec yarn build et nous savons tous que ça peut être très chronophage.

Ça serai bien de réutiliser un build existant si les sources n’ont pas été modifiées entre 2 commits afin de gagner du temps. Pour cela on peut se baser sur le checksum du répertoire de l’application react qui ferais office de clé de cache. Cependant gitlab-ci ou CircleCI ne nous permettent pas de faire ça out of the box (il n’y a pas clé de cache dynamique).

Du coup on peut contourner cette limitation avec un petit script bash :

Pour résumé : on calcul le checksum du répertoire source, il sera utilisé comme nom de dossier pour le build associé. Si ce dossier n’existe pas dans le cache (le dossier builds/{checksum}) on lance un yarn build, s’il existe on le copie tout simplement. Ensuite on supprime les dossiers de plus de 10 jours pour ne pas faire grossir le cache indéfiniment.

Il n’y a plus qu’a ajouter le dossier builds dans le cache :

On utilise ici le binaire checksumdir disponible sur pip. Voici le Dockerfile associé :

4. Tmpfs

Si vous utilisez docker et docker-compose, vous utilisez sans doute des volumes afin de persister les données (une base mysql par exemple), c’est très bien pour développer au quotidien mais ce n’est pas performant. Une autre option existe : c’est le montage tmpfs. C’est un montage temporaire et persisté en mémoire, il a l’avantage d’être rapide en l’I/O.

Utilisons le pour notre container mysql mais uniquement sur la CI :

A l’origine le service mysql est définit dans le fichier docker-compose.yml.

Je l’ai surchargé dans le fichier docker-compose.ci.yml et je m’assure que celui ci est spécifié dans les commandes faites sur la CI :

docker-compose -f docker-compose.yml -f docker-compose.ci.yml

Si votre base de données est remise à zéro avant chaque scénario le gain de temps peut être énorme (divisé par 2 ou plus).

5. Les options utiles

Voyons les différentes astuces dans cet exemple :

L’étape après la préparation de l’environnement est le git clone des sources. Plus votre repository est lourd (beaucoup d’historique avec de gros fichier comités) et plus il sera long à cloner. GIT_DEPTH à 1 permet de faire un shallow clone, c’est à dire de récupérer seulement le dernier commit de la branche et ainsi réduire le nombre de données téléchargées.

interruptible permet d’annuler automatiquement un job si un nouveau pipeline est créé, utile lorsque l’on pousse plein de commits en très peu de temps.

timeout permet de définir une limite plus basse du timeout par défaut (60 min) afin de ne pas monopoliser un runner pour rien lorsqu’une commande est bloquée, cela arrive de temps en temps.

retry permet de relancer un job si celui si échoue. Cela nous permet de détecter les tests aléatoires (ceux qui échouent la première fois mais passent la seconde fois) ou de relancer automatiquement un job si celui ci était bloqué pour une raison quelconque.

6. La puissance de makefile

Ici nous rentrons vraiment dans les micro-optimisations. Saviez vous que l’on pouvait facilement paralléliser les commandes make ?

Si je voulais clear le cache de 2 applications symfony, par défaut je ferais :

time make clear-cache-1 clear-cache-2time docker-compose exec -T app_1 bin/console cache:clear// Clearing the cache for the dev environment with debug                       
// true
[OK] Cache for the "dev" environment (debug=true) was successfully cleared.
0.74user 0.09system 0:11.65 elapsed 7% CPU
time docker-compose exec -T app_2 bin/console cache:clear// Clearing the cache for the dev environment with debug
// true
[OK] Cache for the "dev" environment (debug=true) was successfully cleared.
0.74user 0.11system 0:08.57 elapsed 9% CPU
make clear-cache-1 clear-cache-2
1,49s user 0,21s system 8% cpu 20,238 total

En exécutant ces commandes de manière séquentiel nous sommes à 20 secondes (environ 11 secondes puis 9 secondes respectivement).

Pour les lancer en parallèle j’ajoute l’option -j, j’ajoute aussi -O afin d’avoir un output groupé par commande :

time make -j -O clear-cache-1 clear-cache-2time docker-compose exec -T app_1 bin/console cache:clear// Clearing the cache for the dev environment with debug                       
// true
[OK] Cache for the "dev" environment (debug=true) was successfully cleared.
0.80user 0.11system 0:11.62 elapsed 7% CPU
time docker-compose exec -T app_2 bin/console cache:clear// Clearing the cache for the dev environment with debug
// true
[OK] Cache for the "dev" environment (debug=true) was successfully cleared.
0.82user 0.09system 0:14.33 elapsed 6% CPU
make -j2 -O clear-cache-1 clear-cache-2
1,63s user 0,21s system 12% cpu 14,344 total

Nous sommes maintenant à 14 secondes. A noter que la seconde commande met 5 secondes de plus, la première quant à elle à la même durée.

Cela veut dire que les gains sont fortement dépendant des ressources du système et vous pouvez vous confronter à des bottlenecks (cpu, ram, I/O…). Par exemple phpstan s’avère très gourmant en cpu donc je ne le parallélise pas.

Conclusion

Cette première partie s’achève, j’espère que cela vous donnera quelques idées pour votre propre CI, n’hésitez pas à partager les vôtres.

Dans la seconde partie nous verrons comment avoir une stack de test solide avec behat et selenium : https://rpg600.medium.com/partie-2-des-tests-fiables-et-performants-avec-behat-et-selenium-a202dcdd34fa.

--

--

Grinbergs Reinis

Symfony & React/redux developer, tests are key, coaching teams since 2019 in freelance