
Un playbook idempotent affiche changed=0 au second passage. Si vous voyez changed=N à chaque run, vos tâches mentent sur leur état, chaque exécution refait tout, casse les caches HTTP/CDN, redémarre des services inutilement, et fait perdre confiance dans le code. Cette page diagnostique les 3 anti-patterns les plus fréquents (shell sans creates:, lineinfile sans regexp:, command sans changed_when:), puis enchaîne sur le tuning performances SSH (pipelining + forks + ControlPersist) qui réduit typiquement le temps de run de 50 % à 60 %.
À la fin, vous saurez transformer un playbook bavard et lent en playbook idempotent et optimisé, et automatiser le test d'idempotence dans votre CI.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Identifier les modules non-idempotents par défaut (
command,shell,raw,script). - Rendre idempotent un
shellaveccreates:ouremoves:. - Forcer
changed_when:sur uncommandde lecture. - Fixer un
lineinfilequi duplique avecregexp:. - Mesurer avant/après avec
ansible.posix.profile_tasks. - Tuner SSH :
pipelining + forks + ControlPersistdansansible.cfg. - Automatiser le test d'idempotence en CI.
Prérequis
Section intitulée « Prérequis »- Avoir lu Verbosité Ansible.
- Connaître les modules
shell,command,lineinfile.
Modules non-idempotents par défaut
Section intitulée « Modules non-idempotents par défaut »Quatre modules toujours changed=1 sans configuration explicite :
| Module | Pourquoi non-idempotent | Fix |
|---|---|---|
command | Exécute toujours la commande | changed_when: ou creates:/removes: |
shell | Idem (avec shell) | changed_when: ou creates:/removes: |
raw | Bypass Python, pas d'état | Réservé au bootstrap, sinon switcher vers un module |
script | Exécute un script local | creates:/removes: |
À l'inverse, copy, template, lineinfile, file, dnf, systemd sont idempotents par construction : ils comparent l'état désiré à l'état réel.
Anti-pattern 1, shell sans creates:
Section intitulée « Anti-pattern 1, shell sans creates: »- name: Poser le marqueur de passage ansible.builtin.shell: "echo lab > /tmp/marker"Run 1 : changed=1. Run 2 : changed=1. Run 3 : changed=1. Anti-pattern, refait à l'infini.
Fix avec creates:
Section intitulée « Fix avec creates: »- name: Poser le marqueur de passage ansible.builtin.shell: "echo lab > /tmp/marker" args: creates: /tmp/marker # ← skip si le fichier existeRun 1 : changed=1. Run 2 : ok (skip). Idempotent.
Fix avec removes:
Section intitulée « Fix avec removes: »Variante inverse, pour une commande qui supprime :
- name: Purger l'ancien cache ansible.builtin.command: cmd: rm -rf /tmp/old-cache removes: /tmp/old-cache # ← skip si le fichier n'existe PASCet exemple mérite une réserve : il montre comment rattraper l'idempotence d'une commande, mais supprimer un répertoire n'a aucune raison de passer par une commande. Le module dédié le fait déjà, et il est idempotent par construction, sans removes: à gérer.
- name: Purger l'ancien cache (la bonne façon) ansible.builtin.file: path: /tmp/old-cache state: absentAnti-pattern 2, command de lecture sans changed_when:
Section intitulée « Anti-pattern 2, command de lecture sans changed_when: »- name: Relever la version de l'application ansible.builtin.command: /usr/local/bin/myapp --version register: app_versionLe module command retourne toujours changed=1, alors qu'on n'a fait que lire. Casse l'idempotence du playbook.
Fix avec changed_when: false
Section intitulée « Fix avec changed_when: false »- name: Relever la version de l'application ansible.builtin.command: /usr/local/bin/myapp --version register: app_version changed_when: false # ← lecture seule, jamais de changement🔍 Observation : changed_when: false est le pattern standard pour toute commande de diagnostic / lecture. Préserve l'idempotence.
changed_when: conditionnel
Section intitulée « changed_when: conditionnel »Cas plus subtil, on veut signaler changed uniquement si la sortie indique un changement :
- name: Appeler l'API ansible.builtin.uri: url: "http://localhost:8080/health" return_content: true register: health changed_when: "'OK' not in health.content" # ← changed seulement si KO🔍 Observation : changed_when: accepte une expression Jinja2/Python. Permet de dériver le verdict du résultat de la tâche.
Anti-pattern 3, lineinfile sans regexp:
Section intitulée « Anti-pattern 3, lineinfile sans regexp: »- name: Ajuster myapp.cfg ansible.builtin.lineinfile: path: /etc/myapp.cfg line: "max_connections = 100" state: present create: trueRun 1 : changed=1, fichier contient max_connections = 100.
Plus tard, on update :
- name: Ajuster le fichier de configuration ansible.builtin.lineinfile: line: "max_connections = 200"Run 2 : changed=1, fichier contient maintenant les 2 lignes :
max_connections = 100max_connections = 200Duplication. Casse la config.
Fix avec regexp:
Section intitulée « Fix avec regexp: »- name: Ajuster myapp.cfg ansible.builtin.lineinfile: path: /etc/myapp.cfg regexp: '^max_connections\s*=' # ← match les lignes existantes line: "max_connections = 200" state: present create: true mode: "0644"🔍 Observation : avec regexp:, lineinfile remplace au lieu d'ajouter. Toujours mettre regexp: sauf au tout premier state: present.
Test d'idempotence, automatiser en CI
Section intitulée « Test d'idempotence, automatiser en CI »# Run 1 (peut avoir des changes)ansible-playbook lab.yml > /tmp/run1.log
# Run 2 (DOIT avoir changed=0)ansible-playbook lab.yml | tee /tmp/run2.log
# Échec si le 2e run a des changesif grep -E 'changed=[1-9]' /tmp/run2.log; then echo "❌ IDEMPOTENCE KO" exit 1fiecho "✅ IDEMPOTENCE OK"À mettre dans le pipeline ansible-playbook --check --diff. Bloque les régressions où un dev oublie un changed_when: ou un creates:.
Tuning performances, 3 leviers cumulés
Section intitulée « Tuning performances, 3 leviers cumulés »1. pipelining=True
Section intitulée « 1. pipelining=True »Sans pipelining, chaque tâche fait 3 appels SSH : mkdir tmp/, scp module.py, python module.py. Avec pipelining, 1 seul appel SSH qui pipe tout via stdin.
[ssh_connection]pipelining = TrueGain typique : -30 % à -40 % sur le temps total.
2. forks=20
Section intitulée « 2. forks=20 »[defaults]forks = 20 # default : 5Exécute 20 hôtes en parallèle au lieu de 5. Gain typique : x2-x4 sur une fleet de 50+ hosts.
3. ControlMaster + ControlPersist
Section intitulée « 3. ControlMaster + ControlPersist »[ssh_connection]ssh_args = -C -o ControlMaster=auto -o ControlPersist=60sOpenSSH réutilise la connexion pendant 60 s pour les tâches suivantes, pas besoin de refaire le handshake TCP+SSH (~1s économisée par tâche).
ansible.cfg complet recommandé
Section intitulée « ansible.cfg complet recommandé »[defaults]forks = 20gathering = smartfact_caching = jsonfilefact_caching_connection = /tmp/ansible-fact-cachefact_caching_timeout = 7200callback_result_format = yamlcallbacks_enabled = ansible.posix.profile_tasks, ansible.posix.timer
[ssh_connection]pipelining = Truessh_args = -C -o ControlMaster=auto -o ControlPersist=60s🔍 Observation : avec ces 3 optimisations cumulées, gain typique -50 % à -60 % sur un playbook multi-host. Mesurez avec time ansible-playbook ... avant/après.
Cache des facts (gathering: smart)
Section intitulée « Cache des facts (gathering: smart) »Pour une fleet stable, les facts (collectés au début de chaque play) sont identiques entre runs successifs. Activer le cache :
[defaults]gathering = smartfact_caching = jsonfilefact_caching_connection = /tmp/ansible-fact-cachefact_caching_timeout = 7200 # 2 heures🔍 Observation : gathering: smart ne re-collecte que si le cache est expiré. Sur une fleet de 50 hosts, économise typiquement 5-10 secondes au début de chaque playbook.
Mettre en pratique
Section intitulée « Mettre en pratique »Un playbook qui affiche changed=N à chaque passage ne se répare qu'en observant le second run, pas en relisant le YAML. Le lab vous fait reproduire les trois anti-patterns décrits plus haut, puis les corriger un par un : creates: sur un shell, changed_when: sur une commande de lecture, regexp: sur un lineinfile qui duplique. Vous relevez ensuite un baseline avec profile_tasks avant d'activer pipelining, forks et ControlPersist, et le challenge refactore un playbook complet dont les tâches sont mesurées. La vérification compare le second passage au premier : c'est changed=0 qui fait foi.
À retenir
Section intitulée « À retenir »command,shell,raw,script= non-idempotents par défaut.creates:/removes:rendent unshellidempotent par check de fichier.changed_when: falsesur les commandes de lecture/diagnostic.lineinfiletoujours avecregexp:(sauf premier state: present).- 3 leviers de tuning :
pipelining,forks,ControlPersist. gathering: smart+ cache : économise les facts collectés.- Test idempotence en CI : run 2× et
grep changed=[1-9]doit retourner vide.
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Pipeline CI matrice : Le workflow complet qui rejoue les tests à chaque commit, double passage compris.
- Aide-mémoire RHCE : Une page imprimable qui rassemble les pièges classiques, dont ceux qui cassent l'idempotence.
- Sécurité Ansible : vue d'ensemble : Les règles de production, où l'abus de shell et de command devient un risque de sécurité et plus seulement un défaut de qualité.