Contactez-nous
-
Infrastructure Craftsmanship

Tendre vers une approche GitOps sur des projets Terraform avec Atlantis

L’arrivée des fournisseurs de cloud public a été une révolution sur plusieurs aspects pour la création d’infrastructure : ils ont proposé des API pour créer, modifier ou supprimer les ressources, permis l’élasticité de l’infrastructure, proposé une facturation à la demande, etc.

terraform atlantis

Sommaire

Nos infrastructures peuvent être construites, déployées, répliquées grâce à ces API. Des outils dits d’Infrastructure as Code (IaC) comme AWS Cloud Formation, HashiCorp Terraform, ou encore Pulumi ont émergé pour simplifier la description de nos infrastructures ou nos services SaaS.

Terraform propose de décrire l’infrastructure désirée dans un format texte propriétaire nommé HCL (pour HashiCorp Configuration Langage) puis stocke l’état des ressources dans un fichier d’état une fois le code appliqué.

Nous stockons le code source d’infrastructure dans un dépôt Git. Nous pouvons alors historiser les changements, réaliser un audit sur les modifications et passer en revue le code avant de les appliquer.

Le workflow typique du développement de code d’infrastructure avec Terraform est le suivant :

Workflow simple pour appliquer les modifications d'infrastructure avec Terraform

  1. Modifier le code Terraform depuis sa branche principale locale
  2. Exécuter les planifications des changements (commande terraform plan) et itérer
  3. Appliquer les changements (commande terraform apply)
  4. Pousser le code modifié sur le dépôt Git distant

Cela ressemble fort à l’approche GitOps proposée en 2017 par Weaveworks pour automatiser le déploiement d’applications sur un cluster Kubernetes. Néanmoins, la principale différence est la médiation automatique. Des outils tels que Flux ou ArgoCD sont en effet capables d’appliquer automatiquement les changements en fonction de ce qui est présent sur le dépôt Git.

Comment se rapprocher d’une approche GitOps tout en cloisonnant les changements apportés sur un environnement donné ?

L’outil en code ouvert Atlantis propose de planifier les modifications par le mécanisme de merge request et d’appliquer les modifications une fois la revue de code réalisée par l’équipe. Nous pouvons ainsi réduire les droits d’exécution des développeurs et seul Atlantis peut modifier l’infrastructure.

Dans la suite de cet article, nous décrirons le déploiement typique d’un serveur Atlantis puis nous proposerons un déploiement permettant de cloisonner les environnements.

Atlantis - Terraform, the GitOps way

Atlantis est un projet initialement développé en interne par la société Hootsuite pour unifier les workflows de gestion de leur infrastructure via Terraform. Ce projet a été offert au monde libre en 2017.

C’est un serveur web disponible sous forme d’un binaire ou d’une image Docker. Il peut être déployé aussi bien sur un serveur classique que sur un orchestrateur de conteneurs tel que Kubernetes.

Généralement, une seule instance d’Atlantis est suffisante et permet de déployer le code Terraform sur plusieurs environnements d’un projet.

Ce serveur est déployé dans une zone support qui est indépendante des différents environnements disponibles (traditionnellement un compte par environnement). Par exemple, si nous déployons un serveur sur un environnement AWS, l’infrastructure serait la suivante :

Déploiement typique avec un compte support / tooling

Afin de modifier l’infrastructure sur chacun des environnements cibles, il est nécessaire de donner des droits étendus au serveur Atlantis. Même si nous pouvons réduire au maximum les droits associés au serveur, cela reste un problème en cas d’intrusion.

Atlantis met à disposition une API Web qui permet d’exécuter des commandes Terraform une fois le dépôt Git cloné en local. Pour pouvoir interagir avec le gestionnaire de code source type GitHub ou GitLab, Il faut ajouter l’URL de ce webhook. Certains évènements comme la création d’une Merge / Pull Request (MR/PR) ou l’ajout de commentaires sur une MR / PR seront alors envoyés au serveur Atlantis.

Atlantis introduit le workflow de développement et de déploiement suivant :

Workflow typique pour appliquer les modifications d'infrastructure avec Atlantis

  1. Sur son dépôt Git local, créer une branche de développement. Modifier le code Terraform
  2. Pousser sa branche sur le dépôt Git distant et créer une Merge Request (ou Pull Request)
  3. Atlantis est notifié de la création d’une MR / PR. Le serveur exécute une planification des changements et envoie le résultat de la planification au gestionnaire de code sous forme d’un commentaire de la MR / PR
  4. Itérer sur le code Terraform jusqu’à obtenir l’état de l’infrastructure désiré. Puis proposer une revue du code avec l’équipe. Le responsable de la revue, s'il approuve la MR / PR, envoie une commande à Atlantis via un commentaire sur la MR / PR pour appliquer le code.
  5. Atlantis applique les changements sur l’infrastructure, puis fusionne la branche sur la branche principale si les modifications ont été réalisées avec succès.

À noter que la configuration d’Atlantis se fait via le fichier atlantis.yaml qui doit être stocké à la racine du projet. Ce fichier permet d’indiquer quels sont les répertoires à prendre en compte et quels sont les workflows à appliquer.

Dans ce cadre, comment décrire l’infrastructure de plusieurs environnements sur un même dépôt Git ? Comment s’assurer que les modifications apportées sur un environnement de développement ne modifient pas l’environnement de production ?

Une solution possible est alors d’avoir une stratégie de branches (pour de plus amples détails, se référer à l’article #8 dans les références en fin d’article). Dans ma mission précédente, nous avions opté pour une stratégie de branches type “GitFlow”.

GitFlow - Kézako ?

Dans ce modèle, deux branches sont utilisées. La branche principale conserve l’historique des versions et la branche de développement qui servira l’intégration de nouvelles fonctionnalités. Il est aussi conseillé de positionner des tags sur la branche principale pour identifier les versions livrées.

dépôt Git - Gitflow

Lors de l’introduction d’une fonctionnalité, une nouvelle branche est créée à partir de la branche de développement. Une fois la fonctionnalité implémentée, les modifications sont ensuite reportées sur la branche de développement.

Lorsque nous voulons livrer un ensemble de fonctionnalités, nous devons créer une branche spécifique pour la livraison depuis la branche de développement. Cette branche de livraison permettra entre autres de générer un fichier listant les changements (appelé communément Changelog). Une fois la livraison des fonctionnalités validée, nous pouvons la fusionner avec la branche développement puis avec la branche principale. Généralement, nous pouvons ajouter un tag identifiant le numéro de version au niveau de la branche principale.

Pour plus de détail sur ce workflow de développement, se référer aux articles #5 et #6 dans les références en fin d’article.

Comment améliorer le déploiement de code Terraform multienvironnement avec GitFlow ?

Nous pouvons modéliser notre stratégie de branches pour décrire notre code Terraform et surtout le déployer sur le bon environnement.

Ainsi, nous pouvons imaginer avoir une branche de développement qui permettra de déployer le code Terraform vers un environnement de staging ou de préproduction et avoir la branche principale qui permettra de déployer les modifications uniquement sur l’environnement de production.

Si nous avons besoin de rajouter ou de modifier des ressources en préproduction, nous créerons une branche fonctionnelle depuis la branche de développement. Quand les modifications seront validées, nous fusionnerons cette branche fonctionnelle avec la branche de développement pour déployer les changements sur l’environnement de préproduction.

Quand un ensemble de fonctionnalités est valide, nous pouvons le déployer en production en fusionnant la branche de développement avec la branche principale.

À l’aide d’Atlantis, nous pouvons décrire et automatiser ce workflow. Comment sécuriser et cloisonner les déploiements en fonction des environnements ?

À noter que dans la suite de cet article, nous déployons le serveur Atlantis sur une instance EC2 standard.

Atlantis : tous pour un et un pour tous

Une approche simple serait de déployer un serveur Atlantis par compte. Ainsi, chacune de ces instances aura uniquement les droits de modification sur l’infrastructure de son propre compte.


Déploiement avec serveur Atlantis par environnement

Dans notre gestionnaire de sources, nous devons les ajouter. Les évènements du gestionnaire de sources seront envoyés vers tous les serveurs Atlantis.

Chacun des serveurs Atlantis planifiera le code Terraform sur son environnement même s'il n’est pas concerné par ces changements. Grâce aux droits IAM associés à l’instance, uniquement l’instance concernée par les changements pourra planifier sur son environnement. Les autres instances seront en échec.

Cette première approche est insuffisante vu que le résultat des pipelines de CICD seront en échec et la MR / PR ne pourra pas être appliquée.

Une première solution : générer la configuration à la volée…

Atlantis se repose sur une configuration générale au niveau de l’instance (typiquement /etc/atlantis/repo.yaml sur une instance Debian) si le fichier de configuration au niveau du dépôt n’est pas présent. Cette configuration décrit le workflow à exécuter par défaut.

Atlantis propose aussi des hooks qui seront exécutés avant ou après avoir exécuté le workflow. De ce fait, il est possible de générer le fichier atlantis.yaml dans le repo local juste avant d’exécuter le workflow attendu sur notre projet grâce à un hook de type pré-workflow.

Nous pouvons avoir plusieurs fichiers de configuration (un par environnement) qui décrivent quels sont les répertoires à prendre en compte.

Par exemple, dans l’arborescence suivante, nous avons deux fichiers de configuration :

  • un pour l’environnement de production : atlantis_production.yaml
  • un pour l’environnement de staging : atlantis_staging.yaml
├── ...
├── atlantis_staging.yaml
├── atlantis_production.yaml
├── infra
│ ├── staging
│ │ ├── backend.tf
│ │ ├── input.tf
│ │ ├── locals.tf
│ │ ├── main.tf
│ │ └── provider.tf
│ ├── production
│ │ ├── backend.tf
│ │ ├── input.tf
│ │ ├── locals.tf
│ │ ├── main.tf
│ │ └── provider.tf
│ └── modules
│ └── network
│ │ ├── input.tf
│ │ ├── vpc.tf
│ │ ├── locals.tf
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ ├── security_group.tf
│ └── security
│ │ ├── input.tf
│ ├── iam_roles.tf
│ │ ├── locals.tf
│ │ ├── main.tf
│ │ ├── outputs.tf
├── ...
└── repo-config-generator.sh
Bash
Hiérarchie du projet sur le dépôt git

Au niveau de chacun des serveurs, le fichier repo.yaml décrit comment générer un fichier de configuration atlantis.yaml au moment hook pré-workflow à l’aide du script Bash repo-config-generator.sh qui se trouve à la racine du projet :

repos:
- id: github.com/wescale/.*/
delete_source_branch_on_merge: true
apply_requirements:
- approved
- mergeable
- undiverged

- id: github.com/wescale/atlantis_demo
delete_source_branch_on_merge: true
apply_requirements:
- approved
- mergeable
- undiverged
pre_workflow_hooks:
- run: ./repo-config-generator.sh
YAML
repo.yaml

Ce fichier Bash permet de le copier (un fichier correspond dans ce cas à la branche à déployer) comme suit :

#!/usr/bin/env bash

set -o errexit -o nounset -o pipefail

if [[ "$BASE_BRANCH_NAME" == "develop" ]]; then
cp "$DIR/atlantis_staging.yaml" "$DIR/atlantis.yaml"
elif [[ "$BASE_BRANCH_NAME" == "main" ]]; then
cp "$DIR/atlantis_production.yaml" "$DIR/atlantis.yaml"
else
cd $DIR
echo 'version: 3' >atlantis.yaml
echo 'automerge: false' >> atlantis.yaml

if [[ (-n "$CURRENT_PROJECT") ]]; then
[[ -d fake_dir ]] || mkdir fake_dir
echo 'projects:' >> atlantis.yaml
echo " - name: $CURRENT_PROJECT" >> atlantis.yaml
echo ' dir: fake_dir' >> atlantis.yaml
echo ' autoplan:' >> atlantis.yaml
echo ' when_modified: ["*.tf"]' >> atlantis.yaml
echo ' enabled: true' >> atlantis.yaml

touch fake_dir/main.tf
fi
fi
Bash
repo-config-generator.sh
Atlantis positionne certaines variables d’environnement comme BASE_BRANCH_NAME qui indique quelle est la branche sur laquelle la MR s’applique et DIR, le répertoire local du projet cloné.

Dans cette première version, nous copions le fichier de configuration correspondant à la branche en le nommant atlantis.yaml. Si la branche ne correspond pas à la branche develop ou main, dans ce cas, nous créons un répertoire vide ainsi qu’un fichier atlantis.yaml qui exécutera le workflow par défaut sur le répertoire vide.

Voici les deux fichiers de configuration qui sont disponibles à la racine du dépôt :

version: 3
automerge: true

projects:
- name: atlantis_demo
dir: infra/production
autoplan:
when_modified: [ "../modules/**/*.tf", "*.tf*" ]
enabled: true
YAML
atlantis_master.yaml

La génération du fichier Atlantis est faite avant l’exécution du workflow. Cependant, il reste encore un problème : chacune des instances verra les modifications sur le dépôt Git, mais essaiera d’exécuter le code Terraform même s'il n’est pas concerné par les changements.

... pas si simple finalement

Il faudrait alors pouvoir identifier l’environnement sur lequel est déployée l’instance Atlantis. La configuration sera prise en compte uniquement si l’Atlantis est concerné par les modifications sur son environnement.

Une solution simple est de copier le fichier repo-config-generator.sh dans le répertoire /etc/atlantis/ au niveau de la génération de l’image d’Atlantis. Ensuite, nous pouvons positionner l’environnement au démarrage de l’instance via les user data des instances EC2.

Ainsi le fichier de configuration repos.yaml devient :

repos:
- id: github.com/wescale/.*/
delete_source_branch_on_merge: true
apply_requirements:
- approved
- mergeable
- undiverged

- id: github.com/wescale/atlantis_demo
delete_source_branch_on_merge: true
apply_requirements:
- approved
- mergeable
- undiverged
pre_workflow_hooks:
- run: /etc/atlantis/repo-config-generator.sh
YAML
repo.yaml

Et le fichier de génération de la configuration repo-config-generator.sh:

#!/usr/bin/env bash

set -o errexit -o nounset -o pipefail

CURRENT_ENV="@@ATLANTIS_INSTANCE_ENVIRONMENT@@"

if [[ "$CURRENT_ENV" == "staging" && "$BASE_BRANCH_NAME" == "develop" ]]; then
cp "$DIR/atlantis_staging.yaml" "$DIR/atlantis.yaml"
elif [["$CURRENT_ENV" == "prod" && "$BASE_BRANCH_NAME" == "main" ]]; then
cp "$DIR/atlantis_production.yaml" "$DIR/atlantis.yaml"
else
cd $DIR
echo 'version: 3' > atlantis.yaml
echo 'automerge: false' >> atlantis.yaml

if [[ (-n "$CURRENT_PROJECT") ]]; then
[[ -d fake_dir ]] || mkdir fake_dir
echo 'projects:' >> atlantis.yaml
echo " - name: $CURRENT_PROJECT" >> atlantis.yaml
echo ' dir: fake_dir' >> atlantis.yaml
echo ' autoplan:' >> atlantis.yaml
echo ' when_modified: ["*.tf"]' >> atlantis.yaml
echo ' enabled: true' >> atlantis.yaml

touch fake_dir/main.tf
fi
fi
Bash
repo-config-generator.sh

Au démarrage de l’instance, nous remplacerons la chaîne @@ATLANTIS_INSTANCE_ENVIRONMENT@@ par la valeur de l’environnement (production, preproduction etc...).

Grâce à cette variable d’environnement, le serveur Atlantis sera capable de savoir s'il est  concerné par les modifications apportées par la Merge Request. S'il n’est pas concerné, il essaiera de planifier une commande sur un répertoire qui est vide.

Conclusion

Avec Atlantis, nous pouvons nous rapprocher du modèle GitOps pour gérer nos infrastructures avec Terraform. Malheureusement, Atlantis ne permet pas la réconciliation automatique comme le prône le manifeste de Weaveworks ”#4. Software agents to ensure correctness and alert on divergence.” (se référer au à l’article #7).
Grâce à Atlantis, nous sécurisons nos infrastructures en empêchant de modifier directement les ressources :

  • Les équipes de développement peuvent avoir des accès en lecture seule, voire aucun accès sur l’infrastructure déployée. Ils contribuent aux modifications de code de l’infrastructure via un dépôt Git.
  • Toute modification sur l’infrastructure doit passer obligatoirement par une revue du code. Comme Atlantis exécute le déploiement, il est possible de s’assurer que la branche principale reflète ce qui est réellement déployé en production.

En utilisant une stratégie de branches adaptée telle que le GitFlow, nous pouvons tester de nouvelles fonctionnalités dans différents environnements avant de le déployer en production. Malheureusement, Atlantis ne prévoit pas par défaut d’être déployé sur plusieurs environnements en parallèle et réagir aux évènements uniquement sur certains critères. En générant le fichier de configuration à la volée, nous pouvons modifier ce comportement par défaut.

Références

  1. Site officiel du projet Atlantis
    https://runatlantis.io

  2. Atlantis, le Terraform collaboratif sur le blog wescale https://blog.wescale.fr/atlantis-le-terraform-collaboratif

  3. Article medium expliquant le workflow de developpement avec Atlantis
    https://medium.com/nerd-for-tech/terraforming-the-gitops-way-9417cf4abf58

  4. Article medium expliquant comment déployer atlantis sur un cluster K8s
    https://medium.com/nerd-for-tech/terraforming-the-gitops-way-9417cf4abf58https://medium.com/nerd-for-tech/terraforming-the-gitops-way-9417cf4abf58

  5. Un article décrivant le modèle de gitflow
    https://les-enovateurs.com/gitflow-workflow-git-incontournableprojets-de-qualite

  6. Le modèle de branches gitflow présenté par Atlassian
    https://www.atlassian.com/fr/git/tutorials/comparing-workflows/gitflow-workflow

  7. Série d’articles sur le blog de weaveworks introduisant l’approche gitops
    https://www.weave.works/blog/gitops-operations-by-pull-request

  8. Comparaison de différentes stratégies de branches Git
    https://www.flagship.io/git-branching-strategies/