Aller au contenu
Infrastructure as Code medium

Déclaratif vs impératif : pourquoi Ansible ne dérive pas

17 min de lecture

Logo Ansible

Vous avez un script Bash qui marche au premier run, mais qui casse ou « dérive » dès que vous le relancez. C’est le problème classique de l’impératif : votre script raconte quelles actions enchaîner, sans se soucier de l’état actuel du système. Ansible prend l’approche déclarative opposée — vous décrivez l’état désiré, Ansible converge le système vers cet état, et n’agit que sur ce qui doit changer. Ce guide vous fait le déclic mental sur un cas concret : déployer nginx avec une page d’accueil personnalisée, en Bash puis en Ansible, et observer ce qui se passe en relançant.

Analogie : un script Bash, c’est une recette de cuisine lue mot à mot du début à la fin — relancez-la sur le même plat fini et vous le détruisez. Un playbook Ansible, c’est un inventaire de courses — relancez-le, votre placard reste celui que vous vouliez. Cette différence de philosophie change tout.

  • Voir concrètement la dérive d’un script Bash relancé plusieurs fois.
  • Comprendre la convergence d’un playbook Ansible et la propriété d’idempotence.
  • Identifier les modules Ansible idempotents par conception (dnf, lineinfile, firewalld, systemd).
  • Repérer les pièges qui rendent un playbook non idempotent (modules command/shell sans garde-fou).

Notre objectif est simple : sur le serveur web1.lab, installer nginx, ouvrir le port 80 dans firewalld, démarrer le service, et personnaliser la page d’accueil avec une ligne « Servi par web1 ». Quatre actions banales, que vous feriez à la main en deux minutes.

L’enjeu n’est pas la première exécution — les deux approches y arrivent. L’enjeu, c’est ce qui se passe au deuxième run, au dixième, au centième. Un sysadmin ne lance jamais un script « une seule fois » : il l’intègre dans un cron, dans un pipeline CI/CD, dans une procédure de récupération après incident. Le script sera relancé. La question est : est-ce qu’il fait alors la bonne chose, ou est-ce qu’il dérive ?

Voici un script Bash qui enchaîne les quatre actions. Il est volontairement simpliste — c’est exactement ce que beaucoup de débutants écriraient en première intention. Lisez-le ligne par ligne avant la suite, et repérez la commande qui pose problème au second run :

install-nginx-impératif.sh
#!/bin/bash
set -euo pipefail
echo "[1/4] Installation du paquet nginx..."
sudo dnf install -y nginx
echo "[2/4] Ouverture du port 80 dans firewalld..."
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --reload
echo "[3/4] Démarrage et activation du service nginx..."
sudo systemctl enable --now nginx
echo "[4/4] Personnalisation de la page d'accueil (PIÈGE non idempotent)..."
echo "<p>Servi par $(hostname)</p>" | sudo tee -a /usr/share/nginx/html/index.html >/dev/null
echo "OK : nginx déployé. Occurrences de 'Servi par' dans la page :"
curl -s http://localhost/ | grep -c "Servi par" || true

La commande à problème, c’est l’étape 4 : echo … | tee -a. Le -a signifie append — ajouter à la fin. À chaque run, le script ajoute une nouvelle ligne « Servi par … » à index.html, sans regarder si elle est déjà là. Les trois premières étapes sont par chance quasi idempotentes (dnf install ne réinstalle pas un paquet présent, systemctl enable --now est sûr), mais l’étape 4 trahit la nature impérative du Bash.

Relançons le script trois fois de suite et regardons le compteur d’occurrences. Sortie réelle capturée sur le lab :

3 runs du script Bash (web1.lab)
=== 1er run ===
[1/4] Installation du paquet nginx...
[2/4] Ouverture du port 80 dans firewalld...
[3/4] Démarrage et activation du service nginx...
[4/4] Personnalisation de la page d'accueil (PIÈGE non idempotent)...
OK : Occurrences de 'Servi par' dans la page : 1
=== 2e run ===
[2/4] Ouverture du port 80 dans firewalld...
Warning: ALREADY_ENABLED: http
[4/4] Personnalisation de la page d'accueil (PIÈGE non idempotent)...
OK : Occurrences de 'Servi par' dans la page : 2
=== 3e run ===
[4/4] Personnalisation de la page d'accueil (PIÈGE non idempotent)...
OK : Occurrences de 'Servi par' dans la page : 3

