4. Playbooks, plays and handlers
Ansible · 4. Playbooks, plays and handlersA playbook is a YAML list of plays. A play maps a set of hosts to a list of tasks. That is the whole structure, and everything else is a keyword on one of those two things.
---- name: Configure the web tier hosts: webservers become: true
vars: nginx_worker_processes: auto
pre_tasks: - name: Check we are on a supported release ansible.builtin.assert: that: ansible_facts['distribution_major_version'] | int >= 22
roles: - common - nginx
tasks: - name: Deploy the site configuration ansible.builtin.template: src: site.conf.j2 dest: /etc/nginx/conf.d/site.conf owner: root mode: '0644' validate: 'nginx -t -c %s' notify: Reload nginx
post_tasks: - name: Wait for the port to answer ansible.builtin.wait_for: port: 443 timeout: 30
handlers: - name: Reload nginx ansible.builtin.service: name: nginx state: reloadedThe order things run in
Section titled “The order things run in”This is the part people get wrong, and it is fixed:
1. gather_facts (unless disabled) 2. pre_tasks 3. handlers notified by pre_tasks ← flushed here 4. roles 5. tasks 6. post_tasks 7. handlers notified by roles/tasks/post_tasksTwo things to notice.
Handlers are flushed after pre_tasks, not only at the end. This is why pre_tasks is the right place for anything that must complete before roles begin — draining a load balancer, stopping a service.
Roles run before tasks, regardless of where you write them in the file. A tasks: block written above roles: in the YAML still runs after the roles. If you want tasks before roles, they go in pre_tasks. This surprises people exactly once.
Play-level keywords worth knowing
Section titled “Play-level keywords worth knowing”- hosts: webservers become: true gather_facts: true # default; false is faster serial: 2 # rolling — chapter 11 strategy: linear # default; also free, host_pinned order: inventory # also sorted, reverse_sorted, # reverse_inventory, shuffle any_errors_fatal: false # stop everything if any host fails max_fail_percentage: 25 # abort if more than 25% fail vars_files: - vars/common.yml environment: # env vars for every task in the play http_proxy: http://proxy.example.com:3128 check_mode: false tags: [web]strategy is worth a paragraph because it changes the shape of a run.
linear (default) Every host runs task 1. Ansible waits for ALL of them. Then every host runs task 2. → predictable, and as slow as the slowest host at every single step
free Each host races ahead through the task list as fast as it can, independently. → much faster on heterogeneous fleets → but hosts are at different tasks at the same moment, which breaks anything that assumes ordering across hosts
host_pinned Like free, but a worker sticks with one host until it finishes.Use linear unless you have measured a reason not to. free is genuinely faster on a large mixed fleet and it quietly invalidates any orchestration that depends on all hosts reaching the same point together — which is most of chapter 11.
A task is a name, a module, and its arguments, plus optional keywords.
- name: Ensure the app user exists ansible.builtin.user: name: app uid: 1500 shell: /usr/sbin/nologin state: present become: true when: ansible_facts['os_family'] == 'Debian' tags: [users] register: app_user_resultAlways write name:. It is optional and omitting it is a mistake — the name is what appears in output, what --start-at-task matches, what --list-tasks shows, and what tells the next person what the task was for. An unnamed task prints its module and arguments, which is noise.
Write names as statements of desired state, not actions: “Ensure nginx is installed”, not “Install nginx”. The task may well do nothing, and a log full of “Install nginx → ok” reads oddly. This is a small habit that makes output much easier to read.
The result of a task
Section titled “The result of a task”Every task produces one of four outcomes:
ok the module ran and nothing needed doing changed the module ran and altered the system failed the module returned an error skipped a `when` condition was falsePlus unreachable, which is different in kind — the host could not be contacted at all, so nothing was attempted. Unreachable hosts are removed from the play for its remaining tasks.
register captures the module’s full JSON result into a variable, which is how tasks talk to each other:
- name: Read the current version ansible.builtin.command: /opt/app/bin/version register: version_check changed_when: false
- name: Upgrade if it is old ansible.builtin.include_tasks: upgrade.yml when: version_check.stdout is version('2.4.0', '<')Note changed_when: false on the first task. command always reports changed, because it cannot know better. Chapter 5 is about that.
Handlers
Section titled “Handlers”A handler is a task that runs only if something notified it, and only once, at the end of the play.
tasks: - name: Deploy nginx config ansible.builtin.template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf notify: Reload nginx
- name: Deploy site config ansible.builtin.template: src: site.conf.j2 dest: /etc/nginx/conf.d/site.conf notify: Reload nginx
handlers: - name: Reload nginx ansible.builtin.service: name: nginx state: reloadedBoth templates changed? nginx reloads once. Neither changed? nginx does not reload at all. That is exactly right, and it is why handlers exist.
The five rules
Section titled “The five rules”1. Notification requires changed. A task that reports ok notifies nothing. This is the mechanism working correctly, and it is also the commonest confusion: “my handler did not run” almost always means “the task did not change anything”.
2. Handlers run at the end of the play, not where they are notified. If you need a service restarted before a later task in the same play, you must force it:
- name: Flush handlers now ansible.builtin.meta: flush_handlers3. Handlers run in the order they are defined, not the order they were notified. Two handlers notified in the order B, A run in the order A, B if that is how the handlers: section lists them. To sequence handlers, have one handler notify another.
4. Handlers do not run if the play fails. This is the rule that bites hardest. Host fails at task 12, the handler notified at task 3 never runs, and you are left with a config file written and the service still running the old one — the worst possible half-state.
ansible-playbook site.yml --force-handlers# or, permanently:- hosts: webservers force_handlers: trueConsider force_handlers: true on plays where a half-applied config is worse than a failure, which is most of them.
5. Handlers are per-play. A handler notified in one play does not exist in the next. Notifications do not cross the play boundary.
listen — many notifiers, one name
Section titled “listen — many notifiers, one name”tasks: - ansible.builtin.template: src: a.conf.j2 dest: /etc/app/a.conf notify: Restart the app stack
handlers: - name: Restart the API ansible.builtin.service: {name: api, state: restarted} listen: Restart the app stack
- name: Restart the worker ansible.builtin.service: {name: worker, state: restarted} listen: Restart the app stackOne notification, several handlers. This is how roles expose a stable notification name without callers needing to know how many services are behind it — and it is the right way for a role to publish its handlers.
Validate before you break something
Section titled “Validate before you break something”The template and copy modules take a validate argument, and it deserves more use than it gets:
- ansible.builtin.template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf validate: 'nginx -t -c %s'Ansible writes to a temporary file, runs the command with %s replaced by that path, and only moves it into place if the command succeeds. A syntax error in your template becomes a failed task rather than a service that will not start on next reload.
validate: 'nginx -t -c %s' validate: 'visudo -cf %s' ← especially this one validate: 'sshd -t -f %s' validate: 'named-checkconf %s' validate: '/usr/bin/env python3 -c "import json,sys; json.load(open(sys.argv[1]))" %s'visudo -cf %s on any sudoers file is not optional. A broken sudoers file locks everyone out of privilege escalation on that machine, including Ansible, and fixing it requires console access.
Writing a playbook that reads well
Section titled “Writing a playbook that reads well”---- name: Base configuration for every machine hosts: all become: true roles: [common, ntp, monitoring]
- name: Database tier hosts: databases become: true serial: 1 # one at a time, always roles: [postgresql]
- name: Web tier hosts: webservers become: true serial: '25%' roles: [nginx, app]One playbook, several plays, each targeting a different group. ansible-playbook site.yml configures everything; --limit databases does one tier; --tags monitoring does one concern across all of them. This shape scales from three machines to three thousand without changing.
What to take from this chapter
Section titled “What to take from this chapter”- A playbook is a list of plays; a play maps hosts to tasks. Everything else is a keyword on one of those.
- The order is fixed: facts →
pre_tasks→ handlers →roles→tasks→post_tasks→ handlers. Roles run beforetasksregardless of file order. strategy: linearis the default and usually right.freeis faster on mixed fleets and silently breaks anything that assumes hosts are in step.- Always write
name:, and phrase it as desired state — “Ensure X is present”, not “Install X”. - Outcomes are ok / changed / failed / skipped, plus unreachable, which removes the host from the rest of the play.
- Handlers: notification requires
changed, they run at the end of the play, in definition order, once, and not at all if the play fails. meta: flush_handlersruns them early;force_handlers: trueruns them despite failure — worth defaulting to where a half-applied config is worse than an error.listenlets one notification trigger several handlers, which is how a role publishes a stable name.validate:ontemplateandcopyturns a broken config into a failed task instead of a dead service.visudo -cf %sis mandatory.
Next: modules and idempotence — what changed really means, why command is never idempotent, and the small number of keywords that let you tell Ansible the truth about what a task did.