Terraform at scale! Part1 Layering

le 09/07/2020 par Rémi Rey
Tags: Cloud & Platform, Bonne Pratique, Devops

Après quelques missions d’infra diverses et variées en utilisant ce formidable outil qu’est Terraform, on s’est dit qu’il était nécessaire de partager ce que nous avons appris.

Et des choses… nous en avons apprises ! Tellement, qu’un seul article ne serait pas suffisant. Nous vous proposons donc une série d’articles pour aborder l’utilisation de Terraform… at scale !

Dans ce premier article nous aborderons le sujet de l’organisation du code Terraform pour une grosse infrastructure (le layering pour les intimes).

Avertissement: Cet article vise un public qui aura au moins une première expérience avec Terraform.

Terraform sur un gros projet d’infra

Lorsque l’on écrit du code Terraform “classique”, celui-ci est placé dans un répertoire et le développeur est libre d’organiser son code comme il l’entend. Peu importe le nom des fichiers, ils seront tous lus à condition qu’ils aient l’extension .tf. C’est l’outil qui construit un graphe à partir de la liste complète des ressources et qui orchestre la création de l’infrastructure en le parcourant. Il est d’ailleurs possible de générer un schéma de ce graphe, ce qui peut par exemple, venir enrichir votre documentation.

Cette organisation du code vient avec une limitation, et pas des moindres : une seule personne peut travailler à la fois sur un changement de l’infra.

Explication :

Le tfstate est un composant critique de Terraform qu’il est important de conserver sur un espace partagé (s3, gcs ou storage account pour les 3 principaux Cloud Provider) qui supporte le mécanisme de lock pour éviter les écritures concurrentes.

Avec un code monolithique, l’état de votre infra est stockée dans un seul et même tfstate et l'exécution d’une commande Terraform (plan ou apply à minima) entraînera un lock et par extension, l’impossibilité pour un autre développeur d'exécuter une commande de son coté. Peu importe donc la taille de votre équipe, il faudra faire la queue pour faire une modification !

Pour un gros projet d’infrastructure avec beaucoup de ressources et plusieurs membres d’une même équipe, cette limitation devient très vite un problème car cela va rythmer la capacité à faire de l’équipe... et rarement  au rythme que l’équipe souhaiterait avoir !

Autre exemple, considérons que nos développeurs n’effectuent pas leur commandes en même temps, mais suffisamment espacée dans le temps pour que le “lock” du premier développeur ai été levé au moment de l'exécution des commandes du second développeur. Le second développeur n’ayant pas les modifications apportée par son collègue, la commande apply va tout simplement entraîner la suppression des modifications qu’il aura apporté. Pas facile de tester nos modifications si celle-ci peuvent disparaître à tout moment !

Merci Tanguy Patte pour l'illustration !

"Ils n’ont qu'à travailler sur une instanciation différente de l’infra !" me direz-vous ?

Sur un petit projet pourquoi pas, mais sur un gros projet d’infrastructure, les coûts associés rendent l’opération impossible (trop cher et trop long).

Autre impact ; le temps d'exécution des commandes plan et apply s’allonge avec l’agrandissement de votre infrastructure. Nous avons vu des projets être énormément ralentis par les temps d'exécution de la CI sur leur patchs (jusqu’à 10 minutes juste pour le `plan`), étape incontournable permettant la validation d’un changement.

Combinez le temps d’attente avec la contrainte d’avoir un seul changement à la fois sur l’infrastructure et vous vous retrouvez avec une équipe qui passera son temps à attendre le feedback de la CI.

Terraform contient quelques solutions à ces problèmes :

  • Les workspaces permettent de travailler sur un même code, mais sur un tfstate différent. Ce qui, dans la pratique, va donc créer une nouvelle infrastructure. Ainsi, si chaque développeur possède son propre workspace, “problem solved”. Malheureusement, cela implique une augmentation des coûts linéaire, qui deviendra rapidement problématique une fois le nombre d’OPS conséquent.
  • L’utilisation du flag `-lock=false` permet de récupérer le tfstate sans pour autant le rendre indisponible à d’autres. Attention néanmoins, cette option n’est pas sans risque, surtout lors des opérations `apply` ! Il faudra donc ne l’utiliser que pour le plan par exemple, et n’autoriser le apply que sur la CI par exemple.
  • Le flag `-refresh=false` empêchera terraform de mettre à jour le tfstate avec l’état courant de l’infrastructure lors des étapes de plan ou apply (cela se fait “in memory”, et n’est donc pas persisté dans le `tfstate`). Il peut aider à accélérer vos `plans` en ne faisant pas d’appels d’API au “cloud provider”. On passera également à côté de possibles divergences entre le tfstate et l’état réel de l’infrastructure.
  • Enfin, le flag `-target=resource` permettra d’accélérer vos actions, lorsque votre code d’infrastructure commencera à avoir une taille critique, en limitant l’opération à la ressource spécifiée et ses dépendances, réduisant ainsi la taille du graphe. Mais attention, comme expliqué ici, ce flag n’est pas à utiliser dans un workflow normal.

Vous l’aurez compris, si des solutions de contournement existent, il s’agit principalement de “petits hacks” qui risquent de vous apporter des problèmes sur le long terme.

Maintenant que les problèmes sont posés, nous pouvons parler du layering, car cette technique d’organisation du code va nous aider à résoudre une grande partie de ces problèmes.

Le layering

Le layering est une technique d’organisation du code Terraform qui n’est pas toute nouvelle.

Vous pourrez trouver des articles de blog relatant de la technique qui sont bien plus vieux que le nôtre(1)(2). Mais ce n’est qu’en 2020 que Hashicorp publiera un article qui décrit le fonctionnement sans mentionner le terme de layering(3).

Le principe repose sur un découpage de l’infrastructure en couches (layers) logiques et de représenter ces couches sous forme de répertoires distincts.

Le déploiement d’une infrastructure ne se fera donc plus en executant Terraform dans un seul répertoire, mais dans autant de répertoires qu’il y aura de couches.

On notera également l’introduction d’un tfstate pour chacune des couches isolant ainsi la gestion des ressources.

Chacune des couches pourra souvent être gérée avec l’appel d’un seul module Terraform, rendant l’infrastructure très modulaire et facilement testable. Nous aborderons le sujet des tests dans un autre billet de cette série d’articles.

Exemple

Afin de rendre le principe plus parlant, prenons un exemple d'architecture volontairement simplifiée permettant de comprendre la logique :

Pour cette architecture, le découpage en couches serait le suivant:

  1. La couche réseau contenant le vpc et les subnets etc …
  2. Une couche pour le(s) instance(s) de compute
  3. Une couche pour l’instanciation de la base de données

L’implémentation du layering reviendrait à mettre en place l’arborescence suivante:

├── 10-network
│   ├── README
│   ├── main.tf
│   ├── outputs.tf
│   └── variables.tf
├── 20-compute
│   ├── README
│   ├── main.tf
│   ├── outputs.tf
│   └── variables.tf
└── 30-database
    ├── README
    ├── main.tf
    ├── outputs.tf
    └── variables.tf

La numérotation des répertoires permet de voir en un coup d’oeil l’ordre d'exécution des couches.  L’utilisation d’un incrément de 10 (et non pas de 1) permettra d'insérer une nouvelle couche entre deux couches existantes lorsqu’un nouveau besoin émerge.

Nous pourrons par exemple insérer une couche 15-peering si le besoin émerge dans le projet.

Les couches supérieures ne sont pas totalement indépendantes, car elles peuvent reposer sur une couche inférieure. Les instances de compute, par exemple, ont besoin d’une référence au subnet dans lequel elles vont être lancées.

Pour effectuer ces références, nous utiliserons les datasources de Terraform afin de retrouver les ressources créées par la couche précédente.

Prenons nos couches et regardons ce que le code Terraform peut donner en utilisant les modules Terraform pour AWS que nous trouvons sur le registre public :

Dans la couche 10-network, nous pouvons appeler le module vpc/aws, qui permet de créer toute une partie de la couche réseau :

# 10-network/main.tf

module "vpc" {
  source = "terraform-aws-modules/vpc/aws"

  name = "my-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["eu-west-1a"]
  private_subnets = ["10.0.1.0/24"]
  public_subnets  = ["10.0.101.0/24"]

  enable_nat_gateway = true
  enable_vpn_gateway = true
}

provider "aws" {
  version = "~> 2.0"
  region  = "eu-west-1"
}

La seconde couche peut créer les instances avec le module ec2-instance/aws également disponible sur le registre public.

Cette couche repose sur la couche 10-network, et nous allons devoir faire reference au subnet qui y est créé à l'aide d'une datasource. Le module aws/vpc utilisé précédemment utilise une convention de nommage qui permet d'anticiper le nom des subnets créés, nous pouvons donc :

# 20-compute/main.tf
data "aws_subnet" "public" {
  filter {
      name   = "tag:Name"
      values = ["my-vpc-public-eu-west-1a"]
  }
}

module "ec2_cluster" {
  source  = "terraform-aws-modules/ec2-instance/aws"
  version = "~> 2.0"

  name           = "my-cluster"
  instance_count = 1

  ami                    = "ami-ebd02392"
  instance_type          = "t2.micro"
  key_name               = "user1"
  monitoring             = true
  vpc_security_group_ids = []       # vide pour ne pas alourdir l'exemple
  subnet_id              = data.aws_subnet.public.id
}

provider "aws" {
  version = "~> 2.0"
  region  = "eu-west-1"
}

Dans cette exemple les valeurs sont codées en dur, dans un vrai cas d'usage il sera bien sur attendu de variabiliser au maximum pour rendre le tout plus utilisable.

Les gains et contraintes du layering

Retour du travail en parallèle

Si deux modifications doivent être faites sur la même couche, nous restons confrontés aux limitations que j’ai listées en début d’article.

Cependant, cette organisation apporte un gain immédiat ; chaque couche d’infrastructure permet un niveau de parallélisation du travail sur l’infra :

  • Une personne pourra travailler sur la partie réseau (pour la rendre multi-région)
  • Une autre pourra travailler sur la partie compute (pour mettre en place un auto scaling group)
  • Une autre sur la base de données (pour renforcer la sécurité en stockant le mot de passe dans un coffre fort)

Ces opérations sont possibles en parallèle car chaque couche à son propre tfstate et le lock sur le tfstate ne verrouille qu’une sous partie de l’infrastructure. Avec un éclatement du code monolithique en 3 couches, nous passons donc de 1 à 3 tâches pouvant être effectuées en même temps par des membres d’une équipe.

Il est possible de découper encore plus les couches en séparant chaque groupe de machine, chaque réseau, chaque stockage objet pour limiter encore plus les risques de collisions lorsque plusieurs personne travaille dans le même repo.

Le nombre limité de ressources dans chaque couches permet également d’avoir un temps d'exécution des commandes plan et apply plus court que si toute l’infrastructure était gérée dans une même couche.

Un peu plus d’huile de coude pour l’orchestration

Cette organisation du code est tout de même accompagnée d’inconvénients. L'exécution du code va demander un peu plus de travail car il faudra exécuter les commandes Terraform dans chaque répertoires au lieu d’un seul avec un code monolithique.

Il y a deux manières d’aborder cette problématique:

  1. Gérer la logique de déploiement dans l’outil de CI/CD (Gitlab, Jenkins, etc …) en ajoutant une étape de déploiement pour chaque couche.
  2. Créer un outil (un script shell par exemple) qui ira parcourir l’arborescence tout seul et exécuter les commandes successivement. Le script devient le point d’entrée pour déployer toute l’infra.

Les deux méthodes ont leur avantages et inconvénients :

  • La première vous donnera une visibilité claire dans la CI/CD du statut d'exécution de chaque couche, mais pourra rendre le code de configuration de votre pipeline plus lourd ou plus difficile à maintenir suivant le niveau de templating possible dans votre outil.
  • La seconde permettra de conserver une seule étape de déploiement pour toute l’infra, mais il faudra maintenir ce script et parcourir les logs pour avoir une visibilité du résultat du plan de chaque couches.

À vous de choisir ce qui est acceptable pour votre équipe. De notre côté, nous avons opté la plupart du temps sur la deuxième solution. Certains d’entre nous ont même rendu leur code Open Source.

Nous vous parlerons dans un autre billet de Terragrunt, un wrapper autour de Terraform qui permet (entre autres) de gérer l'exécution de toutes les couches simplement. Mais l’outil mérite plus que quelques lignes dans un article, il faudra donc attendre le billet dédié à l’outil dans cette série d’articles.

Itération forcée

Dernier point, mais pas des moindres, certaines modifications dans l’infra peuvent nécessiter des modifications dans plusieures couches.

Si nous voulons améliorer la disponibilité de notre solution par exemple, nous pourrions ajouter un subnet dans une nouvelle zone de disponibilité, et étaler nos instances dans ce nouveau subnet.

Cette modification nécessite une modification dans la couche 10-network et dans la couche 20-compute.

Oui mais voilà, si nous effectuons ces deux changements en même temps et que nous soumettons notre patch à notre outil de CI, l’étape de plan exécutée dans la couche 20-compute échouera lors de la tentative de résolution de la datasource du nouveau subnet car celui-ci n’existe pas encore, l’apply dans la couche 10-network n’ayant pas encore été appliqué !

Cette modification devra donc être effectuée en deux temps :

  1. Ajouter le nouveau subnet dans l’infra et déployer le changement jusqu’en prod.
  2. Ajouter le nouveau subnet dans la couche 20-compute.

La deuxième étape ne pouvant être effectuée que lorsque la première a déjà été faite.

Ce fonctionnement est certes contraignant, mais il est également en adéquation avec des principes populaires du développement logiciel : faire de petits changement déployés fréquemment jusqu’en production afin de limiter l’impact de chaque déploiement.

Ce principe s’applique également à l’infrastructure !

Selon les points de vue, ce dernier impact peut être perçu comme un avantage ou un inconvénient suivant les pratiques et la maturité de votre équipe.

Conclusion

Résumons en quelques points les avantages et inconvénients du code monolithique et du layering :

Avantages :

  • Stocke les ressources de chaque couche dans un tfstate dédié
  • Permet le travail en parallèle de plusieurs développeurs sur une même infrastructure
  • Optimise le temps d'exécution des commande plan et apply dans chaque couche
  • Force l’approche itérative des modifications d’infrastructure

Inconvénients :

  • Force l’approche itérative des modifications d’infrastructure
  • Complexifie l’ordonnancement dans l’outil de CI/CD
  • Augmente le couplage faible du code Terraform et la nécessité d’utiliser les datasources

Pour un petit projet dont l’infrastructure n’évoluera pas ou peu, le code monolithique fera parfaitement l’affaire, car vous ne serez probablement pas confronté à la nécessité d’effectuer de nombreux changements en parallèle sur l'infrastructure. Mais sur un gros projet nécessitant de faire évoluer en parallèle plusieurs parties de l’infrastructure (réseau, monitoring, etc ...), le layering sera rapidement un atout pour l’équipe en charge de l’infrastructure.

Nous vous recommandons d’utiliser le layering comme organisation du code par défaut lorsque la vision finale de l’infrastructure n’est pas définie ou que la taille de votre infrastructure est significative. Sur un petit projet, le layering sera cependant probablement trop coûteux à mettre en place et n'apportera pas grand chose par rapport à un code monolithique.

Dans le doute, l’utilisation du layering vous permettra d’éviter les embûches, mais si vous partez sur un code monolithique, le passage au layering vous coûtera cher car il faudra prendre en charge le déplacement des ressources d’un tfstate à l’autre avec les commandes import et state rm (ou state mv).

Dans le prochain article de la série, nous aborderons le sujet des modules Terraform et des challenges autour de la maintenance de ce code qui a vocation à être partagé.