Demandez au directeur technique de thoughtbot - Tout sur le CI / CD

Joe Ferris, Akshith Yellapragada, Victoria Guido & Christopher Kuttruff
Traduit par Tresor Bireke
Cet article est également disponible en : English

Quels sont les objectifs du CI / CD ?

Quelles sont les propriétés d'un bon pipeline CI/CD et comment cela a-t-il influencé les choix que nous avons faits en matière de technologie et de processus ?

L'objectif principal de l'intégration continue (IC) est de donner aux gens la certitude que leur travail fonctionne. Les développeurs Ruby expérimentés vivent le CI à travers les tests automatisés. Ils s'attendent à voir leurs tests s'exécuter lorsqu'ils ouvrent une demande de changements. Chez DevOps, SRE & Cloud Platform à thoughtbot, nous nous demandons comment donner aux développeurs la même confiance dans les déploiements.

L'une des solutions consiste à avoir la possibilité de pré-construire une image de conteneur, des paquets et des applications. Cela permet de renforcer la confiance lors du déploiement de pipelines, en particulier dans des environnements conteneurisés.

Un autre grand objectif est le déploiement transparent des changements lorsque vous fusionnez votre demande de retrait. Nous voulons offrir une transparence aux développeurs pendant les déploiements : les alerter lorsque les déploiements sont en cours, lorsqu'ils sont terminés, et leur donner une raison en cas d'échec.

Quelle est la différence entre l'intégration continue, la livraison continue et le déploiement continu ?

Avec l'intégration continue, il s'agit d'exécuter des tests et de réunir les changements dans la branche principale. Il s'agit de s'assurer que tout fonctionne encore après les derniers changements et d'aller aussi loin que possible sans déployer réellement les changements.

La livraison et le déploiement continus ne sont toutefois pas définis de manière cohérente dans le secteur. À un haut niveau, le déploiement et la livraison continus consistent tous deux à diffuser les changements dès qu'ils sont prêts. Cela s'oppose aux versions spécifiques et planifiées, par exemple la version du 5 janvier, qui sont prévues pour résoudre plusieurs problèmes.

Il est vraiment intéressant d'examiner comment nous pouvons faire la distinction entre le déploiement et la livraison à l'aide de drapeaux de fonctionnalités, qui peuvent être implémentés avec un outil comme Rollout. Disons que vous avez un code de fonctionnalité qui s'exécute en arrière-plan et collecte des données, mais que la fonctionnalité n'est pas encore visible pour les utilisateurs. En gardant le drapeau de fonctionnalité désactivé, vous pouvez continuellement déployer le dernier code sans livrer une fonctionnalité inachevée. Il s'agit d'une distinction très spécifique entre la livraison et le déploiement et tout le monde dans le secteur ne fait pas la même distinction.

Il est passionnant de voir ce qu'il est possible de faire pour que les développeurs sachent qu'ils construisent bien les choses, tout en donnant aux responsables des opérations la certitude que les changements de code ne casseront pas le système.

Nous allons maintenant examiner comment l'équipe de thoughtbot DevOps, SRE & Cloud Platform procède au CI/CD à travers plusieurs exemples de projets anonymes.

Premier exemple : Docker

Commençons par examiner une construction CI à partir d'un projet mono-repo exécuté par Docker, en commençant par la section sur le flux de travail, où nous effectuons la construction proprement dite. Si vous connaissez Heroku, une grande partie de ce qui se passe avec Docker est similaire à ce qui se passe avec un push vers Heroku, où la construction et le déploiement sont réunis.

Avec Heroku, lorsque vous poussez la dernière version du code, Heroku essaie de regrouper toutes les gemmes et d'obtenir votre application dans ce qu'ils appellent un slug. La mise à l'échelle d'une application télécharge alors et étend le slug à un dyno pour l'exécution.

Dans notre projet d'exemple, nous utilisons docker build, qui est une autre façon de construire une image de conteneur. Il s'agit essentiellement d'un ensemble d'instructions permettant de passer d'une image de base vierge à une image contenant les dépendances de l'application et le dernier code.

L'objectif de Heroku et de Docker build est le même : vous partez du code de l'application et vous obtenez un paquet qui peut être déployé dans un conteneur. C'est donc la même chose qu'à l'époque où vous compiliez un fichier JAR pour Java. Sauf qu'au lieu d'essayer de déployer vers une VM Java, vous essayez de déployer vers une solution conteneurisée.

La plupart des gens associent fortement les conteneurs à Docker, mais il s'agit en fait de technologies distinctes. Seul un sous-ensemble de Docker est lié à l’Open Container Initiative (OCI).

L'essentiel est double : d'une part, vous avez besoin d'une chaîne d'outils qui passe du code à une image de conteneur. L'OCI est la norme industrielle pour décrire la façon dont ces images seront formatées. D'autre part, vous avez besoin d'un moteur d'exécution de conteneur qui sait comment prendre une de ces images et la transformer en conteneurs en fonctionnement. Il existe quelques moteurs d'exécution de conteneurs, le plus connu étant Docker.

Mais aujourd'hui, la plupart des plateformes qui utilisent des conteneurs ouverts n'utilisent plus Docker. Elles utilisent d'autres moteurs d'exécution de conteneurs, par exemple containerd. De même, il existe désormais des alternatives à la création de fichiers Docker, par exemple Podman.

Le monde des conteneurs est donc bien plus vaste que docker build et docker run. Chez thoughtbot, nous utilisons toujours docker build mais nous n'utilisons pas docker run. L'idée d'une image de conteneur standardisée s'est vraiment imposée dans le secteur, et c'est donc ce que nous utilisons.

Deuxième exemple : Buildpacks

Si l'on s'en tient à cette même norme, et au même runtime, il existe un processus alternatif qui s'est développé dans l'industrie, appelé buildpacks, qui est basé sur l'approche d'Heroku.

L'approche de construction de Docker est basée sur l'idée de couches. Vous commencez avec une base, puis le Dockerfile agit comme une liste d'instructions pour ajouter des couches à cette image de base. Vous obtenez ainsi l'image finale qui est déployée en tant que conteneur.

Les Buildpacks adoptent une approche différente : plutôt que de travailler par couches, ils possèdent leur propre archive de base, qui est la racine du paquet. Ils inspectent ensuite le code et ajoutent des éléments au paquet de manière programmatique.

Les différences entre docker build et les buildpacks sont subtiles mais importantes. Par exemple, la mise en cache dans Docker est entièrement basée sur des couches, ce qui signifie que si vous devez refaire `bundle install`, vous devez refaire cette couche et installer toutes les gemmes à nouveau. L'invalidation du cache est notoirement instable. En rendant le processus très simple, Docker rend peu probable le fait que vous vous retrouviez avec des binaires mis en cache de manière inattendue, ce qui pourrait être très difficile à déboguer.

Les buildpacks, quant à eux, ont mis l'accent sur la vitesse et la commodité, plutôt que sur la cohérence et la simplicité des images en couches. Heroku utilise une approche de buildpack, et les images de buildpack de l'OCI sont modelées sur les buildpacks de Heroku. L'idée est qu'un buildpack est un programme qui sait comment regarder le code que vous poussez et qui peut le transformer en une image OCI. Il peut maintenir son propre type de cache. Ainsi, par exemple, au lieu d'exécuter bundle install de zéro à chaque fois, un builpack peut maintenir le cache des gemmes entre les exécutions. Ainsi, il n'a besoin de télécharger et d'installer que les gemmes qui ont été ajoutées depuis la dernière exécution, ce qui peut être très utile.

Prenons l'exemple du projet que nous examinons : il s'agit d'une application Angular dont l'étape de compilation est très longue. Un buildpack peut mettre en cache les artefacts de compilation d'une manière beaucoup plus significative qu'un cache de couche, car tous les artefacts de compilation proviennent de l'exécution des étapes dans les instructions de couche. Donc, si vous devez refaire cette couche, tout est perdu, tout comme avec bundle install. Cependant, en utilisant un buildpack, nous pouvons garder la plupart des actifs JavaScript compilés et juste recompiler les fichiers qui ont changé de manière significative. Cela augmente la vitesse de l'étape.

