AnsibleXI · PlaybooksPlaybooks
Anatomy of a play
What you'll learn
- Identify the play, not the file, as the unit of Ansible execution
- Name the keys that define a play and state what each one controls
- Write a playbook containing several plays with different targets
- Explain why an unnamed task is an operational defect rather than a style one
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
A playbook is a file. A play is the thing that runs.
That distinction sounds pedantic until the first incident review, when
somebody asks “what did that playbook touch?” and the honest answer is
“it depends which play you mean”. A single .yml file can contain four
plays aimed at four different groups, each with its own privilege
escalation setting, its own variables and its own handlers. Reading the
filename tells you nothing. Reading the plays tells you everything.
The play is a target plus a list of things to be true
Strip a play to its skeleton and it has two mandatory parts: a target expression and a body of work.
- name: Configure the web tier
hosts: web
tasks:
- name: Ensure the vhost configuration is present
ansible.builtin.debug:
msg: 'placeholder for a real module'
Everything else is a modifier on those two. The keys you will use daily, and what each one actually controls:
| Key | Controls | Failure it causes when wrong |
|---|---|---|
hosts | Which hosts this play runs against | The entire blast radius. A typo here is the most expensive typo in the file. |
become | Whether tasks escalate privilege | Tasks fail with permission errors, or worse, succeed as root when they should not have |
gather_facts | Whether the setup module runs first | ansible_facts is empty and every templated value silently becomes undefined |
vars | Play-scoped variables | Values baked into the playbook instead of inventory, invisible to anyone reading the inventory |
tasks | The main body of work | — |
handlers | Tasks that run only when notified | A restart that never happens, or one that happens every night |
pre_tasks / post_tasks | Work that brackets the main body | Drain and restore steps that run in the wrong place |
roles | Reusable task collections applied to this play | — |
serial | How many hosts at a time | Omitted, and the play changes the whole group simultaneously |
strategy | Task scheduling across hosts | Progress becomes hard to interpret when a run stops halfway |
The full list is long and mostly rare. The playbook keywords reference is worth bookmarking rather than memorising; it also tells you which keywords are legal at play level, block level and task level, which is the question that actually comes up.
One file, several plays, different targets
A play boundary is a target change. When the next thing you need to do
happens on a different set of machines, you start a new play — you do
not add a when: inventory_hostname in groups['lb'] to every task.
- name: Configure the load balancer
hosts: lb
gather_facts: false
tasks:
- name: Render the backend pool
ansible.builtin.debug:
msg: 'the balancer is the only host that should see this'
- name: Deploy the web tier
hosts: web
tasks:
- name: Apply the site-specific tuning
ansible.builtin.debug:
msg: 'and the web servers are the only hosts that should see this'
The two plays run in file order: the whole of play one across all its hosts, then the whole of play two. That ordering is the mechanism behind almost every drain-and-restore pattern in the course — the balancer play removes hosts from rotation before the web play touches them.
gather_facts is a decision, not a default
By default a play begins by running the setup module against every
targeted host, which is a full connection plus a few seconds of data
collection per host. On a 500-host play that is measurable time, and
half the time the play never reads a single fact.
gather_facts: false on a play that does not need facts is free speed.
gather_facts: false on a play whose template renders
{{ ansible_facts['default_ipv4']['address'] }} is a run that fails
with an undefined-variable error on every host, and the error will point
at the template rather than at the play header.
The fact-gathering step is a real task with a real name, and it appears
in output as TASK [Gathering Facts] before anything you wrote:
$ ansible-playbook -i inventory.ini facts.ymlPLAY [Where fact gathering sits] ***********************************************
TASK [Gathering Facts] *********************************************************
ok: [localhost]
TASK [First pre_task] **********************************************************
ok: [localhost] => {
"msg": "p"
}
TASK [First task] **************************************************************
ok: [localhost] => {
"msg": "t"
}
PLAY RECAP *********************************************************************
localhost : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Note the ok=3. Fact gathering counts toward the recap totals, so a
play with two tasks that reports ok=3 is not a mystery — it is the
setup module.
Every task gets a name
This is the rule that reads like style advice and is not.
Ansible prints the task name as the run progresses, writes it into the
callback output that your logging pipeline captures, and uses it as the
key for --start-at-task. When a task has no name, Ansible falls back
to the module it invokes. Here is the same playbook with two unnamed
tasks and one named one:
$ ansible-playbook -i inventory.ini unnamed.yml --list-tasksplaybook: unnamed.yml
play #1 (web): Deploy the web tier TAGS: []
tasks:
ansible.builtin.debug TAGS: []
ansible.builtin.command TAGS: []
Reload the web server configuration TAGS: []Now imagine that file has nine ansible.builtin.command tasks, one of
them failed at 03:00, and the on-call engineer is reading the log. Every
line says TASK [ansible.builtin.command]. The log records that
something ran and refuses to say what.
The name is the only human-readable description of intent that survives into the audit trail. Write it as a statement of the desired state, not as a description of the mechanism:
- Good:
Ensure the nginx vhost for the API is present - Poor:
Run template module - Bad: no name at all
A play that is worth reviewing
Putting it together, with the parts labelled:
- name: Deploy the web tier # appears in the log as PLAY [...]
hosts: web # the blast radius
become: true # every task below runs as root
gather_facts: true # the templates below read facts
vars:
app_listen_port: 8080 # play-scoped; prefer inventory
pre_tasks:
- name: Remove the host from the pool
ansible.builtin.debug:
msg: 'drain step'
roles:
- webserver # reusable, versioned, tested
tasks:
- name: Ensure the application config is current
ansible.builtin.debug:
msg: 'main body'
notify: Reload the web server
post_tasks:
- name: Return the host to the pool
ansible.builtin.debug:
msg: 'restore step'
handlers:
- name: Reload the web server
ansible.builtin.debug:
msg: 'runs only if something above reported changed'
Read it top to bottom and you can answer, without running anything: which hosts, with what privilege, using which variables, in what order, and what triggers the reload. That is the property the rest of this part is built on.
Knowledge check
Knowledge check · 4 questions
Q1. A single playbook file contains three plays targeting lb, web and db. What determines the order in which the three groups are touched?
Q2. A play with two tasks and gather_facts left at its default reports ok=3 in the recap. What is the third ok?
Q3. Which are genuine operational consequences of leaving tasks unnamed? Select all that apply.
Q4. Setting become: true at play level means every task in that play, including tasks contributed by roles, will run with escalated privilege and cannot be exempted.
Passing score: 75%. Answers are checked in this browser.