Chapitre 3 : DevOps vu par les équipes développement

La mission des développeurs a bien changé… Aujourd’hui, le développeur doit s’assurer que le code écrit est évolutif, toujours disponible, avec les meilleures performances, une latence plus faible, et produit au meilleur coût.

Ce changement de perspective s’inscrit dans une évolution des architectures applicatives. Oublié le temps des architectures 3-tiers avec la période de maintenance du week-end. La continuité de service doit être assurée, à l’échelle de la planète. Les mécanismes de redondance du cloud apportent un premier niveau de réponse. Mais cela ne suffit pas. Il faut maintenant concevoir les applications comme des suites de services autonomes et faiblement couplés, hautement disponibles, que l’on peut développer, gérer en versions, déployer, et mettre à l’échelle indépendamment, sans oublier le fait que l’on ne peut pas non plus présupposer que les services que l’on va consommer seront toujours sans faille.

Au coeur de cette évolution, le développeur, qui aujourd’hui encore plus qu’hier, joue un rôle clé dans le succès d’une application. Mais pour y parvenir, il doit lui-même se transformer. DevOps devient alors un élément déterminant pour accompagner ce changement plus culturel que technique.

La vision de DevOps pour le développeur suppose donc une évolution de son rôle au sein de l’organisation. Il lui faut adopter de multiples typologies d’outils et de technologies. Il doit participer aux processus collaboratifs impliquant les autres acteurs du système d’information, au cours des différentes étapes de la réalisation d’une application.

La culture DevOps propose un modèle, dans lequel les objectifs assignés au développeur portent sur un périmètre plus étendu. Cette évolution de la mission du développeur se traduit par une transformation de son métier sur le plan technique et sur le plan organisationnel.

DevOps pour le développeur : avant tout, un changement culturel…

Pour le développeur comme pour les autres acteurs du SI, la transformation DevOps dépend avant tout d’un changement de culture. Cette évolution se manifeste par sa capacité à vouloir aller au-delà de son périmètre. Il s’agit pour lui d’étendre la portée de son engagement sur le plan technique en essayant d’acquérir une compréhension globale du système, ce qui suppose une collaboration active avec les équipes opérationnelles et fonctionnelles. Il se doit aussi de comprendre la stratégie et d’avoir conscience des aspects business et financiers liés au produit ou au service dont il a la charge. Il doit être curieux et adepte d’une nouvelle forme d’apprentissage en continu, plus ouverte à la prise de risque et au partage de la connaissance. Dans la suite de ce chapitre, notre objectif est d’illustrer la perspective du développeur dans la mise en place d’un processus de continuous delivery.

Même si notre discours s’oriente souvent sur des points purement techniques, il faudra bien garder en tête le fait, qu’au-delà de l’outillage qui accompagne la démarche DevOps, le principal changement pour le développeur est d’ordre culturel.

À l’origine, le développeur se concentrait d’une part sur la réponse aux exigences fonctionnelles, l’architecture logicielle, le choix des frameworks, le respect des normes de programmation, et d’autre part sur l’implémentation d’un livrable susceptible de passer avec succès les tests de réception de ce livrable. La maintenance de la solution en fonctionnement opérationnel n’était pas de son ressort, aussi n’y accordait-il pas un niveau de priorité très élevé.

Avant DevOps, pour qu’un code soit considéré comme finalisé, il suffisait qu’il passe avec succès les tests associés (et qu’il ait fait l’objet d’une démonstration fonctionnellement concluante devant le responsable produit dans le cas de l’agile).

DevOps demande un niveau d’acceptation plus élevé : pour être considéré comme achevé, ce même code doit être déployé et utilisé en production. La validation de la réception du code est donc directement tributaire de la capacité à le proposer avec un niveau de service satisfaisant sur le plan opérationnel. Le partage de cette responsabilité avec les équipes opérationnelles change la nature même du travail du développeur. Cette évolution se traduit d’abord par une transformation de son métier sur le plan technique.

D’un point de vue technique, la démarche DevOps impacte directement la façon de concevoir et de développer les applications. Le développeur doit acquérir une compréhension globale du système, non seulement du point de vue de son implémentation interne, mais également vis-à-vis des contraintes liées à son contexte d’utilisation. Plus concrètement, cela signifie qu’il ne doit plus se limiter à la connaissance de patterns de code. Il doit également avoir connaissance de patterns liés à la résilience de l’architecture et à son déploiement dans le cloud.

Le développeur DevOps doit comprendre la stratégie produit au-delà du projet et connaître les aspects business et budget lié au produit ou au service. Il en sait également beaucoup plus sur le fonctionnel de l’application qu’il contribue à développer, sur la configuration du système d’exploitation et du réseau des environnements sur lesquels elle va s’exécuter, sur la façon dont elle va être supervisée, ou comment elle va s’adapter à la charge…

Et cette connaissance se traduit par une évolution de l’implémentation de l’application. Comme nous le verrons dans le chapitre 5 sur la qualité, le succès d’une application passe par la mise en place de systèmes de mesure et de processus de remontée d’information L’extension du périmètre de la vision technique de la solution par le développeur s’accompagne donc d’une systématisation de la mise en place de mécanismes d’instrumentation.

Enfin, DevOps encourage le développeur à progresser dans la connaissance par un apprentissage en continu. Cela requiert chez le développeur une certaine curiosité, et la volonté d’explorer les nouveautés de tel ou tel langage, ou tel ou tel framework, et la capacité à procéder à des expérimentations. Il lui faut donc disposer du temps lié à cette veille technologique et des moyens logistiques (environnements d’exécution…) lui permettant d’expérimenter ces nouvelles connaissances avant de les partager avec le reste des participants.

Cette capacité suppose qu’un minimum de latitude lui soit donné d’un point de vue organisationnel et opérationnel. Sur le plan organisationnel, la transformation DevOps du rôle du développeur se traduit par bien d’autres évolutions.

Comme nous l’avons vu, DevOps, au même titre que l’agile invite à plus de collaboration entre les équipes. Le développeur ayant une culture de l’agile devrait donc avoir un temps d’adaptation plus rapide, mais la transformation de son rôle n’en sera pas moins importante, car à la différence de l’agile, DevOps ne se limite pas à fluidifier les échanges entre le métier et les développeurs…

Avec DevOps tous les acteurs du système d’information sont concernés. En effet, DevOps fournit au développeur l’opportunité d’interagir avec de multiples autres intervenants de la chaîne de production logicielle. Le développeur DevOps prend notamment conscience des besoins et des priorités des acteurs de la gestion opérationnelle en étendant son périmètre de responsabilité et sa vision sur l’ensemble des composantes du système. Mais l’évolution de son rôle va au-delà de l’extension de sa capacité à comprendre les implications de ses choix d’implémentation et de son aptitude à prendre en considération des éléments liés à l’opérationnel dans les étapes amont de la mise en production.

En effet, avant DevOps, le manque de confiance entre les parties prenantes se traduisait par la nécessité pour les développeurs de faire appel aux responsables système pour toute action requérant la connaissance du sacro-saint mot de passe administrateur. Il en résultait des délais, des incompréhensions, voire des motifs de tension entre les équipes. Dans un monde DevOps, le développeur est habilité à exercer des responsabilités qui jusqu’alors étaient la prérogative des équipes en charge des opérations, comme par exemple, réinitialiser un environnement de développement ou de tests, sans avoir besoin de passer par une validation par les équipes directement en charge des infrastructures associées.

De même, lorsqu’un problème survient en production, le développeur est lui aussi concerné. De son implication et de sa collaboration avec l’ingénieur système ou réseau dépendra le temps de résolution du dysfonctionnement. Résoudre les problématiques au plus tôt afin de limiter leur impact est l’un des objectifs majeurs de la mise en place d’une transformation DevOps. L’époque du ça fonctionne sur ma machine est donc aujourd’hui bien révolue.

Cette évolution du rôle de développeur DevOps sur le plan organisationnel doit s’inscrire dans de nouveaux processus plus collaboratifs mettant en oeuvre de nouvelles méthodes de travail, qui requièrent l’acquisition de nouvelles compétences et la participation de l’ensemble des acteurs du système d’information. Ces processus nécessitent l’utilisation de solutions technologiques permettant à chacun d’avoir une vue commune sur l’ensemble des actifs et environnements dont dépend l’implémentation de l’application.

L’un des objectifs de DevOps est d’accélérer l’adoption d’une solution en proposant un flux de nouveaux services pour répondre aux attentes du marché en apportant de la valeur en continu. Dans ce contexte, le processus de continuous delivery se caractérise par un objectif ambitieux : celui de faire en sorte que le déclenchement d’une mise à jour sur le code source puisse automatiquement donner lieu à l’intégration des différents éléments constituant la solution et la mise à disposition automatisée d’un nouveau livrable.

Le développeur est un acteur majeur de ce processus. La dimension DevOps d’un projet réalisé dans le cadre d’un processus de continuous delivery se manifeste dans la capacité qui sera offerte aux développeurs et aux équipes de gestion opérationnelle de collaborer plus efficacement pour créer des systèmes complexes avec des niveaux plus élevés de confiance et de contrôle. L’objectif reste toutefois le même : s’assurer que la génération de l’application est effective, en garantissant que le code compile et vérifie avec succès de nombreux types de tests (unitaires, fonctionnels, intégration, performance) qui s’exécutent sur de multiples plateformes automatiquement provisionnées et configurées. Concrètement cela peut se traduire non seulement par une évolution du livrable applicatif et de ses dépendances mais aussi par une évolution de l’infrastructure déployée ou des environnements système.

Le processus de continuous delivery s’organise donc autour de différentes phases qui s’inscrivent dans un cycle itératif : la définition du projet, la conception, l’implémentation et les tests, l’intégration, le déploiement, et le recueil d’informations sur le fonctionnement de l’application pour en tirer un enseignement précieux dans la définition des évolutions à lui apporter. Le schéma de la figure 3.1 offre une vue synthétique de ces différentes étapes.

Fig. 3.1 Le processus de continuous delivery

Le développeur doit disposer d’un environnement de développement complet, à jour, lui permettant de mener à bien les différentes tâches qui lui sont assignées. Cet environnement ne se limite pas à la machine sur laquelle sont installés ses outils de développement. En effet, le développeur doit pouvoir tester le code qu’il produit, et cela suppose la mise en oeuvre de multiples composants logiciels (serveur web, base de données…). Enfin, il doit être représentatif de l’environnement de production pour anticiper au plus tôt les dysfonctionnements qui pourraient se manifester sur la topologie cible.

Une première solution consiste à déployer l’ensemble des composants requis sur la machine du développeur. Cette approche offre au développeur une certaine indépendance, lui donnant même l’opportunité de travailler sans connexion réseau. Mais elle se heurte à de multiples difficultés.

Le système d’exploitation

Un premier obstacle peut être lié au degré d’ouverture de l’application qui peut cibler plusieurs systèmes d’exploitation (Linux, Mac, Windows). De fait, il devient nécessaire pour le développeur de pouvoir tester l’application sur l’ensemble de ces environnements, ou de les émuler sur son poste de travail en s’appuyant sur un hyperviseur qui peut être local.

Le cas épineux du navigateur internet

Considérons maintenant le cas du développeur d’une application web. À différentes étapes de son implémentation, il doit pouvoir tester son fonctionnement avec les différents navigateurs du marché (Chrome, Firefox, Safari, Internet Explorer, Edge…), et parfois même pour de multiples versions du même browser qui n’offriraient pas le même comportement.

Ainsi, il est possible de faire cohabiter plusieurs versions de Firefox et de leur associer un profil créé via l’option -ProfileManager. Il est ensuite très simple de cibler ce profil avec l’option -P :

C:\Program Files (x86)\Mozilla Firefox\firefox.exe -P profil-de-la-version-cible

Dans le cas de Chrome, considérant le fait que la version se met à jour systématiquement, l’approche retenue dans le développement consiste plutôt à tester l’application systématiquement avec la dernière version et à s’assurer qu’il n’y a pas de régression de l’application due au changement de version.

Enfin, comme il n’est pas possible d’installer de multiples versions d’Internet Explorer sur Windows, Microsoft contourne l’obstacle de multiples façons. Une première approche consiste à utiliser un mode d’émulation directement intégré dans le browser (IE 11 inclut les noyaux des différentes versions de ses prédécesseurs…).

Fig. 3.2 Émulation du comportement d’une version spécifique d’un browser IE

