Aller au contenu
Infrastructure as Code medium

Terraform AWS, Déployer une première EC2

20 min de lecture

Le premier échec sur AWS n'est presque jamais le HCL. C'est la configuration du provider : où appelle-t-il, avec quelle identité, et que valide-t-il avant même de planifier ? Ce guide traite ces trois questions dans l'ordre, puis crée une instance et prouve qu'elle tourne.

  • Comment le provider AWS résout son identité, et pourquoi il échoue avant le plan quand il n'en trouve pas
  • Épingler le provider sur une majeure bornée des deux côtés, et vérifier la version réellement installée
  • Poser des tags une fois pour toute une racine avec default_tags, et savoir où ils apparaissent
  • Lire une AMI sans coder son identifiant, et ce qui protège vraiment contre une image inattendue
  • Prouver l'état obtenu par les sorties structurées, pas par un message de succès
  • Terraform 1.11 ou plus récent (les exemples sont vérifiés sur 1.15.4)
  • Un compte AWS avec des droits suffisants pour créer une instance, ou l'émulateur local du lab associé
  • L'AWS CLI configurée si vous visez le vrai AWS, ce que la première section détaille

Avant toute chose, comprenez ce mécanisme : il explique la majorité des échecs du premier jour. Le provider AWS cherche des identifiants dans un ordre déterminé, et s'arrête au premier trouvé.

  1. Les arguments du bloc provider eux-mêmes (access_key, secret_key, profile)
  2. Les variables d'environnement AWS_ACCESS_KEY_ID et AWS_SECRET_ACCESS_KEY
  3. Les fichiers partagés ~/.aws/credentials et ~/.aws/config, que aws configure écrit
  4. La metadata API de l'instance (IMDS), quand Terraform tourne lui-même sur EC2 ou ECS

Cet ordre a une conséquence pratique importante. Une configuration qui fonctionne chez vous peut échouer en CI, non parce que le HCL diffère, mais parce que le poste portait un ~/.aws que le runner n'a pas.

Quand la chaîne n'aboutit sur rien, le plan échoue avant de planifier quoi que ce soit, avec un message qui nomme les deux dernières étapes tentées :

Error: No valid credential sources found
Error: failed to refresh cached credentials, no EC2 IMDS role found,
operation error ec2imds: GetMetadata, access disabled to EC2 IMDS via client

Retenez la forme de ce message : il ne dit pas que votre code est faux, il dit que le provider n'a pas d'identité.

Le bloc provider accepte plusieurs formes, et toutes ne se valent pas.

FormeQuand l'utiliser
profile = "nom"Poste de travail avec plusieurs comptes, le plus courant en local
assume_role { role_arn = ... }Accès inter-comptes, la forme normale en entreprise
assume_role_with_web_identityCI moderne par OIDC, sans secret de longue durée
access_key / secret_keyÀ réserver aux tests contre un émulateur

Les clés statiques écrites dans le HCL sont à proscrire face à un vrai compte : elles finissent dans Git. Elles restent en revanche la bonne réponse face à un émulateur local, qui n'authentifie rien mais exige que quelque chose soit fourni.

Épingler le provider, et vérifier ce qui est installé

Section intitulée « Épingler le provider, et vérifier ce qui est installé »

Le bloc terraform fixe les contraintes. Créez terraform.tf :

terraform {
required_version = ">= 1.11.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.0, < 7.0"
}
}
}

Deux choses méritent d'être soulignées ici. La contrainte vise la majeure 6, actuelle depuis 2025, alors que d'innombrables exemples en ligne traînent encore un ~> 5.0 qui vous ferait démarrer sur une majeure précédente. Et elle est bornée des deux côtés : sans plafond, la majeure 7 s'installerait le jour de sa sortie, avec ses ruptures.

Ne vous fiez pas à ce que vous croyez avoir installé. Demandez-le :

Fenêtre de terminal
terraform init
terraform version -json | jq '.provider_selections'
{
"registry.terraform.io/hashicorp/aws": "6.59.0"
}

