OnCallReady

Lesson 33.5 · Ansible: Configuration as Code · 23 min read

Playbooks: plays, tasks, modules and idempotency

In plain words

A playbook is a recipe card for your servers. At the top it says who it is for ("the web servers"), then lists the steps: "nginx installed", "this config file in place", "nginx running". Each step names a tool from Ansible's kitchen drawer, a module, that knows how to check and do one kind of thing.

When you run the recipe, Ansible goes step by step and tells you, for each server, whether that step was already done (ok) or had to be done (changed). Run the same recipe on the same servers again and every step says ok, because nothing needed doing.

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"

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
  1. Indentation is structure. Two spaces per level is the convention. Tabs are an error.
  2. key: value needs the space after the colon. a: b: c is broken YAML: the second colon starts a mapping where a value was expected.
  3. Quote values that start with {{. msg: {{ name }} is a YAML dictionary inside a dictionary, not a template; write msg: "{{ name }}".
  4. Booleans: write true / false. YAML 1.1 (which Ansible's parser follows) also reads yes, no, on, off as booleans - so country: NO is false. Quote strings that look like booleans or numbers.
  5. File modes are strings: mode: "0644". Unquoted 0644 happens to work (YAML reads it as the octal number 420), but unquoted 644 is 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

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):

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

Why it helps

Playbooks are the unit of work you will review, run and debug for the rest of your Ansible life. Reading the output well matters as much as writing YAML: PLAY and TASK banners, ok and changed per host, the PLAY RECAP at the end, and what a failure looks like.

This lesson also covers the two things that break most first playbooks: YAML details (indentation, quoting values that start with {{, file modes as strings) and the command/shell trap, where a task "works" but reports changed on every run and makes the recap meaningless.

Commands in this lesson

cd ansible-doc ansible-playbook

FAQ

Why does a file mode have to be a quoted string?

YAML reads mode: 644 as the decimal number 644, which is a completely different mode (octal 1204). Ansible then sets that odd mode without any error. Writing mode: "0644" with quotes gives the octal value you meant. ansible-lint flags unquoted modes as risky-octal.

What is the difference between ok and changed?

ok means the module checked and the host was already in the desired state, so nothing was done. changed means the module had to change something. The PLAY RECAP sums them per host. A healthy, idempotent playbook shows changed only on the first run or when something really differed.

What is FQCN and do I need it?

The fully qualified collection name: ansible.builtin.copy instead of copy. Short names still work for builtin modules, but the full name makes clear which collection a module comes from and avoids clashes with other collections. ansible-lint's production profile requires it, so it is worth the habit from the start.

How do I check a playbook without running it?

ansible-playbook --syntax-check parses it and reports YAML and structure errors. --list-tasks and --list-hosts show what would run where. --check runs the modules in check mode without changing anything, and --diff adds the file changes. Use them in that order before the first real run of a new playbook.

When is it fine to use command or shell?

When no module does the job, for example a vendor CLI. Then make the task honest: creates: or removes: to skip it when its result already exists, changed_when: false for read-only commands, or changed_when with a condition on the registered output. Then the recap still means something.

In an interview Junior

What is a playbook made of, and how do you read its output?

A playbook is a list of plays. Each play has hosts: (a pattern), options such as become: true, and tasks; each task has a name and calls one module with its arguments, for example ansible.builtin.apt with name: nginx and state: present. Ansible runs it task by task: one TASK banner per task, then a line per host, ok if the host already matched, changed if the module changed something, failed or skipping otherwise. The PLAY RECAP at the end sums ok, changed, unreachable and failed per host. A second run of a correct playbook shows changed=0, which proves it is idempotent.

Also asked: Why is the command module not idempotent, and how do you fix that? · What does gather_facts do, and when would you turn it off? · What does become: true do?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.