En complément, Microsoft propose en téléchargement des images de différentes versions de systèmes Windows, Mac ou Linux incluant chacune une version du navigateur IE 6, IE 10, qui peut être exécutée sur de multiples hyperviseurs comme VirtualBox, Hyper-V, VMWare Player… Pour éviter d’avoir à régulièrement déployer ces images de tests (elles ont une durée de vie de 3 mois), le développeur web pourra utiliser ses propres images ou s’appuyer sur des services en ligne payants, exposant ces multiples machines virtuelles avec leur configuration spécifique de browser, et proposés par des sociétés tierces comme BrowserStack, par exemple.

Le versioning des middlewares applicatifs : la fin du DLL Hell

La machine locale de développement et de test peut nécessiter la présence de plusieurs versions du même environnement d’exécution appelé runtime. Ces versions peuvent ellesmêmes être utilisées simultanément par plusieurs versions d’applications et de composants. Suivant les technologies utilisées, ce type de contrainte n’est pas toujours aisé à résoudre…

Qui se souvient aujourd’hui de la période où sur Windows, les applications bâties sur le COM (Component Object Model) ne savaient pas faire la distinction entre des versions incompatibles de la même librairie, une situation qui parfois virait au cauchemar pour les développeurs, les opérations et les utilisateurs plongés dans ce qu’il était alors coutume d’appeler le DLL Hell ? Fort heureusement, aujourd’hui, cette problématique n’est plus d’actualité.

Toutefois, le développeur qui souhaiterait mutualiser l’utilisation de son poste de travail pour de multiples applications risquerait encore de faire face à des difficultés car les versions d’un middleware ne sont pas nécessairement compatibles. Cette incompatibilité interdirait au développeur de pouvoir tester simultanément différentes versions de l’application (ou différentes applications) lorsqu’elles seraient elles-mêmes associées à différentes versions de middlewares qui ne pourraient pas cohabiter. De plus, elle ne serait pas de nature à faciliter la réutilisation d’une configuration spécifique d’environnement de développement pour un nouvel arrivant sur le projet.

Prenons le cas de Java. Par défaut, les applications Java utilisent la dernière version du JRE (Java Runtime Engine) sans tenir compte de la version qui serait effectivement requise. Dans ce contexte, le développeur DevOps devra donc installer de multiples versions du runtime et forcer cette affinité par différents moyens techniques. Pour basculer d’une machine virtuelle Java à l’autre il suffit d’automatiser avec un script le changement de classpath et du répertoire dans lequel sera installée la version de la JVM (Java Virtual Machine) cible.

En environnement Linux, cela peut être réalisé par le simple appel de la commande sudo update-alternatives –config java. Enfin, il existe la solution open source jEnv dont c’est la fonction. Elle offre un langage de commande permettant d’ajouter une nouvelle version de JDK (Java Development Kit) comme par exemple jenv add /Library/Java/JavaVirtualMachines/jdk17055.jdk/Contents/Home et de décider de sa visibilité globale ou locale sur la machine.

De même avec le framework événementiel open source Node.js qui est bâti sur le moteur JavaScript de Google complété par un wrapper C++ optimisé pour les entrées-sorties. Il existe de multiples versions de ce framework qui incluent notamment un outil permettant de les gérer sur le poste de travail (NVM, le Node Version Manager), ce qui suivant le niveau d’installation, local ou global, permet ou non de cloisonner son usage par applications.

Enfin, il faut parfois gérer des situations plus complexes dans lesquelles de multiples versions de middleware, de composants ou d’applications doivent pouvoir cohabiter.

Aujourd’hui, suivant le type de runtime utilisé, la démarche a été considérablement simplifiée par le principe du side by side qui permet d’exécuter simultanément ces multiples versions d’une application, d’un composant, ou d’un runtime sur la même machine. Le mode d’exécution proposé offre en général la possibilité de contrôler les associations et les dépendances entre les différentes versions de ces éléments.

Fig. 3.3 Exécution side by side de deux versions d’un runtime ou d’un composant

Dans ce registre on peut citer le cas du .NET Framework. Dans sa version complète, les versions majeures peuvent fonctionner en side by side de façon globale sur le poste de travail (charge à l’application de préciser quelle version du framework elle souhaite alors cibler). Dans sa version open source .NET Core, chaque application est livrée avec le ou les modules du .NET Core dont elle dépend. Le mode d’exécution proposé est alors encore plus flexible.

Une autre solution consiste à adopter une approche plus centralisée en définissant un espace de travail pour chacun des développeurs sur un ou plusieurs serveurs. Il devient alors plus simple et plus rapide de répliquer un environnement de développement. Par contre, cela ne résout pas les problématiques de cohabitation de versions incompatibles de middleware. De plus, l’approche peut également se traduire par des effets collatéraux liés aux conflits de processus lancés par les développeurs sur le ou les serveurs mutualisés.

Aucune de ces deux solutions n’est totalement satisfaisante, c’est pourquoi, le développeur DevOps va chercher à disposer d’un environnement de développement qui reflète l’environnement de production sans altérer la configuration de sa machine. Pour ce faire, il va tirer parti de la virtualisation.

Son objectif est alors de disposer d’une ou plusieurs machines virtuelles qu’il pourra provisionner à volonté sur la base de modèles qui pourront être partagés par l’ensemble des développeurs. Ces machines virtuelles peuvent s’exécuter sous des systèmes d’exploitation Linux ou Windows. Elles peuvent être déployées localement (avec un hyperviseur de type 2 comme VirtualBox ou de type 1 comme KVM sur Linux, XenServer de Citrix, ESX Server de VMWare, ou Hyper-V de Microsoft) sur la machine du développeur, sur un serveur local, ou sur un cloud, qu’il soit public ou privé.

À la virtualisation des machines virtuelles s’ajoute le mécanisme de virtualisation par container, notamment avec la solution Docker, dont nous aurons l’occasion de reparler. Pour configurer son environnement de travail sur Mac ou sur Windows, le développeur peut alors utiliser des accélérateurs, comme Docker ToolBox afin d’installer les multiples outils associés (Docker Client, Docker Compose, Docker Machine, Docker Kinematic…).

Qu’il s’agisse de virtualisation machine ou virtualisation par container, il peut, suivant le contexte, s’avérer contraignant d’avoir à gérer les multiples opérations liées aux démarrages des containers ou machines virtuelles associées à l’environnement de développement. D’où l’intérêt de les automatiser…

Ces opérations de déploiement et de configuration de machines virtuelles peuvent bien entendu être automatisées par différentes techniques de scripting. Toutefois, de nouvelles problématiques viennent contrarier ce type d’initiative.

Une première difficulté est liée aux différences des langages de scripting en fonction du système d’exploitation. Par exemple, même s’il existe des sous-systèmes open source POSIX comme Cygwin ou MinGW (Minimalist GNU for Windows) pour Windows, le langage de scripting de référence y est plutôt PowerShell qu’un shell Unix comme Bash.

Nous touchons là du doigt une des caractéristiques essentielles de DevOps. Non seulement, le développeur DevOps doit s’intéresser au langage lié au contrôle de l’infrastructure qu’il sera amené à utiliser dans ses activités quotidiennes, mais cet intérêt doit aller au-delà de ses préférences pour tel ou tel système d’exploitation. En effet, d’un point de vue DevOps, le fait que l’on ait plus d’expérience sur un langage de script comme PowerShell ne doit pas interdire de coder un script Bash et inversement…

Le deuxième écueil est lié aux spécificités des hyperviseurs, que ces derniers soient exposés dans un cloud privé ou dans un cloud public. Nous reviendrons sur ce point dans le chapitre 4 lié aux opérations.

Bref, le développeur (et le responsable des opérations DevOps) devra savoir maîtriser de multiples langages de script et de multiples API liées à l’automatisation des environnements, que ce soit pour le provisioning ou la configuration.

Il faut toutefois nuancer la difficulté que peut représenter cette hétérogénéité. À l’échelle d’un projet, voire d’une entreprise, le choix d’un hyperviseur est souvent globalisé (notamment pour homogénéiser les formats des machines) et les solutions de provisioning des différents acteurs du cloud sont souvent ouvertes à l’usage de langages de script pouvant s’exécuter sur de multiples systèmes d’exploitation. Par exemple, le langage open sourceAzure CLI, fondé sur Node.js s’exécute depuis Windows, Mac ou Linux et permet de provisionner des machines virtuelles sur la plateforme cloud Microsoft Azure. Les autres solutions de cloud comme celles d’Amazon ou Google offrent également cette capacité à utiliser leurs API depuis de multiples environnements.

Enfin, il reste possible d’uniformiser tout ou partie du processus de mise à disposition de l’environnement, en particulier pour les développeurs, en offrant un niveau d’abstraction supplémentaire, permettant de manipuler des machines virtuelles avec un ensemble de commandes indépendantes de la plate-forme de virtualisation. C’est à ce type de problématique que répond l’outil open source Vagrant.

Vagrant est un logiciel conçu pour faciliter la gestion des environnements de développement ou de test indépendamment des technologies mises en oeuvre, avec un double objectif : celui de pouvoir reproduire ces environnements à la demande et celui de donner la possibilité de partager plus facilement la construction de ces environnements.

Le principe de mise en oeuvre de Vagrant repose sur la définition d’un fichier, le Vagrant File dont la finalité est de permettre de monter très rapidement un environnement de développement entièrement configuré. Ce fichier regroupe de multiples commandes associées à un fichier de configuration afin de définir la topologie et la configuration de l’environnement de développement. Il offre ainsi une isolation des multiples composantes de l’environnement qui peut être partagé au sein d’une équipe de développeurs afin que chacun se crée son propre espace de travail à partir de la même configuration (que ce soit sur Linux, Mac OS X ou Windows). Ainsi le code s’exécute sur le même environnement, avec les mêmes contraintes et la même configuration quel que soit le poste sur lequel il s’exécute.

Une fois défini ce Vagrant File, les développeurs peuvent lancer la commande vagrant up en spécifiant le provider cible (en l’occurrence l’hyperviseur). Ils disposent ainsi d’un environnement de développement automatiquement provisionné avec la configuration cible. Cet environnement de type SandBox présente de multiples avantages : il peut correspondre plus facilement à l’environnement de production tout en étant directement opérationnel. Cela permet d’éliminer les dysfonctionnements liés à une potentielle divergence des environnements. Les développeurs continuent de travailler, de façon totalement transparente, sur leur propre machine, avec les outils qu’ils ont l’habitude d’utiliser (IDE, éditeurs, compilateur, debuggers, framework de tests unitaires, etc.). Une fois les machines de référence téléchargées sur le poste de travail du développeur, la suppression et la création d’un environnement de développement sont quasi instantanées et extrêmement faciles à partager avec d’autres développeurs.

Ainsi, les développeurs sont rapidement opérationnels, sans avoir à maîtriser les subtilités de tel ou tel langage de script, ou tel ou tel hyperviseur. Et bien entendu, Vagrant fonctionne aussi bien sur Linux, sur Mac, que sur Windows, ce qui garantit la cohérence de la mise à disposition de l’environnement de développement et une uniformisation de la démarche associée entre les systèmes d’exploitation.

Fig. 3.4 Principe de fonctionnement de la solution Vagrant

Par exemple, supposons que le développeur doive déployer dans son environnement de développement une image intégrant les composants suivants : une machine virtuelle Ubuntu 14.04 préconfigurée avec Puppet, Docker, et l’image Docker microsoft/aspnet pour un déploiement sur un provider Hyper-v, soit un mélange de technologies assez variées. Vagrant s’appuie sur un référentiel commun, le site https://atlas.hashicorp.com, qui propose des machines virtuelles préconfigurées, les boxes pour les différents types d’hyperviseur supportés. Les commandes Vagrant permettent alors de préciser quelle machine virtuelle obtenir à partir de ce référentiel. Avec une commande vagrant init, il est très facile d’initialiser le Vagrant File correspondant à l’environnement souhaité. Il ne reste plus alors qu’à spécifier, avec la commande vagrant up l’hyperviseur sur lequel la machine virtuelle spécifiée dans le fichier Vagrant File sera déployée.

Une fois cette machine téléchargée dans le répertoire cible (et mise en cache dans un répertoire local .vagrant.d\boxes lié au profil de l’utilisateur), Vagrant lance la configuration, notamment la virtualisation du réseau, la gestion de l’authentification et l’établissement d’un partage de type CIFS, pour permettre à l’ordinateur hôte d’avoir une visibilité sur le système de fichiers de la machine virtuelle déployée. Cette configuration peut être automatisée via un outil de configuration permettant de définir son état. Vagrant supporte nativement Chef ou Puppet. Nous reviendrons sur ces solutions dans le chapitre 4 sur les opérations. Par exemple, pour activer Puppet dans Vagrant, il suffit de mettre à jour le Vagrant File avec la ligne config.vm.provision :puppet. et de créer un répertoire manifests dans lequel le développeur ira publier le fichier déclarant la configuration souhaitée.

