DevOps, CI/CD et conteneurs

Infrastructure as Code : Terraform, Ansible et infrastructure immuable

Avancé13 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

L'infrastructure as code transforme serveurs, réseaux et comptes cloud en fichiers que l'on relit, teste et restaure comme du code applicatif. Ce guide distingue le provisioning de la gestion de configuration, détaille le modèle de state de Terraform et la conception des modules, montre le versant Ansible et aborde les réalités opérationnelles : drift, verrouillage, secrets, et exécution de plan et apply depuis un pipeline plutôt que depuis un poste de travail.

Ce que vous allez apprendre

  • Distinguer le provisioning déclaratif de la gestion de configuration impérative et expliquer l'idempotence
  • Écrire une configuration Terraform avec providers, resources, variables, outputs et modules
  • Expliquer ce qu'est le state, pourquoi il est verrouillé et comment les backends distants le protègent
  • Écrire un playbook Ansible idempotent et savoir quand la gestion de configuration reste pertinente
  • Détecter et traiter le drift, et garder les secrets hors du code et du state
  • Lancer plan sur une pull request et apply au merge, avec des contrôles de policy dans le pipeline

Toute infrastructure finit par atteindre le stade où plus personne ne sait dire exactement ce qui tourne. Une règle de pare-feu a été ajoutée pendant un incident, un serveur a été patché à la main, le timeout d'un load balancer a été augmenté pour une démo il y a trois ans. Reconstruire l'environnement devient un projet de recherche, et la préproduction a divergé de la production depuis si longtemps qu'y tester ne prouve plus grand-chose.

L'infrastructure as code est la discipline qui évite cela : l'infrastructure est décrite dans des fichiers, ces fichiers vivent dans un gestionnaire de versions, et les modifications atteignent l'environnement en appliquant ces fichiers plutôt qu'en cliquant ou en se connectant aux machines. Le gain ne se limite pas à l'automatisation. Vous gagnez la relecture, l'historique, la possibilité de comparer deux environnements et celle de tout reconstruire à partir de rien — ce qui transforme « notre datacenter a brûlé » d'une menace existentielle en une longue journée.

Deux métiers, deux familles d'outils

Le terme recouvre deux activités voisines, et les confondre coûte cher.

Le provisioning crée et détruit des ressources : réseaux virtuels, sous-réseaux, security groups, machines virtuelles, bases de données managées, clusters Kubernetes, enregistrements DNS, rôles IAM. Ces ressources n'existent pas tant que l'outil ne les a pas créées. C'est le domaine de Terraform et de son fork OpenTofu, ainsi que des outils propres aux clouds comme CloudFormation, Bicep et Pulumi.

La gestion de configuration part d'une machine existante et aligne son contenu sur une description : paquets installés, fichiers écrits, services activés, utilisateurs créés. C'est le domaine d'Ansible, Chef, Puppet et Salt.

Les modes de fonctionnement diffèrent aussi. Les outils de provisioning sont déclaratifs et tiennent un registre persistant de ce qu'ils possèdent : ils peuvent donc calculer l'écart entre votre description et la réalité, et détruire ce que vous avez retiré du fichier. Les gestionnaires de configuration sont en général procéduraux mais idempotents : un playbook est une liste ordonnée de tâches, chacune écrite de sorte que l'exécuter sur une machine déjà conforme ne change rien.

L'idempotence est la propriété qui rend tout cela sûr. Appliquer deux fois la même configuration donne le même résultat que l'appliquer une fois ; c'est pour cela qu'une exécution en échec peut simplement être relancée, et que l'outil peut tourner à intervalles réguliers pour corriger le drift. Les scripts shell sont le contre-exemple : useradd deploy réussit une fois puis échoue ensuite, si bien que chaque script accumule les conditions jusqu'à devenir une réimplémentation peu fiable d'un gestionnaire de configuration.

Terraform : le modèle

Terraform lit des fichiers .tf écrits en HCL, dialogue avec des providers (des plugins pour AWS, Azure, Google Cloud, Kubernetes, GitHub, PostgreSQL et des centaines d'autres) et maintient un state qui associe les ressources de votre configuration aux objets réels qu'il a créés.

terraform {
  required_version = "~> 1.9"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60"
    }
  }
}

provider "aws" {
  region = var.region
  default_tags {
    tags = {
      Environment = var.environment
      ManagedBy   = "terraform"
      Repository  = "acme/infrastructure"
    }
  }
}

variable "environment" {
  description = "Deployment environment name"
  type        = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev, staging or prod."
  }
}

variable "region" {
  type    = string
  default = "eu-west-3"
}

resource "aws_vpc" "main" {
  cidr_block           = "10.20.0.0/16"
  enable_dns_hostnames = true
  tags = { Name = "${var.environment}-vpc" }
}

resource "aws_subnet" "private" {
  for_each          = { for i, az in local.azs : az => cidrsubnet(aws_vpc.main.cidr_block, 4, i) }
  vpc_id            = aws_vpc.main.id
  availability_zone = each.key
  cidr_block        = each.value
  tags              = { Name = "${var.environment}-private-${each.key}" }
}

locals {
  azs = ["eu-west-3a", "eu-west-3b", "eu-west-3c"]
}

output "vpc_id" {
  value = aws_vpc.main.id
}

Deux habitudes méritent d'être prises tôt. default_tags sur le provider étiquette tout ce que Terraform crée, ce qui rendra possible plus tard l'imputation des coûts et la traque des ressources orphelines. Et for_each sur une map, plutôt que count sur un nombre, donne à chaque sous-réseau une adresse stable indexée par zone de disponibilité — avec count, supprimer l'élément du milieu renumérote tous ceux qui suivent, et Terraform propose de détruire des ressources qui n'auraient jamais dû être touchées. Épinglez aussi les versions de Terraform et des providers, et commitez .terraform.lock.hcl pour que chaque machine résolve les mêmes builds de providers.

Le workflow

terraform init          # download providers and configure the backend
terraform fmt -recursive
terraform validate      # syntax and internal consistency
terraform plan -out=tfplan
terraform apply tfplan
terraform destroy       # tear the environment down

L'étape importante est le plan, et la compétence importante est de savoir le lire. Un plan liste chaque action que Terraform compte effectuer : création, mise à jour sur place, destruction, ou le redouté « must be replaced ». Un remplacement signifie destruction puis création, et sur une base de données, c'est une perte de données annoncée par une ligne de sortie qui défile vite.

  # aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
      ~ engine_version = "16.3" -> "17.2" # forces replacement
      ~ id             = "shop-prod-db" -> (known after apply)
    }

Appliquez toujours un fichier de plan sauvegardé plutôt que de lancer terraform apply seul. Sans plan, apply recalcule un plan et demande confirmation, et ce qu'il applique peut différer de ce que vous avez relu si le monde a changé entre-temps.

Un plan sans modification doit afficher « No changes ». Si chaque exécution propose au contraire de mettre à jour la même ressource, vous avez affaire à un bug de provider ou à une valeur que le cloud normalise différemment de ce que vous avez écrit. Traquez-la : si le plan n'est jamais propre, un plan propre ne peut plus vous servir de signal.

Le state, et pourquoi il lui faut un backend

Le state est un document JSON qui associe chaque adresse de ressource de votre configuration (aws_vpc.main) à l'identifiant et aux attributs de la ressource réelle. Terraform en a besoin pour savoir ce qu'il a créé, détecter le drift et calculer les dépendances.

Un fichier de state local suffit pour un tutoriel mais devient ingérable en équipe : il n'est pas partagé, ne peut pas être verrouillé, contient des secrets, et le perdre revient à faire oublier à Terraform tout ce qu'il possède. Un backend distant règle ces quatre problèmes.

terraform {
  backend "s3" {
    bucket       = "acme-terraform-state"
    key          = "prod/network/terraform.tfstate"
    region       = "eu-west-3"
    encrypt      = true
    use_lockfile = true          # native S3 locking, Terraform 1.10+
  }
}

Les configurations plus anciennes s'appuient sur une table DynamoDB pour le verrouillage ; les backends Azure et Google Cloud verrouillent via leurs propres primitives de stockage, et Terraform Cloud, Scalr et Spacelift proposent un state managé avec verrouillage et historique des exécutions. Quel que soit votre choix, activez le versioning du bucket pour pouvoir restaurer un state corrompu.

Deux conséquences en découlent. D'abord, le state est sensible : les mots de passe, clés et certificats générés par les ressources y sont stockés en clair. Chiffrez le backend, restreignez qui peut lire le bucket et ne commitez jamais le state dans Git. Ensuite, le state doit être découpé. Un unique state gigantesque pour toute une organisation signifie que chaque modification se planifie contre tout le reste, que les apply prennent vingt minutes et qu'une seule ressource cassée bloque des travaux sans rapport. Découpez selon le cycle de vie et le rayon d'impact — réseau, stockage de données, cluster, applications — et lisez par-delà ces frontières avec des data sources ou les outputs du remote state.

data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "acme-terraform-state"
    key    = "prod/network/terraform.tfstate"
    region = "eu-west-3"
  }
}

resource "aws_instance" "app" {
  subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
  # ...
}

Quand vous devez intervenir sur le state — une ressource renommée, une ressource créée à la main, une ressource qui ne doit plus être gérée —, utilisez les commandes dédiées plutôt que d'éditer le fichier : terraform state mv, terraform state rm et import. Depuis Terraform 1.5, les blocs moved et import permettent d'exprimer ces opérations de façon déclarative dans la configuration, si bien qu'elles passent en relecture comme tout le reste :

moved {
  from = aws_instance.web
  to   = aws_instance.frontend
}

import {
  to = aws_s3_bucket.legacy_assets
  id = "acme-legacy-assets"
}

Les modules

Un module est un répertoire de fichiers Terraform doté d'entrées et de sorties. La configuration racine est elle-même un module ; tout le reste est un module réutilisable.

module "service" {
  source  = "git::https://git.example.com/acme/tf-modules.git//service?ref=v2.3.0"

  name        = "shop-api"
  environment = var.environment
  vpc_id      = data.terraform_remote_state.network.outputs.vpc_id
  subnet_ids  = data.terraform_remote_state.network.outputs.private_subnet_ids
  image_tag   = var.image_tag
  min_size    = 3
  max_size    = 12
}

Un bon module ressemble à une bonne fonction : un objectif clair, des entrées typées et bien nommées avec des valeurs par défaut sensées, et des sorties qui exposent ce dont les appelants ont besoin plutôt que tout. Épinglez la version avec ?ref= sur un tag — un module référencé par branche change sous vos pieds, et vous le découvrirez en plein incident.

Résistez à deux tentations. N'enveloppez pas une ressource unique dans un module qui n'apporte qu'une couche d'indirection. Et ne construisez pas un module géant à quarante flags booléens — la composition l'emporte sur la configuration, et un module dont le comportement dépend de count = var.enabled ? 1 : 0 à vingt endroits est illisible.

Pour les environnements, préférez des répertoires séparés, chacun avec sa clé de backend et ses fichiers .tfvars, aux workspaces Terraform. Les workspaces partagent une seule configuration et un même préfixe de clé de backend, ce qui rend beaucoup trop facile un apply de production lancé alors qu'on pointe sur le mauvais workspace. Des répertoires séparés rendent la cible explicite.

Ansible : configurer l'existant

Terraform s'arrête à la frontière de la machine. Si vous exploitez des machines virtuelles plutôt que des conteneurs, il faut bien que quelque chose installe les paquets, écrive les fichiers de configuration et démarre les services. Ansible s'en charge via SSH, sans agent à installer.

Un inventaire liste les hôtes et les groupes ; un playbook associe des groupes à des rôles et à des tâches ; les modules effectuent des opérations idempotentes.

- name: Configure application servers
  hosts: app_servers
  become: true
  vars:
    java_package: openjdk-21-jre-headless
    app_user: shop
  tasks:
    - name: Install runtime packages
      ansible.builtin.apt:
        name:
          - "{{ java_package }}"
          - ca-certificates
        state: present
        update_cache: true
        cache_valid_time: 3600

    - name: Create the service account
      ansible.builtin.user:
        name: "{{ app_user }}"
        system: true
        shell: /usr/sbin/nologin

    - name: Deploy the systemd unit
      ansible.builtin.template:
        src: templates/shop-api.service.j2
        dest: /etc/systemd/system/shop-api.service
        owner: root
        group: root
        mode: "0644"
      notify: Restart shop-api

    - name: Enable and start the service
      ansible.builtin.systemd_service:
        name: shop-api
        enabled: true
        state: started
        daemon_reload: true

  handlers:
    - name: Restart shop-api
      ansible.builtin.systemd_service:
        name: shop-api
        state: restarted

Chaque module est idempotent : apt avec state: present n'installe que si le paquet manque, template ne réécrit le fichier que si le contenu généré diffère, et le handler ne redémarre le service que si une tâche l'a notifié. Lancez d'abord avec --check --diff pour voir ce qui changerait sans rien modifier — l'équivalent d'un plan Terraform.

ansible-playbook -i inventories/prod site.yml --check --diff --limit app_servers
ansible-playbook -i inventories/prod site.yml --limit app_servers

Regroupez les tâches en rôles (roles/app-server/{tasks,templates,handlers,defaults}) pour garder des playbooks lisibles, et soumettez-les au même processus de relecture que le reste. Les noms des tâches apparaissent dans la sortie : rédigez-les comme des descriptions qu'un collègue peut suivre.

Infrastructure immuable

La gestion de configuration suppose des serveurs à longue durée de vie que l'on corrige en permanence. L'alternative consiste à cesser de corriger pour remplacer : construire une image machine ou une image de conteneur contenant le système entièrement configuré, déployer de nouvelles instances à partir de cette image, et ne jamais modifier une instance en cours d'exécution.

Le pipeline devient : construire une image (Packer pour les images de VM, un Dockerfile pour les conteneurs), la tester, puis laisser l'outil de provisioning remplacer les instances par d'autres basées sur la nouvelle image. Rien n'est patché sur place : il n'y a donc pas de drift à détecter, pas de modification appliquée à moitié, et le rollback consiste à redéployer l'image précédente.

source "amazon-ebs" "app" {
  region        = "eu-west-3"
  source_ami_filter {
    filters     = { name = "ubuntu/images/*24.04-amd64-server-*" }
    most_recent = true
    owners      = ["099720109477"]
  }
  instance_type = "t3.small"
  ssh_username  = "ubuntu"
  ami_name      = "shop-api-{{timestamp}}"
}

build {
  sources = ["source.amazon-ebs.app"]
  provisioner "ansible" {
    playbook_file = "./ansible/site.yml"
  }
}

Notez qu'Ansible n'a pas disparu : il s'exécute désormais une seule fois, au build de l'image, plutôt qu'en continu contre la production. C'est le compromis pragmatique auquel aboutissent la plupart des équipes, et c'est exactement l'argument des conteneurs : le pipeline de livraison construit un artefact immuable une seule fois et le promeut sans le modifier.

L'immuabilité a ses limites : les bases de données portent un état qu'on ne remplace pas à la légère. Appliquez le pattern là où il convient et gardez la gestion de configuration pour le reste.

Le drift, et comment y faire face

Le drift, c'est tout écart entre le code et la réalité : une modification dans la console pendant un incident, un autoscaler qui ajuste une valeur, un fournisseur cloud qui change une valeur par défaut, l'automatisation d'une autre équipe qui touche une ressource partagée.

Détectez-le à intervalles réguliers plutôt qu'au moment d'un déploiement :

terraform plan -detailed-exitcode -lock=false
# exit 0 = no changes, 1 = error, 2 = changes pending

Un job de pipeline nocturne qui lance cette commande et alerte sur le code de sortie 2 transforme le drift en notification plutôt qu'en mauvaise surprise. Le mode --check d'Ansible fournit le signal équivalent pour les hôtes configurés.

Traiter le drift relève du jugement. En général, on réconcilie en réappliquant le code, ce qui annule la modification manuelle — sans risque quand elle était accidentelle, dangereux quand il s'agissait d'un correctif urgent dont personne ne vous a parlé. Parfois, on intègre la modification au code parce qu'elle était juste. Plus rarement, on admet qu'un champ est géré ailleurs et on demande à Terraform de l'ignorer :

lifecycle {
  ignore_changes  = [tags["LastScanned"], desired_count]
  prevent_destroy = true
}

prevent_destroy sur les bases de données et les buckets de state est une assurance bon marché contre un plan que personne n'a lu attentivement.

Les secrets

N'inscrivez jamais d'identifiants dans un fichier .tf ou .yml. Les options réalistes, par ordre croissant de maturité : des variables d'environnement fournies par le pipeline (TF_VAR_db_password) ; un gestionnaire de secrets référencé par une data source au moment du plan ; et des ressources qui ne détiennent jamais le secret, comme une base de données dont le mot de passe est généré directement dans le gestionnaire de secrets du fournisseur cloud.

Ansible dispose de Vault pour chiffrer les fichiers de variables sur place, ce qui permet de garder les secrets dans Git sous forme chiffrée :

ansible-vault encrypt group_vars/prod/secrets.yml
ansible-playbook site.yml --vault-password-file ~/.ansible-vault-pass

Et n'oubliez pas la contrainte évoquée plus haut : un secret que Terraform crée ou lit atterrit en clair dans le state. Marquer une variable sensitive = true ne fait que la masquer dans la sortie console. Si une valeur ne doit jamais se retrouver dans le state, la ressource qui la porte ne doit pas être gérée par Terraform.

L'exécuter depuis un pipeline

Appliquer depuis les postes de travail est à l'origine de la plupart des incidents d'IaC : versions d'outils différentes, providers périmés, pas de verrou, pas de piste d'audit, et des identifiants qui traînent dans le profil shell de quelqu'un. Passez tout cela en CI.

Le schéma classique : plan sur la pull request, apply au merge.

  1. À chaque pull request

    Lancez fmt -check, validate, un scan de sécurité (tfsec, Checkov ou le mode IaC de Trivy) et plan -out=tfplan. Publiez le plan en commentaire pour que les relecteurs voient le diff d'infrastructure à côté du diff de code.
  2. S'appuyer sur des policies, pas sur la vigilance

    Ajoutez du policy-as-code — OPA/Conftest, Sentinel ou les règles du scanner — pour les invariants qui doivent toujours tenir : aucun bucket de stockage public, chiffrement activé, tags obligatoires, aucun security group ouvert au monde entier sur le port 22.
  3. Appliquer au merge le plan relu

    Appliquez l'artefact de plan sauvegardé lors de l'exécution de la pull request, et non un plan recalculé, afin que ce qui part en production soit ce qui a été approuvé. Utilisez des identifiants éphémères issus d'une fédération OIDC plutôt que des clés d'accès à longue durée de vie.
  4. Promouvoir d'un environnement à l'autre

    Les mêmes versions de modules sont appliquées en dev, puis en staging, puis en production avec des fichiers de variables différents. Les différences entre environnements doivent être des valeurs, jamais du code distinct.
  5. Détecter le drift chaque nuit

    Un plan -detailed-exitcode planifié pour chaque state, qui alerte lorsque la réalité s'est éloignée du code.

Le prolongement en libre-service, c'est une plateforme : les mêmes modules mis à la disposition des équipes produit derrière une pull request, si bien qu'une équipe obtient une file, un bucket ou une base de données en ajoutant dix lignes à un dépôt plutôt qu'en ouvrant un ticket — le modèle d'équipe plateforme décrit dans l'introduction à DevOps.

Ce qui tourne mal

  • Le state dans Git. Il contient des secrets, il crée un conflit à chaque merge, et il finira par être perdu. Utilisez un backend distant dès le premier jour.
  • Un seul state pour tout. Des plans lents, un rayon d'impact énorme, une contention permanente sur le verrou. Découpez selon le cycle de vie.
  • Des modules référencés par branche. Un commit en amont change le plan de chaque consommateur sans prévenir. Épinglez des tags.
  • count là où il faudrait for_each. L'adressage par index transforme la suppression d'un élément au milieu d'une liste en une cascade de remplacements.
  • Ignorer le plan. « Must be replaced » sur une base de données n'est pas une formalité. Lisez chaque ligne de destruction avant de taper yes.
  • Une infrastructure à moitié migrée. Certaines ressources dans le code, d'autres créées à la main dans la console, et personne ne sait lesquelles. Importez les ressources orphelines ou supprimez-les, et mettez en place une policy de tags pour rendre visibles les ressources non gérées.
  • Traiter l'IaC comme du code en écriture seule. Du code que personne n'a réappliqué depuis un an n'est qu'une hypothèse. Reconstruisez régulièrement un environnement éphémère à partir de zéro pour prouver qu'il fonctionne encore.

Points clés à retenir

  • Les outils de provisioning créent des ressources et en gardent la trace dans le state ; les gestionnaires de configuration alignent des machines existantes sur une description. La plupart des parcs ont besoin des deux, ou d'un build d'image qui intègre le second au premier.
  • L'idempotence rend la réexécution sûre, et c'est elle qui rend possibles la correction automatique et les pipelines sans surveillance.
  • Le plan Terraform est l'artefact de relecture : lisez-le, sauvegardez-le, appliquez le fichier sauvegardé, et ne laissez jamais un « must be replaced » défiler sans l'examiner.
  • Le state est partagé, sensible et verrouillable : placez-le dans un backend distant chiffré et versionné, découpez-le selon le rayon d'impact, et modifiez-le avec moved, import et les commandes state plutôt qu'à la main.
  • Écrivez les modules comme des fonctions, épinglez-les par tag, préférez for_each à count, et utilisez des répertoires séparés plutôt que des workspaces pour les environnements.
  • Les images immuables éliminent le drift en remplaçant les serveurs au lieu de les corriger ; gardez la gestion de configuration pour ce qui ne peut pas être reconstruit.
  • Détectez le drift à intervalles réguliers avec plan -detailed-exitcode, et décidez délibérément pour chaque écart s'il faut réconcilier, adopter ou ignorer.
  • Lancez plan sur les pull requests avec des contrôles de policy, appliquez le plan approuvé au merge avec des identifiants éphémères, et promouvez les mêmes modules d'un environnement à l'autre en ne changeant que les valeurs.

Sources et lectures complémentaires

Questions fréquentes

Terraform ou Ansible : lequel me faut-il ?
Ils ne résolvent pas le même problème et sont souvent utilisés ensemble. Terraform est un outil de provisioning : il crée et détruit des ressources cloud (réseaux, bases de données, clusters, enregistrements DNS) et garde la trace de ce qu'il possède dans le state. Ansible est un outil de gestion de configuration : il aligne une machine existante sur une configuration décrite, en installant des paquets et en écrivant des fichiers via SSH. Provisionnez avec l'un, configurez avec l'autre, ou intégrez la configuration à une image et faites l'économie de la seconde étape.
Puis-je modifier l'infrastructure dans la console cloud après un apply ?
Vous le pouvez, et le plan suivant proposera d'annuler la modification. C'est du drift, et c'est le mécanisme qui fonctionne exactement comme prévu. Une modification d'urgence dans la console est parfois inévitable ; la règle est alors de la reporter dans le code le jour même. Si une ressource doit être gérée hors de Terraform, excluez-la explicitement plutôt que de laisser deux propriétaires se la disputer à chaque apply.
Que se passe-t-il si deux personnes lancent apply en même temps ?
Avec un backend qui gère le verrouillage, la seconde exécution échoue immédiatement avec un message indiquant qui détient le verrou. Sans verrouillage, les deux exécutions lisent le même state, l'écrivent toutes les deux, et un ensemble de modifications est perdu — il reste des ressources qui existent dans le cloud mais pas dans le state. C'est la meilleure raison d'utiliser un backend distant et de lancer apply depuis un pipeline plutôt que depuis des postes de travail.
Les secrets sont-ils en sécurité dans le state Terraform ?
Non. Les mots de passe générés, les clés d'accès et autres valeurs de ce type sont écrits en clair dans le state, même lorsque la variable est marquée sensitive, ce qui ne fait que les masquer dans la sortie console. Traitez le state comme un secret : stockez-le dans un backend chiffré à l'accès strictement contrôlé, ne le commitez jamais, et préférez des ressources qui référencent un gestionnaire de secrets à des ressources qui créent et détiennent elles-mêmes le secret.