Introduction
Tout projet de développement logiciel traverse successivement un ensemble de phases d'activités distinctes.
Plusieurs méthodologies de développement peuvent être choisies, mais nous focaliserons cet article sur un cycle de développement dit "agile". Dans ce type de méthodologie, le projet n'est défini que superficiellement en début de développement. Néanmoins, le problème auquel répond le logiciel est lui bien identifié et les acteurs qui peuvent y répondre également.
Dans cette configuration, nous avons une équipe de développeurs qui va - après une réunion de présentation des besoins - réaliser une succession de périodes de développement (qu'on appelle 'sprints').
Chacun des ces sprints aboutit à une livraison de fonctionnalité(s) ou de sous partie(s) de fonctionnalité(s) - sous forme de lot - du logiciel.
Durant un sprint, des rencontres régulières sont organisées entre l'équipe et les différentes parties prenantes dont les demandeurs (c'est à dire les acteurs qui font face au problème à résoudre au niveau logiciel). Ces derniers peuvent ainsi suivre l'évolution des développements mais surtout, voir en temps réel comment s'oriente la conception et à quoi ressemble la solution en cours de construction.
De plus, ces différentes rencontres permettent d'orienter les choix de développement en cours de route, et donc de concevoir un logiciel au plus proche possible des besoins du ou des demandeurs.
Toutefois, livrer une fonctionnalité dès qu'elle est prête suppose au préalable de l'avoir intégrée très tôt au "squelette" du logiciel. Dans la pratique cela se traduit par des merge réguliers sur la branche principale - dans l'idéal au moins une fois par jour. On appelle ce concept l'intégration continue (CI : continuous integration).
La CI permet de détecter au plus tôt les éventuels problèmes afin de les corriger au plus vite et de poursuivre sur des bases saines.
Pour être efficace, elle doit s'appuyer sur une rigueur sans faille de l'équipe, au travers :
- de tests automatiques et de non régressions,
- d'une bonne couverture de tests,
- d'un suivi rigoureux des bonnes pratiques de versionning (Git ou autre).
Une fois la (les) nouvelle(s) fonctionnalité(s) développée(s) et testée(s) son (leur) intégration peut être validée et elle(s) est (sont) prête(s) à être livrée(s) au(x) demandeur(s). L'automatisation de cette livraison (par exemple en fin de chaque sprint) est appelée livraison continue (CD : continuous delivery).
Enfin, du coté du (des) demandeur(s), deux méthodes permettent de finir le processus :
- soit il(s) déploie(nt) immédiatement et automatiquement la (les) nouvelle(s) fonctionnalité(s) - dans ce cas nous sommes dans un fonctionnement appelé Déploiement Continu (CD : continuous deployment) -,
- soit il(s) préfère(nt) faire des vérifications manuelles et réalise(nt) manuellement le déploiement.
C'est dans ces contextes d'intégration, de livraison et de déploiement continus qu'on est amené à utiliser Jenkins.
Présentation de Jenkins
Jenkins est un outil Open Source d'automatisation des chaînes de développement et existe depuis 2005 (originellement sous le nom de Hudson).
Il se présente sous la forme d'un serveur autonome et permet l'automatisation de presque toutes les phases décrites en introduction (à l'exception évidemment de la production de code). Cela comprend les phases suivantes :
- le build,
- l'exécution des tests,
- la génération de package - comprenant les phases de création de l'assistant d'installation (logiciel d'interface utilisé pour installer l'application) pour les logiciels qui en possèdent.
Il permet de créer des architectures de productions de gestion de CI/CD efficaces en permettant notamment d'avoir une machine 'maître' distribuant les tâches à plusieurs machines dites 'esclaves'.
Un bon choix pour la gestion du cycle de développement de votre produit ?
D'aucun pourrait argumenter en faveur d'autres outils pour ce type de tâches.
Il est notamment coutumier de reprocher à Jenkins :
- la complexité de sa configuration initiale dans certains environnements (en particulier dans les secteurs de l'industrie),
- le nombre d'installations importantes requises pour faire tourner l'outil car il nécessite très souvent l'installation de plusieurs plugins (parfois plusieurs dizaines),
- le temps potentiellement important de prise en main de Jenkins par des novices.
Néanmoins, gageons que les arguments ci-après puissent faire changer d'avis les plus réfractaires à ce choix :
- Jenkins est Open Source et bénéficie d'une communauté active d'utilisateurs et de développeurs bénévoles
- Jenkins possède une licence libre MIT,
- Jenkins fait le travail qu'on attend de lui, il le fait bien, et depuis longtemps (robuste et fiable)
- Quelques soient ses aléas économiques, n'importe quelle entreprise faisant un usage extensif de Jenkins pour le maintien de son logiciel est assurée de son fonctionnement sur le long terme,
- La création de plugins permet d'étendre - facilement et relativement rapidement - Jenkins pour des besoins spécifiques,
- Jenkins est compatible avec toutes les plateformes capables de faire tourner une machine Java (c'est à dire quasiment toutes les plateformes existantes)
- Jenkins fonctionne grâce à une syntaxe facile à lire et à comprendre (Groovy)
- Jenkins est leader du secteur depuis si longtemps, que presque tous les développeurs savent s'en servir - ce qui permet de relativiser concernant le problème de prise en main un peu complexe,
- En cas de problème, la communauté Jenkins est réactive, et sa taille importante assure qu'il sera toujours possible d'obtenir des réponses à vos questions sur les forums
- Le système de plugins permet à Jenkins d'être très versatile et de pouvoir être adapté à presque toutes les situations, ce qui le rend plus flexible par rapport à certains concurrents
- Tout ou une partie du processus pris en charge par Jenkins peut être exécuté uniquement lorsque les charges CPU et RAM sont les plus disponibles - comme pendant la nuit
- Les services offerts par Jenkins apportent une réelle valeur à la chaîne de développement, comme :
- l'automatisation du processus de livraison,
- l'exécution des tests,
- le(s) rapport(s) de tests,
- l'envoi automatique d'email(s) ou d'alerte(s) en cas d'incident sur la chaîne,
- la gestion très fine des droits de chaque utilisateur,
- la planification de tâche(s),
- la gestion des capacités des machines hôtes, etc.
Intégration de Jenkins dans le milieu du développement
Jenkins est l'un des outils les plus utilisés pour la gestion du processus CI/CD (sinon le plus utilisé).
Cette place prédominante sur le marché a permis à la communauté des développeurs de Jenkins mais aussi d'autres outils, de concevoir tout un écosystème plaçant Jenkins au coeur de bon nombre de projets.
Nous trouvons des modules d'intégration facilitant l'usage de Jenkins dans la plupart des IDE du marché (Eclipse, VSCode, Emacs, Vim, etc), et symétriquement, des plugins Jenkins pour la gestion de la plupart des outils de développement du marché (Exemple: Un plugin permettant la prise en charge de Conan pour les projets C++).
Démonstration
Considérons que nous disposons de trois machines.
La première, la plus importante, est la machine de développement. Elle contient l'environnement de développement, la chaîne de compilation, les différentes unités logiques et matérielles du projet (dépendances, CPU/GPU d'exécution, code, IDE etc). A chaque fonctionnalité importante qui sera développée, sera associé un commit dans le système de versionning du projet. Chacun de ces commits sera envoyé sur le réseau pour être traité par la seconde machine.
La seconde contient l'instance principale de Jenkins et répartit les commits sur les différentes machines de travail disponibles sur l'infrastructure. Elle récupère régulièrement les sources du projets - c'est là qu'elle détecte tout nouveau commit - et va lire un fichier de configuration appelé Jenkinsfile pour chacun d'eux. Ce fichier, inclus dans les sources du projet au niveau de la racine et dont la syntaxe - proche de celle de Groovy - est facilement lisible par les développeurs, permet de lancer toutes les opérations du processus. Dans notre cas, la deuxième machine envoie chaque commit sur la troisième machine.
Cette troisième machine exécute les instructions données dans le Jenkinsfile pour compiler le projet, lancer les tests, afficher ou envoyer un rapport de test, puis exécute les opérations de packaging et de livraison selon les modalités prévues dans le Jenkinsfile.
Examinons un exemple :
Ci dessous, un fichier Jenkinsfile qui pourrait se trouver à la racine d'un projet C++.
// Il est possible de laisser un commentaire de la même façon qu'en C++
pipeline {
agent any
triggers {
cron('H H(19-23) * * 1-5')
}
environnement {
WORKSPACE = '~/monProjet'
VERSION = '2.2.0'
}
stages {
stage('Build') {
echo 'Building project...'
sh("make -C $WORKSPACE")
}
stage('Tests') {
echo 'Testing project...'
sh("$WORKSPACE/build/path/to/tests/executable")
}
stage('Deploy') {
sh("make deploy")
}
}
}
Alors comment interpréter ce fichier ?
Nous commençons par déclarer un pipeline, qui représente la suite d'opérations d'intégration / déploiement continue suivie par le logiciel.
On y représente souvent trois étapes principales :
- le build,
- le test,
- et le déploiement.
Cette suite d'étapes prend place à l'intérieur d'une balise 'stages' qui comprend chacune des étapes individuelles encapsulées dans les balises 'stage'.
Chacune des balises 'stage' prend en paramètre son nom, et dans une accolade, les instructions qu'elle doit effectuer.
Nous pouvons ajouter des variables d'environnement dans une balise spécialisée (optionnelle).
En revanche, la balise 'agent' est obligatoire. Elle sert à spécifier la machine qui va exécuter le pipeline. Dans le cas de la valeur 'any', Jenkins va passer en revue tous les agents disponibles et affectera l'exécution à celui qui est configuré par défaut. C'est en changeant cette valeur que nous pouvons imposer à Jenkins une exécution dans un conteneur Docker ou sur une autre machine par exemple.
L'exemple ci-après montre comment engager une action dans un conteneur Docker.
agent {
docker { image 'node:16.13.1-alpine' }
}
Cette simplicité est rendue possible par la configuration dans l'interface graphique de Jenkins des paramètres réseau corrects pour ce conteneur (c'est à dire spécifier le réseau et le port qui doivent être utilisés, renseigner les éventuels mots de passe ou clés SSH, etc).
Il faut également savoir que Jenkins permet une intégration bien plus poussée de Docker en rendant par exemple possible le build et la configuration de conteneurs Docker directement dans un pipeline.
Enfin, la balise 'triggers' permet de planifier sous forme de syntaxe Cron la séquence de déclenchement du pipeline.
Dans cet exemple, le pipeline se lancera automatiquement tous les jours entre 20h et minuit, au moment ou le CPU de l'hôte est le plus disponible.
Pratiques courantes ou avancées
Nous avons présenté un exemple simple de Jenkinsfile, mais dans la vrai vie, cet exemple ne suffit pas.
Notifications d'échec
Tout d'abord, nous aimerions que notre pipeline envoie un email au développeur lorsqu'il pousse un commit qui viendrait casser le build.
Cette tâche n'est pas simplifiée par le fait que très souvent, une équipe de développeurs utilisant Jenkins est constituée de... plusieurs développeurs.
Deux solutions s'offrent à nous :
- Soit quelqu'un est désigné intégrateur, et manage la CI/CD, les intégrations, et les rapports de tests. C'est cette personne qui recevra tous les emails d'échec.
- Soit il faut envoyer le mail au développeur même qui a poussé le commit, et il faut donc récupérer ses coordonnées d'une manière ou d'une autre.
Ceci peut se réaliser de cette manière :
Tout d'abord, nous devons renseigner dans les paramètres du pipeline les bonnes valeurs pour l'accès à un serveur de mails. Cette interface étant très claire et auto-documentée dans Jenkins, nous présenterons ici uniquement la partie Jenkinsfile.
// Il est possible de laisser un commentaire de la même façon qu'en C++
pipeline {
agent any
triggers {
cron('H H(19-23) * * 1-5')
}
environnement {
WORKSPACE = '~/monProjet'
VERSION = '2.2.0'
COMMITER = """${sh(
returnStdout: true,
script: 'git --no-pager show -s --format="%%ae"'
)}"""
}
stages {
stage('Build') {
echo 'Building project...'
sh("make -C $WORKSPACE")
}
stage('Tests') {
echo 'Testing project...'
sh("$WORKSPACE/build/path/to/tests/executable")
}
stage('Deploy') {
sh("make deploy")
}
}
post {
failure {
mail to: "$COMMITER",
subject: "Failed build after your last commit",
body: "Some interesting detailed explanations"
}
}
}
Deux choses importantes à noter :
- Tout d'abord, l'ajout d'un bloc "post" en bas du pipeline, qui peut contenir un certain nombre de sous blocs qui doivent s'exécuter après le pipeline. Il est possible de choisir parmi plusieurs sous blocs en fonction de la condition qui doit déclencher l'exécution des instructions de ce sous bloc. Ici nous avons choisi un sous bloc de type "failure" qui, comme son nom l'indique, déclenche l'exécution des instructions uniquement en cas d'échec du build. Il existe d'autres types de sous blocs permettant par exemple de toujours réaliser les instructions fournies quelque soient les résultats du build. Ceci pourrait être utilisé pour faire du nettoyage, de la gestion de fichiers temporaires, etc.
- Ensuite, dans le bloc contenant les variables d'environnement, notez l'apparition d'un variable appelée 'COMMITER', qui est initialisée et prend une valeur dynamiquement au moment de l'exécution du pipeline. Ici, cette valeur est initialisée avec l'adresse email du développeur ayant poussé le dernier commit. Cette adresse est récupérée dans les logs de Git à l'aide d'une commande de l'outil de versionning. La présence du double esperluette n'est pas une erreur. Le premier permet d'échapper le second afin que son sens sémantique soit bien pris en compte par Git. En cas de présence d'un intégrateur dans l'équipe, nous n'aurions pas eu besoin de cette variable 'COMMITER' et nous aurions pu hardcoder le paramètre "mail to" avec l'adresse de l'intégrateur.
Environnements multiples
Dans la vrai vie, il est également courant de manipuler plusieurs environnements pour un même projet. Nous avons par exemple un environnement de développement, un environnement de tests, un environnement de production, et ainsi de suite.
La gestion d'une multitude d'environnements est facilitée par la syntaxe de Jenkins.
Nous montrons ici, comment gérer une multitude d'environnement à travers l'usage de plusieurs containers docker.
Chaque partie du pipeline peut être affectée et traitée dans un environnement différent, et être liée à un docker ou à un serveur.
Vous pouvez tout à fait remplacer l'usage des containers docker par des références vers des serveurs.
pipeline {
agent none
stages {
stage('Build') {
agent {
docker { image 'maven:3.9.1-adoptopenjdk-11' }
}
steps {
sh 'mycommand --version'
}
}
stage('Tests') {
agent {
docker { image 'node:16.17.1-alpine' }
}
steps {
sh 'mycommand --version'
}
}
}
}
Mais aussi...
Il faut enfin savoir que les possibilités de Jenkins sont vastes, et celles offertes par l'ensemble des plugins encore plus.
Nous listons dans cette section un ensemble de possibilités qui peuvent être utilisées en routine :
- Déployer et exécuter un pipeline ou une section (stage) dans un cluster Kubernetes
- Gestion des secrets (clés SSH, crédentials divers, etc)
- Emploi de timeout associé à un pipeline ou à une section (stage) après lequel ledit pipeline sera avorté
- Emploi de timestamps associés à chaque run
- Management des essais (retry) en cas d'erreurs
- Gestion d'inputs (dans le cas où l'on impose le renseignement de paramètres aux utilisateurs)
- Gestion des conditions d'exécution pour un pipeline ou une section (stage)
- Gestion du séquençage des sections (stage) et du parallélisme
- Gestion des filtres d'exclusion
- Possibilité d'avoir des blocs de scripts (syntaxe Groovy)
- etc
Conclusion
Dans cet article, nous avons vu comment mettre en place concrètement et pratiquement une chaîne de développement d'intégration et de livraison dite 'continue' avec Jenkins.
La principale clé pour la mise en place d'un tel processus - en limitant les pertes de temps - est de le mettre en place le plus tôt possible dans le projet. De ce fait, la principale tâche de CI/CD à effectuer en cours de projet est la rédaction des tests et l'augmentation de la couverture de tests.
Enfin, il faut également considérer que la principale difficulté de ce type de procédé, est la nécessité de soigner la communication entre les différentes activités et parfois entre différentes équipes d'une entreprise.
Cela demande une bonne gestion des interactions entre les équipes de tests, les développeurs, les responsables de l'infrastructure ainsi que les moyens généraux.
La partie CI/CD nécessite donc un ensemble de compétences techniques (connaissance du code, du système de build et de packaging, etc) couplé à un ensemble de compétences humaines (communications, diplomatie, gestion des intérêts des uns et des autres, sens pratique, sens de la mission et du projet).
Finalement, trouver la bonne personne - pour mener à bien cette mission - est sans doute la partie la plus compliquée pour une entreprise qui voudrait mettre en place ce processus.