Précisons que Vagrant n’impose pas l’utilisation de machines virtuelles et qu’il peut s’appuyer nativement sur une virtualisation par les containers. En effet, Vagrant supporte l’utilisation d’un provider Docker et permet ainsi de construire des environnements de développement intégrant des containers construits avec Docker, ce qui offre un niveau d’indépendance supplémentaire par rapport à l’hyperviseur, tout en offrant les avantages que nous avons présentés (en particulier l’automatisation de la configuration et le partage des fichiers avec l’environnement virtualisé). Nous reviendrons sur Docker dans le dernier chapitre de cet ouvrage.

Enfin, n’oublions pas l’importance de pouvoir assurer le partage et la gestion des versions des éléments décrivant l’environnement de développement. Le développeur DevOps a alors tout intérêt à enregistrer le Vagrant File dans le référentiel de code source.

La dimension DevOps liée à l’environnement de développement repose sur la capacité du développeur à tirer parti des possibilités qu’offrent aujourd’hui les mécanismes d’automatisation des environnements. L’utilisation d’une solution comme Vagrant, n’est pas un passage obligé et sera sans doute plus fréquemment rencontrée dans le monde Linux que dans le monde Windows. Par contre, les mécanismes de virtualisation et d’automatisation qui la sous-tendent doivent faire partie des fondamentaux du développeur DevOps.

De multiples outils sont associés à l’activité de production logicielle du développeur comme les outils de gestion de code source, de gestion de tickets, de code review… De leur niveau d’intégration dépend aussi la qualité des processus liés au continuous delivery.

En complément de ses outils de production logicielle, le développeur DevOps est amené à utiliser des outils qui permettent de faciliter la communication avec ses pairs, avec les équipes opérationnelles ou avec le système. Certaines solutions sont nativement fournies avec les environnements collaboratifs des différents éditeurs, d’autres comme Slack viennent très simplement s’intégrer dans un processus DevOps en permettant d’établir des canaux de communication entre les différents acteurs afin d’être notifiés en temps réel sur telle ou telle étape du projet.

Une fois cet environnement mis à sa disposition (et automatisé par ses soins ou par ceux de l’un de ses collègues), le développeur DevOps peut se focaliser sur l’implémentation de l’application.

L’influence de l’approche DevOps sur l’implémentation d’une application se manifeste à différents niveaux, dans les phases de conception comme dans celles de mise au point. La phase de conception logicielle est au coeur du dispositif permettant d’assurer la qualité du projet. C’est le respect des règles qui seront établies durant cette étape qui garantira la cohérence du code source et qui permettra ultérieurement de mieux en assurer la maintenance.

La conception d’un logiciel vu sous l’angle DevOps doit prendre en considération le contexte de mise à disposition de ce logiciel. Comme nous l’avons vu, cela implique la capacité à faire évoluer rapidement ce logiciel en fonction des attentes du marché ou du renouvellement des technologies. Cela se traduit par de multiples conséquences pour la conception. La première d’entre elles est l’absolue nécessité d’une conception dite évolutive.

Une conception évolutive

Quel que soit le niveau de complexité d’un système, son développement requiert une phase de conception amont pour en figer les grandes lignes. Ainsi les développeurs savent globalement dans quelle direction avancer, quels composants implémenter. Cela permet d’identifier un certain nombre de tâches dès le début du projet et de procéder à une estimation du temps requis pour leur réalisation. Toutefois, cette phase de conception suppose chez le développeur un certain niveau d’adaptation au changement. D’un point de vue technique, cela suppose la définition d’une architecture logicielle plus flexible (notamment grâce à des patterns que nous verrons un peu plus loin). Toutefois, cette flexibilité ne doit pas se construire au détriment de la productivité, et les choix d’implémentation qui en découlent doivent être pragmatiques. C’est donc le principe de simplicité qui, souvent, doit prévaloir.

Simplicité

Ainsi, la nécessité de livrer au plus tôt de nouvelles fonctions incite le développeur DevOps à adopter une approche de conception simplifiée que certains appellent YAGNI (You Aren’t Going to Need It).

Concrètement, cela veut dire qu’il est inutile de se lancer dans une implémentation complexe à plusieurs niveaux d’interface pour un composant qui ne serait utilisé qu’une seule fois. La première logique de ce raisonnement est purement économique. Mais là n’est pas le seul intérêt de la démarche. La complexité de l’implémentation d’un système le rend moins compréhensible et par conséquent, freine son évolutivité. La fameuse maxime d’Antoine de Saint-Exupéry, « La perfection est atteinte, non pas lorsqu’il n’y a plus rien à ajouter, mais lorsqu’il n’y a plus rien à retirer » pourrait donc fort bien s’appliquer à la conception logicielle…

Toutefois, il reste important de garder à l’esprit que l’architecture d’un composant sera amenée à changer, et de faire en sorte que ces évolutions soient les plus simples possibles (design for change).

Architecture microservices

Au-delà de la fiabilité apportée par les mécanismes de redondance du cloud, l’architecture microservices décrit une manière particulière de concevoir une application comme une suite de services hautement disponibles, autonomes et faiblement couplés, que l’on peut développer, versionner, déployer, scaler indépendamment. L’indépendance du déploiement de microservices pose également la question de la gestion des versions des API et de leur référentiel (question à laquelle les solutions d’API Management peuvent apporter un premier niveau de réponse).

C’est aujourd’hui le type d’architecture vers lequel s’orientent de nombreuses applications, et c’est donc au développeur DevOps qu’incombe la responsabilité de les implémenter. Pour ce faire, il peut utiliser une solution open source comme Docker qui facilite l’implémentation d’architectures microservices par la décomposition en containers.

Il peut également tirer parti de solution PaaS (Plateform as a Service) qui au-delà de l’isolation par container, inclut des fonctions liées au state management, ou à la résilience d’application microservices. Par exemple, Service Fabric est une plate-forme de systèmes distribués utilisée depuis plusieurs années par Microsoft, pour créer des applications évolutives et fiables composées de microservices s’exécutant sur un cluster. Cette plateforme héberge les microservices à l’intérieur des conteneurs qui sont déployés et activés sur un cluster en fournissant un runtime pour des services distribués stateless ou stateful. Ce runtime peut s’exécuter sur tout type de cloud ou à demeure ; il fonctionne sur Windows mais, à terme, il supportera à Linux et les conteneurs (Windows ou Docker). Il permet de déployer et gérer un exécutable développé dans différents langages (Microsoft de .NET Framework, Node.js, Java, C++). Avec Service Fabric, l’application devient une collection de multiples instances de services constitutifs assurant une fonction complète et autonome. Ces services sont composés de code, configuration ou données et peuvent démarrer, fonctionner, être gérés en version et mis à jour indépendamment. Service Fabric permet de gérer des données persistantes au sein d’un microservice dont l’exécution est potentiellement répartie sur plusieurs machines d’un même cluster. Pour construire les services, il propose deux API (Reliable Actors et Reliable Services) qui diffèrent en terme de gestion de la concurrence, du partitionnement et de la communication.

Le rôle de l’architecte logiciel

L’architecte logiciel est à l’origine des choix de conception. Il doit disposer des compétences qui lui permettent de communiquer sur ses choix vis-à-vis de ses interlocuteurs techniques et fonctionnels, sachant que tôt ou tard, il sera amené à les reconsidérer. Il doit s’assurer que ces choix sont respectés tout en ayant une vision des évolutions à y apporter en identifiant au plus tôt les chantiers de rétro-conception de l’architecture logicielle existante. Il est partie prenante dans le refactoring, et doit s’assurer que ne subsiste pas de code mort dans la solution.

La compréhension globale du système qu’acquiert le développeur DevOps en fait un interlocuteur de poids face à l’architecte logiciel, qui de son côté, se doit d’être au fait de telle ou telle contrainte liée à l’implémentation de la solution. Leur interaction suppose donc une égalité dans les périmètres de responsabilité.

D’un point de vue DevOps, l’implémentation d’une application se caractérise par une approche itérative privilégiant l’expérimentation, le partage de la connaissance, le refactoring et la mise en oeuvre de patterns éprouvés. Enfin, elle suppose la mise en oeuvre d’outils permettant d’observer les interactions avec le système.

Culture de l’expérimentation

L’implémentation de l’application s’inscrit dans un processus itératif. Il s’agit de pouvoir anticiper au plus tôt les choix de développement qui pourraient se traduire ultérieurement par des contraintes fortes, voire des impossibilités techniques. Dans ce contexte, le développeur DevOps ne doit pas hésiter à initier une démarche de prototypage (design for change, un spike pour employer la terminologie Scrum).

Refactoring

Pour le développeur, DevOps est synonyme de changement. Le développeur DevOps doit périodiquement revoir son code, échanger avec ses pairs et procéder le cas échéant à un refactoring du code existant. L’objectif este d’offrir une meilleure capacité de réutilisation et une optimisation de la qualité du code, notamment pour réduire la dette technique, un sujet sur lequel nous reviendrons dans le chapitre 5 lié à la qualité. Bien qu’il n’existe pas de règles absolues, une approche par refactoring a posteriori est sans doute plus efficace et plus réaliste que l’alternative qui consisterait à proposer un modèle d’architecture logicielle susceptible de s’adapter à toutes les conditions et toutes les demandes avant même qu’elles ne se présentent.

Patterns

La réutilisation de patterns de conception logicielle n’est pas spécifique au développeur DevOps. Toutefois, elle s’inscrit totalement dans la culture DevOps de partage de retour d’expérience et d’amélioration continue. Elle permet notamment d’accélérer les développements en capitalisant sur des modèles éprouvés et en évitant les chausse-trappes dans lesquels le développeur n’aurait pas manqué de tomber… De plus, la systématisation de l’usage de ces patterns facilite la relecture de code par les développeurs qui se les approprient et offre également des mots-clés qui permettent aux développeurs d’échanger sur les éléments d’implémentation de la solution.

=== Le développeur DevOps doit donc avoir une bonne connaissance de modèles de code comme les patterns de création (Singleton, Abstract factory, Object pool…), de structure (Façade, Proxy, Module, Composite…), de comportement (Mediator, Publish/subscribe, Command…) ou de concurrence (Reactor, Event based asynchronous, Scheduler…). Ces patterns ont fait l’objet de multiples publications, dont l’ouvrage produit par le fameux Gang of Four sous le titre Design Patterns : Elements of Reusable Object-Oriented Software. Dans cet ouvrage de référence, après avoir exposé les subtilités de la programmation orientée objets, Erich Gamma, Richard Helm, Ralph Johnson et John Vlissides nous proposent 23 patterns classiques et leur implémentation en Smalltalk ou C++.

Et pour un même pattern, plusieurs implémentations peuvent parfois être proposées. Prenons le cas du pattern Singleton. Ce pattern, sans doute l’un des plus connus, permet de s’assurer qu’une unique instance d’une classe peut être créée et expose une interface d’accès sur cette instance. Ainsi, dans son ouvrage C# in Depth, Jon Skeet propose cinq implémentations de ce même pattern, offrant différents niveaux de performance et d’isolation. Voici l’implémentation numéro cinq correspondant au Fully Lazy Instanciation du pattern Singleton.

public sealed class Singleton
{
     Singleton()
     {
     }
 
     public static Singleton Instance
     {
          get
          {
            return Nested.instance;
          }
     }
 
     class Nested
     {
          static Nested()
          {
          }
 
          internal static readonly Singleton instance = new Singleton();
     }
}

La compréhension globale du système qu’acquiert le développeur DevOps le prédispose maintenant à également avoir connaissance de patterns liés à la résilience de l’architecture et à son déploiement dans le cloud (Command and Query Responsibility Segregation, Circuit Breaker, Compensating Transaction, Cache-aside…).

Considérons une application interagissant avec des services s’exécutant dans le cloud (services applicatifs, services de base de données, services de recherche, services de cache…). Pour améliorer la stabilité d’une application, le développeur DevOps pourra faire bon usage des patterns Retry et Circuit Breaker.

Commençons par le Retry pattern. Notre application est susceptible d’être confrontée à de multiples erreurs provisoires, qui peuvent être dues à l’indisponibilité d’un service trop sollicité ou à une perte de connectivité réseau. Il est donc utile d’anticiper ce risque d’échec et de veiller à inclure une logique applicative permettant l’exécution différée d’une nouvelle tentative. Dans ce contexte, il suffit d’exécuter la même requête après un délai très court pour parvenir, cette fois-ci, à un résultat positif. C’est à ce type de logique que correspond le Retry pattern.

