Du code Terraform de production
Pourquoi une infrastructure de production prend si longtemps, et comment structurer des modules petits, composables, testables et versionnés.
Construire une infrastructure de production (production-grade infrastructure) est difficile, stressant et chronophage. Par là, l'auteur entend l'ensemble des serveurs, magasins de données, répartiteurs de charge (load balancers), fonctions de sécurité, outils de supervision et d'alerte, pipelines de build et tous les autres rouages techniques nécessaires pour faire tourner une entreprise. Votre société parie sur vous : elle parie que votre infrastructure ne s'effondrera pas si le trafic augmente, ne perdra pas vos données en cas de panne, et ne les laissera pas être compromises quand des attaquants tenteront d'entrer. Si ce pari échoue, l'entreprise peut tout simplement mettre la clé sous la porte. C'est là l'enjeu réel de ce chapitre.
Pourquoi cela prend si longtemps
Avant toute chose, il faut accepter une vérité dérangeante : bâtir une infrastructure de production prend bien plus de temps qu'on ne l'imagine. Sur la base de centaines de missions, l'auteur propose des ordres de grandeur étonnamment longs, résumés dans le tableau ci-dessous.
| Type d'infrastructure | Exemple | Estimation |
|---|---|---|
| Service managé | Amazon RDS | 1 à 2 semaines |
| Système distribué auto-géré (sans état) | Un cluster d'applications Node.js | 2 à 4 semaines |
| Système distribué auto-géré (avec état) | Amazon Elasticsearch | 2 à 4 mois |
| Architecture complète | Applications, magasins de données, load balancers, supervision… | 6 à 36 mois |
Ces chiffres provoquent invariablement des réactions incrédules : « Comment cela peut-il prendre aussi longtemps ? », « Je déploie un serveur en quelques minutes, le reste ne peut pas prendre des mois ! », ou, de la part d'un ingénieur trop confiant, « Ces chiffres valent pour les autres, moi je boucle ça en quelques jours. » Pourtant, quiconque a traversé une migration cloud d'ampleur sait que ces estimations sont optimistes — un meilleur des cas. L'infrastructure et le DevOps sont l'illustration ultime de la loi de Hofstadter : « cela prend toujours plus de temps que prévu, même en tenant compte de la loi de Hofstadter ».
Trois raisons l'expliquent. D'abord, le DevOps en tant que discipline est encore à l'âge de pierre : les termes « cloud computing », « infrastructure as code » (IaC) et « DevOps » ne sont apparus qu'au milieu des années 2000, et des outils comme Terraform, Docker, Packer ou Kubernetes datent du milieu des années 2010. Tout y est récent, immature et change vite ; peu de gens ont une expérience profonde de ces outils. Ensuite, le DevOps est particulièrement sujet au « rasage de yak » (yak shaving) : cette cascade de petites tâches sans rapport apparent qu'il faut accomplir avant de pouvoir faire ce qu'on voulait vraiment faire. Vous partez déployer un correctif d'une coquille d'un caractère, ce qui déclenche un bug de configuration, qui révèle un problème de certificat TLS, qui mène à un système de déploiement défaillant, qui tient à une version obsolète de Linux — et vous voilà en train de mettre à jour le système d'exploitation de toute votre flotte de serveurs. Chaque changement dans le monde DevOps ressemble à tirer un seul câble USB d'une boîte de fils emmêlés : tout le reste vient avec.
Note
Les deux premières raisons relèvent de la complexité accidentelle (accidental complexity) — les problèmes imposés par les outils et processus choisis. La troisième relève de la complexité essentielle (essential complexity) : il existe une véritable liste de contrôle de tâches à accomplir pour préparer une infrastructure à la production, et la plupart des développeurs en ignorent la majeure partie. Quand ils estiment un projet, ils oublient un nombre considérable de détails critiques et chronophages.
La liste de contrôle de l'infrastructure de production
Voici une expérience instructive : faites le tour de votre entreprise et demandez « quelles sont les exigences pour passer en production ? ». Posez la question à cinq personnes et vous obtiendrez cinq réponses différentes. L'une parlera de métriques et d'alertes ; une autre de planification de capacité et de haute disponibilité ; une troisième de tests automatisés et de revues de code ; une quatrième de chiffrement, d'authentification et de durcissement (hardening) des serveurs ; et avec un peu de chance, quelqu'un pensera aux sauvegardes et à l'agrégation de logs. La plupart des entreprises n'ont pas de définition claire des prérequis de mise en production, si bien que chaque morceau d'infrastructure est déployé un peu différemment et peut manquer d'une fonctionnalité critique.
Le tableau suivant rassemble la liste de contrôle de l'infrastructure de production : la plupart des items clés à considérer avant de déployer en production.
| Tâche | Description | Outils typiques |
|---|---|---|
| Install | Installer les binaires logiciels et toutes leurs dépendances. | Bash, Chef, Ansible, Puppet |
| Configure | Configurer le logiciel à l'exécution : ports, certificats TLS, découverte de services, leaders/followers, réplication. | Bash, Chef, Ansible, Puppet |
| Provision | Provisionner l'infrastructure : serveurs, load balancers, réseau, pare-feu, permissions IAM. | Terraform, CloudFormation |
| Deploy | Déployer le service, déployer les mises à jour sans interruption : blue-green, rolling, canary. | Terraform, CloudFormation, Kubernetes, ECS |
| Haute disponibilité | Résister aux pannes de processus, serveurs, services, datacenters et régions. | Multi-datacenter, multirégion, réplication, auto scaling, load balancing |
| Scalabilité | Monter et descendre selon la charge, horizontalement et/ou verticalement. | Auto scaling, réplication, sharding, cache |
| Performance | Optimiser CPU, mémoire, disque, réseau, GPU : tuning de requêtes, benchmarks, tests de charge, profilage. | Dynatrace, valgrind, VisualVM, ab, JMeter |
| Réseau | IP statiques et dynamiques, ports, découverte de services, pare-feu, DNS, accès SSH et VPN. | VPC, firewalls, routeurs, registrars DNS, OpenVPN |
| Sécurité | Chiffrement en transit (TLS) et au repos, authentification, autorisation, gestion des secrets, durcissement. | ACM, Let's Encrypt, KMS, Cognito, Vault, CIS |
| Métriques | Métriques de disponibilité, métier, applicatives, serveur ; événements, observabilité, tracing, alerting. | CloudWatch, DataDog, New Relic, Honeycomb |
| Logs | Faire la rotation des logs sur disque, agréger les données vers un emplacement central. | CloudWatch Logs, ELK, Sumo Logic, Papertrail |
| Sauvegarde et restauration | Sauvegarder bases, caches et données régulièrement ; répliquer vers une région/un compte séparé. | RDS, ElastiCache, réplication |
| Optimisation des coûts | Bons types d'instances, spot et reserved instances, auto scaling, suppression des ressources inutiles. | Auto scaling, spot instances, reserved instances |
| Documentation | Documenter code, architecture et pratiques ; créer des playbooks de réponse aux incidents. | READMEs, wikis, Slack |
| Tests | Écrire des tests automatisés pour le code d'infrastructure ; les exécuter après chaque commit et chaque nuit. | Terratest, inspec, serverspec, kitchen-terraform |
La plupart des développeurs connaissent les premières tâches — install, configure, provision et deploy. Ce sont toutes les suivantes qui les prennent au dépourvu. Avez-vous réfléchi à la résilience de votre service si un serveur tombe ? Si un load balancer tombe ? Si un datacenter entier s'éteint ? Les tâches réseau sont notoirement délicates : VPC, VPN, découverte de services et accès SSH sont essentiels et peuvent prendre des mois, alors qu'ils sont souvent totalement absents des plans de projet. Les tâches de sécurité — chiffrer les données en transit via TLS, gérer l'authentification, stocker les secrets — sont elles aussi oubliées jusqu'à la dernière minute.
Astuce
Chaque fois que vous travaillez sur un nouveau morceau d'infrastructure, parcourez cette liste. Toutes les infrastructures n'ont pas besoin de chaque item, mais vous devez documenter consciemment et explicitement lesquels vous avez implémentés, lesquels vous avez décidé d'ignorer, et pourquoi.
De petits modules
Maintenant que la liste des tâches est claire, voyons les bonnes pratiques pour bâtir des modules réutilisables qui les implémentent. Les débutants en Terraform, et en IaC en général, définissent souvent toute leur infrastructure pour tous leurs environnements (Dev, Stage, Prod…) dans un seul fichier ou un seul module. C'est une mauvaise idée. L'auteur va même plus loin : les gros modules — ceux qui dépassent quelques centaines de lignes ou déploient plus de quelques composants étroitement liés — doivent être considérés comme nuisibles.
Les inconvénients sont nombreux. Les gros modules sont lents : sur un module qui définit toute l'infrastructure, le moindre terraform plan peut prendre cinq à six minutes. Ils sont peu sûrs : pour changer quoi que ce soit, il faut les droits d'accès à tout, ce qui force presque chaque utilisateur à être administrateur, à rebours du principe de moindre privilège. Ils sont risqués : tous les œufs dans le même panier, une erreur n'importe où peut tout casser — une coquille en modifiant une application frontale en staging, et vous supprimez la base de données de production. Ils sont difficiles à comprendre, difficiles à relire (qui lira un terraform plan de plusieurs milliers de lignes, et y repérera la petite ligne rouge signalant la suppression de la base ?), et difficiles à tester.
En bref, construisez votre code à partir de petits modules qui font chacun une seule chose. Ce conseil n'a rien de neuf ; vous l'avez déjà entendu, sous d'autres formes, notamment chez Robert C. Martin :
La première règle des fonctions est qu'elles doivent être petites. La seconde est qu'elles doivent être plus petites que ça.
Imaginez tomber sur une fonction de 20 000 lignes dans n'importe quel langage généraliste : vous y verriez immédiatement un code smell, et la refactoriseriez en une série de petites fonctions autonomes faisant chacune une chose. Appliquez la même stratégie à Terraform. Le module webserver-cluster construit jusqu'ici devient un peu gros et gère trois tâches assez indépendantes : il déploie un groupe d'autoscaling (Auto Scaling Group, ASG) capable de faire un déploiement progressif (rolling) sans interruption, il déploie un répartiteur de charge applicatif (Application Load Balancer, ALB), et il déploie une simple application « Hello, World ». Refactorisons-le en trois modules plus petits.
module webserver-cluster (gros, 3 responsabilités)
│
┌─────────────────────────┼──────────────────────────┐
▼ ▼ ▼
modules/cluster/ modules/networking/ modules/services/
asg-rolling-deploy alb hello-world-app
(ASG rolling deploy, (ALB générique, (cas d'usage spécifique :
générique, réutilisable) générique, réutilisable) compose les deux modules) Concrètement, on crée un dossier modules/cluster/asg-rolling-deploy et l'on y déplace les ressources de l'ASG (aws_launch_configuration, aws_autoscaling_group, les deux aws_autoscaling_schedule, le aws_security_group des instances, ses règles, et les deux aws_cloudwatch_metric_alarm), accompagnées des variables correspondantes. On crée ensuite modules/networking/alb et l'on y déplace les ressources de l'ALB (aws_lb, aws_lb_listener, le aws_security_group de l'ALB et ses règles), avec une unique variable d'entrée.
variable "alb_name" {
description = "The name to use for this ALB"
type = string
} resource "aws_lb" "example" {
name = var.alb_name
load_balancer_type = "application"
subnets = data.aws_subnet_ids.default.ids
security_groups = [aws_security_group.alb.id]
}
resource "aws_security_group" "alb" {
name = var.alb_name
} Des modules composables
Vous disposez désormais de deux petits modules — asg-rolling-deploy et alb — qui font chacun une chose et la font bien. Comment les faire travailler ensemble ? Comment bâtir des modules réutilisables et composables (composable) ? La question n'est pas propre à Terraform ; les programmeurs y réfléchissent depuis des décennies. Doug McIlroy, l'inventeur des tubes Unix, l'a résumée ainsi :
Voici la philosophie Unix : écrivez des programmes qui font une seule chose et la font bien. Écrivez des programmes qui travaillent ensemble.
Un moyen d'y parvenir est la composition de fonctions (function composition) : prendre les sorties d'une fonction pour les passer en entrées d'une autre. La clé est de minimiser les effets de bord (side effects) : éviter de lire l'état du monde extérieur, et le recevoir plutôt par des paramètres d'entrée ; éviter d'écrire vers le monde extérieur, et retourner plutôt le résultat via des paramètres de sortie. On ne peut pas totalement éliminer les effets de bord en infrastructure, mais on peut suivre les mêmes principes dans ses modules Terraform : tout passer par des variables d'entrée, tout retourner par des variables de sortie, et bâtir des modules plus complexes en combinant des modules plus simples.
On ajoute donc à asg-rolling-deploy quatre nouvelles variables d'entrée qui externalisent ce qui était auparavant codé en dur.
variable "subnet_ids" {
description = "The subnet IDs to deploy to"
type = list(string)
}
variable "target_group_arns" {
description = "The ARNs of ELB target groups in which to register Instances"
type = list(string)
default = []
}
variable "health_check_type" {
description = "The type of health check to perform. Must be one of: EC2, ELB."
type = string
default = "EC2"
}
variable "user_data" {
description = "The User Data script to run in each Instance at boot"
type = string
default = ""
} La variable subnet_ids indique au module dans quels sous-réseaux déployer : là où webserver-cluster était figé sur le VPC et les sous-réseaux par défaut, le module devient utilisable avec n'importe quel VPC. Les variables target_group_arns et health_check_type configurent l'intégration de l'ASG avec les load balancers — aucun, un ALB, plusieurs NLB, etc. Enfin, user_data permet de passer un script User Data : au lieu d'un script codé en dur ne servant qu'à l'application « Hello, World », l'ASG peut désormais déployer n'importe quelle application. On câble ces variables sur les ressources concernées.
resource "aws_autoscaling_group" "example" {
name = "${var.cluster_name}-${aws_launch_configuration.example.name}"
launch_configuration = aws_launch_configuration.example.name
vpc_zone_identifier = var.subnet_ids
# Configure integrations with a load balancer
target_group_arns = var.target_group_arns
health_check_type = var.health_check_type
min_size = var.min_size
max_size = var.max_size
min_elb_capacity = var.min_size
} Symétriquement, on expose des variables de sortie pour rendre les modules encore plus réutilisables : leurs consommateurs peuvent ainsi greffer de nouveaux comportements, par exemple attacher des règles personnalisées au security group.
output "asg_name" {
value = aws_autoscaling_group.example.name
description = "The name of the Auto Scaling Group"
}
output "instance_security_group_id" {
value = aws_security_group.instance.id
description = "The ID of the EC2 Instance Security Group"
} Dernière étape : transformer webserver-cluster en un module hello-world-app qui déploie l'application en composant asg-rolling-deploy et alb. C'est la composition de fonctions à l'œuvre : on construit un comportement complexe (l'application) à partir de briques plus simples.
module "asg" {
source = "../../cluster/asg-rolling-deploy"
cluster_name = "hello-world-${var.environment}"
ami = var.ami
user_data = data.template_file.user_data.rendered
instance_type = var.instance_type
min_size = var.min_size
max_size = var.max_size
enable_autoscaling = var.enable_autoscaling
subnet_ids = data.aws_subnet_ids.default.ids
target_group_arns = [aws_lb_target_group.asg.arn]
health_check_type = "ELB"
custom_tags = var.custom_tags
}
module "alb" {
source = "../../networking/alb"
alb_name = "hello-world-${var.environment}"
subnet_ids = data.aws_subnet_ids.default.ids
} La variable environment impose une convention de nommage : toutes les ressources sont préfixées par l'environnement (hello-world-stage, hello-world-prod). On branche la règle d'écoute de l'ALB sur la sortie du module alb, puis l'on fait remonter les sorties importantes des sous-modules comme sorties du module hello-world-app. Ce schéma fait apparaître deux familles de modules : les modules génériques (generic modules) comme asg-rolling-deploy et alb, briques de base réutilisables dans une grande variété de cas (un ASG pour Kafka, un ALB partagé entre plusieurs applications, ce qui coûte moins cher qu'un ALB par application) ; et les modules spécifiques à un cas d'usage (use-case-specific modules) comme hello-world-app, qui combinent plusieurs modules génériques pour servir un besoin précis.
À retenir
Dans la vraie vie, on découpe parfois encore plus finement pour mieux composer. Les modules open source pour HashiCorp Consul isolent ainsi leurs vingt-et-quelques règles de security group dans un module autonome consul-security-group-rules. En production, Consul tourne sur un ASG dédié (module consul-cluster) ; en staging, pour économiser, on le fait tourner sur le même ASG que Vault (module vault-cluster). Si les règles étaient enfermées dans consul-cluster, impossible de les réutiliser sans copier-coller ; isolées, on peut les attacher à vault-cluster ou presque n'importe quel cluster. Extraire les règles de security group, les politiques IAM et autres préoccupations transverses dans des modules autonomes est souvent essentiel pour supporter des schémas de déploiement variés.
Des modules testables
À ce stade, vous avez écrit trois modules : asg-rolling-deploy, alb et hello-world-app. Reste à vérifier qu'ils fonctionnent. Ces modules ne sont pas des modules racines (root modules) destinés à être déployés directement : pour cela, il faut du code Terraform qui fournit les arguments, configure le provider et le backend. Le meilleur moyen est de créer un dossier examples qui montre, comme son nom l'indique, comment utiliser vos modules.
provider "aws" {
region = "us-east-2"
}
module "asg" {
source = "../../modules/cluster/asg-rolling-deploy"
cluster_name = var.cluster_name
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
min_size = 1
max_size = 1
enable_autoscaling = false
subnet_ids = data.aws_subnet_ids.default.ids
}
data "aws_vpc" "default" {
default = true
}
data "aws_subnet_ids" "default" {
vpc_id = data.aws_vpc.default.id
} En quelques fichiers et lignes de code, ce minuscule exemple acquiert une puissance considérable. Il fournit un harnais de test manuel : pendant que vous travaillez sur le module, vous le déployez et le détruisez à répétition via terraform apply et terraform destroy pour vérifier son comportement. Il fournit un harnais de test automatisé : ce même code d'exemple sert de base aux tests automatisés (qui vont dans un dossier test). Et il fournit une documentation exécutable : si vous commitez l'exemple et son README.md en gestion de version, le reste de l'équipe peut le trouver, comprendre le module et l'essayer sans écrire une ligne — et, avec des tests automatisés autour, vous garantissez que ce matériel pédagogique fonctionne toujours.
La règle est simple : chaque module du dossier modules doit avoir un exemple correspondant dans examples, et chaque exemple un test correspondant dans test. On a souvent plusieurs exemples (et donc plusieurs tests) par module, illustrant différentes configurations. La structure d'un dépôt de modules typique ressemble à ceci.
modules
└ examples
└ alb
└ asg-rolling-deploy
└ one-instance
└ auto-scaling
└ with-load-balancer
└ custom-tags
└ hello-world-app
└ mysql
└ modules
└ alb
└ asg-rolling-deploy
└ hello-world-app
└ mysql
└ test
└ alb
└ asg-rolling-deploy
└ hello-world-app
└ mysql Astuce
Une excellente pratique consiste à écrire le code d'exemple en premier, avant la moindre ligne du module. Si vous commencez par l'implémentation, il est trop facile de se perdre dans les détails et de remonter à la surface avec une API peu intuitive. En partant de l'exemple, vous concevez d'abord l'expérience utilisateur idéale et l'API la plus propre, puis vous travaillez à rebours vers l'implémentation. Comme l'exemple est de toute façon le principal moyen de tester un module, c'est une forme de développement piloté par les tests (Test-Driven Development, TDD).
Épingler les versions
Une autre pratique devient indispensable dès qu'on teste régulièrement : l'épinglage de version (version pinning). Épinglez tous vos modules à une version précise de Terraform via l'argument required_version. Au strict minimum, exigez une version majeure spécifique.
terraform {
# Require any 0.12.x version of Terraform
required_version = ">= 0.12, < 0.13"
} C'est critique, car chaque version majeure de Terraform est rétro-incompatible : passer de 0.11.x à 0.12.x exige de nombreuses modifications de code, qu'on ne veut surtout pas déclencher par accident. Pour du code de production, l'auteur recommande d'épingler encore plus strictement, à une version exacte.
terraform {
# Require Terraform at exactly version 0.12.0
required_version = "= 0.12.0"
} Piège courant
Même un saut de version corrective (0.12.0 → 0.12.1) peut poser problème. Mais l'enjeu majeur est qu'une fois un fichier d'état (state) écrit avec une version plus récente, il n'est plus utilisable avec aucune version antérieure. Si tout est déployé en 0.12.0 et qu'un développeur qui a 0.12.1 installé lance terraform apply, les fichiers d'état concernés ne fonctionnent plus en 0.12.0 — vous voilà forcé de mettre à jour tous les postes et tous les serveurs CI d'un coup. En épinglant à une version exacte, c'est vous qui choisissez le moment de la mise à jour.
Il faut de même épingler les versions de providers. Selon le provider, on épingle à une version majeure ou exacte selon sa qualité de rétrocompatibilité. Le provider AWS, par exemple, se met à jour souvent en maintenant bien la compatibilité ; on l'épingle donc à une majeure tout en laissant remonter automatiquement les correctifs.
provider "aws" {
region = "us-east-2"
# Allow any 2.x version of the AWS provider
version = "~> 2.0"
} La syntaxe ~> 2.0 équivaut à « version >= 2.0 et < 3.0 ».
Des modules publiables
Une fois écrits et testés, les modules sont prêts à être publiés (releasable). Le moyen le plus simple est d'utiliser des tags Git avec du versionnage sémantique (semantic versioning).
git tag -a "v0.0.5" -m "Create new hello-world-app module"
git push --follow-tags On déploie alors une version précise du module dans un environnement en référençant le tag via le paramètre ref de l'URL source.
provider "aws" {
region = "us-east-2"
# Allow any 2.x version of the AWS provider
version = "~> 2.0"
}
module "hello_world_app" {
# TODO: replace this with your own module URL and version!!
source = "git@github.com:foo/modules.git//services/hello-world-app?ref=v0.0.5"
server_text = "New server text"
environment = "stage"
db_remote_state_bucket = "(YOUR_BUCKET_NAME)"
db_remote_state_key = "stage/data-stores/mysql/terraform.tfstate"
instance_type = "t2.micro"
min_size = 2
max_size = 2
enable_autoscaling = false
} Si le déploiement se passe bien, vous pouvez ensuite déployer exactement la même version — donc exactement le même code — dans les autres environnements, production comprise. Et en cas de problème, le versionnage permet de revenir en arrière en déployant une version antérieure.
Une autre option consiste à publier dans le registre Terraform (Terraform Registry). Le registre public héberge des centaines de modules open source maintenus par la communauté pour AWS, Google Cloud, Azure et bien d'autres providers. Pour y publier, quelques exigences : le module doit vivre dans un dépôt GitHub public, nommé selon la convention terraform-<PROVIDER>-<NAME> (où PROVIDER est par exemple aws et NAME le nom du module) ; il doit suivre une structure de fichiers précise, avec du code Terraform à la racine, un README.md et les fichiers conventionnels main.tf, variables.tf et outputs.tf ; et le dépôt doit utiliser des tags Git en versionnage sémantique (x.y.z). Terraform offre alors une syntaxe spéciale plus courte pour consommer ces modules, qui sépare la source de la version.
module "vault" {
source = "hashicorp/vault/aws"
version = "0.12.2"
# (...)
} Les clients de Terraform Enterprise peuvent reproduire cette expérience avec un registre privé (Private Terraform Registry), hébergé dans leurs propres dépôts Git et accessible à leur seule équipe — un excellent moyen de partager des modules en interne.
Au-delà des modules Terraform
Bien que ce livre porte sur Terraform, bâtir une infrastructure de production complète exige d'autres outils : Docker, Packer, Chef, Puppet et, bien sûr, le ruban adhésif du monde DevOps, le fidèle script Bash. L'essentiel de ce code peut résider dans le dossier modules, aux côtés du code Terraform — ainsi le dépôt Vault contient-il à la fois des modules Terraform et des scripts Bash comme run-vault, exécuté au démarrage du serveur via User Data. Mais il arrive qu'on doive exécuter du code non-Terraform directement depuis un module, soit pour s'intégrer à un autre système, soit pour contourner une limitation de Terraform (une API de provider manquante, une logique trop complexe pour un langage déclaratif). Terraform offre pour cela quelques « trappes d'évasion » (escape hatches) : les provisioners, les provisioners avec null_resource, et la source de données externe.
Les provisioners
Les provisioners Terraform exécutent des scripts, sur la machine locale ou distante, au moment où vous lancez Terraform — typiquement pour du bootstrapping, de la gestion de configuration ou du nettoyage. Il en existe plusieurs : local-exec (script local), remote-exec (script sur une ressource distante), chef, ou file (copie de fichiers). On les déclare dans un bloc provisioner au sein d'une ressource.
resource "aws_instance" "example" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
provisioner "local-exec" {
command = "echo "Hello, World from $(uname -smp)""
}
} Un provisioner remote-exec est plus délicat : le client Terraform doit pouvoir communiquer avec l'instance EC2 sur le réseau (via un security group ouvrant le port 22 pour SSH) et s'y authentifier (via des clés SSH). On utilise un argument inline pour passer une liste de commandes, puis un bloc connection pour indiquer comment se connecter.
resource "aws_instance" "example" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
vpc_security_group_ids = [aws_security_group.instance.id]
key_name = aws_key_pair.generated_key.key_name
provisioner "remote-exec" {
inline = ["echo "Hello, World from $(uname -smp)""]
}
connection {
type = "ssh"
host = self.public_ip
user = "ubuntu"
private_key = tls_private_key.example.private_key_pem
}
} Notez le mot-clé self, utilisable uniquement dans les blocs connection et provisioner, pour référencer un attribut de la ressource englobante : une référence du type aws_instance.example vers elle-même provoquerait une erreur de dépendance circulaire. Par défaut, un provisioner est un provisioner de création (creation-time provisioner) : il ne s'exécute qu'au premier terraform apply, lors de la création initiale de la ressource. Avec when = "destroy", il devient un provisioner de destruction, exécuté juste avant la suppression. L'argument on_failure indique s'il faut ignorer une erreur ("continue") ou tout abandonner ("abort").
Note
Provisioners contre User Data. L'auteur juge généralement le script User Data plus utile que le provisioner remote-exec. User Data n'exige que l'accès à l'API AWS (que vous avez déjà), sans ouvrir SSH ou WinRM ; il fonctionne avec les ASG, garantissant que chaque serveur exécute le script au démarrage, y compris ceux lancés par un événement d'auto scaling — alors que les provisioners n'agissent que pendant l'exécution de Terraform et ne marchent pas du tout avec les ASG ; enfin, on peut le consulter dans la console EC2 et trouver son log d'exécution sur l'instance. Les provisioners n'ont que deux avantages : ils ne sont pas limités à 16 Ko comme User Data, et les provisioners Chef, Puppet et Salt installent et lancent automatiquement leurs clients respectifs.
Provisioners avec null_resource
Un provisioner ne peut être défini que dans une ressource ; pour en exécuter un sans le lier à une ressource réelle, on utilise le null_resource, qui se comporte comme une ressource normale mais ne crée rien. Son argument triggers accepte une map : dès que ses valeurs changent, le null_resource est recréé, forçant la réexécution de ses provisioners. En combinant triggers avec la fonction uuid(), qui retourne un UUID neuf à chaque appel, on force l'exécution à chaque terraform apply.
resource "null_resource" "example" {
# Use UUID to force this null_resource to be recreated on every
# call to 'terraform apply'
triggers = {
uuid = uuid()
}
provisioner "local-exec" {
command = "echo "Hello, World from $(uname -smp)""
}
} La source de données externe
Parfois, on cherche non pas à exécuter un script, mais à récupérer des données pour les rendre disponibles dans le code Terraform. La source de données externe (external data source) le permet : un programme externe qui implémente un protocole simple agit comme une data source. Terraform lui passe des données en JSON via l'argument query (lu sur stdin) ; le programme renvoie des données en écrivant du JSON sur stdout, accessible ensuite via l'attribut result.
data "external" "echo" {
program = ["bash", "-c", "cat /dev/stdin"]
query = {
foo = "bar"
}
}
output "echo" {
value = data.external.echo.result
}
output "echo_foo" {
value = data.external.echo.result.foo
} echo = {
"foo" = "bar"
}
echo_foo = bar Attention
La source de données externe est une jolie trappe d'évasion quand aucune data source existante ne sait récupérer la donnée voulue. Mais restez conservateur dans l'usage de ce mécanisme et de toutes les autres trappes d'évasion de Terraform : elles rendent le code moins portable et plus fragile. L'exemple ci-dessus dépend de Bash, ce qui empêche de déployer ce module depuis Windows.
Mettre tout cela ensemble
Vous connaissez désormais tous les ingrédients du code Terraform de production. La prochaine fois que vous attaquez un nouveau module, suivez ce processus : (1) parcourez la liste de contrôle de production, identifiez explicitement les items que vous implémentez et ceux que vous ignorez, puis combinez ce résultat aux estimations de durée pour donner un chiffre réaliste à votre direction ; (2) créez un dossier examples et écrivez le code d'exemple d'abord, pour définir la meilleure expérience utilisateur et l'API la plus propre ; (3) créez un dossier modules et implémentez cette API sous forme de petits modules réutilisables et composables, en mêlant Terraform à d'autres outils (Docker, Packer, Bash) et en épinglant vos versions de Terraform et de providers ; (4) créez un dossier test et écrivez des tests automatisés pour chaque exemple — sujet du chapitre suivant.
À retenir
- Bâtir une infrastructure de production prend bien plus longtemps qu'on ne l'imagine : d'une à deux semaines pour un service managé, jusqu'à 6 à 36 mois pour une architecture complète. La cause profonde est la complexité essentielle — une longue liste de tâches que la plupart des développeurs ignorent en estimant un projet.
- Parcourez la liste de contrôle de production (install, configure, provision, deploy, haute disponibilité, scalabilité, performance, réseau, sécurité, métriques, logs, sauvegarde, coûts, documentation, tests) pour chaque morceau d'infrastructure, et documentez explicitement ce que vous implémentez et ce que vous ignorez.
- Construisez de petits modules qui font chacun une seule chose : les gros modules sont lents, peu sûrs, risqués, difficiles à comprendre, à relire et à tester.
- Rendez les modules composables : tout passer par des variables d'entrée, tout retourner par des sorties, et bâtir des modules complexes (spécifiques à un cas d'usage) en combinant des modules simples (génériques) — c'est la composition de fonctions appliquée à l'infrastructure.
- Rendez les modules testables via un dossier
examples(un exemple par module, un test par exemple) ; écrivez l'exemple avant l'implémentation, et épinglez les versions de Terraform et de providers pour éviter les mises à jour accidentelles d'état. - Rendez les modules publiables via des tags Git en versionnage sémantique ou le registre Terraform, ce qui permet de déployer exactement le même code partout et de revenir en arrière en cas de problème.
- Au-delà de Terraform, les trappes d'évasion (provisioners,
null_resource, source de données externe) exécutent du code non-Terraform — à utiliser avec parcimonie, car elles rendent le code moins portable ; préférez souvent User Data au provisionerremote-exec.