Un playbook Ansible qu’on peut relancer sans risque, encore et encore, avec le même résultat à chaque fois : c’est la promesse de l’idempotence, la même exigence que pour l’infrastructure as code au sens large, et c’est aussi la raison pour laquelle Ansible s’est imposé pour la configuration de serveurs là où un script bash accumule des effets de bord à chaque exécution. Cette promesse tient très bien pour les modules déclaratifs, et beaucoup moins bien dès qu’un playbook contient une tâche shell ou command.

Ce qui rend un module réellement idempotent

Un module Ansible bien conçu ne décrit pas une action, il décrit un état voulu, et compare cet état à la réalité avant d’agir.

- name: S'assurer que nginx est installé
  ansible.builtin.package:
    name: nginx
    state: present

Cette tâche vérifie d’abord si le paquet est déjà installé. Si oui, elle ne fait rien et se marque ok (pas changed). Ce n’est possible que parce que le module package sait interroger le gestionnaire de paquets sous-jacent pour connaître l’état réel avant de décider s’il doit agir. La distinction entre ok et changed dans la sortie d’un playbook est le signal le plus fiable pour repérer un module qui ne vérifie pas vraiment l’état avant d’agir : un module authentiquement idempotent rapporte changed uniquement la première fois, jamais aux exécutions suivantes.

Le piège : shell et command n’ont aucune idée de l’idempotence

Les modules shell et command exécutent littéralement ce qu’on leur donne, sans aucune logique de comparaison d’état. Une tâche comme celle-ci s’exécute à chaque fois, sans exception :

- name: Ajouter une ligne à un fichier de config
  ansible.builtin.shell: echo "max_connections = 200" >> /etc/app/config.ini

Rejouée dix fois, cette tâche ajoute la ligne dix fois. Elle se marque changed à chaque exécution, ce qui est au moins honnête sur ce qui s’est réellement passé, mais l’état final du fichier dépend du nombre de fois où le playbook a tourné, pas de sa définition. Le module lineinfile, conçu spécifiquement pour ce cas, vérifie si la ligne existe déjà avant de l’ajouter :

- name: Ajouter une ligne à un fichier de config (idempotent)
  ansible.builtin.lineinfile:
    path: /etc/app/config.ini
    line: "max_connections = 200"

Le check mode ne détecte pas ce problème

ansible-playbook --check simule l’exécution sans rien changer réellement, et rapporte ce qui aurait changé. C’est un outil précieux pour prévisualiser l’effet d’un playbook avant de l’appliquer en production, mais il a une limite qui piège régulièrement une équipe qui lui fait une confiance excessive : les modules shell et command n’ont aucune façon de prédire leur propre effet sans s’exécuter réellement, donc Ansible les traite en mode « on ne sait pas » lors d’un check. Selon la version et le contexte, cela se traduit soit par un skip silencieux de la tâche pendant le check (ce qui donne un faux sentiment de sécurité : le check passe alors que l’exécution réelle ferait quelque chose), soit par un marquage systématique changed même quand rien ne changerait en réalité (ce qui produit l’effet inverse, un faux positif qui alarme sans raison).

Une règle simple pour trier ses tâches

Le réflexe pratique : chercher un module dédié avant d’écrire une tâche shell ou command. La quasi-totalité des opérations courantes de gestion de configuration a un équivalent natif : lineinfile ou blockinfile pour éditer un fichier, copy ou template pour en déposer un, systemd pour gérer un service, user et group pour la gestion de comptes. Quand aucun module natif ne convient et qu’une commande shell reste nécessaire, deux options limitent les dégâts : ajouter une condition creates: ou removes: qui rend la tâche conditionnelle à la présence ou l’absence d’un fichier marqueur, ou écrire la commande elle-même de façon idempotente (un mkdir -p plutôt qu’un mkdir, par exemple, qui échouerait sur une deuxième exécution).

- name: Initialiser la base une seule fois
  ansible.builtin.command: /opt/app/init-db.sh
  args:
    creates: /opt/app/.db-initialized

Où ça se range

Un pipeline qui exécute des playbooks pour provisionner ou configurer une infrastructure hérite de tous les effets de bord de tâches non idempotentes à chaque nouvelle exécution du même job, ce qui rend le comportement du pipeline dépendant de son historique d’exécutions plutôt que de sa définition ; c’est le même risque de dérive silencieuse qu’un état Terraform mal géré, sur un tout autre outil. Fiabiliser ce genre de détail fait partie de ce qui se traite dans une mission d’industrialisation CI/CD.

À retenir

L’idempotence d’un module Ansible vient de sa capacité à comparer l’état voulu à l’état réel avant d’agir ; les modules shell et command n’ont aucune notion de cet état et s’exécutent à chaque fois sans condition. Le check mode ne rattrape pas ce problème : il traite ces modules en zone grise, produisant selon les cas un faux sentiment de sécurité ou un faux positif. Chercher un module dédié avant d’écrire une commande shell reste la meilleure protection.