14. Failure modes
Ansible · 14. Failure modesSymptoms first, because that is how you meet them.
1. The playbook succeeded and did nothing
Section titled “1. The playbook succeeded and did nothing”Symptom. Exit code 0, a recap with no hosts in it, and no change on any machine.
[WARNING]: Could not match supplied host pattern, ignoring: webserver PLAY [Configure web tier] **************** skipping: no hosts matched PLAY RECAP *******************************Cause. The host pattern matched nothing — a typo in a group name, the wrong inventory, or a --limit that intersects to nothing. Ansible treats this as a warning and exits 0.
Fix.
ansible-inventory -i inventory/production --graphansible webservers --list-hostsAnd in CI, assert that work happened rather than trusting the exit code — a PLAY RECAP with no hosts is a failed deployment that reported success.
2. A variable will not change no matter where I set it
Section titled “2. A variable will not change no matter where I set it”Symptom. You set nginx_port in group_vars, in host_vars, in the play. It stays at the old value.
Causes, in order of likelihood:
a. it is in the role's vars/main.yml, not defaults/ → precedence 15 beats every group_vars (chapter 6)
b. something did a set_fact on it → precedence 19, beats nearly everything
c. it is being passed with -e somewhere → precedence 22, beats everything
d. the group_vars file is named after a group that does not exist (webserver vs webservers) → loads for nobody, silentlyFind it.
ansible-inventory --host web1.example.com # merged varsansible-playbook site.yml -vvv | grep -i nginx_portgrep -rn "nginx_port" roles/ group_vars/ host_vars/Fix. (a) is the common one and the fix is to move the variable to defaults/main.yml, where callers can override it.
3. My handler never runs
Section titled “3. My handler never runs”Symptom. The config file is updated. The service is not restarted.
Causes:
a. the task reported `ok`, not `changed` → nothing was notified. This is correct behaviour and it is the answer 80% of the time.
b. a later task failed → handlers do not run on a failed host (chapter 4)
c. the handler name does not match the notify string, exactly, including case
d. the handler is in a different play → notifications do not cross plays
e. you expected it to run at that point in the play → handlers run at the ENDFix. For (b), --force-handlers, or force_handlers: true on the play. For (e), meta: flush_handlers. For (a), confirm with --diff that the file genuinely did not change — if it did not, the handler is right not to run.
4. The second run reports twenty changes
Section titled “4. The second run reports twenty changes”Symptom. Nothing changed on the machine, and the playbook says otherwise.
Cause. Non-idempotent tasks (chapter 5). Almost always:
command / shell with no changed_when lineinfile whose regexp matches what it writes replace with a self-matching pattern template rendering a timestamp or a random value file: mode set without a leading zero — '644' is not the same as '0644' a git module on a branch that movesFind it.
ansible-playbook site.yml --diff | grep -B5 'changed:'Fix. changed_when: false on read-only commands, creates:/removes: where possible, and quote file modes as '0644'. The mode one is worth emphasising: unquoted 0644 in YAML is the octal-looking integer 644, which is a different permission set, and it changes every run.
5. sudo: a password is required
Section titled “5. sudo: a password is required”Symptom. Everything fails at the first become: true.
Cause. No passwordless sudo, and no password supplied.
Fix.
ansible-playbook site.yml --ask-become-pass # -KOr configure passwordless sudo for the automation user, scoped:
# /etc/sudoers.d/ansible — validate with visudo -cf deploy ALL=(ALL) NOPASSWD: ALLAnd note chapter 4’s warning: any playbook that writes a sudoers file must use validate: 'visudo -cf %s', because a broken one locks out privilege escalation entirely, including Ansible’s.
6. Failed to connect to the host via ssh
Section titled “6. Failed to connect to the host via ssh”Symptom. unreachable, not failed.
Causes, in order:
a. the host really is down b. host key verification failed (new or changed key) c. wrong ansible_user or no key on the target d. a bastion is needed and is not configured e. ansible_host is wrong or DNS is staleDiagnose with the actual SSH command Ansible is running:
ansible-playbook site.yml -vvvv 2>&1 | grep -m1 'SSH: EXEC'Then run that command by hand. This resolves the majority of connection problems in one step, because you see the real arguments rather than guessing at them.
For (d), the fix belongs in inventory rather than in someone’s ~/.ssh/config:
ansible_ssh_common_args: '-o ProxyJump=bastion.example.com'7. /usr/bin/python: not found
Section titled “7. /usr/bin/python: not found”Symptom. Every module fails on one host, or on a whole distribution.
Cause. No Python, or Ansible’s discovery picked a path that does not exist.
Fix. Bootstrap with raw, which needs no Python (chapter 3):
- hosts: new_machines gather_facts: false tasks: - ansible.builtin.raw: | test -e /usr/bin/python3 || \ (apt-get update && apt-get install -y python3-minimal) changed_when: false - ansible.builtin.setup:Then pin ansible_python_interpreter in inventory so discovery cannot change its mind later.
8. The conditional check failed
Section titled “8. The conditional check failed”Symptom.
fatal: [web1]: FAILED! => {"msg": "The conditional check 'app_config.stdout is defined' failed. The error was: error while evaluating conditional: 'dict object' has no attribute 'stdout'"}Cause. A registered variable from a task that was skipped. The variable exists — with skipped: true — but has no stdout.
Fix.
when: app_config is not skipped and 'x' in app_config.stdout# orwhen: app_config.stdout | default('') is search('x')The same error under --check usually means a command task skipped in check mode. Add check_mode: false to read-only commands (chapter 5).
9. My when condition is always true
Section titled “9. My when condition is always true”Symptom. A task guarded by when: enable_feature runs even when the feature is off.
Cause. enable_feature is the string "false", not the boolean. Non-empty strings are true. This happens with -e enable_feature=false, with values from environment variables, and with quoted booleans in YAML.
Fix.
when: enable_feature | boolPut | bool on every boolean that came from outside a plain YAML file. Chapter 7 has the full explanation; this is the single most common one-line bug in Ansible.
10. Tags do not do what I expect
Section titled “10. Tags do not do what I expect”Symptom. --tags setup runs far more than the setup tasks, or nothing at all.
Cause. include_tasks with a tag on the include statement. The tag applies to the include, not to the tasks inside it — so once the include runs, everything inside runs unconditionally (chapter 8).
Fix.
- ansible.builtin.include_tasks: file: setup.yml apply: tags: [setup] tags: [setup]Or use import_tasks, where tags propagate automatically. Check what you are about to run:
ansible-playbook site.yml --list-tasks --tags setup11. It takes twenty minutes and it should not
Section titled “11. It takes twenty minutes and it should not”Symptom. Slow, out of proportion to what it does.
Causes, in order of impact:
a. forks = 5 (the default) b. pipelining off (the default) c. fact gathering on every host, uncached d. a loop over `command` or `package` that should be one call with a list e. ControlPersist too short for the gaps between tasksFind it before fixing it.
callbacks_enabled = ansible.posix.profile_tasksAlmost always one or two tasks are the whole runtime, and it is usually (c) or (d).
12. A file is world-readable and contains a password
Section titled “12. A file is world-readable and contains a password”Symptom. Found in a review, or an audit, or worse.
Causes:
a. template/copy with no `mode:` — the default follows the remote user's umask, often 0644 b. no_log missing, so the value is in a CI log forever c. a loop printed its items, passwords included d. someone ran `ansible-vault decrypt` and committed the resultFix. Explicit mode: '0600' and owner: on anything with a secret, no_log: true on the task, loop_control: label: on the loop, and ansible-vault edit rather than decrypt (chapter 12).
And treat a secret that reached a CI log as compromised, not as something to delete. Rotate it.
The debugging order
Section titled “The debugging order”# 1. What will run, and where?ansible-playbook site.yml --list-hosts --list-tasks
# 2. What would it change?ansible-playbook site.yml --check --diff --limit web1
# 3. What does this host actually believe?ansible-inventory --host web1ansible web1 -m ansible.builtin.setup
# 4. What is Ansible really doing?ansible-playbook site.yml -vvv --limit web1
# 5. Step through itansible-playbook site.yml --step --limit web1
# 6. Resume from where it brokeansible-playbook site.yml --start-at-task "Deploy the config"Steps 1 and 2, on one host, answer most questions before anything is changed. And --start-at-task is the flag that turns “the playbook failed at task 40” from a fifteen-minute re-run into a fifteen-second one.
The retry file is worth knowing about too:
[defaults]retry_files_enabled = trueretry_files_save_path = ~/.ansible-retryansible-playbook site.yml --limit @~/.ansible-retry/site.retryThat re-runs only the hosts that failed, which on a two-hundred-host run is the difference between a coffee and an afternoon.
What to take from this chapter
Section titled “What to take from this chapter”- A host pattern that matches nothing exits 0. Assert in CI that hosts were actually touched.
- A variable that will not change is usually in a role’s
vars/instead ofdefaults/— or was set byset_fact, or passed with-e, or lives in agroup_varsfile named after a group that does not exist. - A handler that never runs usually means the task reported
ok— which is correct. The other causes are a failed host, a name mismatch, a play boundary, or expecting it to run early. - A second run with changes means a non-idempotent task. Watch for
commandwithoutchanged_when, self-matchinglineinfileregexes, and unquoted file modes —0644is not'0644'. -vvvvand then run the printedSSH: EXECcommand by hand. It resolves most connection problems in one step.- Bootstrap missing Python with
raw, then pin the interpreter. - “dict object has no attribute stdout” means the task was skipped. Guard with
is not skipped, and addcheck_mode: falseto read-only commands. - A
whenthat is always true is a string.| bool. - Tags do not reach inside
include_taskswithoutapply:. Check with--list-tasks --tags. - Slow is usually uncached facts or a loop that should be a list.
profile_tasksfinds it. - Set
mode: '0600'andno_log: trueon anything with a secret, and rotate anything that reached a log. - Debug in order:
--list-tasks→--check --diff --limit→ansible-inventory --host→-vvv→--start-at-task. And--limit @...retryre-runs only what failed.
Next: the honest verdict — where Ansible is clearly right, where containers and Terraform win, and the two questions that decide it.