L'autre avantage que nous avons mentionné avec les buildpacks est la commodité. Les développeurs sont habitués à git push, qui fonctionne intuitivement sur Heroku. C'est parce que les buildpacks peuvent inspecter intelligemment le code qu'ils exécutent.

En revanche, un Dockerfile est un processus très simple et inintelligent : il suffit de suivre un ensemble d'instructions pour ajouter des couches. Et il n'y a pas d'intelligence là-dedans. Je suis sûr que vous avez déjà vu des gens essayer de faire preuve d'ingéniosité avec les Dockerfiles, et cela mène inévitablement à une horrible confusion.

Les Buildpacks abordent la question sous un autre angle : ils veulent faire en sorte que les développeurs n'aient pas à se souvenir de faire des choses comme mettre en cache leurs dépendances ou déterminer la version de Ruby qui s'y trouve. Dans un Dockerfile, vous devez toujours déclarer l'image de base, y compris la version de Ruby. Mais un buildpack essaiera de le déduire en inspectant intelligemment le Gemfile. Il ajoute donc cette commodité.

La contrepartie est la complexité et la perte de fiabilité de l'utilisation des buildpacks. Car si vous mettez en cache les dépendances entre deux constructions, cela signifie que les constructions sont interdépendantes. Tout comme lorsque vous ne nettoyez pas correctement après un test, cela peut faire échouer le test suivant. L'idée qu'une construction puisse changer les résultats d'une construction future introduit beaucoup de confusion potentielle pour l'intégration continue. Heroku a déployé des efforts considérables pour éliminer ces bogues sur les plateformes qu'il prend en charge. Si vous avez travaillé sur l'une des plateformes bien supportées, comme Ruby ou Node, vous avez probablement eu une expérience assez transparente. Mais si vous sortez des limites de ce que Heroku a couvert de manière approfondie, vous commencez à rencontrer des choses bizarres où un déploiement réussit alors qu'un autre échoue parce qu'il se trouve qu'il récupère quelque chose de bizarre dans le cache. Et vous commencez aussi à faire des choses obscures comme effacer manuellement le cache de construction de slugs de Heroku.

Les gens ont généralement été attirés par la simplicité des Dockerfiles, même s'ils ne sont pas aussi pratiques que les buildpacks et qu'ils sont, en moyenne, plus lents à construire. Mais comme ils sont très simples, les gens peuvent les apprendre rapidement. Vous pouvez lire un Dockerfile et comprendre ce qu'il fait, car il est constitué de commandes que vous exécuteriez vous-même. Et il n'y a aucune question quant à ce que sera le résultat. Parce qu'il n'y a pas de conditionnel à démêler comme c'est le cas dans un buildpack.

En dehors de Heroku, il y a l'effort des buildpacks natifs en nuage, buildpacks.io, qui sont des buildpacks de type Heroku qui construisent une image de conteneur OCI qui fonctionnera sur Docker, containerd, ou cri-o. Nous utilisons containerd sur Kubernetes. EKS nodes utilisent un système d'exploitation appelé Bottlerocket, qui exécute containerd. Il s'agit d'un système d'exploitation basé sur Linux et axé sur les conteneurs. Il est open source et principalement utilisé par Amazon.

La plupart des principaux référentiels hébergés, y compris celui que nous utilisons (ECR d'AWS), prennent en charge l'analyse automatique des images à la recherche de vulnérabilités connues. Ils recherchent donc les binaires intégrés à l'image (par exemple, une ancienne version d'OpenSSL qui pourrait contenir une vulnérabilité) et vous en avertissent. Vous pouvez même définir des politiques dans AWS qui rejetteront l'image du référentiel si elle est vulnérable.

Il existe des outils qui analysent et vérifient si votre fichier Docker utilise la dernière version. Mais un outil qui soumettrait automatiquement les demandes de retrait de cette manière serait plutôt cool. L'une des raisons pour lesquelles ce processus n'est pas aussi transparent pour les Dockerfiles est qu'il n'existe pas d'accord général sur l'endroit où ces versions seront déclarées.

Ainsi, l'un des inconvénients de l'approche Dockerfile est que la version de Ruby dans l'image de base doit correspondre à la version dans votre Gemfile. Cela n'est pas automatisé. Si elles ne correspondent pas, cela ne fonctionnera tout simplement pas. Il existe plusieurs façons différentes de déclarer une version de Ruby, et il se peut qu'ils n'aient pas encore trouvé un moyen fiable de s'assurer que chaque façon est mise à jour simultanément.

La raison principale pour laquelle nous mettons continuellement à jour les Dockerfiles est qu'ils contiennent le runtime de ce que nous déployons. Si l'application est mise à niveau vers une nouvelle version de Ruby, par exemple, le Dockerfile doit être modifié. Mais Ruby n'est pas toujours rétrocompatible. Ainsi, si nous disposons d'un bon processus de CI, nous ne pouvons pas mettre à jour Ruby sans vérifier également tout le reste. Cela aide car si nous avons modifié la version de Ruby dans le Gemfile et le Dockerfile, nous verrons si les tests fonctionnent et si le docker build réussit toujours. Mais c'est quelque chose que nous devons nous assurer de coordonner étroitement avec le reste de l'équipe de développement. Personne ne veut que sa version de Ruby soit mise à jour de façon inattendue.

Troisième exemple : Les GitHub Actions

Notre prochain exemple de projet est une application de gestion des commandes, qui est déployée à l'aide de GitHub Actions.

Les étapes des GitHub Actions sont assez similaires à celles de l'exemple précédent. En résumé : construire avec Docker et pousser vers ECR. Je pense que la différence ici est le CI/CD : la construction et le transfert de l'image par Docker et le déploiement sur le cluster sont dépendants l'un de l'autre et s'exécutent en même temps. Cela signifie que le docker build ne s'exécute pas avec chaque PR, il ne s'exécute qu'en mode PR. C'est parce que nous ne voulions pas déployer le code sur le cluster avec chaque poussée PR.


Cette approche est similaire à celle de Heroku, où nous avons une séparation entre la configuration et le code. Nous voulons maintenir ce que nous avons fait dans Kubernetes, où nous avons un dépôt d'application qui contient tout le code de l'application, mais nous avons aussi un dépôt de manifeste qui contient la configuration. Le dépôt de manifestes comprend des éléments tels que les variables d'environnement, les processus à exécuter avec quels arguments, le nombre de processus à exécuter, la façon de les faire évoluer, etc. C'est pratique car vous n'avez pas à vous soucier des conflits de fusion et autres lorsque vous mettez à jour la configuration. Dans le processus CI/CD, cela signifie que le déploiement doit utiliser ces deux référentiels.

C'était un défi de s'assurer que lorsque des modifications sont apportées au dépôt de manifeste, le déploiement est également déclenché pour le dépôt d'applications. Cette répartition du flux de travail à partir du dépôt de manifestes pourrait être améliorée, car, pour l'instant, nous n'avons qu'un seul pipeline pour tout, ce qui le rend plus simple. Mais nous pourrions certainement être plus intelligents et éviter que les deux types de changements déclenchent le même flux de travail.

En outre, il n'y a pas de modèle de sécurité intégré pour cela, il y a donc deux autorisations qui doivent être accordées pour que cela fonctionne. La première est que le flux de travail qui effectue le déploiement doit avoir accès à l'autre dépôt, car les flux de travail GitHub ont automatiquement accès à leur propre dépôt pour vérifier le code. Mais ils peuvent extraire n'importe quel autre dépôt, même au sein de la même organisation. Et il n'y a rien dans l'interface utilisateur ou dans la configuration qui vous permet de le faire. La seule façon de le faire est de créer manuellement un jeton d'accès et de le définir comme un secret sur le projet. Ainsi, lorsqu'il extrait le second dépôt, il le fait en tant qu'utilisateur différent. Il y a des frais généraux de sécurité importants : cette créance doit être créée et doit être maintenue, et vous devez faire attention à ne pas la divulguer.

Deuxièmement, si vous voulez être capable de lancer le workflow du référentiel, et que le deuxième référentiel change, vous devez prendre un jeton qui a la permission d'exécuter des workflows. Et encore une fois, il n'y a pas de mécanisme dans GitHub Actions lui-même pour faire cela. Vous pouvez mettre en place ce genre de recettes, mais c'est un peu comme AWS, où l'on ne cesse de leur dire de simplement lire un lambda. Avec GitHub, vous pouvez toujours créer un jeton d'accès personnel (PAT) et utiliser l'API. Mais dans leur documentation, ils insistent généralement sur la faible sécurité des PAT et sur le fait que vous devriez probablement essayer d'utiliser une application ou une autre fonctionnalité lorsque cela est possible.

Nous espérions qu'ils construiraient une solution pour ce problème, mais au fil du temps, il semble qu'ils soient assez bien installés sur le modèle de flux de travail à référentiel unique.

Quatrième exemple : Dépôt unique combinant code et manifestes

Dans cet exemple, nous avons un référentiel unique qui comprend à la fois le code de l'application et les définitions des manifestes. L'intention est que les développeurs interagissent directement avec le dépôt d'applications pour toutes les modifications du code d'application, et nous avons un répertoire pour les manifestes. Dans le répertoire des manifestes, nous avons une définition pour les manifestes Kubernetes, ou tout autre manifeste qui doit être défini pour Kubernetes. En procédant de cette manière, on élimine la complexité de devoir déclencher une répartition de flux de travail pour déclencher le pipeline pour le dépôt de manifestes une fois le code de l'application terminé. Et maintenant, en prenant une étape à la définition du flux de travail réel, nous aurons la définition du flux de travail pour l'application. Et puis pour le manifeste, je pense que je pourrais simplement aller aux actions plutôt.

La première tâche de notre workflow construit l'image Docker et la pousse vers ECR, et cela ne dépend que du code de l'application. La tâche suivante, qui gère les déploiements vers EKS, se déclenche dès que la première tâche est terminée. Nous générons ensuite les fichiers manifestes à l'aide de Helm, puis nous effectuons un déploiement vers EKS, en utilisant l'image construite lors de la première étape.

Cette méthode est plus simple, car il n'est pas nécessaire de créer des autorisations ou des jetons pour donner accès à un autre référentiel. Cependant, un inconvénient potentiel est que les développeurs et les ingénieurs DevOps doivent apporter des modifications au même référentiel. Il n'est pas toujours facile de savoir qui a apporté quelles modifications. D'un autre côté, faire participer activement les développeurs à la mise à jour des fichiers de définition du manifeste présente également des avantages, car les développeurs savent clairement que s'ils doivent apporter des changements à la définition du manifeste, ils le feront également dans le tableau, qui se trouve dans le référentiel principal. Et ils ont la liberté d'interagir avec les fichiers de manifeste quand ils le souhaitent. Il s'agit donc d'une idéologie différente qui a ses avantages et ses limites.

Une autre complication de cette approche est qu'il est impossible d'apporter des modifications aux fichiers manifestes sans pousser les changements de code qui ont été fusionnés dans la branche principale, mais qui ne sont pas encore prêts à être déployés. Cela va à l'encontre de l'idée de la Twelve-Factor App de séparer le code de la configuration. Mais la simplicité de cette approche, et la proximité de la configuration avec les développeurs, sont toutes deux très attrayantes. C'est un petit changement, mais le fait d'avoir le manifeste dans le même référentiel que le code de l'application augmente considérablement la probabilité d'obtenir l'engagement des développeurs.

Nous avons constaté de visu qu'avec cette approche, lorsque les développeurs doivent ajouter de nouvelles variables d'environnement ou de nouveaux secrets, il leur est plus facile de le faire et ils sont prêts à le faire. Il leur suffit ensuite de demander une révision à l'équipe DevOps.

Dans le cadre de certains projets utilisant des approches différentes, les développeurs ont apporté des modifications à un référentiel de manifestes, mais il s'agissait plutôt d'un saut et d'une courbe d'apprentissage. L'utilisation d'un dépôt séparé donne l'impression que vous êtes en dehors de votre domaine : vous êtes dans le domaine des ingénieurs de la plateforme. Alors que de cette façon, en gardant les manifestes dans le dépôt d'applications, on a l'impression de venir chez eux, ce qui est vraiment intéressant.

Avant de passer à notre prochain exemple, plongeons un peu plus profondément dans la façon dont nous testons Helm, l'outil que nous utilisons pour générer les fichiers manifestes dans ce projet. Nous passons des valeurs échantillons au tableau Helm et validons les manifestes générés pour confirmer que le tableau Helm est correctement configuré.

Dans la plupart de nos applications, nous avons utilisé un outil appelé kustomize qui vous permet d'apporter des modifications structurelles aux manifestes Kubernetes. Vous commencez avec une base, puis vous la transformez lentement en ajoutant des étiquettes ou des correctifs et des valeurs. Helm adopte une approche de modèle de chaîne, un peu comme PHP ou ERP, où vous commencez avec un ensemble de modèles, puis vous interpolez les valeurs. Et il peut aussi faire des constructions simples, comme des boucles “pour” et des conditionnels. Nous avons donc eu l'idée de créer un modèle de diagramme Rails Helm qui traiterait les cas les plus courants, afin de faciliter la tâche des développeurs, comme la modification des variables d'environnement. Ensuite, tout ce que les développeurs auraient à faire serait de mettre à jour le fichier de valeurs. En adoptant une approche TDD, nous avons également écrit des tests pour les modèles de graphiques Helm que nous écrivions.

Nous aimons bien Helm parce qu'il est très agréable de construire des modèles simples dans les différents environnements pour lesquels nous construisons. Le fait de n'avoir qu'un seul fichier de valeurs, au lieu d'avoir besoin de plusieurs dossiers différents, a été très utile.

Cinquième exemple : CodeBuild et CodePipeline

Enfin, examinons une autre approche utilisant CodeBuild et CodePipeline d'AWS, qui est l'approche utilisée dans ce dernier exemple et que nous avons longtemps utilisée avant d'adopter les actions GitHub. CodePipeline relie entre elles un ensemble d'étapes qui doivent être exécutées lorsque le code a été modifié. CodeBuild est l'environnement dans lequel elles doivent être exécutées. C'est donc un peu comme GitHub Actions : un flux de travail par opposition à une tâche. La caractéristique que nous aimons vraiment de CodePipeline et que nous n'avons pas vu dans d'autres outils est la façon dont ils gèrent les workflows multi-dépôts.

Vous pouvez simplement déclarer plusieurs sources pour votre pipeline, il gardera la trace de la dernière version de chacune. Et dès que l'une d'entre elles est modifiée, il l'exécutera avec les deux derniers dépôts. Ainsi, le pipeline est très clair quant à ce qui se passe avec les dépôts distincts de sources et de manifestes : vous pouvez voir quelle version a été modifiée.

Lorsqu'il passe à l'étape de construction, il crée des projets séparés pour le manifeste et le code d'application, puis le déploiement les combine. La présentation et la mise en œuvre sont donc beaucoup plus simples que l'approche inter-référentiels que nous adoptons avec GitHub Actions. Il est intégré que vous pouvez spécifier quels pipelines peuvent accéder à quels dépôts. Il n'est pas nécessaire qu'un référentiel déclenche un workflow sur l'autre référentiel, car le pipeline vit en dehors du référentiel et fait partie de l'infrastructure.

Ce serait bien si GitHub avait quelque chose comme ça. Cependant, l'utilisation des actions Github est beaucoup plus simple et élimine une grande partie de la complexité, avec l'avantage supplémentaire de mettre le CI/DC au bout des doigts des développeurs.

D'autres questions ?

Si vous souhaitez en savoir plus sur les pipelines d'intégration ou de livraison continue de votre équipe, contactez l'équipe DevOps, SRE & Cloud Platform.

**