Terraform: Up & Running
Chapitre 2 / 8 · 23 min de lecture

Démarrer avec Terraform

Déployer pas à pas un serveur, puis un cluster auto-scalé derrière un load balancer sur AWS — providers, ressources, variables et outputs.

Terraform est un outil facile à prendre en main : en une quarantaine de pages, on passe de sa toute première commande au déploiement d'un cluster de serveurs web placé derrière un répartiteur de charge qui distribue le trafic entre eux. Ce socle est un excellent point de départ pour faire tourner des services web scalables et hautement disponibles, et nous le ferons évoluer dans les chapitres suivants. Terraform sait provisionner de l'infrastructure chez de nombreux fournisseurs (cloud public comme AWS, Azure, Google Cloud, DigitalOcean ; cloud privé et virtualisation comme OpenStack ou VMware), mais dans tout ce chapitre — et la quasi-totalité du livre — nous utiliserons AWS (Amazon Web Services), le fournisseur le plus répandu, doté d'un généreux palier gratuit (free tier) qui permet de faire tourner ces exemples sans (presque) rien dépenser.

Préparer son compte AWS

Si vous n'avez pas encore de compte AWS, créez-en un. À la première inscription, vous vous connectez en tant qu'utilisateur racine (root user) : ce compte a tous les droits, et l'utiliser au quotidien est une mauvaise pratique du point de vue de la sécurité. La seule chose à faire avec le root user est de créer d'autres comptes aux permissions plus limitées, puis de basculer immédiatement sur l'un d'eux.

C'est le service IAM (Identity and Access Management) qui gère les comptes d'utilisateurs et leurs permissions. Dans la console IAM, créez un nouvel utilisateur en veillant à générer une clé d'accès (access key) pour lui : AWS vous montre alors un Access Key ID et un Secret Access Key. Sauvegardez-les sur-le-champ — ils ne seront plus jamais réaffichés — et stockez-les dans un endroit sûr, par exemple un gestionnaire de mots de passe. Par défaut, un nouvel utilisateur IAM n'a strictement aucune permission ; pour lui en accorder, on lui associe une ou plusieurs politiques IAM (IAM Policies), des documents JSON décrivant ce qu'il a le droit de faire. Pour ce chapitre, la politique gérée (Managed Policy) AmazonEC2FullAccess suffit ; les chapitres suivants en exigeront d'autres (AmazonS3FullAccess, AmazonDynamoDBFullAccess, etc.).

Note

Presque toutes les ressources AWS se déploient dans un VPC (Virtual Private Cloud), une zone isolée de votre compte dotée de son propre réseau virtuel et de sa plage d'adresses IP. Si vous ne précisez rien, la ressource atterrit dans le VPC par défaut (Default VPC), présent dans chaque nouveau compte. Tous les exemples de ce chapitre s'appuient sur ce VPC par défaut ; si vous l'avez supprimé, changez de région ou recréez-le, faute de quoi il faudrait ajouter un paramètre vpc_id ou subnet_id à presque chaque exemple.

Installer Terraform

Téléchargez Terraform depuis sa page d'accueil, dézippez l'archive — elle ne contient qu'un seul binaire nommé terraform — et ajoutez-le à votre variable d'environnement PATH. Sur macOS, brew install terraform fait aussi l'affaire. Tapez terraform sans argument pour vérifier que tout fonctionne : vous devriez voir la liste des commandes (apply, console, destroy, fmt, et les autres).

Pour que Terraform puisse agir sur votre compte AWS, exposez les identifiants de l'utilisateur IAM via deux variables d'environnement :

$ export AWS_ACCESS_KEY_ID=(votre access key id)
$ export AWS_SECRET_ACCESS_KEY=(votre secret access key)

Ces variables ne valent que pour le shell courant : après un redémarrage ou dans un nouveau terminal, il faudra les réexporter. Terraform supporte par ailleurs les mêmes mécanismes d'authentification que la CLI et les SDK AWS — fichier $HOME/.aws/credentials, rôles IAM (IAM Roles), etc.

Déployer un serveur unique