Fig. 3.6 Retry pattern (Source : livre blanc Cloud Design Patterns: Prescriptive Architecture Guidance for Cloud Applications)

Le Retry pattern peut être combiné avec le pattern Circuit Breaker dont l’objectif est de permettre d’éviter qu’une application soit amenée à exécuter de façon répétée une opération qui aboutirait à un échec, en laissant l’exécution se poursuivre sans attendre que l’erreur soit corrigée. Ce pattern permet aussi à l’application de savoir si le problème est réglé et si l’application peut à nouveau essayer d’exécuter l’opération. Le Circuit Breaker joue le rôle de proxy pour les opérations qui peuvent échouer en décidant s’il convient ou non d’exécuter l’opération en se fondant sur le nombre récent d’échecs constatés. Ce proxy peut être implémenté comme une machine à état offrant le comportement d’un disjoncteur, d’où le nom de ce pattern.

Fig. 3.7 Pattern Circuit Breaker (Source : livre blanc Cloud Design Patterns: Prescriptive Architecture Guidance for Cloud Applications)

En utilisant le Retry pattern pour faire appel à une opération via un Circuit Breaker, le code implémentant le Retry pattern abandonnera ses tentatives d’exécution, si le Circuit Breaker remonte une erreur qu’il considère comme non provisoire.

Enfin, pour le développeur DevOps, il ne s’agit pas de simplement connaître les patterns de code et de résilience architecturale. Il s‘agit de savoir les utiliser à bon escient, et le cas échéant, pouvoir s’en affranchir.

Patterns et frameworks

Dans la plupart des cas, implémenter un pattern signifie reproduire un modèle de code dûment documenté. Mais parfois, il s’agit de s’appuyer sur un framework plus complet.

Considérons par exemple le pattern d’inversion de contrôle (IoC) ou plus précisément sa déclinaison dans le monde objet que l’on présente sous le nom d’injection de dépendances qui permet de découpler les dépendances entre objets. Il existe de très nombreux frameworks open source IoC dans les différents langages (Spring, Unity, StructureMap…). Dans la plupart des conteneurs exposant ce type de mécanisme, on fait usage d’une configuration (effectuée par fichier ou par code) permettant d’établir l’association d’une classe avec une interface. Une fois la configuration effectuée et la cible de l’association spécifiée, c’est le rôle du framework d’injection d’instancier la classe cible.

Cela offre au développeur DevOps bien des possibilités comme par exemple celle d’activer un type de journalisation différent sans avoir besoin de modifier le code, en réinitialisant simplement la cible de l’association…

La phase de diagnostic et de mise au point

Produire du code nécessite l’usage d’outils de développement mais également d’outils de diagnostic. Et le développeur DevOps se différentie aussi par sa capacité à vérifier son code, non seulement d’un point de vue fonctionnel, mais également d’un point de vue performances.

Pour ce faire, il fera usage d’outils dont le but est d’isoler et de déterminer la cause première d’un comportement anormal du point de vue des performances, et d’identifier la configuration ou la condition d’erreur qui entrave le fonctionnement normal du système.

Ces outils lui permettent d’accéder à des informations à un instant t pour valider le bon fonctionnement de son application (debugger), mais aussi de disposer d’un historique du comportement de l’application avec une sauvegarde de la call stack et des variables locales (comme le propose la technologie Intellitrace par exemple). Enfin, il est en situation de pouvoir effectuer, avec ou sans debugger, une analyse de la performance globale de son code (consommation mémoire et CPU). Les outils de développement les plus avancés permettent aujourd’hui d’associer ces différents types d’information et le cas échéant d’établir une corrélation directe entre les pics de CPU et le code qui s’exécute entre deux points d’arrêt.

D’un point de vue DevOps, le développement d’une application se caractérise par une conception évolutive, privilégiant, la simplicité (la sophistication ultime selon Léonard de Vinci…). Elle emprunte différents éléments à la culture DevOps, comme l’expérimentation, le partage de la connaissance via les patterns et l’amélioration continue par le refactoring. Enfin, elle suppose l’acquisition de nouvelles connaissances, notamment du point de vue du système.

Parmi les outils mis en œuvre, dans le cadre d’une démarche DevOps appliquée au développement et à la mise en production d’une application, le contrôle de code source joue un rôle fondamental.

Le processus de développement requiert nécessairement un outil de contrôle de version du code source de l’application, également appelé SCM (Source Control Management). Le principe est de proposer la possibilité de conserver un historique des versions, de différencier deux versions du même code, de gérer des branches de code, de les fusionner… L’objectif de ce type de solution est de définir des règles qui régissent les archivages, les fusions et les autres interactions autour du code. Chaque acteur du cycle de production logicielle se doit de les respecter.

Qu’elles soient open source ou propriétaires, les premières solutions proposées reposaient sur un modèle centralisé (CVCS : Centralized Version Control System) comme Subversion, ClearCase, Visual Source Safe ou Microsoft Team Foundation Version Control (le système historique de Team Foundation Server). Aujourd’hui il semble que le mode de configuration le plus fréquemment rencontré se fonde sur un modèle distribué (DVCS : Distributed Version Control System) comme Mercurial ou Git.

À l’origine, Git a été conçu et développé par Linus Torvalds pour le noyau Linux. Aujourd’hui, c’est devenu un standard de fait. Dans le modèle proposé par Git, les développeurs travaillent avec un référentiel local qui est synchronisé avec le référentiel en ligne centralisé. Cette approche leur permet de travailler en mode hors connexion, indépendamment d’un système central et facilite la capacité d’apporter des changements rapidement. Git permet ainsi très facilement de cloner un référentiel dans lequel on peut avoir de multiples sous-référentiels. Git offre les notions de branches (que l’on retrouve également dans d’autres systèmes de contrôle de code source) et de fork. Le système de branches offre la possibilité de faire évoluer le code dans une direction différente (évolution fonctionnelle, correctif) de l’implémentation principale pour une éventuelle fusion ultérieure. Un fork est une copie personnelle d’un dépôt que l’on peut ensuite faire évoluer sans conserver de lien avec la version d’origine. Nous reviendrons sur ces deux notions un peu plus loin dans ce chapitre.

Git diffère d’autres systèmes de contrôle de version dans la façon dont il traite les données. La plupart des autres solutions traitent les changements comme des différentiels par rapport au fichier d’origine. À l’inverse Git prend un instantané de l’ensemble des fichiers dans le référentiel. Pour optimiser le stockage, il ne stocke pas les fichiers qui ne sont pas changés entre les versions.

Enfin, Git est disponible sous la forme d’une solution logicielle que l’on peut installer à demeure sous Linux (avec une configuration SSH supplémentaire si on souhaite un serveur Git) ou sous Windows (Git for Windows) que l’on peut compléter avec des solutions clientes, open source comme TortoiseGit ou Msysgit, ou serveur propriétaire comme Team Foundation Server). Enfin, Git est également exposé sous forme de services en ligne comme GitHub, Bitbucket ou Visual Studio Team Services et bénéficie ainsi des avantages liés à l’utilisation d’un service managé (haute disponibilité, backup, migration…). Le schéma de la figure 3.9 représente une vue d’ensemble conceptuelle de Git.

Fig. 3.9  Interaction avec un serveur (ou un service Git)

Le contrôleur de code source offre également des interfaces avec les autres outils de la chaîne de production logicielle. Par exemple, lorsqu’un développeur archive des modifications qui peuvent se traduire par une incapacité à générer un build (avec ou sans exécution de tests automatisés sur le code), cela peut entraîner des conséquences négatives pour la productivité globale. Pour éviter ce type de situation, il est fort utile, lorsque le logiciel ou le service de gestion de code source le permet, de lier le contrôle de code avec le système de gestion des builds par une définition de build d’archivage contrôlé (gated check-in), qui garantit la qualité du code avant son archivage.

Cette relation avec le système de build est très fréquemment mise en œuvre. Nous en reparlerons un peu plus loin lorsque nous aborderons le sujet de l’intégration continue.

L’usage du contrôle de code source, va s’étendre dans le contexte d’une démarche DevOps. En effet, il s’agit non seulement de pouvoir assurer la gestion des versions du code produit par les développeurs, mais aussi de proposer un outil permettant de gérer l’historique des scripts liés à l’automatisation des environnements, à commencer par l’environnement de développement, comme nous l’avons déjà signalé à propos du fichier Vagrant File. Or chaque environnement est différent (système d’exploitation et paramètres de configuration de middleware, emplacement des bases de données et des services externes et toutes autres informations de configuration qui doivent être définies au moment du déploiement).

Il ne suffit donc pas d’archiver les scripts de déploiement. La gestion de configuration logicielle suppose également la conservation (et la gestion en versions) d’un fichier de paramètres distincts pour chaque environnement. Ces fichiers seront ensuite utilisés par le même script de déploiement. Développeurs et responsables opérationnels ont donc tout intérêt à conserver et partager les fichiers de scripts et de paramètres dans le même outil de contrôle de version.

Tous les types d’objets (composants applicatifs, configuration des logiciels d’infrastructure) intervenant dans le cycle de vie l’application sont donc archivés avec leurs versions. Cela permet à chaque instant d’être en mesure de reproduire un environnement, avec sa version d’application, de système d’exploitation, de base de données ou de middleware…

Qu’ils soient open source ou propriétaires, qu’ils soient utilisés depuis une interface graphique (intégrée ou non dans l’outil de développement) ou en ligne de commande, le contrôle de code source peut être facilement mutualisé entre les deux types de profil Dev et Ops et permettre une collaboration plus active dans la mise à disposition d’éléments liés à la configuration des environnements.

En plus de permettre de déterminer les composantes logicielles d’une version, le SCM permet également de déterminer quelles sont les modifications réalisées par qui, quand et pour peu que l’information ait été renseignée, pourquoi, ce qui s’inscrit dans une vision DevOps du partage de l’information et de la traçabilité de l’évolution du système dans sa globalité.

Dans les systèmes de contrôle de code source, une branche représente une ligne indépendante du développement. En offrant un niveau d’abstraction sur le processus de publication du code, l’utilisation de branches permet aux développeurs de faire évoluer ce code simultanément sur plusieurs lignes de développement tout en préservant les relations entre ces différents chemins.

Feature Branch Workflow

Le modèle de développement Feature Branch consiste à créer une branche chaque fois qu’un développeur commence à travailler sur une nouvelle fonction. Le développement a alors lieu sur une branche dédiée plutôt que sur la branche master, ce qui facilite les évolutions sans altérer la base principale de code. Il s’agit d’une technique éprouvée et fréquemment mise en œuvre sur les projets de développement de taille significative.

GitFlow

Un workflow plus abouti est celui que propose Vincent Driessen : GitFlow définit un modèle de branche conçu pour la gestion des grands projets. Le principe retenu est d’affecter des rôles aux différentes branches et de préciser la nature de leurs interactions. GitFlow met en œuvre deux branches principales pour gérer l’historique du projet, la branche master qui permet de gérer les versions de l’application, et la branche develop qui permet d’intégrer les nouvelles fonctions. Dans ce modèle, le développement de nouvelles fonctions donne lieu à la création de nouvelles branches feature dont le parent est la branche develop (et non la branche master). Lorsqu’une fonction est complète, la branche feature est fusionnée (merge) avec la branche develop. Lorsque la branche develop intègre suffisamment de nouvelles fonctions, une branche release est créée par un fork de la branche develop. Lorsque la release est considérée comme prête, elle est fusionnée dans la branche master avec un numéro de version. Cette approche présente également l’avantage de permettre de gérer en parallèle plusieurs branches release (et donc plusieurs versions). À cela s’ajoutent les branches de maintenance, qui sont issues de la branche master. Leur rôle est de permettre le développement de correctifs pour la version en production. Une fois le code correspondant au correctif validé, la branche de maintenance doit être fusionnée avec les branches master et develop.

GitFlow et processus de continous delivery

Il n’y a pas si longtemps l’ajout d’une branche supplémentaire entraînait nécessairement un surcroît significatif de maintenance sur le code source (efforts de fusion, gestion des conflits…) plutôt incompatible avec une intégration continue. Avec Git, cet impact s’est considérablement réduit.

Toutefois, un processus de continuous delivery nécessite une validation des changements sur la branche principale aussi fréquemment que possible, puisque c’est la branche à partir de laquelle on automatise le déploiement de l’application une fois le build effectué.

