
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: »- 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: »- 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 :
- ansible.builtin.shell: "rm -rf /tmp/old-cache" args: removes: /tmp/old-cache # ← skip si le fichier n'existe PASAnti-pattern 2 — command de lecture sans changed_when:
Section intitulée « Anti-pattern 2 — command de lecture sans changed_when: »- ansible.builtin.command: curl --version register: curl_outLe 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 »- ansible.builtin.command: curl --version register: curl_out 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 :
- 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: »- 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 :
- 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: »- ansible.builtin.lineinfile: path: /etc/myapp.cfg regexp: '^max_connections\s*=' # ← match les lignes existantes line: "max_connections = 200" state: present create: true🔍 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 = 7200stdout_callback = 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.
Lab pratique
Section intitulée « Lab pratique »Le lab troubleshooting/idempotence-perfs (labs/troubleshooting/idempotence-perfs/) couvre les 7 exercices : 3 anti-patterns d’idempotence + baseline de perfs + tuning + test idempotence en CI. Challenge final : refactorer un playbook non-idempotent vers idempotent (3 tâches mesurées).
À 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.