Le code Terraform s'écrit dans le langage HCL (HashiCorp Configuration Language), dans des fichiers à l'extension .tf. C'est un langage déclaratif : votre but est de décrire l'infrastructure souhaitée, et Terraform se charge de déterminer comment la créer. La première étape consiste presque toujours à configurer le ou les fournisseurs (provider) que l'on veut utiliser. Créez un dossier vide et placez-y un fichier main.tf :

provider "aws" {
  region = "us-east-2"
}

Cela indique à Terraform d'utiliser AWS et de déployer dans la région us-east-2 (Ohio). AWS regroupe ses datacenters en régions — zones géographiques distinctes comme eu-west-1 (Irlande) ou ap-southeast-2 (Sydney) — chacune subdivisée en plusieurs zones de disponibilité (Availability Zones, AZ) isolées les unes des autres, comme us-east-2a ou us-east-2b.

Pour chaque fournisseur, il existe quantité de types de ressources (resource) à créer — serveurs, bases de données, load balancers. La syntaxe générale est la suivante : on déclare resource "<PROVIDER>_<TYPE>" "<NOM>" suivi d'un bloc de configuration. Le PROVIDER est le nom du fournisseur (aws), le TYPE le genre de ressource (instance), le NOM un identifiant interne pour y faire référence ailleurs dans le code, et le corps regroupe les arguments propres à cette ressource. Pour déployer un serveur virtuel unique sur AWS — une instance EC2 (Elastic Compute Cloud) — on utilise la ressource aws_instance :

resource "aws_instance" "example" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"
}

Cette ressource accepte des dizaines d'arguments, mais deux suffisent pour l'instant. ami désigne l'image machine Amazon (Amazon Machine Image) à exécuter sur l'instance ; ici, l'ID d'une image Ubuntu 18.04, gratuite, disponible dans us-east-2. instance_type désigne le gabarit de l'instance : t2.micro (un vCPU, 1 Go de mémoire) fait partie du free tier.

Astuce

Terraform supporte des dizaines de fournisseurs, chacun avec des dizaines de ressources, chacune avec des dizaines d'arguments : impossible de tout retenir. Gardez la documentation Terraform ouverte en permanence et consultez la page de chaque ressource (par exemple celle de aws_instance). Même après des années de pratique, on l'ouvre plusieurs fois par jour.

Le cycle init / plan / apply

Placez-vous dans le dossier contenant main.tf et lancez terraform init. Le binaire terraform ne contient que les fonctionnalités de base : il n'embarque le code d'aucun fournisseur. La commande init scanne votre code, repère les providers utilisés et télécharge leur code dans un dossier de travail .terraform (que vous voudrez ajouter à votre .gitignore). Il faut lancer init chaque fois qu'on démarre sur du nouveau code ; la commande est idempotente, donc sans danger à relancer.

$ terraform init

Initializing the backend...
Initializing provider plugins...
- Downloading plugin for provider "aws" (terraform-providers/aws) 2.10.0...

Terraform has been successfully initialized!

Une fois le code du provider téléchargé, lancez terraform plan. Cette commande vous montre ce que Terraform ferait avant toute modification réelle — un excellent moyen de vérifier votre code avant de le lâcher dans la nature. Sa sortie ressemble à celle de diff sous Unix ou Git : tout ce qui porte un + sera créé, un - supprimé, un ~ modifié sur place.

$ terraform plan

Terraform will perform the following actions:

  # aws_instance.example will be created
  + resource "aws_instance" "example" {
      + ami                          = "ami-0c55b159cbfafe1f0"
      + arn                          = (known after apply)
      + instance_type                = "t2.micro"
      + id                           = (known after apply)
      (...)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Une seule instance EC2 sera créée, rien d'autre : exactement ce que l'on veut. Pour la créer réellement, lancez terraform apply. La commande réaffiche le même plan et demande confirmation ; tapez yes puis Entrée.

$ terraform apply

(...)
Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Enter a value: yes

aws_instance.example: Creating...
aws_instance.example: Still creating... [10s elapsed]
aws_instance.example: Creation complete after 38s [id=i-07e2a3e006d785906]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

À retenir

Bien que plan existe comme commande à part, il sert surtout aux vérifications rapides et aux revues de code. La plupart du temps, on lance directement apply et on relit le plan qu'il affiche avant de confirmer. Retenez le cycle fondamental : init une fois pour amorcer, puis la boucle écrire le code → plan → apply pour chaque changement.

Votre instance n'a pas de nom. Pour lui en donner un, ajoutez des étiquettes (tags) à la ressource :

resource "aws_instance" "example" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"

  tags = {
    Name = "terraform-example"
  }
}

Relancez apply : Terraform indique Refreshing state..., car il garde la trace des ressources déjà créées pour ce jeu de fichiers. Il sait donc que l'instance existe déjà et affiche un diff (~) entre l'état déployé et votre code, ne voulant créer que le tag Name — un avantage majeur d'un langage déclaratif sur un langage procédural. Une fois ce code en place, stockez-le en gestion de version (git init, git add, git commit) et ajoutez un .gitignore qui exclut le dossier .terraform et les fichiers *.tfstate — ces derniers stockent l'état (state) et ne doivent jamais être commités (on verra pourquoi au chapitre 3).

Déployer un serveur web

L'étape suivante consiste à faire tourner un serveur web sur cette instance. Pour rester simple, on lance un serveur minimaliste qui renvoie toujours « Hello, World » :

#!/bin/bash
echo "Hello, World" > index.html
nohup busybox httpd -f -p 8080 &

Ce script Bash écrit le texte dans index.html et lance busybox (installé par défaut sur Ubuntu) pour servir ce fichier sur le port 8080. Les nohup et & font tourner le serveur en arrière-plan, en permanence, tandis que le script lui-même peut se terminer.

Note

Pourquoi le port 8080 plutôt que le port HTTP standard 80 ? Parce qu'écouter sur un port inférieur à 1024 exige les privilèges du root user, ce qui est un risque de sécurité : tout attaquant qui compromet le serveur hérite alors de ces privilèges. La bonne pratique est de faire tourner le serveur sous un utilisateur non privilégié, donc sur un port élevé — et de configurer plus tard un load balancer qui écoute sur le port 80 et route vers ces ports élevés.

Comment l'instance EC2 exécute-t-elle ce script ? En l'occurrence, le serveur tient en une ligne, donc inutile de fabriquer une AMI personnalisée avec Packer : il suffit de passer le script en données utilisateur (User Data) de l'instance, via l'argument user_data. Ce code s'exécute au démarrage de l'instance.

resource "aws_instance" "example" {
  ami                    = "ami-0c55b159cbfafe1f0"
  instance_type          = "t2.micro"
  vpc_security_group_ids = [aws_security_group.instance.id]

  user_data = <<-EOF
              #!/bin/bash
              echo "Hello, World" > index.html
              nohup busybox httpd -f -p 8080 &
              EOF

  tags = {
    Name = "terraform-example"
  }
}

Les <<-EOF et EOF constituent la syntaxe heredoc de Terraform, qui permet d'écrire des chaînes multilignes sans semer des caractères de saut de ligne partout. Mais une chose manque encore : par défaut, AWS n'autorise aucun trafic entrant ni sortant vers une instance EC2. Pour qu'elle reçoive du trafic sur le port 8080, il faut créer un groupe de sécurité (security group) :

resource "aws_security_group" "instance" {
  name = "terraform-example-instance"

  ingress {
    from_port   = 8080
    to_port     = 8080
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Ce groupe autorise les requêtes TCP entrantes sur le port 8080 depuis le bloc CIDR 0.0.0.0/0. Les blocs CIDR sont une notation concise pour des plages d'adresses IP : 10.0.0.0/24 représente toutes les IP entre 10.0.0.0 et 10.0.0.255, et 0.0.0.0/0 englobe toutes les adresses possibles — autrement dit, n'importe quelle IP peut atteindre le port 8080.

Références et graphe de dépendances

Créer le groupe de sécurité ne suffit pas : il faut indiquer à l'instance de l'utiliser, en passant son ID à l'argument vpc_security_group_ids. Pour cela, on emploie une expression Terraform — tout ce qui retourne une valeur. Le type le plus utile ici est la référence, qui donne accès à des valeurs définies ailleurs dans le code. La référence à un attribut de ressource suit la syntaxe <PROVIDER>_<TYPE>.<NOM>.<ATTRIBUT> : le groupe de sécurité exporte un attribut id, donc on y accède par aws_security_group.instance.id (visible dans le bloc aws_instance ci-dessus).

Ajouter une référence d'une ressource vers une autre crée une dépendance implicite. Terraform analyse ces dépendances, en construit un graphe et en déduit automatiquement l'ordre de création : ici, il sait qu'il doit créer le groupe de sécurité avant l'instance EC2, puisque celle-ci référence son ID. La commande terraform graph produit ce graphe au format DOT (que l'on peut transformer en image). Quand il parcourt l'arbre, Terraform crée en parallèle autant de ressources que possible : on décrit le quoi, Terraform trouve la manière la plus efficace de le réaliser.

Lancez apply. Terraform veut créer le groupe de sécurité et remplacer l'instance par une nouvelle dotée des données utilisateur. Le -/+ du plan signifie « remplacer » ; cherchez la mention « forces replacement » pour comprendre ce qui le déclenche.

$ terraform apply

  # aws_instance.example must be replaced
-/+ resource "aws_instance" "example" {
      ami                          = "ami-0c55b159cbfafe1f0"
    ~ availability_zone            = "us-east-2c" -> (known after apply)
    + user_data                    = "c765373..." # forces replacement
      (...)
  }

Plan: 2 to add, 0 to change, 1 to destroy.

De nombreux arguments de aws_instance forcent un remplacement : l'instance d'origine est terminée et une toute nouvelle est créée — c'est le paradigme de l'infrastructure immuable (immutable infrastructure). Le serveur étant remplacé, ses utilisateurs subiraient une interruption ; on verra comment réaliser un déploiement sans coupure (zero-downtime) au chapitre 5. Le plan étant correct, tapez yes. Récupérez l'IP publique de la nouvelle instance dans la console EC2, laissez-lui une minute ou deux pour démarrer, puis testez :

$ curl http://<EC2_INSTANCE_PUBLIC_IP>:8080
Hello, World

Vous avez désormais un serveur web fonctionnel dans AWS.

Attention

Ces exemples se déploient dans le VPC par défaut et ses sous-réseaux (subnets) publics, qui obtiennent des IP accessibles depuis Internet — pratique pour un test, mais c'est un risque en conditions réelles. Des attaquants scannent en permanence les IP au hasard ; un seul port laissé ouvert par mégarde, ou un logiciel vulnérable, suffit. En production, déployez serveurs et data stores dans des sous-réseaux privés (private subnets), inaccessibles depuis Internet, et ne laissez en sous-réseaux publics qu'un petit nombre de reverse proxies et de load balancers verrouillés au maximum.

Rendre le serveur web configurable

Le numéro de port 8080 est dupliqué dans le groupe de sécurité et dans les données utilisateur, ce qui viole le principe DRY (Don't Repeat Yourself) : si on change le port à un endroit en oubliant l'autre, tout casse. Pour rendre le code plus DRY et configurable, Terraform permet de définir des variables d'entrée (input variables). Une déclaration variable accepte trois paramètres, tous optionnels :

  • description documente l'usage de la variable ; elle s'affiche lors des commandes plan et apply.
  • default fournit une valeur de repli. À défaut, on peut passer la valeur en ligne de commande (-var), via un fichier (-var-file) ou une variable d'environnement nommée TF_VAR_<nom>. Sans valeur ni défaut, Terraform demande la valeur de façon interactive.
  • type impose une contrainte de type. Terraform supporte string, number, bool, list, map, set, object, tuple et any (le type par défaut).

On peut composer les types — par exemple list(number) exige une liste de nombres, map(string) une table dont les valeurs sont des chaînes — voire construire des types structurels avec object(...) et tuple(...). Tenter d'affecter une valeur incompatible déclenche une erreur de type immédiate. Pour notre serveur, une simple variable de port suffit :

variable "server_port" {
  description = "The port the server will use for HTTP requests"
  type        = number
  default     = 8080
}

Sans default, apply demanderait interactivement la valeur ; on pourrait aussi la fournir par terraform plan -var "server_port=8080" ou via export TF_VAR_server_port=8080. Pour exploiter une variable dans le code, on emploie une référence de variable de la forme var.<NOM>. Voici comment l'utiliser dans le groupe de sécurité :

resource "aws_security_group" "instance" {
  name = "terraform-example-instance"

  ingress {
    from_port   = var.server_port
    to_port     = var.server_port
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Pour insérer une référence à l'intérieur d'une chaîne, on recourt à l'interpolation, de la forme dollar-accolades, dans laquelle on place une référence valide que Terraform convertit en chaîne. Dans le script de données utilisateur, cela donne :

user_data = <<-EOF
            #!/bin/bash
            echo "Hello, World" > index.html
            nohup busybox httpd -f -p ${var.server_port} &
            EOF

Les variables de sortie

Terraform permet aussi de définir des variables de sortie (output variables) avec output "<NOM>", dont le value peut être n'importe quelle expression. Le paramètre optionnel description documente la donnée ; sensitive = true empêche Terraform de la journaliser en fin d'apply (utile pour des mots de passe ou clés privées). Plutôt que d'aller fouiller la console EC2 pour trouver l'IP du serveur, exposez-la en sortie :

output "public_ip" {
  value       = aws_instance.example.public_ip
  description = "The public IP address of the web server"
}

Au prochain apply, Terraform n'applique aucun changement (rien n'a bougé) mais affiche la sortie à la toute fin. On peut aussi lister les sorties sans rien appliquer avec terraform output, ou cibler une sortie précise :

$ terraform output public_ip
54.174.13.5

C'est très pratique pour le scripting : un script de déploiement peut lancer apply, récupérer l'IP via terraform output public_ip, puis exécuter un curl dessus comme test de fumée (smoke test). Variables d'entrée et de sortie sont les ingrédients essentiels d'un code d'infrastructure configurable et réutilisable, sujet du chapitre 4.

Déployer un cluster de serveurs web

Faire tourner un seul serveur, c'est un bon début, mais c'est un point unique de défaillance (single point of failure) : s'il tombe ou se sature, le site devient inaccessible. La solution est un cluster de serveurs, capable de contourner les machines en panne et d'ajuster sa taille selon le trafic. Gérer un tel cluster à la main représente beaucoup de travail ; heureusement, AWS s'en charge via un groupe d'auto-scaling (Auto Scaling Group, ASG), qui lance le cluster, surveille la santé de chaque instance, remplace celles qui échouent et ajuste la taille en fonction de la charge.

La première étape est de créer une configuration de lancement (launch configuration), qui décrit comment configurer chaque instance de l'ASG. La ressource aws_launch_configuration reprend presque les mêmes paramètres que aws_instance (deux changent de nom : ami devient image_id, et vpc_security_group_ids devient security_groups) :

resource "aws_launch_configuration" "example" {
  image_id        = "ami-0c55b159cbfafe1f0"
  instance_type   = "t2.micro"
  security_groups = [aws_security_group.instance.id]

  user_data = <<-EOF
              #!/bin/bash
              echo "Hello, World" > index.html
              nohup busybox httpd -f -p ${var.server_port} &
              EOF

  # Indispensable avec une launch configuration dans un ASG.
  lifecycle {
    create_before_destroy = true
  }
}

Pourquoi le bloc lifecycle ? Les launch configurations sont immuables : changer un paramètre force Terraform à en créer une nouvelle. Or, normalement, Terraform supprime l'ancienne ressource avant de créer la nouvelle — impossible ici, puisque l'ASG y fait référence. Chaque ressource Terraform supporte des réglages de cycle de vie (lifecycle) ; create_before_destroy = true inverse l'ordre : Terraform crée d'abord le remplacement (et met à jour les références) avant de supprimer l'ancienne ressource.

Sources de données pour les sous-réseaux

L'ASG a besoin d'un paramètre subnet_ids indiquant dans quels sous-réseaux du VPC déployer les instances. Chaque sous-réseau vit dans une AZ isolée ; en répartissant les instances sur plusieurs sous-réseaux, le service survit à la panne d'un datacenter. Plutôt que de coder en dur la liste des sous-réseaux — peu maintenable et peu portable — on utilise des sources de données (data sources).

Une source de données représente une information en lecture seule récupérée auprès du fournisseur à chaque exécution de Terraform. Elle ne crée rien : elle interroge les API du provider et met le résultat à disposition du reste du code. Le fournisseur AWS expose quantité de sources de données (données de VPC, de sous-réseaux, IDs d'AMI, identité de l'utilisateur courant…). La syntaxe ressemble à celle des ressources : on déclare data "<PROVIDER>_<TYPE>" "<NOM>". Voici comment récupérer le VPC par défaut, puis ses sous-réseaux :

data "aws_vpc" "default" {
  default = true
}

data "aws_subnet_ids" "default" {
  vpc_id = data.aws_vpc.default.id
}

Avec une source de données, les arguments passés sont généralement des filtres de recherche : default = true demande à Terraform de trouver le VPC par défaut. On lit ensuite ses attributs via la syntaxe data.<PROVIDER>_<TYPE>.<NOM>.<ATTRIBUT> — par exemple data.aws_vpc.default.id. On peut alors créer l'ASG et lui passer les sous-réseaux via l'argument (curieusement nommé) vpc_zone_identifier :

resource "aws_autoscaling_group" "example" {
  launch_configuration = aws_launch_configuration.example.name
  vpc_zone_identifier  = data.aws_subnet_ids.default.ids

  min_size = 2
  max_size = 10

  tag {
    key                 = "Name"
    value               = "terraform-asg-example"
    propagate_at_launch = true
  }
}

Cet ASG fera tourner entre 2 et 10 instances (2 au lancement initial), chacune étiquetée terraform-asg-example.

Déployer un répartiteur de charge

L'ASG en place, un problème subsiste : on a plusieurs serveurs, chacun avec sa propre IP, alors qu'on veut offrir aux utilisateurs une seule adresse. La solution est de déployer un répartiteur de charge (load balancer) qui distribue le trafic entre les serveurs et présente à tous une seule adresse (en fait, un nom DNS). Là encore, AWS s'en occupe via son service de répartition de charge élastique. AWS propose trois types :

TypeUsage idéalCouche OSI
ALB (Application Load Balancer)trafic HTTP / HTTPSapplication (couche 7)
NLB (Network Load Balancer)trafic TCP / UDP / TLS, très haut débittransport (couche 4)
CLB (Classic Load Balancer)l'ancêtre, bien moins de fonctionnalitéscouches 4 et 7

Notre serveur étant une application HTTP sans exigences de performance extrêmes, l'ALB est le meilleur choix. Un ALB se compose de plusieurs parties : un écouteur (listener) qui écoute sur un port et un protocole donnés ; des règles d'écouteur (listener rules) qui aiguillent les requêtes selon leur chemin ou leur nom d'hôte vers tel ou tel groupe cible (target group) ; et les groupes cibles eux-mêmes, qui regroupent les serveurs recevant les requêtes et effectuent leurs contrôles de santé (health checks).

                      ┌──────────────────────────────────────┐
   Utilisateurs ─80──►│  ALB                                  │
                      │  ┌──────────┐   ┌──────────────────┐  │
                      │  │ Listener │──►│  Listener rules   │  │
                      │  │  :80     │   │  (path-pattern *) │  │
                      │  └──────────┘   └────────┬──────────┘  │
                      └─────────────────────────┼─────────────┘

                                    ┌────────────────────────┐
                                    │  Target group (HTTP)   │
                                    │  health check /  → 200 │
                                    └───┬───────────┬────────┘
                                        ▼           ▼
                                   EC2 (ASG)   EC2 (ASG)  …

La première étape crée l'ALB lui-même avec la ressource aws_lb, en lui passant tous les sous-réseaux du VPC par défaut. Un load balancer AWS n'est pas un serveur unique mais plusieurs serveurs répartis sur des sous-réseaux (donc des datacenters) distincts : AWS en ajuste le nombre selon le trafic et gère le basculement, offrant scalabilité et haute disponibilité d'emblée.

resource "aws_lb" "example" {
  name               = "terraform-asg-example"
  load_balancer_type = "application"
  subnets            = data.aws_subnet_ids.default.ids
  security_groups    = [aws_security_group.alb.id]
}

Comme toute ressource AWS, l'ALB n'autorise par défaut aucun trafic : il lui faut son propre groupe de sécurité, ouvert en entrée sur le port 80 (accès HTTP) et en sortie sur tous les ports (pour les health checks) :

resource "aws_security_group" "alb" {
  name = "terraform-example-alb"

  # Requêtes HTTP entrantes
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  # Toutes requêtes sortantes
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

On définit ensuite l'écouteur avec aws_lb_listener : il écoute sur le port 80 en HTTP et renvoie une page 404 par défaut pour les requêtes qui ne correspondent à aucune règle.

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.example.arn
  port              = 80
  protocol          = "HTTP"

  # Par défaut, renvoyer une simple page 404
  default_action {
    type = "fixed-response"

    fixed_response {
      content_type = "text/plain"
      message_body = "404: page not found"
      status_code  = 404
    }
  }
}

Vient le groupe cible, avec aws_lb_target_group. Il vérifie périodiquement la santé des instances en leur envoyant une requête HTTP et ne les considère « saines » que si la réponse correspond au matcher configuré (ici un 200). Une instance qui ne répond plus — en panne ou saturée — est marquée « malsaine » et cesse aussitôt de recevoir du trafic.

resource "aws_lb_target_group" "asg" {
  name     = "terraform-asg-example"
  port     = var.server_port
  protocol = "HTTP"
  vpc_id   = data.aws_vpc.default.id

  health_check {
    path                = "/"
    protocol            = "HTTP"
    matcher             = "200"
    interval            = 15
    timeout             = 3
    healthy_threshold   = 2
    unhealthy_threshold = 2
  }
}

Comment le groupe cible sait-il à quelles instances envoyer les requêtes ? Avec un ASG, les instances apparaissent et disparaissent à tout moment, donc une liste statique ne convient pas. On exploite l'intégration native entre l'ASG et l'ALB : on revient au bloc aws_autoscaling_group pour pointer son target_group_arns vers le groupe cible, et on passe le health_check_type à "ELB".

resource "aws_autoscaling_group" "example" {
  launch_configuration = aws_launch_configuration.example.name
  vpc_zone_identifier  = data.aws_subnet_ids.default.ids

  target_group_arns = [aws_lb_target_group.asg.arn]
  health_check_type = "ELB"

  min_size = 2
  max_size = 10

  tag {
    key                 = "Name"
    value               = "terraform-asg-example"
    propagate_at_launch = true
  }
}

Le health_check_type par défaut, "EC2", est minimal : il ne juge une instance malsaine que si l'hyperviseur AWS la déclare totalement hors service. Le type "ELB" est plus robuste : il fait reposer le verdict sur le health check du groupe cible et remplace une instance dès qu'elle est signalée malsaine — y compris quand elle a cessé de servir les requêtes faute de mémoire ou après le crash d'un processus critique. Enfin, on relie le tout par une règle d'écouteur avec aws_lb_listener_rule : elle envoie toute requête (chemin *) vers le groupe cible de l'ASG.

resource "aws_lb_listener_rule" "asg" {
  listener_arn = aws_lb_listener.http.arn
  priority     = 100

  condition {
    field  = "path-pattern"
    values = ["*"]
  }

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.asg.arn
  }
}

Dernière chose avant de déployer : remplacer l'ancienne sortie public_ip par le nom DNS de l'ALB.

output "alb_dns_name" {
  value       = aws_lb.example.dns_name
  description = "The domain name of the load balancer"
}

Lancez terraform apply : l'instance EC2 isolée est supprimée et remplacée par une launch configuration, un ASG, un ALB et un groupe de sécurité. À la fin, la sortie alb_dns_name apparaît :

Outputs:
alb_dns_name = terraform-asg-example-123.us-east-2.elb.amazonaws.com

Comptez une ou deux minutes pour que les instances démarrent et passent en « healthy » dans le groupe cible, puis testez :

$ curl http://<alb_dns_name>
Hello, World

L'ALB route le trafic vers vos instances, en choisissant une instance différente à chaque requête. Vous avez maintenant un cluster de serveurs web pleinement fonctionnel. Terminez une instance depuis la console : l'ALB cesse aussitôt d'y router, et peu après, l'ASG détecte qu'il reste moins de deux instances et en relance une de remplacement — c'est l'auto-réparation (self-healing). On peut aussi observer le redimensionnement en ajoutant un paramètre desired_capacity et en relançant apply.

Nettoyer

Quand vous avez fini d'expérimenter, supprimez toutes les ressources créées pour qu'AWS ne vous les facture pas. Comme Terraform garde la trace de ce qu'il a créé, le nettoyage tient en une commande : terraform destroy.

$ terraform destroy

(...)
Plan: 0 to add, 0 to change, 8 to destroy.

Do you really want to destroy all resources?
  There is no undo. Only 'yes' will be accepted to confirm.

  Enter a value:

Piège courant

Ne lancez destroy qu'avec une extrême prudence, et jamais — ou presque — en production : il n'y a aucun « annuler ». Terraform vous laisse une dernière chance en listant tout ce qu'il s'apprête à supprimer et en exigeant un yes explicite. Après confirmation, il bâtit le graphe de dépendances et supprime les ressources dans le bon ordre, avec le maximum de parallélisme.

Ne supprimez pas le code Terraform pour autant : on continuera de faire évoluer cet exemple dans les chapitres suivants. C'est toute la beauté de l'infrastructure as code : toute l'information sur ces ressources est capturée dans le code, donc on peut tout recréer à n'importe quel moment d'un seul terraform apply.

Vous avez désormais les bases de Terraform. Le langage déclaratif rend facile la description de l'infrastructure voulue ; la commande plan permet de vérifier les changements et d'attraper les bugs avant de déployer ; variables, références et dépendances éliminent la duplication et rendent le code hautement configurable. Au chapitre 3, on verra comment Terraform garde la trace de l'infrastructure déjà créée — l'état — et l'impact profond que cela a sur la façon de structurer son code ; au chapitre 4, comment créer de l'infrastructure réutilisable avec les modules.

À retenir

  • Le flux de travail fondamental tient en un cycle : terraform init une fois pour télécharger le code des providers, puis la boucle écrire le HCL → planapply ; plan montre un diff (+ créer, - supprimer, ~ modifier, -/+ remplacer) avant tout changement réel.
  • Un fichier .tf décrit en HCL déclaratif un provider (ex. aws avec sa region) et des resource "<type>" "<nom>" ; on commence par un seul serveur (aws_instance) avant de monter en complexité.
  • On transforme une instance en serveur web via le script user_data (heredoc <<-EOF) et un aws_security_group qui ouvre le port ; référencer un attribut (aws_security_group.instance.id) crée une dépendance implicite dont Terraform déduit l'ordre de création.
  • Les variables d'entrée (variable, avec type, default, description) et les variables de sortie (output) éliminent la duplication (DRY) et rendent le code configurable et réutilisable ; on accède aux valeurs par var.<nom> et à l'interpolation dans les chaînes.
  • Pour la haute disponibilité, on remplace le serveur unique par un ASG (aws_autoscaling_group + aws_launch_configuration avec create_before_destroy), réparti sur plusieurs sous-réseaux découverts via les sources de données aws_vpc et aws_subnet_ids (lecture seule).
  • Un ALB (aws_lb, aws_lb_listener, aws_lb_target_group, aws_lb_listener_rule, plus son groupe de sécurité) distribue le trafic, effectue des health checks et s'intègre nativement à l'ASG via target_group_arns + health_check_type = "ELB", offrant scalabilité, haute disponibilité et auto-réparation.
  • terraform destroy supprime tout ce que Terraform a créé (sans annulation possible) ; comme l'infrastructure est entièrement décrite en code, on peut la recréer à l'identique d'un simple apply.