Pour résoudre cette problématique, certains vont jusqu’à proposer de ne plus faire usage des branches feature et de se limiter uniquement à des branches release. Plutôt que d’utiliser des branches de code spécifiques pour les nouvelles fonctions, les développeurs DevOps sont alors amenés à apporter leurs modifications sur la branche de code master, à l’aide de logique conditionnelle ou de features switches. Les features switches permettent en outre de déployer le code en production sans activer la nouvelle fonction ou de l’appliquer pour une population d’utilisateurs contrôlée selon différentes stratégies (Canary Releases, A/B testing) sur lesquelles nous reviendrons ultérieurement dans le chapitre 5 lié à la qualité.

Nous ne recommanderons pas l’abandon des features branches. Pour tirer le meilleur parti des possibilités offertes par le contrôleur de code source, il convient avant tout d’éviter les branches feature d’une trop longue durée de vie au regard de la fréquence du cycle de livraison. L’approche consiste donc à mettre en œuvre des branches release tout en utilisant des branches feature par micro-équipes permettant de travailler sur une tâche commune de très courte durée. Comme l’affirme, Jezz Humble : « Branching is fine, there is no problem with branching it’s just this practice of feature branching where developers don’t merge into trunk regularly which is problematic and we still see that today frankly, a lot, much more than we should. »

Fig. 3.10  Contrôle de code source et stratégie de branches

Le développement d’applications invite à la réutilisation de ressources, de composants, de code et autres artefacts pour gagner en efficacité, en fiabilité et en maintenabilité. Cette situation se traduit par la nécessité de disposer de librairies pouvant être partagées au sein de l’équipe de développement ou, à une plus grande échelle, sur des référentiels proposés par des éditeurs ou par une communauté. La gestion de ces librairies, de leurs dépendances et de leurs versions est un processus souvent complexe, qui requiert l’utilisation de solutions de package management.

Les solutions de packaging permettent de rechercher, d’ajouter et de mettre à jour des librairies partagées sur des référentiels publics comme npm.org pour les développeurs JavaScript/Node.js ou nuget.org pour la plateforme de développement .NET. Leur principe est de permettre la construction et la définition des dépendances des librairies qui seront incluses dans l’application au cours du processus de packaging, et le cas échéant publiées par la suite dans le référentiel pour une réutilisation par d’autres développeurs.

Considérons l’exemple de npm. Ce service propose un dépôt public de référence pour tous les modules nodejs construits et maintenus par la communauté : la plupart sont hébergés dans GitHub. En complément, il offre un programme en lignes de commande pour télécharger, installer et gérer les modules. Le service npm propose deux modes d’installation de ces packages : local, pour une utilisation spécifique au développement d’une application avec un répertoire d’installation différent, et global à l’échelle du poste du développeur. La configuration des modules requis (et de leur version) est réalisée grâce à un fichier manifeste qui décrit les dépendances. La copie d’écran suivante illustre l’association entre ce fichier package.json et les packages référencés grâce à npm.

Fig. 3.11  Configuration des dépendances d’une application Node.js via un fichier package.json

Le comportement de NuGet est très similaire. La différence réside dans le nom du fichier baptisé package.config et son format XML plutôt que json. De même RubyGems, propose un format standard gem pour la distribution de programmes et librairies Ruby. NuGet et npm offrent, l’un comme l’autre, des utilitaires en ligne de commande pour créer des packages à partir d’un fichier de spécification et le publier si nécessaire. Le développeur DevOps a donc toute latitude pour automatiser les processus de packaging et de publication de ces packages par une extension du processus de build à l’aide de scripts déclenchés en phase de pré- ou de post-génération.

Considérons le cas des développeurs web. Pendant des années, la plupart ne se sont pas préoccupés du poids des pages de leur site web qui, avec l’imbrication de toujours plus de scripts et de médias, ne cessait de croître. Pourtant, une page trop volumineuse impacte le délai de chargement, a fortiori pour une application mobile qui ne disposerait pas d’un accès à un réseau haut débit. C’est la cause d’une insatisfaction potentielle de l’utilisateur et donc le risque de le perdre. En outre, pour le classement des sites, les moteurs de recherche tiennent comptent du temps de chargement des pages. Autant de raisons de veiller à optimiser le contenu des pages. De multiples techniques ont donc été mises au point pour réduire le volume des pages (minification de CSS/JS, avec des frameworks comme JSHint ou JSLint) et augmenter ainsi les performances du site web (concaténation des fichiers CSS et JavaScript, compression des images, suppression des instructions de debug de scripts).

De plus, avec la généralisation de l’usage de jQuery, le langage JavaScript s’est répandu au point de déclencher des initiatives open source comme l’émergence de compilateurs qui à partir de langages plus évolués tels que TypeScript ou CoffeeScript permettent de générer du JavaScript. Dans un même ordre d’idée sont apparus des pré-processeurs comme SASS (Syntactically Awesome Style Sheets) ou Less (Style Sheets Language) qui permettent de générer dynamiquement des feuilles de styles CSS. Ces extensions facilitent la production du code généré. Enfin, pour faciliter le développement de sites web dynamiques, des frameworks ont exploité ces langages de plus haut niveau (comme bootstrap, par exemple, qui est composé d’une série de modèles de feuilles de styles Less, ainsi que d’extensions optionnelles pour le langage JavaScript).

Tout cela a rendu possible la construction d’applications de plus en plus complexes… et a suscité de nouveaux besoins, comme par exemple, la possibilité de mettre en place des liaisons de données (databinding) entre les contrôles HTML de présentation et le JavaScript client et l’arrivée de nouveaux frameworks pour y répondre, comme Angular ou Knockout.

Cette évolution du développement web s’est traduite par deux conséquences non neutres dans une perspective DevOps :

  • Avec l’utilisation de langages de plus haut niveau, ce n’est plus le code source qui est directement publié sur le serveur. Il faut donc maintenant gérer le pré- processing du code client JavaScript ou CSS, qui auparavant était exempt de toute compilation. Ce pré-processing peut être réalisé directement sur le poste du développeur web DevOps ou sur un serveur de compilation. Nous reviendrons sur ce point dans le sous-chapitre consacré au système de build (§ 3.6).
  • Il devient absolument nécessaire de gérer le packaging de ces différents éléments et de leurs dépendances. De même que dans le cas de solution de gestion de librairies comme Nuget ou NPM, il faut donc proposer un mécanisme pour un packaging de ces librairies côté client (AngularJS, JQuery, BootStrap…). On retrouve d’ailleurs la même logique de fichier manifeste descriptif des dépendances entre les différentes librairies.

Le principe proposé pour partager des librairies à l’ensemble des développeurs de la planète est naturellement applicable à une échelle plus réduite. Aussi bien Npm que Nuget peuvent ainsi être utilisés pour packager des librairies dans le cadre d’un projet de développement logiciel pour en restreindre l’accès et pour filtrer les packages que l’on souhaite voir utiliser au sein du développement. Npm.org propose ce type de dépôt en mode service (payant) mais il est également possible de bâtir un référentiel sur sa propre infrastructure. De même, il est possible de définir un serveur Nuget interne, afin qu’il expose la source des données sur le protocole OData (Open Data Protocol). Il est naturellement possible de mettre en place des environnements du même type pour le partage de librairies ou de modules sur d’autres types de langages (Java, C++, Python, Fortran…).

Parfois, le même développeur peut être amené à utiliser des dépôts qui vont varier en fonction de son contexte. Ce type de situation peut représenter une contrainte d’un point de vue DevOps parce qu’un changement de référentiel de librairie peut requérir un changement de configuration sur le poste du développeur.

Pour gérer ce type de problématiques, l’approche DevOps pour le développeur va consister à fusionner plusieurs référentiels différents en un seul et automatiser le processus permettant d’associer le dépôt de packages cible en fonction des circonstances. Cela permet d’offrir une abstraction des détails de configuration des dépôts réels et autorise différents scénarios tels que la mutualisation des référentiels, l’organisation de référentiels distincts pour différentes branches ou projets de développement. L’objectif est d’offrir plus de souplesse dans l’accès à différentes versions de librairies, qui en fonction du contexte, pourront être utilisées avec le minimum d’effort pour le développeur.

Le système de build cible l’automatisation du processus de création d’une version de logiciel et inclut non seulement la compilation de code source en code binaire, mais aussi la production des librairies correspondantes. Ce processus peut également donner lieu à l’exécution des tests automatisés, que nous traiterons dans le chapitre 5 présentant l’application de la démarche DevOps à la qualité.

La définition d’un build doit être indépendante de l’environnement sur lequel il sera exécuté, d’où l’intérêt des solutions de package management (en dépôts publics et/ou privés) que nous avons déjà évoquées. L’objectif est de faire en sorte que les liens vis-à- vis de modules externes puissent être correctement gérés lors du build. Cette indépendance permet également de s’affranchir de toute spécificité liée à l’environnement dans lequel l’application a été développée. C’est également dans la définition de build qu’est référencé le premier niveau de tests automatisés destiné à accroître la qualité du code produit. Enfin, comme tout artefact lié au cycle de vie de l’application, la définition du build doit être archivée dans le contrôleur de code source ou sur le serveur de build lui-même (comme c’est le cas pour VSTS…).

Il est souvent utile de pouvoir conserver le lien entre la version du code source prise en compte par le build et le numéro du build. Cette fonction peut être nativement assurée par la solution de build. Si l’on souhaite inclure ce numéro de build dans le binaire produit, le développeur DevOps peut alors étendre la définition de build pour y inclure des informations directement issues de la compilation. Prenons l’exemple d’une extension de build pour Visual Studio Team Services (on pourrait arriver à un résultat similaire en personnalisant une définition de build Maven géré dans Jenkins pour un code Java). Le principe consiste à développer une librairie exploitant les interfaces d’extension de la solution cible. Là encore, une bonne occasion de faire appel à un package manager comme Nuget.

Fig. 3.13 Personnalisation d’une définition de build : obtention du sdk

Le développeur DevOps peut alors produire le code correspondant à la fonction souhaitée en référençant la librairie permettant d’étendre la solution de build. Le code suivant illustre la personnalisation d’une définition de build par l’implémentation d’une activité personnalisée.

using Microsoft.TeamFoundation.Build.Client;
using System;
using System.Activities;
using System.IO;
 
[BuildActivity(HostEnvironmentOption.All)]
public class GenerateVersionFile : CodeActivity
{
    public InArgument<DateTime> InputDate { get; set; }
    public InArgument<string> Configuration { get; set; }
    public InArgument<string> FilePath { get; set; }
    public InArgument<string> TemplatePath { get; set; }
 
    protected override void Execute(CodeActivityContext context)
    {
        var timestamp = this.InputDate.Get(context);
        var configuration = this.Configuration.Get(context);
        var version = new Version(timestamp.Year, timestamp.Month * 100
                        + timestamp.Day, timestamp.Hour * 100
                        + timestamp.Minute, timestamp.Second);
 
        var filePath = this.FilePath.Get(context);
        var versionFileTemplate = this.TemplatePath.Get(context);
        File.WriteAllText(filePath, string.Format(File.ReadAllText(
        versionFileTemplate), version.ToString(4),configuration));
        }
    }
}

Il suffit alors de compiler la librairie associée à cette activité personnalisée afin de l’inclure dans la définition de build. Dans l’exemple ci-dessous, (qui correspond à l’ancien système de build édité sous Visual Studio), le template de build est modifié pour utiliser la librairie GenerateVersionFiIe.

Certains langages (comme JavaScript) sont dits interprétés, ce qui signifie qu’il n’est pas nécessaire de procéder à une phase de compilation avant leur exécution sur la machine. Pour d’autres langages (Fortran, C, C++, Java, C#…), cette compilation est un prérequis. L’automatisation de la génération de la librairie ou de l’exécutable est réalisée grâce un type de fichier appelé makefile (et ce quel que soit l’environnement Unix, Linux, Windows…) par une compilation et une liaison du code source. La syntaxe de ces fichiers varie en fonction du compilateur du langage et de la plateforme d’exécution.

De multiples utilitaires (Make, Maven, Ant, MSbuild, Gulp, Grunt, Gradl, Cmake…) viennent donc compléter les outils de développement. L’intégration de multiples utilitaires de compilation afin de permettre de générer une application, bâtie sur des services dépendants chacun de middlewares différents, est aujourd’hui facilitée par la mise à disposition de solutions de build centralisées.

Les serveurs de build centralisent l’exécution des processus de build en utilisant les utilitaires précédemment décrits. L’objectif est de s’affranchir de toute dépendance vis-à- vis d’un environnement de compilation. L’utilisation d’un serveur de build centralisé permet également d’organiser les étapes du projet selon un agenda permettant de gérer des contraintes liées au temps de production des builds (avec par exemple, une exécution nocturne des compilations et tests, si elle s’avère particulièrement coûteuse en temps et en ressources).

La solution de build centralisée joue un rôle primordial dans un processus de livraison logiciel. Elle se doit d’être efficace et transparente, afin que les développeurs puissent totalement se concentrer sur la production de leur livrable. Au-delà du temps gagné sur la génération de livrables ayant fait l’objet d’un premier niveau de validation, les implémentations de ce type de solution permettent d’obtenir un premier niveau d’information sur l’avancement du projet et sur la qualité du code produit et de le partager entre les différents acteurs du cycle de vie logiciel de l’application.

Le déclenchement du processus de build est réalisé sur la base d’une planification, ou suite à un évènement comme un archivage de code (dans le cas de l’intégration continue, comme nous le verrons un peu plus loin dans ce chapitre). C’est également à partir de ces serveurs que sont lancés les déploiements automatisés sur les différentes plateformes. À ce titre, ils constituent un élément clé du dispositif DevOps.

Il existe de nombreuses solutions commerciales intégrant des serveurs de build (TeamCity, Team Foundation Server, Visual Studio Team Services…). Dans le monde open source, le serveur de build le plus connu est très certainement Jenkins. Il s’agit d’un outil qui a été développé en Java par Kohsuke Kawaguchi, alors qu’il travaillait chez Sun. La solution s’appelait initialement Hudson, mais le rachat de Sun par Oracle a entraîné un fork de la solution sous le nom de Jenkins, qui est maintenu depuis par une communauté très active.

Parmi les caractéristiques reconnues de cette solution on peut mettre en avant son ouverture et sa flexibilité. Automatiser l’intégration de multiples projets suppose la capacité à gérer de multiples langages : Jenkins supporte Java, .NET, C++, Ruby, PHP… De plus, un serveur de build doit pouvoir s’intégrer sur de multiples types de contrôleurs de code source, bâtis sur de multiples standards. Jenkins propose donc un système d’extensibilité par plug-in (plus d’un millier…) qui permet, par exemple, de définir un build dans Jenkins sur la base de sources gérés sur un serveur Team Foundation ou sur GitHub et de lancer des commandes Docker au cours de ce build.

La centralisation des processus de build n’exempte pas le développeur DevOps de tâches liées à la compilation au niveau de son poste de travail. Comme nous l’avons vu dans le paragraphe sur le package management des frameworks web frontend, l’évolution du développement web impose la gestion d’un pré-processing du code client JavaScript ou CSS, qui auparavant était exempt de toute compilation.

Certes, cette nouvelle contrainte n’est pas fondamentalement révolutionnaire pour un processus de production logicielle classique, mais elle ajoute une dimension nouvelle liée à la nécessité de pouvoir automatiser un certain nombre de tâches côté client afin de compiler, tester, et réduire la taille du code. Ce type de tâches devient rapidement répétitif et sujet à erreur. Le développeur DevOps se doit donc de disposer d’un environnement lui permettant de définir un processus d’automatisation de cette pré-production.

Comme souvent, la définition du processus de génération est plus complexe que l’exécution individuelle de chacune des tâches correspondantes. Mais là encore, cet investissement est rentable, car non seulement il permettra de gagner du temps mais il évitera bien des erreurs. La démarche d’automatisation de ce type de tâche est assez classique. Il s’agit en général d’identifier les tâches répétitives les plus longues comme la concaténation de fichiers, la suppression des instructions de debugging, la réduction de la taille du code…

Gulp et Grunt sont des systèmes de build qui permettent d’automatiser ce type de tâches. Grunt utilise des fichiers de configuration JSON. Gulp est construit sur Node.js, et le fichier Gulp dans lequel sont définies les tâches est écrit en JavaScript (ou en CoffeeScript). Il permet ainsi de compiler et analyser le code permettant de générer JavaScript ou CSS, dès la modification du fichier. Avec ces outils, le build de la partie frontend peut être réalisé aussi bien côté client que côté serveur. Pour le développeur DevOps, cela implique que les outils de build qu’il utilise puissent également être mis à disposition sur un serveur centralisé.

Dans l’exemple suivant (extrait d’une solution open source baptisée Vorlon.js), le code est développé en TypeScript. Il faut inclure dans le script Gulp, une tâche permettant d’automatiser la compilation du code TypeScript en JavaScript à l’aide du module gulp- typescript. De plus pour faciliter son déploiement, il peut être utile d’archiver l’ensemble des fichiers à l’aide du module gulp-zip. Enfin, il faut pouvoir automatiser les tests avec Mocha, une infrastructure de test JavaScript bâtie sur Node.js. Le principe est de pouvoir offrir une série de tests asynchrones et de remonter les exceptions non gérées. Pour en faire usage depuis le script Gulp, il suffit de faire appel au module gulp-mocha. Une fois les prérequis en termes de modules définis en en-tête du fichier gulpfile.js, il suffit d’utiliser les alias correspondants en déclarant différentes tâches que l’on peut ensuite séquencer dans une tâche dite default. Le déclenchement de cette tâche default peut alors être commandé manuellement ou associé à l’observation du système de fichier par l’appel à la commande gulp.watch qui dans notre contexte va s’activer lorsqu’un fichier TypeScript sera modifié. Ainsi, à chaque modification, le fichier JavaScript est également mis à jour de façon totalement transparente pour le développeur.

var gulp = require(‘gulp’),
   typescript = require(‘gulp-typescript’),
   mocha = require(‘gulp-mocha’),
   zip = require(‘gulp-zip’);
 
gulp.task(‘typescript-to-js’, function() {
   var tsResult = gulp.src([‘./**/*.ts’, ‘!./node_modules’, ‘!./node_modules/**/], { base: ‘./})
                  .pipe(typescript({noExternalResolve: true,
                           target: ‘ES5’, module: ‘commonjs’}));
 
   return tsResult.js
            .pipe(gulp.dest(‘.’));
});
 
gulp.task(‘zip’, [‘tests’], function() {
   gulp.src(‘./**/*.*)
         .pipe(zip(‘archive.zip))
         .pipe(gulp.dest(‘dist’));
});
 
gulp.task(‘tests’, [‘typescript-to-js’], function() {
 
gulp.src(‘test/test.js)
   .pipe(mocha());
});
 
gulp.task(default’, [‘zip’], function() {
});
 
/**
* Watch typescript task, will call the default typescript task
if a typescript file is updated.
*/
 
gulp.task(‘watch’, function() {
   gulp.watch([
    ‘./**/*.ts’,
   ], [default]);
});

Il reste toutefois une question complexe à laquelle ces solutions ne répondent pas aussi simplement. Il s’agit de la problématique de création d’un environnement de build hybride de cross-compilation permettant de cibler un environnement depuis un poste de développement ne correspondant pas nécessairement à cet environnement cible.

Un premier exemple de ce type de problématique est adressé par la solution Xamarin.

Un code source pour les unifier tous…

Xamarin est une évolution du projet open source Mono qui ciblait le portage de .NET sur plateforme Unix ou Linux. Xamarin cible le développement d’applications natives sur de multiples types de devices (iOS, Android, Windows 10, Windows 10 Mobile…) à partir du même code source.

D’un point de vue développement, cela signifie la possibilité de pouvoir utiliser des librairies communes à l’ensemble des environnements. Par exemple, pour l’accès aux données, le développeur pourra utiliser la librairie open source SQLite qui offre un moteur de base de données permettant de gérer le stockage des données et d’offrir les méthodes pour les lire et les écrire. Pour assurer la compatibilité multi-plateformes, le moteur SQLite fonctionne sur une grande variété de plates-formes. Il est directement inclus sur iOS, sur Android (API Level 10) et la version C# de l’assembly correspondante peut être déployée avec l’application sur les différents environnements Microsoft. Toutefois, cette disponibilité multi-plateformes peut s’accompagner de subtiles différences sur les signatures des méthodes qu’il conviendra donc d’adresser.

D’un point de vue compilation, l’infrastructure mise en place à destination du développeur DevOps doit permettre de construire l’application sur de multiples environnements. Dans le cas d’une application iOS bâtie sur Windows le développeur doit disposer d’un Mac (ou de l’image virtuelle d’un Mac) connecté en réseau et fournissant le service de génération et de déploiement. De nouveaux services en ligne proposent aujourd’hui de faciliter cette démarche en offrant le processus de compilation iOS sans avoir besoin de monter l’environnement correspondant, qu’il soit virtuel ou physique.

L’adaptation du code source en fonction de la cible

Il est parfois nécessaire d’adapter le code source avant de générer un exécutable pour un système d’exploitation (par exemple Windows) à partir de code source ciblant par défaut un autre système d’exploitation (par exemple Linux). En effet, suivant l’implémentation d’origine, l’existence de compilateurs ciblant les mêmes langages sur ces différentes plates-formes ne suffit pas à elle seule pour garantir la portabilité du code source. Au-delà de la maîtrise conjointe de multiples systèmes d’exploitation, une opération de portage d’un environnement à l’autre suppose la mise en œuvre d’un certain nombre d’aptitudes plutôt orientées DevOps.

Prenons l’exemple de l’application open source Wavewatch III qui simule le mouvement des vagues en fonction du comportement des vents soufflant sur la surface de l’océan. Elle permet d’obtenir des résultats sur une période étendue ou de prévoir la houle en se basant sur les prévisions de vent fournies par le Weather Forecast System (WRF) du National Oceanic and Atmospheric Administration (NOAA). Elle repose sur un modèle relativement complexe, la croissance des vagues étant gouvernée par de multiples processus. Compte tenu de la puissance de calcul nécessaire pour obtenir des résultats significatifs, le code Fortran proposé par le NOAA est optimisé pour un traitement parallélisé. Il offre ainsi deux modèles de compilation et de déploiement : OpenMP (exécution par de multiples threads en mémoire partagée) et MPI (exécution en mémoire distribuée par de multiples processus).

Cette solution est aujourd’hui proposée par défaut pour Linux. WW3 est codé en Fortran- 90 standard, sans dépendance vis-à-vis de l’environnement. Par contre, le code source proposé par la NOAA utilise son propre système de prétraitement pour choisir les modèles de compilation, les options de tests… Ce système de pre-processing est associé à des scripts Linux destinés à automatiser et faciliter installation et exécution… en environnement Linux. Ces scripts ne sont pas nativement supportés sous Windows, ce qui complexifie fortement le portage de cette application vers Windows. Le développeur DevOps est alors amené à considérer différentes approches pour répondre à cette problématique.

Une première solution consiste à créer un environnement de build hybride de cross- compilation utilisant un sous-système Unix hébergé dans Windows avec une compilation sous Windows. Le principe est alors de construire les exécutables pour Windows en réutilisant directement les scripts en shell Unix permettant de générer les fichiers makefile et en les exécutant dans un sous-système Unix de Windows. SUA (Subsystem for Unix- based Application) étant aujourd’hui en voie d’obsolescence, l’approche recommandée consiste à utiliser un autre sous-système, comme les projets open source Mingw64 ou Cygwin.

Une autre solution est de faire l’inverse, c’est-à-dire de créer un environnement hybride de cross-compilation s’exécutant sous Linux pour une production d’un exécutable Windows. Ce type de solution propose l’utilisation d’outils tels que Parallel Tools for Windows Binaries on Linux, s’exécutant directement sur un environnement HPC Linux afin de produire un exécutable Windows via une cross-compilation fondée également sur MinGW64.

Enfin, et c’est parfois la solution la plus simple, il est possible d’aménager la génération du code source et des makefiles sur Linux pour une compilation sous Windows. C’est d’ailleurs l’approche recommandée par Hendrik Tolman, l’auteur du code WW3. Il s’agit de déclarer dans les fichiers de configuration sous Linux les options de compilation, puis d’extraire le code source cible et les makefiles, grâce à un shell script Linux permettant de générer sur la machine Linux les fichiers sources dans un répertoire de travail en vue de leur compilation ultérieure sous un autre environnement (en l’occurrence Windows).

Ce cas un peu extrême met en évidence la multiplicité de compétences dont doit disposer un développeur DevOps. La difficulté consiste alors à pouvoir disposer de l’ensemble des librairies utilisées par le code ainsi généré, comme par exemple la librairie NetCDF (Network Common Data Form) très utilisée dans le monde scientifique pour manipuler des tableaux de données dans de multiples langages et sur de nombreuses plates-formes. Il faut alors pouvoir recompiler ces librairies en vue de leur utilisation sur le système cible ce qui, là encore, peut être source de complexité.

Pour faciliter cette démarche, le développeur peut s’appuyer sur une solution de développement cross-platform. Il existe différentes solutions open source qui ciblent ce type d’objectifs comme par exemple Scons, Autotools (GNU), et CMake. Ce dernier se distingue dans la façon dont il adresse la problématique. Son principe n’est pas de générer la librairie ou l’exécutable cible – cela reste le rôle des compilateurs installés sur la machine – mais de permettre de créer les fichiers de configuration en fonction de cette cible. Cela permet au développeur d’utiliser l’outil qu’il maîtrise tout en facilitant grandement sa tâche.

Une application est rarement monolithique. Son implémentation suppose donc la mise au point de multiples composants ou modules dont les versions vont évoluer séparément. À l’échelle d’un développeur, cela suppose déjà un minimum d’organisation, mais si l’on considère le cas d’une application plus complexe, sur laquelle de multiples développeurs sont impliqués, la nécessité de valider fréquemment l’intégration du travail de chacun devient impérative.

En effet, au-delà d’un certain seuil de complexité, la validation par le développeur de l’intégration de l’ensemble des composants sur sa machine ne suffit plus. De plus, les vérifications à mettre en place doivent être totalement indépendantes du contexte d’exécution lié au poste du poste du développeur. Il faut pouvoir automatiser l’exécution de tests permettant de vérifier le bon fonctionnement de l’application sur un environnement dont on maîtrise la configuration. L’objectif est de détecter les erreurs d’intégration au plus tôt et de notifier les personnes concernées d’un éventuel souci lié à cette intégration. Le processus peut être effectué régulièrement (par exemple le soir, si le volume de code suppose une compilation de longue durée et l’exécution de séries de tests assez consommatrice en temps). Il peut être également déclenché à chaque mise à jour du référentiel de code source. On parle alors d’intégration continue.

L’intégration continue vise à réduire les efforts d’intégration en assurant automatiquement la génération, l’assemblage et les tests des composants de l’application. Voici la définition qu’en donnait Martin Fowler, en mai 2006 :

« Continuous Integration is a software development practice where members of a team integrate their work frequently, usually each person integrates at least daily - leading to multiple integrations per day. Each integration is verified by an automated build (including test) to detect integration errors as quickly as possible. »

La culture avant les outils

Les principes de mise en œuvre d’un processus d’intégration continue sont connus depuis longtemps. Ils étaient mis en œuvre bien avant que l’on parle de démarche DevOps. La dimension DevOps se concrétise dans la capacité qu’elle offre d’inscrire l’intégration continue dans un processus plus étendu, celui du continuous delivery. Comme tout processus lié à la démarche DevOps, la réussite de la mise en œuvre d’une chaîne d’intégration continue est d’abord une question de culture. Avant de confier le soin à un outil logiciel, aussi bon soit-il, de déclencher une compilation et des tests, dès lors qu’une modification de code source a été détectée, il faut d’abord avoir validé la définition d’un build, fiable et reproductible. En effet, il ne servirait pas à grand-chose de déclencher en continu des séries ininterrompues de builds qui échoueraient. Intégrer en continu suppose donc d’avoir validé en amont le bon fonctionnement des tests et les différentes étapes de l’automatisation du déploiement, autant d’actions qui nécessitent souvent l’intervention de multiples acteurs. De plus, cela suppose un temps de génération et d’exécution des tests suffisamment réduit pour rendre possible ce déroulement en continu.

La mise en œuvre d’un processus d’intégration continue

Dans sa mise en œuvre, le processus d’intégration continue s’articule autour de plusieurs étapes. On peut considérer qu’il démarre depuis la phase d’implémentation durant laquelle le développeur produit et valide localement le bon fonctionnement de son code avant de l’archiver dans le référentiel de code source. Le contrôleur de code source notifie alors le système de build afin que soit lancée une nouvelle compilation.

Le serveur de build extrait alors la dernière version du code depuis le contrôleur de code source, génère les livrables et exécute les tests permettant d’offrir un premier niveau de validation du code produit. Plus que la mise en place d’une plateforme permettant l’enchaînement de ces opérations, c’est bien l’automatisation des différentes étapes liées à la compilation du code et au déclenchement des tests qui constitue l’une des difficultés majeures de ce type de processus.

Des outils pour faciliter la mise en œuvre du processus

Le choix de l’outil est un élément particulièrement structurant pour le processus d’intégration continue. Compte tenu de l’importance de cette étape du cycle de vie, la confiance dans l’outil doit être absolue.

Il s’agit avant tout d’un orchestrateur. Il peut donc être complètement externe au monde du développement logiciel, et l’on peut rencontrer des mises en œuvre d’outils plutôt orientés infrastructure, comme System Center Orchestration Manager ou Service Management Automation, mais ce n’est pas la norme, car dans la plupart des cas, la solution retenue est une solution dédiée à l’intégration continue.

Il peut s’agir d’une solution open source comme Jenkins. Au-delà de l’extensibilité dont nous avons déjà parlé, l’un des atouts de Jenkins est sa capacité à pouvoir orchestrer de très nombreux processus eux-mêmes liés à de multiples technologies. Pour mettre en œuvre une intégration continue, Jenkins propose un modèle dans lequel un master peut piloter un cluster de machines esclaves sur lequel des agents virtuels baptisés executors peuvent exécuter les tâches qui leur sont assignées. C’est du nombre de ces agents que dépendra le nombre de jobs de compilation qui pourront être lancés simultanément lors de l’intégration. On retrouve des approches relativement similaires sur des solutions commerciales comme TeamCity, Team Foundation Server ou Visual Studio Team Services.

Les problématiques soulevées par l’intégration continue

Qu’elles soient commerciales ou open source, ces solutions doivent pouvoir être notifiées par le contrôleur de code source de l’occurrence d’une nouvelle mise à jour. Si le contrôleur de code source n’est pas nativement intégré au service de build, il faut au préalable avoir géré les contraintes liées à l’authentification sur ce système. Dans le cas de GitHub par exemple, on peut définir un jeton d’accès que l’on réutilise ensuite pour configurer la solution d’intégration retenue. Cela permet à cette dernière de se connecter sur le dépôt GitHub. Il faut ensuite demander explicitement l’activation d’un élément déclencheur depuis la solution d’intégration continue sur le système GitHub via un web hook.

Un problème classique d’un processus de build (auquel l’intégration continue par la fréquence des builds donne encore plus d’impact) est lié au fait que les processus de build partagent les ressources (fichiers, réseaux) qui y sont liées. Ce contexte peut se traduire par des échecs temporaires (le job de build s’exécute normalement dès lors que la ressource n’est plus verrouillée), ou par des échecs plus permanents dans le cas où un précédent job a modifié l’environnement de build le rendant inopérant. D’un point de vue DevOps, ce type de situation doit absolument être évité : la confiance dans la solution de build doit être absolue. Le build ne doit échouer que si le code est incorrect. Ainsi, le développeur saura avec certitude qu’il doit rapidement apporter un correctif à sa livraison et non suspecter une éventuelle défaillance du build.

Par rapport à ce type d’objectif, le cloud peut apporter un premier niveau de réponse (notamment en proposant un environnement de build réinitialisé pour chaque nouvelle génération). Une autre approche peut également consister à s’appuyer sur des containers, en faisant le lien depuis la solution de build avec un framework de packaging et de distribution comme Docker. Nous reviendrons sur ce point dans le dernier chapitre.

Une fois que la compilation des éléments constituants le logiciel sur le serveur de build s’est achevée et que les premiers tests de vérification de la qualité du code produit se sont exécutés avec succès, l’application est déployée sur de multiples environnements et validée via de nombreux tests (tests d’interfaces graphiques automatisés…) selon un processus baptisé release management. Ce processus est souvent complexe et exige la collaboration de plusieurs personnes voire de plusieurs équipes. Le point d’entrée de ce processus est la création de packages de déploiement.

Il s’agit de pouvoir assembler les données correspondant aux livrables (binaires, fichiers de configuration, fichiers de déploiement, fichiers de test…) dans un format cohérent et exploitable, afin qu’elles puissent être automatiquement acheminées vers l’environnement de déploiement. Les éléments correspondants sont partagés dans un dépôt commun aux équipes de développement et de gestion opérationnelle. En ce qui concerne l’application, la question se pose de savoir quel format retenir pour le transfert entre les différentes cibles de déploiement.

Les risques liés à l’utilisation du code comme format pivot

Certains prônent l’utilisation du code archivé dans le système de contrôle de version, plutôt que le binaire comme l’élément pivot des multiples étapes du processus de déploiement. Dans cette approche, le code est compilé à plusieurs reprises dans différents contextes : pendant le processus d’archivage, lors des tests d’intégration, lors des tests fonctionnels et pour chaque environnement de déploiement cible. Or, chaque fois que le code est compilé, il est possible que le résultat soit sensiblement différent du précédent. La version du compilateur installé sur telle ou telle plate-forme peut varier, au même titre que les librairies dont dépend la solution. En outre, il est préférable d’éviter de rajouter le temps nécessaire à la recompilation, qui, pour certains logiciels, peut être assez significatif.

Le binaire comme format de référence

Il est préférable de déployer le même binaire (quand il s’agit d’un langage compilé) sur l’ensemble des plateformes. Une version des binaires ne devrait donc être produite qu’une seule fois, au début du cycle, lors de la phase d’intégration continue. Cette version pourra ainsi être réutilisée sur chaque environnement cible du processus de déploiement.

Les multiples versions de ces binaires devraient donc être conservées sur un système de fichiers (pas dans le contrôle de configuration source) afin de pouvoir être réutilisées à chaque étape du processus. Le serveur d’intégration continue assurera cette tâche en associant le numéro de build avec la version (comme nous l’avons vu précédemment, il est possible également d’inclure automatiquement ce numéro dans les propriétés du binaire produit…). Il est par contre inutile d’archiver la totalité des binaires produits. Le système devrait théoriquement permettre si nécessaire, de générer à nouveau ces binaires pour une version de code donnée.

Le cas particulier des configurations

Si le binaire ou le code sont identiques quel que soit l’environnement cible de déploiement, il n’en va pas de même pour la configuration. Cette dernière reste susceptible de différer entre les environnements. La gestion de configuration doit donc pouvoir s’intégrer avec les différents outils jouant un rôle dans la chaîne de release management.

L’objectif d’un processus de release management est d’éliminer les versions inaptes à partir en production et de pouvoir remonter au plus tôt les raisons de cet échec. Une approche DevOps doit permettre à chaque instant d’être en mesure d’évaluer la viabilité en production de la version du livrable en cours. Pour ce faire, il faut être en mesure de définir et maintenir un modèle de gestion des versions d’application offrant la création de chemins d’accès de configuration et la configuration d’orchestrations de déploiement.

L’ensemble de ces étapes constitue le pipeline de déploiement (terminologie issue de l’ouvrage Continuous Delivery de Jez Humble et David Farley). Il s’agit de l’un des éléments clés du processus de continuous delivery. La mise en œuvre de ce pipeline fournit une visibilité sur le statut de l’application à chaque étape de son cheminement vers la mise en production. Chaque étape est associée à une série de tâches qui se dérouleront sur l’environnement cible. Cela suppose l’automatisation des tests et du déploiement sur les multiples environnements de validation. Ainsi les versions déployées en production ont nécessairement fait l’objet de tests permettant de s’assurer de leur bon fonctionnement et de leur adéquation avec l’usage ciblé, ce qui permet d’éviter des régressions, en particulier dans le contexte d’application de correctifs.

Bien que le processus de livraison logicielle puisse être entièrement automatisé, la mise en œuvre d’un pipeline de déploiement n’exclut pas une interaction humaine avec le système. Et bien entendu, elle suppose l’utilisation d’un outil permettant de construire ce pipeline dans lequel développeurs et responsables système vont pouvoir définir les actions et critères de déploiement, de vérification et de validation avec contrôles manuels ou automatiques. Ce pipeline devrait pouvoir utiliser des objets partagés au sein d’un référentiel commun. Ce référentiel doit offrir un contrôle de version, ainsi qu’un tableau de bord permettant de suivre l’état d’avancement du déploiement des différentes versions des applicatifs déployés : une vision historique du pipeline de déploiement.

Il ne suffit pas d’automatiser le workflow lui-même, mais aussi la création des environnements, leur mise en service, et la maintenance de l’infrastructure. L’automatisation du processus de déploiement le rend plus rapide, reproductible et fiable, ce qui permet de l’activer beaucoup plus fréquemment. Cela permet également de faire des retours sur une version précédente beaucoup plus rapidement. Enfin, ce processus devrait être automatiquement déclenché par l’occurrence d’un nouveau build.

Stratégie de gestion des branches et release management sont intimement liés. Un cas de figure simplifié est représenté sur le schéma de la figure 3.22.

Fig. 3.22  Stratégie de branches et release management

Les nouvelles fonctions sont expérimentées sur une branche de développement qui fait l’objet d’une intégration continue. Si le résultat est satisfaisant, une fusion peut avoir lieu sur la branche principale qui dans cet exemple est liée à un build nocturne. Le livrable entre alors en release management et traverse les différentes étapes avec ou sans validation manuelle. En cas de dysfonctionnement constaté sur la production, le bug peut être corrigé sur une branche dédiée, testé directement en production et pouvant faire l’objet d’une fusion sur la branche principale si la correction est effective…

Sur le plan conceptuel, le processus de continuous deployment est très proche du processus de continuous delivery. Il se différencie simplement par l’automatisation de la dernière étape, celle de la mise en production.

Dans le cas du processus de continous deployment, la chaîne de production logicielle est automatisée de bout en bout. Le déclenchement de l’archivage d’un code source peut donc se traduire par la mise en production d’une nouvelle fonction sans la moindre intervention humaine… Ce qui paraissait totalement illusoire il y a quelques années est donc devenu aujourd’hui une réalité pour bien des fournisseurs d’offres cloud, qu’il s’agisse d’Amazon, de Google ou de Microsoft.

Fig. 3.23  Continuous delivery et continuous deployement

La création de boucles de rétroaction entre les opérations et le développement est un élément essentiel de DevOps. Les développeurs DevOps utilisent les données issues de production pour prendre des décisions sur les améliorations et les modifications à apporter à leur code.

Tôt ou tard, le code mis en production rencontre un dysfonctionnement. Mais dans ce contexte, il devient beaucoup plus difficile de déterminer pourquoi une application est suspendue, pourquoi une fuite de mémoire s’est produite ou pourquoi l’application a crashé. En effet, lorsque l’application est utilisée en conditions réelles, il faut éviter toute intervention qui soit de nature à altérer la qualité du service. Parmi les multiples tactiques permettant de résoudre au plus tôt la problématique rencontrée, l’une d’elles offre une possibilité de réduire considérablement les coûts de prise en charge et de temps : le debugging en production. Le principe est de lancer un debugger sur le processus incriminé s’exécutant sur l’infrastructure de production.

Il diffère du debugging sur un environnement de développement par de multiples points. Ainsi, lorsqu’il cible un environnement de production, déterminer la cause du dysfonctionnement est souvent moins important que remettre le système dans un état opérationnel. De plus, lorsqu’il est mis en œuvre dans ce contexte, le debugging en production doit respecter de strictes contraintes de temps, pour éviter de pénaliser l’utilisateur de l’application.

Enfin, certains dysfonctionnements ne se manifestent que lorsque le système est soumis à une forte sollicitation. Les causes d’une erreur non déterministe sont nécessairement plus difficiles à diagnostiquer. Et les conditions dans lesquelles elle se produit rendent l’analyse plus complexe. En effet, souvent les traitements serveur s’exécutent en parallèle sur de multiples threads et traitent simultanément de multiples requêtes, ce qui peut rendre particulièrement complexe le suivi de l’une d’entre elles. Et si la solution s’exécute sur une ferme de serveurs, la difficulté s’accroît avec le nombre de serveurs impactés. La seule solution réside alors dans l’analyse des journaux, ceux que proposent nativement les systèmes, et ceux qui pourront être renseignés par l’application en cas d’erreur.

Lorsque l’application rencontre un dysfonctionnement en production, le développeur DevOps fait équipe avec le responsable opérationnel pour parvenir à rapidement corriger ce dysfonctionnement. Il est donc crucial pour le développeur DevOps de comprendre les principes du système d’exploitation sur lequel va s’exécuter le code qu’il a produit, en particulier d’avoir une bonne compréhension des mécanismes de journalisation du système. Nous étudierons plus en détail, les mécanismes natifs de journalisation proposés par les différents systèmes dans le chapitre lié aux opérations. En général, et quel que soit le système d’exploitation, ces mécanismes de journalisation intègrent un système d’horodatage qui permet d’avoir une idée très précise du délai d’exécution de chacune des opérations tracées et exposent des événements fournis nativement par le système d’exploitation (processus, threads, CPU, changements de contexte, défauts de page, mémoire, I/Os réseau, I/Os disque…). Ils doivent être peu coûteux en termes de performance, ne pas perturber le système observé si on les met en œuvre et doivent pouvoir être facilement désactivés : ils deviennent alors totalement transparents sur le fonctionnement de la solution. Enfin, ils permettent d’ajouter des données applicatives à celles fournies par le système d’exploitation. Et la responsabilité de cette instrumentation incombe alors au développeur.

La centralisation des données d’instrumentation peut être assurée de multiples façons. Une première solution consiste à remonter directement ces données en faisant directement appel à des API qui peuvent être ou non associées à différents protocoles standards tels que SNMP ou WMI. Ces protocoles seront étudiés plus en détail dans le chapitre 4 lié à la gestion des opérations. Mais parfois le besoin va au-delà de la remontée d’un évènement de type exception. Il s’agit par exemple de remonter chaque seconde les caractéristiques (CPU, consommation mémoire, I/Os) sur des centaines de serveurs. La solution peut alors consister à mettre en place un mécanisme permettant de remonter périodiquement les logs pour les centraliser sur le serveur de suivi, soit sous leur forme native, soit en les injectant dans une base de données. En général, les systèmes de supervision interrogent périodiquement les sources de logs pour consolider les données avec une fréquence de l’ordre de la minute. Certaines solutions de cloud proposent nativement ce type de mécanismes de regroupement périodique des informations de log.

On peut alors s’appuyer alors sur des solutions clés en mains (nous reviendrons sur ce point dans le chapitre 4 DevOps vu par les équipes opérations). Mais parfois, par exemple lorsqu’il est nécessaire de disposer de données avec une très faible latence, le développeur DevOps est appelé à participer à une implémentation spécifique d’un système de remontée d’informations de supervision. Ce type d’implémentation se fonde alors sur l’utilisation de technologies CEP (Complex Event Processing) ou de realtime distributed processing. Nous reviendrons ultérieurement sur ces mécanismes dans le chapitre 5 DevOps vu par la qualité.

Au même titre que l’administrateur système, le développeur DevOps doit avoir connaissance des différents mécanismes de trace proposés par les systèmes d’exploitation sur lesquels vont s’exécuter les composantes de l’application qu’il développe. Nous reviendrons sur ce sujet dans le prochain chapitre.

Le développeur DevOps doit s’assurer que son application est instrumentée avec les bons paramètres afin qu’elle puisse être supervisée efficacement par les solutions de monitoring utilisées par les équipes en charge de l’exploitation du système. Il peut s’agir de données de diagnostic recueillies lors d’un crash de l’application, qui incluent des informations sur les performances et la disponibilité du système en production. Le développeur peut ainsi analyser les données recueillies pour trouver la cause profonde des problèmes et corriger les bugs ou les comportements non souhaités.

D’un point de vue qualité, la portée de l’instrumentation doit cibler l’intégralité de l’expérience de l’utilisateur, avec une perspective qui soit alignée sur la perception qu’il peut en avoir, et qui ne se limite pas à une combinaison des compteurs de performances relevés sur chacune des composantes de l’infrastructure. Une performance de référence pourra ainsi être établie et toute dérive devra pouvoir être détectée. D’où la nécessité pour les développeurs de communiquer aux responsables des opérations l’ensemble des éléments requis pour une supervision effective…

Pour ce faire, ils peuvent s’appuyer sur les frameworks associés aux mécanismes natifs de journalisation et bénéficier ainsi d’une capacité à instrumenter l’application en étant très proches du système. Toutefois, comme nous le verrons dans le chapitre 4 lié aux opérations, leur utilisation n’est pas exempte de complexité et il n’est donc pas toujours très simple d’intégrer ces mécanismes dans un processus de développement classique, d’où l’intérêt de proposer des frameworks proposant un niveau d’abstraction plus élevé.

Il existe de multiples frameworks d’instrumentation d’une application, qui la plupart du temps sont liés à l’environnement d’exécution.

Ainsi, dans le cas de Java, le système de logging open source de référence est Log4J. Ce produit offre un moyen hiérarchique d’insérer l’enregistrement d’instructions au sein d’un programme Java permettant de faire usage de déclarations de débogage comme les classiques System.out.println ou printf et de tracer le détail de structures de données sur plusieurs formats de sortie et plusieurs niveaux d’informations de journalisation. L’utilisation de ce framework permet d’éviter d’avoir à gérer un grand nombre d’instructions de débogage en exposant leur contrôle et leur paramétrage dans des scripts de configuration livrés avec l’application. L’implémentation du framework Log4J a également été déclinée sur d’autres environnements d’exécution comme par exemple les versions Log4c pour les applications développées en C, Log4cpp pour celles codées en C++, et Log4Net pour celles qui utilisent le .NET Framework.

L’écosystème Microsoft se distingue en proposant de multiples frameworks permettant d’instrumenter une application en se fondant non seulement sur des frameworks ciblant l’environnement d’exécution, mais aussi sur des frameworks plus proches du système d’exploitation. Ainsi, au niveau applicatif, Microsoft propose, également en open source, des extensions comme le Logging Application Block de l’Enterprise Library permettant de gérer par configuration un usage des multiples API liées à System.Diagnostics dans l’environnement .NET. Tandis qu’au niveau du système d’exploitation, Microsoft propose EWT (Event Windows Tracing), un framework que nous reverrons dans le chapitre 4 sur les opérations. Microsoft fournit également la classe System.Diagnostics.Tracing.EventSource afin de disposer de l’ensemble des caractéristiques ETW (typage fort, versioning, extensibilité), sans avoir à en maîtriser toutes les subtilités ou en subir toutes les contraintes. Ainsi, le développeur n’a plus besoin de construire explicitement un manifeste EWT, il suffit avec une ligne de code de définir un nouvel événement.

En définitive il ne s’agit pas de débattre sur les vertus supposées de tel ou tel framework de journalisation. Dans ses activités de production logicielle, le développeur DevOps se doit d’utiliser un framework. En général, le choix de ce framework a fait l’objet d’une validation par l’ensemble des acteurs concernés. La responsabilité du développeur est donc de mettre en place un système robuste de gestion des exceptions qui fasse appel à ce framework en respectant les patterns définis en phase de conception.

Enfin, comme nous l’avons vu, la portée de ces frameworks peut aller au-delà de l’environnement d’exécution (machine virtuelle Java, .NET framework…) et des dépendances avec le système d’exploitation, en ciblant une plate-forme au sens le plus large du terme. Cette notion de plate-forme prend toute son importance lorsque la solution s’exécute sur une ferme de serveurs, car il s’agit non seulement d’intercepter et tracer les erreurs, mais aussi d’être en situation de pouvoir consolider le stockage lié à leur journalisation et centraliser leur restitution, indépendamment du nombre de serveurs sur lesquels s’exécute la solution.

En résumé

DevOps pour les développeurs c’est avant tout un changement de culture. Une extension de leur périmètre de connaissance sur tout un ensemble de sujets qu’ils considéraient jusqu’à présent comme connexes à leurs principales activités.

Le développeur DevOps doit avoir soif d’apprendre, envie d’expérimenter de nouveaux langages, de nouveaux frameworks, de partager ses nouveaux savoirs, de participer à l’outillage de la chaîne de production, de prendre position sur les choix d’implémentation. Son rôle s’inscrit dans un processus de continuous delivery dans lequel l’intégration continue est elle-même complétée par d’autres mécanismes issus de l’adoption de pratiques éprouvées.

Parmi celles-ci figurent en bonne place l’automatisation du déploiement du logiciel construit sur la plateforme cible (test, intégration, pré-production voire production dans le cas du continuous deployment) à chaque nouvelle génération de livrable ainsi que l’ensemble des moyens permettant de mettre en place le suivi en continu du comportement de l’application.

Autant de sujets sur lesquels la collaboration entre développeurs et responsables de la gestion opérationnelle est déterminante.

Ce site web utilise des cookies. En utilisant le site Web, vous acceptez le stockage de cookies sur votre ordinateur. Vous reconnaissez également que vous avez lu et compris notre politique de confidentialité. Si vous n'êtes pas d'accord, quittez le site.En savoir plus