Lecture de la sortie : au premier run, la page contient 1 ligne « Servi par ». Au deuxième, 2 lignes. Au troisième, 3 lignes. Le script dérive linéairement. Si vous l’intégrez dans un cron horaire, votre page d’accueil contiendra 24 lignes demain matin et 8760 lignes dans un an. C’est l’inverse de ce qu’on veut.

Voici le playbook équivalent. Lisez-le entièrement avant la suite, et notez que la structure est différente : on décrit l’état, on n’enchaîne pas des actions.

playbook.yml
---
- name: Déployer nginx (équivalent déclaratif du script Bash)
hosts: web1.lab
become: true
tasks:
- name: Installer le paquet nginx
ansible.builtin.dnf:
name: nginx
state: present
- name: Ouvrir le service http dans firewalld de manière permanente
ansible.posix.firewalld:
service: http
permanent: true
immediate: true
state: enabled
- name: Activer et démarrer nginx
ansible.builtin.systemd:
name: nginx
enabled: true
state: started
- name: Garantir une UNIQUE ligne « Servi par … » dans index.html (idempotent)
ansible.builtin.lineinfile:
path: /usr/share/nginx/html/index.html
regexp: "^<p>Servi par "
line: "<p>Servi par {{ inventory_hostname_short }}</p>"
state: present
create: false

Ce playbook contient quatre tâches qui correspondent une à une aux quatre étapes du script Bash, mais formulées en termes d’état. La tâche 1 ne dit pas « installe nginx », elle dit « state: present » — peu importe comment on y arrive. La tâche 4 ne dit pas « append cette ligne », elle dit « garantis qu’une ligne matchant ce regex existe » avec ce contenu — la lecture sémantique est : je veux exactement une ligne, voici son motif et sa valeur.

C’est le module lineinfile qui porte cette logique. Il lit le fichier, cherche une ligne qui matche le regexp, et fait l’une de ces trois choses :

  • Si aucune ligne ne matche : il ajoute la ligne fournie.
  • Si une seule ligne matche et a déjà la valeur attendue : il ne fait rien (changed=0).
  • Si une ligne matche avec une valeur différente : il la remplace par la nouvelle valeur.

Cette logique « match-or-add » est l’essence de l’idempotence déclarative. Vous la retrouverez dans dnfstate: present » = installé, peu importe la version actuelle), firewalldstate: enabled » = règle active, peu importe l’historique), systemdstate: started » = service en marche), et la quasi-totalité des modules Ansible bien écrits.

Relançons le playbook trois fois de suite et regardons le compteur changed. Sortie réelle capturée sur le lab :

3 runs du playbook Ansible (web1.lab)
=== 1er run (état initial : nginx absent) ===
PLAY RECAP *********************************************************************
web1.lab : ok=4 changed=4 unreachable=0 failed=0
=== 2e run (idempotence) ===
PLAY RECAP *********************************************************************
web1.lab : ok=4 changed=0 unreachable=0 failed=0
=== 3e run (idempotence) ===
PLAY RECAP *********************************************************************
web1.lab : ok=4 changed=0 unreachable=0 failed=0
=== Vérification finale ===
$ curl -s http://10.10.20.21/ | grep -c "Servi par"
1

Trois lectures à faire ici. La première : au 1er run, changed=4 — Ansible a appliqué les 4 changements (paquet, firewall, service, ligne de page). La deuxième : au 2e et au 3e run, changed=0 — Ansible a vérifié l’état, constaté qu’il était déjà conforme à ce qu’on demandait, et n’a rien touché. La troisième : la page contient toujours exactement une ligne « Servi par », même après plusieurs runs. C’est la convergence.

Tous les modules Ansible ne sont pas idempotents par défaut. Voici la règle pour s’y retrouver, et les pièges les plus fréquents.

La grande majorité des modules « système » sont idempotents parce qu’ils raisonnent en état désiré : dnf, apt, service, systemd, user, group, file, copy, template, lineinfile, blockinfile, firewalld, selinux, mount, cron. Quand vous les utilisez avec state: present ou équivalent, ils vérifient l’état actuel avant d’agir. C’est ce qu’on appelle un module structuré.