Cette sortie est la seule preuve de la version réellement utilisée. Le .terraform.lock.hcl la fige ensuite pour toute l'équipe, ce qui évite qu'un collègue résolve une version différente de la vôtre.

Créez providers.tf. Voici la version complète, commentée argument par argument :

provider "aws" {
region = var.aws_region
default_tags {
tags = var.common_tags
}
}

Face à un vrai compte AWS, c'est tout. La région suffit, l'identité venant de votre ~/.aws ou de vos variables d'environnement, et default_tags se charge des étiquettes.

default_tags applique une carte de tags à toutes les ressources de la racine qui en acceptent. Cela remplace le tags = var.common_tags recopié sur chaque ressource, oublié une fois sur trois, et impossible à auditer.

Un détail de ce mécanisme surprend tout le monde au moins une fois, et il vaut d'être vu tout de suite. Les tags par défaut n'apparaissent jamais dans l'attribut tags de la ressource. Ils ne sont visibles que dans tags_all, qui est la fusion des deux :

Fenêtre de terminal
terraform show -json | jq '.values.root_module.resources[].values | {tags, tags_all}'
{
"tags": null,
"tags_all": {
"environnement": "formation",
"gestion": "terraform",
"projet": "lab-terraform"
}
}

La ressource ne déclare aucun tag, et pourtant elle en porte trois. tags reflète ce que la ressource déclare, tags_all reflète ce qu'elle porte vraiment. C'est tags_all qu'il faut regarder pour vérifier une politique d'étiquetage.

Le bloc provider en accepte bien d'autres, que la documentation liste mais qu'on ne rencontre qu'au moment d'un incident.

ArgumentCe qu'il évite
allowed_account_idsAppliquer en production une racine destinée au bac à sable
forbidden_account_idsLa même protection, exprimée par exclusion
max_retries, retry_modeLes échecs sur throttling d'API dans les grosses racines
ignore_tagsLes diffs perpétuels causés par des tags posés par un autre outil

allowed_account_ids mérite une mention particulière : c'est le garde-fou le moins coûteux contre l'erreur la plus chère, celle du mauvais compte. Le provider vérifie l'identité du compte avant d'agir, et refuse d'appliquer si elle ne figure pas dans la liste.

Si vous travaillez sans compte AWS, trois ajouts changent la donne. Le bloc endpoints redirige un service vers une autre adresse, et les arguments qui commencent par skip_ désactivent les validations distantes qui, elles, partiraient interroger le vrai AWS :

provider "aws" {
region = var.aws_region
access_key = "test"
secret_key = "test"
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
default_tags {
tags = var.common_tags
}
endpoints {
ec2 = var.emulateur_endpoint
}
}

Ces trois skip_* ne sont pas des raccourcis de confort. Sans eux, le provider valide les identifiants auprès de STS, interroge la metadata API, et réclame l'identifiant du compte, trois appels qui sortiraient vers AWS quelle que soit votre redirection endpoints.

Une AMI est le modèle de disque dont part une instance. Son identifiant change selon la région et à chaque publication de l'éditeur : le coder en dur rend la configuration inutilisable ailleurs, et périmée en quelques semaines.

data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}

Le point important n'est pas most_recent, c'est owners. Sans restriction de propriétaire, « l'image la plus récente qui s'appelle à peu près comme ça » peut être publiée par n'importe qui : c'est une porte d'entrée classique dans la chaîne d'approvisionnement. owners est ce qui protège, most_recent ne fait que trancher entre les candidates restantes.

Le provider en a d'ailleurs tiré les conséquences. En version 6, most_recent = true sans filtre restreignant le propriétaire ou l'image est refusé :

Error: Most Recent Image Not Filtered

Notez que terraform validate passe sur cette configuration : c'est le plan qui échoue, parce que le refus vient du provider, pas de la grammaire HCL.

resource "aws_instance" "lab" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
}

Aucun réseau n'est précisé. Sur un compte disposant d'un VPC par défaut dans la région visée, AWS y place l'instance et lui attribue un sous-réseau, ce qui permet à cet exemple de tenir en quatre lignes. Ce VPC par défaut n'est pas garanti : il peut avoir été supprimé, ou absent d'un compte créé par une organisation qui l'interdit. Dans ce cas, l'instance demande un subnet_id explicite, ce que couvre le guide sur les réseaux.

Un apply qui rend « Apply complete » signifie que l'API a accepté la demande, pas que la machine tourne. La distinction est visible dans l'état :

Fenêtre de terminal
terraform show -json | jq '.values.root_module.resources[] | {address, mode, type, state: .values.instance_state}'
{
"address": "aws_instance.lab",
"mode": "managed",
"type": "aws_instance",
"state": "running"
}

Deux champs valent la lecture. instance_state distingue pending de running : une instance fraîchement créée passe par le premier avant d'atteindre le second. Et mode vaut managed pour une ressource que Terraform crée et détruit, contre data pour une data source qui ne fait que lire, comme l'aws_ami précédente.

La représentation JSON du plan expose aussi ce que le provider a réellement reçu, ce qui permet de contrôler une racine sans ouvrir un seul fichier .tf :

Fenêtre de terminal
terraform plan -out=tfplan
terraform show -json tfplan | jq '.configuration.provider_config.aws'

Vous y trouvez full_name, le version_constraint déclaré, et les expressions de chaque argument. C'est la base sur laquelle un contrôle automatisé peut vérifier qu'une équipe épingle bien ses providers.

Fenêtre de terminal
terraform destroy -auto-approve
terraform show -json | jq '.values.root_module.resources | length'

La seconde commande doit rendre 0. Détruire est la seule façon d'arrêter la facturation : une instance oubliée continue de coûter, y compris arrêtée, pour son volume attaché.

Voici, rassemblés, les écarts qui coûtent le plus de temps.

Il vérifie la grammaire et la cohérence interne, pas ce que le provider accepte. Un provider incomplètement configuré, ou un aws_ami insuffisamment filtré, lui passent sous le nez et sortent en code 0. Seul le plan interroge réellement le provider.

Un tag de la ressource écrase un tag par défaut, en silence

Section intitulée « Un tag de la ressource écrase un tag par défaut, en silence »

Si default_tags pose projet = "lab" et que la ressource déclare projet = "autre", la ressource gagne. Aucune erreur, aucun avertissement : la valeur de tags_all est simplement celle de la ressource. C'est le mode d'échec typique d'une politique d'étiquetage qu'on croit appliquée.

Une contrainte sans borne haute est une promesse en l'air

Section intitulée « Une contrainte sans borne haute est une promesse en l'air »

version = ">= 6.0" autorise la majeure 7. ~> 6.0 la refuse, et se lit plus vite. L'important est qu'un plafond existe.

AuthFailure ne se soigne pas par les pleins pouvoirs

Section intitulée « AuthFailure ne se soigne pas par les pleins pouvoirs »

Face à un refus d'API, la tentation est d'attacher une policy FullAccess et de passer à autre chose. C'est un mauvais réflexe : cela contredit le moindre privilège, et masque la vraie cause, qui est le plus souvent une région, un profil ou un compte différent de celui qu'on croit viser. Lisez le message, vérifiez l'identité effective avec aws sts get-caller-identity, puis n'accordez que la permission manquante.

Les identifiants du poste masquent une configuration incomplète

Section intitulée « Les identifiants du poste masquent une configuration incomplète »

Tant que votre ~/.aws existe, une racine sans identité déclarée fonctionne chez vous. Elle cassera en CI. Pour vous en assurer, jouez la racine dans un environnement purgé de toute variable AWS_* et avec un HOME sans .aws : ce qui n'est pas dans la configuration n'existe alors plus.

  1. Le provider résout son identité dans un ordre déterminé, et échoue avant le plan s'il n'en trouve aucune
  2. terraform version -json dit la version réellement installée, la contrainte dit seulement ce qui est permis
  3. Une contrainte de version se borne des deux côtés
  4. default_tags n'écrit jamais dans tags, seulement dans tags_all
  5. Sur une data source aws_ami, c'est owners qui protège, pas most_recent
  6. instance_state distingue une instance acceptée d'une instance qui tourne
  7. Détruire reste la seule façon d'arrêter la facturation

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn