Plusieurs façons de gérer son code et ses environnements avec Terraform
Au fil des années, et depuis maintenant pratiquement six ans que je pratique terraform, j’ai pu voir et écrire une quantité non négligeable de lignes de code terraform, mes propres méthodes et préférences ayant évolué avec le temps. Je vous propose donc un petit florilège de ce qu’on peut faire avec cet outil merveilleux.
Ai-je encore besoin de présenter cet incontournable du domaine qu’on appelle l’infrastructure-as-code ? L’outil d’Hashicorp est devenu une référence en quelques années dans le domaine de la gestion d’infrastructure dans les nuages. Son modèle à base d’extensions, les « providers », la capacité de reposer sur des modules de codes pré-établis, ont conquis bon nombre d’équipes et d’indépendants, et de bricoleurs du dimanche comme moi. Le fait que l’outil soit open-source est évidemment un autre critère à ne pas oublier (alors oui, bon, entre temps, ça s’est un peu compliqué…). Il ne faut par contre pas croire ceux qui vous sortent encore qu’un seul code peut permettre de déployer chez n’importe quel fournisseur, c’est juste de la grosse connerie. Mais les concepts d’utilisation et le langage restent eux bien identiques, et surtout, permettent dans la même exécution de déployer chez plusieurs fournisseurs en même temps; moi par exemple, quand je déploie une nouvelle machine sur mon serveur Proxmox, je peux déclarer une entrée DNS chez OVH dans le même temps, le tout avec une seule exécution de terraform. C’est ce genre de puissance qui fait que l’outil est apprécié (on peut même commander ses pizzas avec :P).
Un autre aspect est de pouvoir reproduire pratiquement à l’identique et de manière illimitée un même environnement. La souplesse dans l’utilisation du langage est un autre critère qui a fait son succès, et j’ai pu le comprendre en partie face aux différents « codes » que j’ai pu croiser, et certains modèles de code permettant notamment de gérer plusieurs environnements m’ont vraiment plu dans leur philosophie. C’est ce que je compte vous partager aujourd’hui. D’une manière assez rigolote : on va y aller par étape dans la complexité.
1° niveau : tout hardcodé
C’est ce qu’on voit dans les documentations des « providers », et c’est généralement la première étape de ce qu’on produit comme code quand on débute. Je vais volontairement éluder l’organisation des fichiers pour me concentrer sur le code lui-même : on peut tout coller dans un seul fichier, ou dans plusieurs, et leur nommage importe peu tant que leur extension est .tf
.
resource "ovh_domain_zone_record" "vps_ipv4" { zone = "domain.tld" subdomain = "mysub" fieldtype = "A" ttl = "60" target = "1.2.3.4" }
Simple, efficace, mais absolument pas souple quand il s’agira de reproduire ça pour d’autres enregistrements. La méthode simple consistera à copier/coller ce code pour créer une deuxième ressource, une troisième, etc. Ça devient vite un enfer, et en plus, tout est dans le même code donc le même fichier d’état dont on reparlera assez vite. C’est là que les variables entrent en jeu.
2° niveau : variables, variables everywhere (et des maps)
Certes ce n’est pas ce que je recommande, mais une des premières utilisations des variables que j’ai faites à été de créer une variable par propriété que je souhaitais pouvoir moduler/réutiliser. Ces variables sont à enregistrer dans un fichier .tfvars, on comprend vite la signification de l’extension du fichier. Sans beaucoup plus d’explications parce que l’exemple sera suffisamment parlant, voilà le résultat :
resource "ovh_domain_zone_record" "vps_ipv4" { zone = var.domain subdomain = var.subdomain fieldtype = "A" ttl = "60" target = var.ip_address }
Ma première évolution de ça a été de dupliquer le code comme au premier niveau, et donc avec les variables. En clair, j’avais un jeu de variables pour chaque domaine que je souhaite ajouter. C’est lourdingue, surtout quand on sait qu’on doit déclarer chaque variable qu’on compte utiliser avant de lui affecter une valeur. Aussi, le jour où une évolution majeure demande de réécrire le code (parce qu’on aura voulu renommer la propriété target par exemple), ça demandera de faire x fois la même modification. On reste également dans une gestion unique de l’état.
Une première manière de réduire un peu le poids de ces déclarations de variables a été l’utilisation de listes et de maps. Plutôt que d’avoir une variable par propriété d’une même ressource, on crée une variable par ressource, et cette variable est en l’occurrence une map contenant toutes les propriétés dont on a besoin. Le résultat est parlant là encore :
resource "ovh_domain_zone_record" "vps_ipv4" { zone = var.domain["domain_name"] subdomain = var.domain[subdomain"] fieldtype = "A" ttl = "60" target = var.domain["ip_address"] } variable "domain" {} domain = { "domain_name" = "domain.tld" "subdomain" = "mysub" "ip_address" = "1.2.3.4" }
J’ai inclus un exemple de variable en dessous pour qu’on comprenne la différence avec le cas précédent. Si on commence à modulariser ce code, l’utilisation d’une map comme celle-ci permet de grandement limiter la duplication de code, même si pour l’instant, nous n’avons pas encore évolué sur notre gestion de l’état.
Un très bonne évolution amenée par Hashicorp a été l’utilisation de boucles, via la fonction for_each
. Elle permet de boucler sur une map ou une liste pour répéter un même code à partir du contenu de celle-ci. Ajoutez le fait que cette map peut être indexée, vous commencerez peut-être à comprendre où je veux en venir. On peut dès lors créer la liste des ressources dans une seule variable avec une série de propriétés. Ici, il « suffit » de faire la liste des domaines et des IP associées dans la variable domain, et on peut boucler dessus:
resource "ovh_domain_zone_record" "vps_ipv4" { for_each = var.domains zone = each.value.domain_name subdomain = each.value.subdomain fieldtype = "A" ttl = "60" target = each.value.ip_address } variable "domains" { type = map(any) } domains = { record1 = { domain_name = "domain.tld", subdomain = "mysub", ip_address = "1.2.3.4"}, record2 = { domain_name = "domain2.tld", subdomain = "mysub2", ip_address = "4.5.6.7"} }
Et depuis, ce for_each
a été étendu pour être utilisé non seulement sur les ressources, mais aussi carrément sur les modules eux-mêmes. Ça donne quelque chose de particulièrement puissant et qui peut rester assez léger à lire. Swag.
3° niveau : le couple fichier tfvars + changer la clé de son backend
Eh oui, il est temps de parler d’un élément que je un peu trop vite éludé pour l’instant. Le fonctionnement de terraform repose sur l’enregistrement de l’état de l’infrastructure décrite dans le code, une fois celui-ci appliqué. Lors des exécutions successives, l’outil compare le code, l’état, et ce qui est présent côté plateforme cible pour ensuite indiquer si des changements sont à effectuer (ajout, suppression, modification). Le résultat est donc stocké dans cet état, le fameux « tfstate ».
Quand on ne déclare rien, terraform va stocker son état dans un fichier local, appelé terraform.tfstate. Mais on peut configurer un backend distant pour le stockage de ce fichier, qui est d’ailleurs nécessaire dès lors que le code est partagé via un outil type git, pour que chaque intervenant susceptible de travailler avec le code puisse exploiter un état commun. C’est là qu’on commence à rigoler, mais aussi à faire une chose lourde et rétrospectivement assez dangereuse : associer une clé, c’est-à-dire le chemin du fichier dans le stockage, et un fichier de variables. Pourquoi ? parce qu’on ne peut pas variabiliser une clé de backend comme on le ferait pour le reste de la configuration des providers. Mais bon, on peut enfin séparer les états selon des critères (généralement, la cible d’exécution : sandbox, production…). Mais plantez-vous une fois, et appliquez le mauvais fichier sur l’environnement cible, et c’est potentiellement tout une production qui tombe. C’est clairement pas ce que je recommande, mais j’ai eu à gérer à un moment donné un backend qui ne supportait pas l’utilisation des Workspaces, et c’était à l’époque la seule solution qui nous était apparu comme acceptable pour éviter trop de duplications de code, du genre un dossier par environnement, chacun avec sa conf, son fichier de variables, son backend. Le jour où on doit modifier quelque chose, il faut le répliquer ensuite dans chaque dossier. Pas toujours foufou quand on ne peut pas exploiter les modules.
4° Niveau: les workspaces !
Ça a été une fonctionnalité qui, si le backend le supporte, permet de réduire un peu la lourdeur de la gestion de l’état. En effet, vous déclarez une clé de backend « de base » dans votre configuration, et chaque workspace créé va générer une clé de backend à partir de la base, mais avec un suffixe qui est le nom du workspace. Pourquoi je dit « si le backend le supporte » ? Parce que le caractère séparateur est un « : » qui n’est pas forcément bien toléré par la technologie de stockage. On reste malgré tout dans le domaine du « faut faire gaffe au fichier de conf », mais l’avantage c’est qu’on a « juste » à gérer un tfvars. Je vous laisse lire l’excellente page de Stéphane Robert sur le sujet pour en comprendre l’intérêt.
5° Niveau: Le boss final de l’élégance
Vraiment, c’est ce que j’ai eu l’occasion de voir le plus élégant, tout en étant pas évidemment la solution absolue. Seulement, c’est particulièrement pratique si on doit répliquer sur plusieurs environnements. On reprend une grande partie des éléments précédents, à savoir les workspaces, les maps pour les variables. Mais exit les tfvars, tout repose sur le workspace et les valeurs « par défaut » des variables.
Je ne vais pas reprendre l’exemple du domaine parce que ça n’aurait que peu de sens. On va cette fois prendre comme exemple la définition d’un subnet dans un projet Hetzner Cloud pour trois environnements différents:
resource "hcloud_network" "privNet" { name = terraform.workspace ip_range = var.subnets[terraform.workspace].subnet } variable "subnets" { description = "List of subnets for all envs" type = map(any) default = { dev = { subnet = "10.1.0.0/24" }, preprod = { subnet = "10.2.0.0/24" }, prod = { subnet = "10.3.0.0/24" } } }
Ensuite, il n’y a plus qu’à initialiser son environnement, son workspace donc, et d’appliquer le code, et il va sélectionner le subnet en fonction du workspace. D’ailleurs, il est possible aussi de créer dans la map une sous-liste si on veut créer plusieurs subnets, et d’appliquer un for_each. Ça pourrait alors donner un truc dans le genre:
resource "hcloud_network" "privNet" { for_each = toset(var.subnets[terraform.workspace].subnets) name = "${terraform.workspace}-${index(var.subnets[terraform.workspace].subnets, each.value) +1}" ip_range = each.value } variable "subnets" { description = "List of subnets for all envs" type = map(any) default = { dev = { subnets = [ "10.1.0.0/24", "10.2.0.0/24" ] }, preprod = { subnet = [ "10.3.0.0/24", "10.4.0.0/24" ] }, prod = { subnet = [ "10.5.0.0/24", "10.6.0.0/24" ] } } }
Bon là je me complique la vie parce qu’avec une simple liste sans « label », faut ruser pour trouver un nom pour chaque subnet côté Hetzner, mais vous avez l’idée. Testé rapidement, ça donne ça comme plan :
tofu plan OpenTofu used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create OpenTofu will perform the following actions: # hcloud_network.privNet["10.5.0.0/24"] will be created + resource "hcloud_network" "privNet" { + delete_protection = false + expose_routes_to_vswitch = false + id = (known after apply) + ip_range = "10.5.0.0/24" + name = "prod-1" } # hcloud_network.privNet["10.6.0.0/24"] will be created + resource "hcloud_network" "privNet" { + delete_protection = false + expose_routes_to_vswitch = false + id = (known after apply) + ip_range = "10.6.0.0/24" + name = "prod-2" } Plan: 2 to add, 0 to change, 0 to destroy.
Vous pouvez donc dans un seul fichier de variables, définir l’intégralité de vos environnements. Et si pour une quelconque raison certaines ressources n’ont pas à être déployées dans tous les environnements, vous pouvez conditionner leur application :
resource "hcloud_network" "privNet" { count = try(terraform.workspace == "prod") ? 1 : 0 name = terraform.workspace ip_range = var.subnets[terraform.workspace].subnet }
Ici, on ne créera le subnet que si l’on est « en prod ».
Et tant d’autres subtilités
Il y a certainement plein d’autres méthodes pour gérer son code terraform, je n’ai ici recensées que celles que j’ai pu voir en cinq ans de pratique (ça fait un an que j’en avais pas touché, ça fait bizarre et c’est toujours aussi rigolo à bricoler). Je suis d’ailleurs curieux de voir un peu comment vous procédez. D’ailleurs, si on va voir du côté de chez Julien Hommet on peut voir que la définition de la map va plus loin que mon simple « any » de gros feignant que je suis, je recommande évidemment sa syntaxe, plus prédictive. J’imagine qu’il y a d’autres aspects que je n’ai pas abordé ici et que vous pourriez certainement porter à ma connaissance (et à ceux qui lisent l’article du coup)