Trois modules cassent l’idempotence parce qu’ils exécutent une commande shell brute sur la cible : ansible.builtin.command, ansible.builtin.shell, ansible.builtin.raw. Ils n’ont aucun moyen de savoir si la commande a déjà été lancée, et la rejouent systématiquement. Le compteur changed augmente à chaque run.

Pour les rendre idempotents, deux garde-fous existent. Le premier est le couple creates: / removes: : « ne lance la commande que si ce fichier n’existe pas / existe déjà ». Le second est changed_when: false : déclarer explicitement que la commande ne change rien (utile pour des commandes de lecture comme id ou getent).

- name: Compiler le code uniquement si le binaire n'existe pas
ansible.builtin.command: make build
args:
chdir: /opt/myapp
creates: /opt/myapp/bin/myapp # garde-fou : pas de re-exec si présent
- name: Vérifier que l'utilisateur 'ansible' existe (lecture seule)
ansible.builtin.command: id ansible
changed_when: false # déclare : « jamais changed »

Règle pratique : préférez toujours un module structuré quand il existe. Si vous tapez command: dnf install nginx, c’est un mauvais signe — utilisez ansible.builtin.dnf à la place. Si aucun module ne fait ce que vous voulez, ajoutez creates: ou changed_when: false pour rester idempotent.

Pour fixer le déclic mental, voici la comparaison côte à côte des deux approches. Lisez les colonnes : à gauche, ce qu’on perd avec un Bash impératif ; à droite, ce qu’on gagne avec Ansible déclaratif.

AspectScript Bash impératifPlaybook Ansible déclaratif
Ce qu’on écritUne séquence d’actionsUn état désiré
1er runFonctionneFonctionne
2e runDérive (ligne dupliquée)changed=0 (rien à faire)
Modèle mental« Comment »« Quoi »
Connaissance de l’état actuelAucuneVérifié avant chaque action
Modules type lineinfileInexistantStandard
Annulation propre (state: absent)À coder à la mainBuilt-in
Adapté au cron / CINon sans précautionOui
Lisibilité long termeDiminue avec la complexitéReste constante

La conclusion concrète : tant que votre script est petit et lancé une fois, le Bash fait l’affaire. Dès qu’il est rejoué, intégré dans un workflow ou partagé en équipe, l’idempotence devient indispensable. C’est précisément la valeur d’Ansible. Vous gagnez en fiabilité ce que vous perdez en familiarité — et la familiarité s’acquiert vite.

  • Un script impératif dit comment enchaîner des actions. Un playbook déclaratif dit quel état on veut.
  • L’idempotence signifie qu’un second run ne change rien (changed=0) — c’est le critère qualité d’un playbook.
  • Les modules structurés (dnf, systemd, lineinfile, firewalld) sont idempotents par conception. Les modules command/shell/raw ne le sont pas — ajoutez creates: ou changed_when: false.
  • Sur l’examen RHCE comme en code review, on attend changed=0 au second passage. Un playbook qui dérive est non conforme.
  • Le lab decouvrir/declaratif-vs-imperatif/ du dépôt lab-ansible reproduit cette démonstration en quelques minutes.

Cette page a un lab d’accompagnement : labs/decouvrir/declaratif-vs-imperatif/ dans stephrobert/ansible-training. Il contient un README.md guidé, un Makefile (make verify lance les tests), et un challenge final auto-évalué : comparer un script Bash impératif (qui dérive) à un playbook Ansible déclaratif (qui converge).

Une fois le lab provisionné :

Fenêtre de terminal
cd ~/Projets/ansible-training/labs/decouvrir/declaratif-vs-imperatif/
cat README.md # tuto pas à pas
cat challenge/README.md # consigne du challenge final
pytest -v challenge/tests/ # lancer les tests testinfra

Si les tests passent, vous maîtrisez les concepts couverts dans ce guide. En cas de blocage, docs/troubleshooting.md à la racine du repo couvre les pièges fréquents (rate-limit SSH, clé absente, collection manquante).

Ce site vous est utile ?

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

Je maintiens +700 guides gratuits, sans pub ni tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn