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)