The problem
Ad-hoc commands are one module at a time, typed by hand, gone when you close the terminal. The real work - "install nginx, put the config in place, make sure it runs"
- is several steps, in order, on a group of hosts, and it has to be repeatable and
reviewable in git. That is a playbook: a YAML file describing the desired state, which you run with ansible-playbook.
What you need to know already: the previous lesson (inventory, patterns, ansible.cfg, ad-hoc output), 1.11 (apt), 2.18 (enabled vs started), 7.11 (lists and dictionaries in structured data).
Enough YAML for Ansible
A playbook is YAML, a text format for nested data. You need five rules:
# a comment
name: web-1 # a mapping (dictionary): key, colon, SPACE, value
ports: [80, 443] # a list, inline
packages: # a list, one "- " item per line
- nginx
- htop
service: # nesting = indentation, with SPACES (never tabs)
name: nginx
enabled: true
- Indentation is structure. Two spaces per level is the convention. Tabs are an error.
key: valueneeds the space after the colon.a: b: cis broken YAML: the second colon starts a mapping where a value was expected.- Quote values that start with
{{.msg: {{ name }}is a YAML dictionary inside a dictionary, not a template; writemsg: "{{ name }}". - Booleans: write
true/false. YAML 1.1 (which Ansible's parser follows) also readsyes,no,on,offas booleans - socountry: NOisfalse. Quote strings that look like booleans or numbers. - File modes are strings:
mode: "0644". Unquoted0644happens to work (YAML reads it as the octal number 420), but unquoted644is the decimal 644 - which is octal 1204, a very strange mode.
Multi-line text uses | (keep the newlines) or > (fold them into spaces):
content: |
line one
line two
A play, its tasks, their modules
# site.yml
- name: Web servers # a PLAY: which hosts, how, what to do
hosts: web # a pattern, like ad-hoc
become: true # sudo for every task in the play
tasks:
- name: Install nginx # a TASK: one module call, with a name
ansible.builtin.apt: # the module
name: nginx # its arguments
state: present
- name: Start and enable nginx
ansible.builtin.service:
name: nginx
state: started
enabled: true
- The file is a list of plays (the leading
-). Each play maps a pattern (hosts:) to a list of tasks. - Each task calls one module with arguments.
name:is what the output prints; always write one, in plain words, like a step in a runbook. ansible.builtin.aptis the module's FQCN (fully qualified collection name): collectionansible.builtin, moduleapt. The short nameaptstill works, but the FQCN says exactly whichaptyou mean once collections add their own modules.- The modules describe state:
state: present(installed),state: started(running),enabled: true(starts at boot). Not "install", not "start" - so running it again does nothing.
ansible-doc is the manual for every module, offline:
$ cd ~/oncall-lab/labs/1a-ansible/try/playbooks
$ ansible-doc -s ansible.builtin.service
- name: Manage services
ansible.builtin.service:
arguments: # .
enabled: # Whether the service should start on boot.
name: # (required) Name of the service.
pattern: # .
runlevel: # .
sleep: # .
state: # started/stopped are idempotent actions that will not run commands unless necessary.
use: # .
Running it
$ ansible-playbook site.yml
PLAY [Web servers] *************************************************************
TASK [Gathering Facts] *********************************************************
ok: [web-1]
ok: [web-2]
TASK [Install nginx] ***********************************************************
changed: [web-1]
changed: [web-2]
TASK [Start and enable nginx] **************************************************
ok: [web-1]
ok: [web-2]
PLAY RECAP *********************************************************************
web-1 : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web-2 : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Read the output top to bottom (and notice the service task said ok: on Ubuntu, the nginx package starts and enables the service when it is installed, so there was nothing left to do):
PLAY [Web servers]- a banner per play, padded with*to the terminal width.TASK [Gathering Facts]- before your tasks, Ansible runs thesetupmodule on every host to collect facts (OS, memory, IPs; two lessons from now). Turn it off withgather_facts: falsewhen you do not need them.- One
TASK [...]banner per task, then one line per host:ok,changed,skippingorfatal. Ansible runs task by task: every host does task 1, then every host does task 2. A host that fails drops out of the rest of the play; the others go on. PLAY RECAP- one line per host:
ok tasks that ran successfully (changed ones included)
changed tasks that changed something on the host
unreachable could not connect
failed tasks that failed (the host stopped there)
skipped tasks whose `when:` was false
rescued failures handled by a rescue block
ignored failures with ignore_errors: true
Now the test from the first lesson. Run it again:
$ ansible-playbook site.yml
PLAY [Web servers] *************************************************************
TASK [Gathering Facts] *********************************************************
ok: [web-1]
ok: [web-2]
TASK [Install nginx] ***********************************************************
ok: [web-1]
ok: [web-2]
TASK [Start and enable nginx] **************************************************
ok: [web-1]
ok: [web-2]
PLAY RECAP *********************************************************************
web-1 : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web-2 : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
changed=0: both hosts were already in the desired state, so nothing happened. That second run is how you know the playbook is idempotent.
The exit code tells scripts what happened: 0 all fine, 2 a task failed on some host, 4 a host was unreachable (also used for "the playbook does not parse").
Before you run: checking a playbook
ansible-playbook site.yml --syntax-check parse only: YAML, play keys, module names
ansible-playbook site.yml --list-hosts which hosts each play would touch
ansible-playbook site.yml --list-tasks the tasks, in order, with their tags
ansible-playbook site.yml -l web-1 only web-1 this time
ansible-playbook site.yml -v each result as JSON (-vvv: much more)
A YAML mistake is reported before anything runs. Since ansible-core 2.19 the error shows the file, line and column, the lines before it and a caret, and for the common mistakes says what to do:
$ ansible-playbook broken.yml
[ERROR]: YAML parsing failed: Colons in unquoted values must be followed by a non-space character.
Origin: broken.yml:6:26
4 tasks:
5 - name: Install nginx
6 ansible.builtin.apt: name: nginx
^ column 26
For example:
raw: echo 'name: ansible'
Should be:
raw: "echo 'name: ansible'"
The line it points at, ansible.builtin.apt: name: nginx, has two key: value on one line. Older Ansible (and every blog post before 2025) printed PyYAML's raw message for the same mistake: mapping values are not allowed here. It means the same thing.
command and shell: the idempotency trap
command and shell cannot know what your command did, so they report changed every time:
- name: Turn swappiness down
ansible.builtin.shell: echo "vm.swappiness=10" >> /etc/sysctl.conf
That task is wrong twice: it appends a duplicate line on every run, and it reports changed every run. The fix is almost always a module that knows the state:
- name: Turn swappiness down
ansible.builtin.lineinfile:
path: /etc/sysctl.conf
regexp: '^vm\.swappiness'
line: vm.swappiness=10
lineinfile makes sure exactly that line is there (replacing a line matching regexp if one exists) and reports changed only when it edited the file.
When there is no module, make the command honest:
- name: Initialise the app database (once)
ansible.builtin.command: /opt/app/bin/init-db
args:
creates: /var/lib/app/.initialised # skip if this file exists
- name: Check the nginx config
ansible.builtin.command: nginx -t
changed_when: false # it only reads; never "changed"
creates: / removes: skip the command when a file exists / does not exist. changed_when: sets changed from a condition (false for read-only commands). The next missions and an incident later in the chapter are about exactly this.
What you can now do
- Read and write the YAML of a playbook, and avoid the classic mistakes (tabs,
key: valuetwice on a line, unquoted{{,yes/no, file modes). - Structure plays and tasks with FQCN module names and look a module up with
ansible-doc. - Run a playbook and read every part of the output, the recap and the exit code.
- Prove idempotency with a second run, and replace non-idempotent
shell:tasks with modules,creates:orchanged_when:.