Le 27 mai, dans la salle principale de la conférence DevOpsConf 2019, qui se déroule dans le cadre du festival , dans le cadre de la section « Livraison Continue », une présentation intitulée « werf — notre outil pour CI/CD sur Kubernetes » a été faite. Elle traite des problèmes et défis auxquels chacun fait face lors du déploiement sur Kubernetes, ainsi que des nuances qui peuvent ne pas être immédiatement visibles. En examinant les solutions possibles, nous montrons comment cela est réalisé dans un outil Open Source .
Depuis la présentation, notre utilitaire (anciennement connu sous le nom de dapp) a franchi le cap historique de 1000 étoiles sur GitHub — nous espérons que la communauté croissante de ses utilisateurs facilitera la vie de nombreux ingénieurs DevOps.

Alors, nous vous présentons (~47 minutes, beaucoup plus informatif qu'un article) et un extrait principal sous forme de texte. Allons-y !
Livraison de code sur Kubernetes
La présentation ne portera plus sur werf, mais sur CI/CD sur Kubernetes, en supposant que notre logiciel est empaqueté dans des conteneurs Docker (j'en ai parlé dans ), et K8s sera utilisé pour le déployer en production (à ce sujet — dans ).
À quoi ressemble la livraison sur Kubernetes ?
- Il y a un dépôt Git avec le code et des instructions pour le construire. L'application est construite en une image Docker et publiée dans Docker Registry.
- Dans le même dépôt, il y a aussi des instructions sur la façon de déployer et d'exécuter l'application. Lors de la phase de déploiement, ces instructions sont envoyées à Kubernetes, qui obtient l'image nécessaire depuis le registre et la lance.
- De plus, il y a généralement des tests. Certains peuvent être exécutés lors de la publication de l'image. Il est également possible (selon les mêmes instructions) de déployer une copie de l'application (dans un espace de noms K8s séparé ou un cluster distinct) et d'y exécuter des tests.
- Enfin, il faut un système CI qui reçoit des événements de Git (ou des clics de boutons) et appelle toutes les étapes spécifiées : build, publish, deploy, test.

Il y a ici quelques remarques importantes :
- Puisque nous avons une infrastructure immuable (immutable infrastructure), l'image de l'application, celle utilisée à toutes les étapes (staging, production, etc.) doit être unique. Pour plus de détails à ce sujet avec des exemples, je l'ai évoqué .
- Puisque nous suivons l'approche infrastructure comme code (IaC), le code de l'application, ainsi que les instructions pour sa construction et son exécution doivent se trouver dans un seul et même dépôt. Pour plus de détails, consultez .
- La chaîne de livraison (delivery) est généralement vue comme suit : l'application est construite, testée, et mise en production. (étape de release) et voilà — la livraison a eu lieu. Mais en réalité, l'utilisateur reçoit ce que vous avez déployé, ne au moment où vous l'avez livré en production, et quand il a pu y accéder et que cette production fonctionnait. C'est pourquoi je pense que la chaîne de livraison se termine uniquement à l'étape d'exploitation (run), et pour être plus précis, même au moment où le code a été retiré de la production (remplacé par un nouveau).
Revenons au schéma de livraison mentionné ci-dessus dans Kubernetes : il a été inventé non seulement par nous, mais par littéralement tout le monde qui s'est attaqué à ce problème. En fait, ce modèle est maintenant appelé GitOps (vous pouvez lire plus sur le terme et les idées qui y sont associées ). Regardons les étapes du schéma.
Phase de construction (build)
On pourrait penser qu'il y a peu à dire en 2019 sur la construction d'images Docker, puisque tout le monde sait écrire des Dockerfiles et les exécuter. docker build?.. Вот нюансы, на которые хотелось бы обратить внимание:
- Le poids de l'image est important, donc utilisez , pour ne garder dans l'image que ce qui est réellement nécessaire au fonctionnement de l'application.
- Le nombre de couches doit être minimisé, en regroupant les chaînes de
RUN-commandes par sens. - Cependant, cela entraîne des problèmes de débogage, car lorsqu'une construction échoue, il faut retrouver la commande précise de la chaîne qui a causé le problème.
- La vitesse de construction est importante, parce que nous voulons déployer rapidement les changements et voir le résultat. Par exemple, nous ne voulons pas recompresser les dépendances des bibliothèques du langage à chaque construction de l'application.
- Souvent, un seul dépôt Git nécessite de nombreuses images, ce qui peut être résolu par un ensemble de Dockerfiles (ou des étapes nommées dans un fichier) et un script Bash avec leur construction séquentielle.
Ce n'était que la pointe de l'iceberg à laquelle tout le monde est confronté. Mais il y a d'autres problèmes, notamment :
- Souvent, à l'étape de construction, nous avons besoin de quelque chose à monter (par exemple, mettre en cache le résultat d'une commande apt dans un répertoire externe).
- Nous voulons Ansible au lieu d'écrire en shell.
- Nous voulons construire sans Docker (pourquoi devrions-nous avoir une machine virtuelle supplémentaire où tout doit être configuré, alors qu'il y a déjà un cluster Kubernetes où nous pouvons exécuter des conteneurs ?).
- Construction parallèle, qui peut être comprise de différentes manières : différentes commandes d'un Dockerfile (si on utilise multi-stage), plusieurs commits d'un même dépôt, plusieurs Dockerfiles.
- Construction distribuée: nous souhaitons collecter quelque chose dans des pods, qui sont « éphémères », car leur cache disparaît, ce qui signifie qu'il faut donc le stocker ailleurs.
- Enfin, j'ai nommé le sommet des désirs automagie: il serait idéal d'entrer dans le dépôt, de saisir une certaine commande et d'obtenir une image prête, assemblée en sachant comment et quoi faire correctement. Cependant, personnellement, je ne suis pas sûr que tous les détails puissent être prévus de cette manière.
Et voici des projets :
- — un assembleur de la société Docker Inc (déjà intégré dans les versions récentes de Docker), qui essaie de résoudre tous ces problèmes ;
- — un assembleur de Google, permettant de construire sans Docker ;
- — une tentative de la CNCF de créer une automagie et, en particulier, une solution intéressante de rebase pour les couches ;
- et encore une multitude d'autres outils, tels que , …
… et regardez combien d'étoiles ils ont sur GitHub. Donc, d'un côté, docker build il y en a et peut faire quelque chose, mais en réalité, la question n'est pas entièrement résolue — la preuve en est le développement parallèle d'assembleurs alternatifs, chacun d'eux résolvant une partie des problèmes.
L'assemblage dans werf
Ainsi, nous avons atteint (anciennement comme dapp) — l'outil Open Source de l'entreprise « Flant », que nous développons depuis de nombreuses années. Tout a commencé il y a environ 5 ans avec des scripts Bash optimisant l'assemblage des Dockerfile, et ces 3 dernières années, un développement complet a été lancé dans le cadre d'un projet unique avec son propre dépôt Git (d'abord en Ruby, puis en Go, et au passage renommé). Quelles questions d'assemblage sont résolues dans werf ?

Les problèmes marqués en bleu ont déjà été réalisés, l'assemblage parallèle a été réalisé sur une seule machine, et les questions mises en jaune sont prévues pour être complétées d'ici la fin de l'été.
Étape de publication dans le registre (publish)
Nous avons rempli docker push… — qu'est-ce qui peut être compliqué à charger une image dans le registre ? Et la question se pose : « Quel tag mettre sur l'image ? » Elle survient en raison du fait que nous avons Gitflow (ou une autre stratégie Git) et Kubernetes, et l'industrie tend à faire en sorte que ce qui se passe dans Kubernetes suive ce qui se fait dans Git. Après tout, Git est notre seule source de vérité.
Qu'est-ce qui est complexe là-dedans ? Garantir la reproductibilité: du commit dans Git, qui par nature est immuable (immutable), à l'image Docker, qui doit rester la même.
Il est également important pour nous de déterminer l'origine, car nous voulons comprendre de quel commit a été construite l'application déployée sur Kubernetes (alors nous pourrons faire des diff et des choses similaires).
Stratégies de tagging
La première est un simple git tag. Nous avons un registry avec une image taguée comme 1.0. Sur Kubernetes, il y a stage et production, où cette image est déployée. Dans Git, nous faisons des commits et à un moment donné, nous mettons un tag 2.0. Nous le construisons selon les instructions du dépôt et le plaçons dans le registry avec le tag 2.0. Nous déployons sur stage et, si tout va bien, ensuite sur production.

Le problème avec cette approche est que nous avons d'abord mis le tag, puis testé et déployé. Pourquoi ? Tout d'abord, ce n'est tout simplement pas logique : nous publions une version d'un logiciel que nous n'avons même pas encore vérifié (nous ne pouvons pas faire autrement, car pour vérifier, il faut mettre un tag). Deuxièmement, ce chemin n'est pas compatible avec Gitflow.
La deuxième option est git commit + tag. Dans la branche master, il y a un tag 1.0; pour celui-ci, dans le registry, il y a une image déployée sur production. En outre, dans le cluster Kubernetes, il y a des environnements de preview et de staging. Ensuite, nous suivons Gitflow : dans la branche principale de développement (develop) nous ajoutons de nouvelles fonctionnalités, ce qui crée un commit avec l'identifiant #c1. Nous le construisons et le publions dans le registry en utilisant cet identifiant (#c1). Avec le même identifiant, nous le déployons sur preview. Nous faisons de même avec les commits #c2 et #c3.
Quand nous comprenons que les fonctionnalités sont suffisantes, nous commençons à stabiliser. Dans Git, nous créons une branche release_1.1 ), utilisé dans RAPIDS. BlazingSQL est une couche supplémentaire qui fonctionne au-dessus de cuDF et utilise la bibliothèque cuIO pour lire les données depuis le disque. Les requêtes SQL sont traduites en appels de fonctions cuUDF, permettant de charger des données dans le GPU et d'effectuer des opérations de fusion, d'agrégation et de filtrage. La création de configurations distribuées, couvrant des milliers de GPU, est prise en charge. #c3 de develop). Nous n'avons pas besoin de construire cette version, car cela a été fait à l'étape précédente. Nous pouvons donc simplement le déployer sur staging. Nous corrigeons les bugs dans #c4 et déployons de la même manière sur staging. Parallèlement, le développement se poursuit dans develop, où des modifications sont périodiquement intégrées depuis release_1.1. À un moment donné, nous obtenons un commit construit et déployé sur staging, dont nous sommes satisfaits (#c25).
Alors nous fusionnons (avec fast-forward) la branche de release (release_1.1) dans master. Nous mettons sur ce commit un tag avec la nouvelle version (1.1). Mais cette image est déjà construite dans le registry, donc pour ne pas la reconstruire encore une fois, nous ajoutons simplement un second tag sur l'image existante (maintenant elle a dans le registry les tags #c25 et 1.1). Après cela, nous le déployons sur production.
Il y a un inconvénient, c'est qu'une image est déployée sur staging (#c25), alors que sur production, c'est comme si c'était une autre (1.1), mais nous savons que « physiquement », c'est la même image du registry.

Le véritable inconvénient est qu'il n'y a pas de support pour les commits de fusion, il faut faire un fast-forward.
On peut aller plus loin et réaliser un truc… Prenons l'exemple d'un Dockerfile simple :
FROM ruby:2.3 as assets
RUN mkdir -p /app
WORKDIR /app
COPY . ./
RUN gem install bundler && bundle install
RUN bundle exec rake assets:precompile
CMD bundle exec puma -C config/puma.rb
FROM nginx:alpine
COPY --from=assets /app/public /usr/share/nginx/www/publicConstruisons-le selon le principe suivant :
- SHA256 des identifiants des images utilisées (
ruby:2.3etnginx:alpine), qui sont des sommes de contrôle de leur contenu ; - toutes les commandes (
RUN,CMDet autres); - SHA256 des fichiers qui ont été ajoutés.
… et prenons la somme de contrôle (encore SHA256) de ce fichier. Cela définit la signature de tout ce qui définit le contenu de l'image Docker.

Revenons au schéma et au lieu des commits, nous utiliserons ces signatures, c'est-à-dire que nous taguerons les images avec des signatures.

Maintenant, lorsqu'il sera nécessaire, par exemple, de fusionner les modifications de la version dans master, nous pouvons effectuer un vrai commit de fusion : il aura un identifiant différent, mais la même signature. Avec le même identifiant, nous déploierons l'image en production.
L'inconvénient est qu'il ne sera plus possible de déterminer quel commit a été déployé en production — les sommes de contrôle fonctionnent seulement dans un sens. Ce problème est résolu par une couche supplémentaire de métadonnées — j'en parlerai plus en détail ensuite.
Taguer dans werf
Dans werf, nous sommes allés encore plus loin et préparons une construction distribuée avec un cache qui n'est pas stocké sur une seule machine… Ainsi, nous construisons des images Docker de deux types, que nous appelons ) — une unité d'organisation du pipeline, contenant 1+ tâche, et image.
Dans le dépôt Git de werf, se trouvent des instructions spécifiques pour la construction, décrivant les différentes étapes de la construction (beforeInstall, install, beforeSetup, configuration). La première image de stage est construite avec une signature définie comme la somme de contrôle des premières étapes. Ensuite, nous ajoutons le code source, pour la nouvelle image de stage, nous calculons sa somme de contrôle… Ces opérations se répètent pour toutes les étapes, ce qui nous permet d'obtenir un ensemble d'images de stage. Ensuite, nous créons l'image finale, contenant aussi des métadonnées sur son origine. Et c'est déjà cette image que nous taguons de diverses manières (les détails viennent plus tard).

Supposons qu'un nouveau commit apparaisse, dans lequel seul le code de l'application a été modifié. Que se passera-t-il ? Un patch sera créé pour les modifications de code, un nouveau stage-image sera préparé. Sa signature sera définie comme une somme de contrôle de l'ancien stage-image et du nouveau patch. Un nouveau final image-image sera formé à partir de cette image. Un comportement similaire se produira lors des modifications à d'autres étapes.
Ainsi, les stage-images sont un cache qui peut être stocké de manière distribuée, tandis que les image-images créées à partir de celui-ci sont chargées dans le Docker Registry.

Nettoyage du registry
Il ne s'agit pas de supprimer des couches qui sont restées en attente après la suppression de tags, - c'est une fonctionnalité standard du Docker Registry lui-même. Il s'agit d'une situation où de nombreux tags Docker s'accumulent et nous comprenons qu'une certaine partie n'est plus nécessaire, mais qu'elle occupe de l'espace (et/ou que nous en payons).
Quelles sont les stratégies de nettoyage ?
- On peut simplement ne rien faire ne pas nettoyer. Parfois, il est effectivement plus simple de payer un peu pour de l'espace supplémentaire que de démêler un énorme enchevêtrement de tags. Mais cela ne fonctionne que jusqu'à un certain point.
- Réinitialisation complète. Si tous les images sont supprimés et que seuls les actuels sont reconstruits dans le CI système, un problème peut survenir. Si le conteneur se redémarre en production, il téléchargera une nouvelle image - qui n'a pas encore été testée. Cela détruit l'idée d'une infrastructure immuable.
- Blue-green. Un registry a commencé à se remplir - nous chargeons des images dans un autre. Le même problème que dans la méthode précédente : à quel moment peut-on nettoyer le registry qui a commencé à se remplir ?
- Par temps. Supprimer toutes les images de plus d'un mois ? Mais il y a forcément un service qui n'a pas été mis à jour pendant un mois...
- Manuellement déterminer ce qui peut déjà être supprimé.
Les deux options véritablement viables sont de ne pas nettoyer ou bien une combinaison de blue-green + manuel. Dans ce dernier cas, il s'agit de la manière suivante : lorsque vous comprenez qu'il est temps de nettoyer le registry, vous en créez un nouveau et ajoutez toutes les nouvelles images pendant, par exemple, un mois. Après un mois, vous regardez quels pods dans Kubernetes utilisent encore l'ancien registry, et vous les transférez aussi dans le nouveau registry.
Où en sommes-nous dans werf? Мы собираем:
- Git head : tous les tags, toutes les branches - en supposant que tout ce qui est tagué dans Git est nécessaire et dans les images (et si ce n'est pas le cas, il faut le supprimer dans Git) ;
- tous les pods qui sont actuellement extraits dans Kubernetes;
- les anciennes ReplicaSets (ce qui a été récemment extrait), ainsi que les versions Helm que nous allons scanner et sélectionner les dernières images là-bas.
… et nous faisons de cet ensemble une liste blanche — une liste d'images que nous ne supprimerons pas. Tout le reste est nettoyé, après quoi nous trouvons des images orphelines de stage et les supprimons aussi.
Phase de déploiement
Déclarativité fiable
Le premier point sur lequel je voudrais attirer l'attention lors du déploiement est le déploiement d'une configuration mise à jour des ressources, déclarée de manière déclarative. Le document YAML original décrivant les ressources Kubernetes diffère toujours considérablement du résultat réel fonctionnant dans le cluster. Car Kubernetes ajoute à la configuration :
- des identifiants;
- des informations de service;
- de nombreuses valeurs par défaut;
- une section avec l'état actuel;
- des modifications apportées dans le cadre de l'exploitation du webhook d'admission;
- le résultat du travail de divers contrôleurs (et du planificateur).
Donc, lorsque nous avons une nouvelle configuration de ressource (new), nous ne pouvons pas simplement remplacer l'actuelle, "vivante", configuration (live). Pour cela, nous devrons comparer new avec la configuration appliquée précédemment ("last-applied") et appliquer le patch obtenu. live Cette approche est appelée
fusion à 2 voies . Elle est utilisée, par exemple, dans Helm.Il existe aussi la
fusion à 3 voies , qui se distingue par le fait que :en comparant
- , nous regardons ce qui a été supprimé; "last-applied" et new, nous regardons ce qui a été ajouté ou modifié;
- , nous regardons ce qui a été supprimé; new et livenous appliquons le patch cumulé sur
- Nous déployons plus de 1000 applications avec Helm, donc nous vivons en fait avec la fusion à 2 voies. Cependant, elle présente un certain nombre de problèmes que nous avons résolus avec nos propres patches pour aider Helm à fonctionner correctement. live.
Statut réel du déploiement
Après qu'un événement a généré une nouvelle configuration pour Kubernetes, notre système CI la transmet pour application
(apply) dans le cluster — à l'aide de Helm ou . Ensuite, la fusion N-way déjà décrite se produit, à quoi l'API Kubernetes répond positivement au système CI, et celui-ci à son utilisateur. kubectl applyCependant, il y a un énorme problème : car

une application réussie ne signifie pas un déploiement réussi. Si Kubernetes a compris quelles modifications appliquer, il les applique — nous ne savons pas encore quel sera le résultat. Par exemple, la mise à jour et le redémarrage des pods en frontend peuvent réussir, tandis qu'en backend, ce n'est pas le cas, et nous obtiendrons différentes versions des images de l'application en cours d'exécution.. Si Kubernetes a compris quelles modifications appliquer, il les appliquera — nous ne savons pas encore quel sera le résultat. Par exemple, la mise à jour et le redémarrage des pods en frontend peuvent se dérouler avec succès, tandis qu'en backend, cela peut échouer, et nous nous retrouverons avec différentes versions des images de l'application en cours d'exécution.
Pour bien faire les choses, ce schéma nécessite un élément supplémentaire : un traqueur spécial qui recevra des informations sur l'état depuis l'API Kubernetes et les transmettra pour une analyse approfondie de la situation réelle. Nous avons créé une bibliothèque Open Source en Go — (voir son annonce ), — qui résout ce problème et est intégrée dans werf.
Le comportement de ce traqueur au niveau de werf est configuré à l'aide d'annotations qui sont appliquées aux Deployments ou StatefulSets. L'annotation principale — fail-mode — comprend les valeurs suivantes :
-
IgnoreAndContinueDeployProcess— nous ignorons les problèmes de déploiement de ce composant et continuons le déploiement ; -
FailWholeDeployProcessImmediately— une erreur dans ce composant arrête immédiatement le processus de déploiement ; -
HopeUntilEndOfDeployProcess— nous espérons que ce composant fonctionnera d'ici la fin du déploiement.
Par exemple, une telle combinaison de ressources et de valeurs d'annotation fail-mode:

Lors du premier déploiement, la base de données (MongoDB) peut ne pas être prête — les Deployments échoueront. Mais nous pouvons attendre qu'elle démarre, et le déploiement pourra quand même se faire.
Il y a aussi deux autres annotations pour kubedog dans werf :
-
failures-allowed-per-replica— le nombre d'échecs autorisés pour chaque réplique ; -
show-logs-until— régule le moment jusqu'auquel werf affiche (dans stdout) les journaux de tous les pods déployés. Par défaut, c'estPodIsReady(pour ignorer les messages qui ne sont probablement pas utiles lorsque le pod commence à recevoir du trafic), mais les valeurs suivantes sont également acceptables :ControllerIsReadyetEndOfDeploy.
Qu'attendons-nous d'un déploiement ?
En plus des deux points décrits, nous aimerions :
- voir les journaux — et seulement ceux qui sont nécessaires, pas tous à la suite ;
- suivre le progrès, car si une tâche « reste silencieuse » pendant plusieurs minutes, il est important de comprendre ce qui se passe ;
- avoir un retour automatique au cas où quelque chose se passerait mal (il est donc crucial de connaître le statut réel du déploiement). Le déploiement doit être atomique : il réussit totalement ou tout revient à l'état précédent.
Résultats
Nous, en tant qu'entreprise, avons besoin d'un système CI et d'une utilitaire .
En guise de conclusion :

Avec werf, nous avons fait de bons progrès pour résoudre un grand nombre de problèmes pour les ingénieurs DevOps et nous serions ravis si une communauté plus large essayait au moins cet utilitaire dans la pratique. Obtenir de bons résultats ensemble sera plus simple.
Vidéos et diapositives
Vidéo de la présentation (~47 minutes) :

Présentation de l'exposé :
P.S.
D'autres rapports sur Kubernetes dans notre blog :
- «» (Dmitry Stolyarov ; 27 avril 2019 à « Stachka »);
- «» (Andrei Polovov ; 8 avril 2019 à Saint HighLoad++);
- «» (Dmitry Stolyarov ; 8 novembre 2018 à HighLoad++);
- «» (Dmitry Stolyarov ; 28 mai 2018 à RootConf);
- «» (Dmitry Stolyarov ; 7 novembre 2017 à HighLoad++);
- «» (Dmitry Stolyarov ; 6 juin 2017 à RootConf).
Source : habr.com
