Introduction aux pipelines de CI en python avec Gitlab-ci
Introduction
Aujourd’hui, nous sommes nombreux à utiliser la plateforme GitLab afin de gérer nos projets de code python. Nous faisons souvent appel à la fonctionnalité GitLab CI/CD pour automatiser la phase de test et s’assurer ainsi que le code qui s’intègre au dépôt partagé (repository) ne contient pas de bogues et se comporte comme on le souhaite.
Mais voilà, ce n’est pas forcément évident de construire un bon pipeline de CI, et puis d’abord, c’est quoi un bon pipeline de CI ? Comment le construit-on, qu’est ce qu’on y met ?
Après avoir mis en avant l’intérêt d’utiliser une CI, nous partagerons quelques bonnes pratiques pour construire ce pipeline et également profiter pleinement de la puissance de GitLab CI/CD.
De l’intérêt d’utiliser un pipeline de CI
Au cours de nos projets python, on est amené à concevoir des fonctionnalités et donc notre code va se trouver modifié. A supposer que le versionning soit géré par un VCS tel que git, on va effectuer des commit puis des push à chaque modification du code. Se pose alors un premier problème : Comment s’assurer que nous n’avons pas par inadvertance introduit un bogue dans le code ? Lorsque l’on travaille à plusieurs, comment s’assurer que notre collègue développeur, en ajoutant de nouvelles features, n’a pas cassé le fonctionnement du code ?
On commence d’abord par construire un ensemble de tests vérifiant le comportement du code existant (si vous souhaitez en savoir plus sur comment construire des tests, c’est par ici). On utilise ensuite une librairie comme pytest pour les faire tourner. Ainsi chaque membre de l’équipe s’engage à écrire des tests pour chaque nouvelle fonctionnalité ajoutée et à les faire tourner avant d’effectuer un git push. Chacun s’engage également à corriger le code en cas d’échec des tests.
Mais voilà, comme vous pouvez vous en douter un tel mode de fonctionnement est limité car il ne nous prémunit pas des erreurs d'inattention des membres de l’équipe. Un des développeurs peut tout simplement oublier d’écrire un test, de supprimer du code inutile ou encore d'exécuter les tests avant le push. En outre, les tests réalisés localement ne sont pas nécessairement une image fidèle du comportement du code en production (l’environnement de test local n’est pas toujours le même que celui de production). En fait, ce que l’on voudrait ce serait la garantie qu’à chaque fois qu’un membre de l’équipe effectue un git push (ou bien faire un merge de sa branche sur la branche en production) les tests ne lèvent pas d’exceptions. Dans le cas contraire, on veut que GitLab rejette notre push.
Tout cela tombe plutôt bien car c’est précisément la fonction de GitLab CI/CD. Cet outil est directement accessible sur l’interface gitlab.com et permet à l’aide d’un simple fichier .yml d’écrire un pipeline de CI qui sera exécuté automatiquement à chaque push des membres du projet. Il se décompose en stages (étapes) qui contiennent eux-mêmes des steps (sous-étapes).
Maintenant voyons un peu ce que l’on peut mettre dans ce pipeline afin de s’assurer de la qualité du code.
Construire son premier pipeline de CI
Concrètement, comment je construis mon premier pipeline ? Commençons par le fameux “Hello World” !
Tout d’abord, créons-nous un projet sur gitlab que l’on va appeler “hello_world_ci”. On clone ce dernier sur notre machine et l’on vient se placer dans le dossier cloné.
On y ajoute un fichier “hello_word_ci.py” qui contient le script suivant :
Ensuite on ajoute le fichier .gitlab-ci.yml suivant :
C’est précisément ce fichier qui va servir à configurer notre pipeline de CI : Ici on lui dit simplement de lancer la commande “python hello_world_ci.py”.
Dans l’onglet CI/CD > Jobs, on peut alors visualiser le résultat de notre pipeline de CI :
Maintenant, voyons ce que l’on peut mettre dans notre pipeline.
Les étapes d’une CI Python
Une bonne CI est une CI qui s’assure de la qualité du code et fournit des feedbacks les plus rapides possibles sur ce qui ne va pas dans le code. Mais alors qu’est-ce qu’un code de qualité ? Il n’y a pas de réponse toute faite à cette question mais on peut lister malgré tout quelques éléments : d’abord un code qui a le comportement souhaité mais aussi qui est lisible, compréhensible, facilement transmissible à un autre développeur, etc. (pour en savoir plus je vous invite à creuser les principes S.O.L.I.D.)
On peut donc dire qu’une bonne CI doit valider deux choses : le fond et la forme du code. Prenons un projet dont le code source se situe dans le package “demo_app”. On procédera alors de la sorte :
Pour la forme on peut commencer par vouloir s’assurer que notre code est lisible et respecte les normes de la PEP 8. Pour cela il y a la librairie flake8 qui agit comme un linter sur chaque ligne de code. Pour exécuter la vérification il suffit simplement d’écrire :
Et on a un feedback direct : ici flake8 nous signale que la fonction print est utilisée. On peut utiliser un fichier .flake8 afin de personnaliser un peu le comportement de la librairie et ainsi ignorer certaines erreurs en indiquant le code erreur mais également exclure des fichiers/dossiers de l’analyse.
Flake8 possède également un éco-système de plugins qui permettent d’ajouter des règles supplémentaires de formatage du code. Si l’on souhaite par exemple vérifier que nos noms de tests respectent une certaine convention d’écriture, le plugin flake8-test-name est fait pour nous.
Comme nous sommes des codeurs très rigoureux, nous avons suivi la maxime qui impérieusement nous ordonne de mettre partout du Type Hinting. Nous souhaitons en vérifier la cohérence : mypy a justement été créée dans ce but. Comme flake8, il viendra analyser le code ligne par ligne et s’assurera de la cohérence entre le type de l’input déclaré d’une fonction et celui réellement utilisé lors de l’invocation de cette fonction (dans une autre fonction par exemple), idem pour les output. Son fonctionnement est similaire à celui de flake8 et l’on peut configurer son comportement via un fichier mypy.ini.
Ici on indique à mypy d’ignorer les erreurs d’imports manquant pour la librairie sqlalchemy.
Chose à laquelle on ne pense pas toujours spontanément : la sécurité. On veut vérifier que notre code ne contient pas de failles de sécurité connues (une fonction qui laisserait place à de l’injection SQL par exemple) avec la librairie bandit.
Elle nous fournit un output comme suit :
Bandit nous informe en effet de la possibilité d’une injection SQL.
Maintenant que notre code est sécurisé et bien “orthographié”, on va pouvoir vérifier son comportement. Pour cela on va faire tourner nos tests. On peut également utiliser pytest avec son plugin py coverage qui permet de mesurer notre couverture de test. Ceci nous assure dans une certaine mesure que nos tests couvrent l’ensemble du code source de notre projet - on parle de couverture de test (coverage). On peut alors configurer notre pipeline de CI pour s’interrompre si la couverture de test tombe en dessous d’un certain seuil. Cela permet de s’assurer qu’il y a un minimum de tests dans notre projet.
La commande suivante permettra donc d'exécuter nos tests tout en affichant le coverage et en validant le fait que ce dernier est supérieur à 80%.
Note: le coverage ne vérifie pas que tous les comportements du code sont testés mais simplement qu’un certain pourcentage de ligne de code est exécuté lorsque les tests tournent.
Et enfin on peut terminer notre pipeline par le build et la publication de notre code sous forme d’une archive wheel via les libraires build et twine.
Notons également que de nombreuses autres bibliothèques python permettent des vérifications diverses qu’il peut être intéressant d’ajouter dans son pipeline de CI :
- Vulture pour le code mort
- Safety pour les failles de sécurité dans les librairies de notre requirements.txt
- Semgrep pour vérifier le respect de standards de code
- Pour vérifier la sécurité des conteneurs utilisés dans un projet, cet excellent article présente des librairies
Avant d’utiliser une librairie il est important de se fixer des standards d’équipes et de s’assurer qu’elles sont intégrées à notre pipeline. On a vu que l’on peut facilement ajouter des fichiers de configuration pour chaque librairie utilisée et ainsi ignorer un certain nombre d’erreurs qui ne nous semblent pas pertinentes.
Ajoutons à cela que l’utilisation de la CI ne doit pas nous empêcher de lancer les tests localement avant de commiter afin de corriger un maximum d’erreurs et d’avoir du feedback le plus rapidement possible. En définitive, la CI sert avant tout d’ultime garde fou avant d’intégrer le nouveau code. De plus, même si elle donne confiance dans le code, elle ne remplace pas les bonnes pratiques de code en équipe telles que le pair / mob programming et les codes reviews.
Quelques tips pour gérer son pipeline
Parfois, il peut être compliqué ou un peu long de déboguer son pipeline de CI. On lance notre git push, la CI met du temps à tourner et on se retrouve avec une erreur qui n’est pas liée à notre code… Ou encore on ajoute une étape et on souhaite vérifier qu’elle a le comportement attendu donc on multiplie les commit afin de la tester, on introduit des changements dans le code afin de tester son comportement… bref, cela peut vite être source de désordre et de confusion.
Pour résoudre ce problème, on peut adopter une stratégie locale. On crée une sorte de clone de notre pipeline (on reprend toutes les étapes unes à unes) destiné à tourner localement. On peut utiliser pour ce faire des commandes bash ou mieux encore, un Makefile. Il s’agit d’un fichier qui va venir regrouper un ensemble de commandes bash à exécuter. On peut ainsi facilement lister les commandes de notre CI dans notre makefile et les exécuter localement par ce biais. Voici un exemple :
Il suffit alors de rentrer dans le terminal la commande suivante :
Il peut également être intéressant d’utiliser une image Docker comme environnement d'exécution plutôt que notre shell. Prenons un exemple simpliste : mon application python nécessite la présence d’une librairie tierce qui était préalablement installée sur la machine utilisée pour coder, ainsi lorsque je fais tourner localement mon pipeline de CI tout fonctionne. En revanche, lorsque je vais exécuter ce pipeline sur une autre machine, un bogue surviendra car cette dernière n’a pas la librairie requise. En faisant tourner la CI via Docker, je m’assure de détecter le bogue avant et donc de le corriger.
Pour utiliser Docker comme environnement d’exécution, on peut prendre l’image Docker utilisée par Gitlab dans la CI et ajouter la commande suivante dans notre Makefile :
On lance ensuite la commande :
Ce qui va exécuter la CI dans le conteneur Docker utilisant notre image Docker souhaitée.
Ceci implique bien sûr de maintenir un Makefile en même temps que notre fichier .yml mais le gain est bien réel car cela permet d'une certaine manière de tester son pipeline de CI localement et également de le déboguer plus facilement.
Maintenant posons-nous la question de savoir comment organiser les étapes de cette CI. Avec l’aide de notre CI locale, on peut aisément avoir une idée de la durée de chaque étape de la CI. On peut alors vouloir l’organiser de sorte à ce que les étapes les plus rapides soient réalisées en premier afin d’établir une boucle de feedback la plus courte possible. Cela permet aussi d’éviter le gaspillage de ressources en mettant les étapes qui en consomment le plus à la fin.
Petit tour sous le capot
Vous vous demandez peut-être maintenant ce qui se passe lorsque le fichier .yml est exécuté, ou encore sur quelle machine les tests sont réalisés. Et bien jetons un œil sous le capot !
Lorsque vous allez écrire votre pipeline, le fichier .yml qui décrit les instructions à suivre va être stocké sur le serveur GitLab qui contient également l’ensemble de votre code, documentations etc. Lors d’un git push, GitLab va automatiquement créer un conteneur Docker sur une machine virtuelle GCP qu’il a provisionné. Ce conteneur Docker va utiliser l’image (ou les images) spécifiées dans votre fichier .yml. A chaque stage de la CI, gitlab construit un nouveau conteneur Docker avec l’image spécifiée et exécute dans celui-ci les steps qui composent le stage.
Ceci est un comportement par défaut. Maintenant, imaginons que vous ayez un environnement de production très spécifique (un Raspberry Pi par exemple). Dans votre pipeline de CI, il est fort probable que vous souhaitiez réaliser une batterie de tests qui nécessitent la présence d’un hardware particulier (une caméra de Raspberry par exemple). On va alors avoir besoin d’avoir un véritable Raspberry (avec une caméra) comme environnement de tests. Comment faire en sorte que Gitlab exécute le pipeline sur un Raspberry pi plutôt que dans un conteneur Docker hébergé sur GCP ?
Sur cette machine GCP tourne un Gitlab runner. C’est un process lancé par le logiciel éponyme gitlab-runner qui va servir à exécuter le pipeline de CI. L'environnement d’exécution quant à lui s’appelle l’Executor. Ainsi par défaut gitlab-runner tourne sur une VM GCP (Gitlab parle de shared runner car il s’agit de VM qui sont allouées de manière ponctuelle à l’utilisateur qui souhaite exécuter son pipeline) et son Executor est Docker.
Maintenant ce que je peux faire c’est choisir dans l’UI Gitlab un runner spécifique : n’importe quel serveur sur lequel est installé le logiciel éponyme gitlab-runner. Ainsi je peux prendre un raspberry Pi identique à celui utilisé en prod en y appliquant la même configuration (pourquoi pas avec Ansible). Sur mon raspberry Pi de test, je peux alors choisir d’ajouter une configuration avec un executor = shell executor. Et maintenant chaque pipeline de CI sera dirigé sur le raspberry pi qui exécutera les tests non plus dans un conteneur mais dans un shell.
Il existe également d’autres types d’executor (Kubernetes par ex) ce qui ouvre la possibilité également à plus de puissance de calcul pour effectuer nos tests. D’autres raisons peuvent nous pousser à utiliser un runner différent de celui proposé par défaut par Gitlab CI : des raisons de sécurité, la nécessité d’avoir une machine plus puissante, etc.
Schéma Archi défaut (shared runner + Docker Executor) :
Schéma Archi Raspberry PI + Shell Executor
Conclusion
La CI/CD, pilier du DevOps, s’avère être un outil incontournable afin de construire un code de qualité dans lequel l'ensemble de l'équipe a confiance. On ne veut pas arriver en production avec la peur que notre release comporte des bogues de partout. On ne veut pas non plus que lorsqu’un nouveau développeur arrive sur un projet il doive passer des jours à comprendre un code difficilement lisible, qui ne respecte pas les standards fixés par l’équipe. Enfin, on préfère éviter de perdre des heures à faire des review de chaque merge request en s’attardant sur le format du code. En bref, une bonne CI c’est une CI qui permet de gagner du temps et surtout d’avoir confiance dans le code du repo !
Sources & liens utiles :
Tutoriel pour utiliser un Raspberry Pi comme Gitlab Runner : https://blog.zenika.com/2021/05/10/installation-dun-gitlab-runner-sur-un-raspberry-pi-en-10-minutes/
Doc gitlab runner : https://docs.gitlab.com/runner/install/
Remerciements : Mehdi Houacine, Emmanuel Lin Toulemonde, Amric Trudel, Léa Naccache, Ismaïl Lachheb et enfin Sébastien Gahat pour leur relecture ainsi que leurs nombreux commentaires