Skip to content
Weibo's Home

Ansible


A run is a grid: tasks down, hosts across, and every cell one module built on your machine and run on theirs.

Part I

Ten steps

One file, built in front of you. Scroll and it grows; the pane keeps up.

site.yml step 1 / 10

- name: Web tier  hosts: webservers  become: true  vars:    nginx_port: 8080  handlers:    - name: Reload nginx      ansible.builtin.service:        name: nginx        state: reloaded  tasks:    # implicit: gather_facts     - name: Install nginx      ansible.builtin.package:        name: nginx        state: present     - name: Write the site config      ansible.builtin.template:        src: site.conf.j2        dest: /etc/nginx/conf.d/site.conf        mode: "0644"      notify: Reload nginx     - name: Open the port      ansible.posix.firewalld:        port: "{{ nginx_port }}/tcp"        permanent: true        state: enabled      when: ansible_facts.os_family == "RedHat"     - name: Start nginx      ansible.builtin.service:        name: nginx        state: started        enabled: true - name: Database  hosts: dbservers  become: true  tasks:     # implicit: gather_facts     - name: Create the application database      community.postgresql.postgresql_db:        name: app        state: present        encoding: UTF-8

what it prints

nothing to run yet
  1. 01

    The problem

    Four machines. Three of them should be serving nginx on port 8080, with a config you can read, a firewall rule, and the service enabled at boot. The fourth needs a database. You could open four terminals — and then do it again next month, from memory, hoping you remember which of the four you already did.

    Ansible is that list written down, in a form a machine can carry out against all four — and safely carry out twice.

  2. 02

    One host, one module

    Start with no file at all. Ansible can run a single module against a single host from the command line, and everything else on this page is that, repeated and written down.

    $ ansible -i inventory/hosts.ini web1 -m ansible.builtin.ping
      does SSH work, and is there a Python at the other end
    web1 | SUCCESS => {"ping": "pong"}
    
    $ ansible -i inventory/hosts.ini web1 \
        -m ansible.builtin.package -a 'name=nginx state=present' -b
      install nginx, as root
    web1 | CHANGED => { … }
    
    -i inventory/hosts.ini
    the file that says which machines exist and what they are called. Without it Ansible has no idea what web1 means.
    -m
    the module: the unit of work. ping, package, copy, service — around a thousand of them ship in collections.
    -a
    the module's arguments, as key=value pairs here and as YAML everywhere else.
    -b
    become: run it as root, via sudo. Without it you are the deploy user and the install fails.

    A module plus its arguments plus a host is the whole unit. Nothing is installed on web1 to make this work — Ansible speaks SSH and Python.

  3. 03

    Write it down

    The same thing, in a file. A TASK is a name, a module and the module's arguments — the three things you just typed, one per line. A task cannot stand alone, so it arrives wrapped in a PLAY, and --limit web1 keeps it to the one machine for now.

    $ ansible-playbook -i inventory/hosts.ini site.yml --limit web1
      the file, against one host
    
    - name:
    a label. It is what the output prints and nothing else; the task works without it. Without it the output prints the module name, and a run becomes much harder to read.
    ansible.builtin.package:
    the module, fully qualified — namespace, collection, module. ansible.builtin ships with Ansible; others come from Galaxy.
    state: present
    an assertion about how the host should end up, not a command to run. This is the difference that makes the next step possible.
    hosts:
    which machines. A task cannot stand on its own, so it arrives inside a play, and the play names its targets.
    become:
    the -b from step 2, set once for every task in the play instead of per command.
    tasks:
    everything below here runs, in order, against every host the play named.
    Gathering Facts
    a task you did not write. Every play opens by running setup against each host to learn ~1,200 facts about it. Step 7 is where that pays off. gather_facts: false turns it off — and anything reading a fact then breaks.

    A task is a module call with a label, and it lives in a play. The YAML maps one-to-one onto the command line you just used.

  4. 04

    Run it twice

    Run the same file again, immediately. Nothing is installed a second time — the module looks at the host, finds nginx already present, and says so. That property has a name, and it is the reason Ansible is safe to run on a schedule, from CI, or by someone who does not know what the last person did.

    $ ansible-playbook -i inventory/hosts.ini site.yml --limit web1
      the same command, a second time
    
    changed
    the module altered the host.
    ok
    the module ran, checked, and found there was nothing to do.

    first run

    PLAY [Web tier] ******************************
     
    TASK [Gathering Facts] ***********************
    ok: [web1]
     
    TASK [Install nginx] *************************
    changed: [web1]
     
    PLAY RECAP ***********************************
    web1    : ok=2    changed=1    unreachable=0   failed=0   skipped=0
    

    run it again

    PLAY [Web tier] ******************************
     
    TASK [Gathering Facts] ***********************
    ok: [web1]
     
    TASK [Install nginx] *************************
    ok: [web1]
     
    PLAY RECAP ***********************************
    web1    : ok=2    changed=0    unreachable=0   failed=0   skipped=0
    

    Idempotent: running it twice is the same as running it once. You get that free from state: modules and lose it the moment you reach for shell:.

  5. 05

    More than one machine

    Drop the --limit. Nothing in the file changes — it already said hosts: webservers, and that one line is the fan-out. The task you wrote once now runs against three machines and comes back with three different answers.

    $ ansible-playbook -i inventory/hosts.ini site.yml
      no --limit: every host in the group
    
    hosts: webservers
    a group from the inventory. Ansible expands it to web1, web2 and web3 and runs every task against all of them. --limit narrows it at the command line without editing the file, which is what the last two steps were doing.
    web2 says ok
    the same task, the same file, a different answer — because web2 already had nginx. A verdict describes the host, not the task.
    web1 web2 web3 Gathering Facts ok ok ok Install nginx changed ok changed

    Play = hosts + tasks. The same task, the same file, three machines — and three different answers, because web2 already had nginx.

  6. 06

    More than one task

    Add a second task. Tasks run top to bottom, and every host finishes a task before any host starts the next — so the grid grows downwards, a row at a time.

    state: started
    running now.
    enabled: true
    running after a reboot. Two independent assertions in one task, and one changed covering both.
    web1 web2 web3 Gathering Facts ok ok ok Install nginx changed ok changed Start nginx changed ok changed

    Order is the file's order. A play is a column of task rows, each run against every host.

  7. 07

    Hosts differ

    web3 is Debian; the other two are Red Hat. Ansible already knows, because of the facts it gathered — so a task can be given a condition, and a file can be given a variable instead of a number.

    vars:
    names you set once and use everywhere below. nginx_port: 8080.
    {{ nginx_port }}
    Jinja2. It is rendered on YOUR machine, before anything is sent — the host never sees the braces.
    mode: "0644"
    quoted, always. Unquoted, YAML reads 0644 as an octal number and the file lands with the wrong permissions.
    when:
    a condition, tested on your machine against the facts. False means the task is skipped and no connection is opened at all.
    template:
    renders a .j2 file locally, compares it with what is on the host, and transfers only if they differ.
    web1 web2 web3 Gathering Facts ok ok ok Install nginx changed ok changed Write the site config changed changed ok Open the port changed ok skipped Start nginx changed ok changed

    Facts describe the host; variables describe your intent; when joins them. One file, three different runs.

  8. 08

    Repeat, capture, look

    Three things you will reach for constantly, none of which this particular file needs. They are shown here as they are used, not folded into site.yml — a guide that teaches you to write code you would delete is worse than one that says not here.

    $ ansible-playbook -i inventory/hosts.ini site.yml --limit web1 -v
      the same run, with every module's reply printed
    changed: [web1] => {"changed": true, "rc": 0, "results": [...]}
    
    -v
    verbosity. -v prints each module's JSON reply, -vv adds the task's file and line, -vvv adds the SSH commands themselves. This is the fastest way to find out why something said changed.

    loop — one task, many items

    - name: Install the tools
      ansible.builtin.package:
        name: "{{ item }}"
        state: present
      loop:
        - nginx
        - curl
        - jq
    TASK [Install the tools] ****************
    changed: [web1] => (item=nginx)
    ok: [web1] => (item=curl)
    changed: [web1] => (item=jq)
    

    item is the loop variable. The task reports once per item, and the task as a whole is changed if any item was.

    register + debug — see what came back

    - name: Check the config
      ansible.builtin.command: nginx -t
      register: result
      changed_when: false
    
    - name: Show it
      ansible.builtin.debug:
        var: result.stdout
    TASK [Show it] **************************
    ok: [web1] => {
        "result.stdout": "syntax is ok"
    }
    

    register captures a module's whole JSON reply into a variable. debug prints it — and runs on your machine, so it costs nothing. changed_when: false stops a read-only command reporting changed every time.

    block — group, and recover

    - name: Try the fast path
      block:
        - ansible.builtin.command: /usr/local/bin/fastpath
      rescue:
        - ansible.builtin.command: /usr/local/bin/slowpath
      always:
        - ansible.builtin.file:
            path: /tmp/lock
            state: absent
    TASK [command] **************************
    fatal: [web1]: FAILED! => { … }
     
    TASK [command] **************************
    changed: [web1]
    

    A failure inside block jumps to rescue instead of removing the host from the play. always runs either way.

    loop repeats, register captures, debug shows, block recovers. With -v these are the whole debugging loop.

  9. 09

    React to a change

    nginx needs reloading when its config changes — and only then. A HANDLER is a task that does nothing until something notifies it, and then runs once, at the end of the play.

    handlers:
    tasks defined but not run. They sit in the play alongside tasks:.
    notify: Reload nginx
    queues the handler by name — but only if this task reported changed. An ok task notifies nothing. That is why web3, whose config already matched, does not reload.
    web1 web2 web3 Gathering Facts ok ok ok Install nginx changed ok changed Write the site config changed changed ok Open the port changed ok skipped Start nginx changed ok changed Reload nginx (handler) changed changed ·

    Notified on changed, de-duplicated, and flushed at the end of the play — not where you wrote it.

  10. 10

    A second play, and running it for real

    The database is a different group with different work, so it is a different play. Plays run one after another, always. That is the whole file — and this is how you actually set it going.

    $ ansible-playbook -i inventory/hosts.ini site.yml
      the whole thing
    $ ansible-playbook -i inventory/hosts.ini site.yml --check --diff
      change nothing; report what WOULD change, and show the diffs
    $ ansible-playbook -i inventory/hosts.ini site.yml --limit web1
      one host out of the group
    $ ansible-playbook -i inventory/hosts.ini site.yml --tags nginx
      only tasks carrying that tag
    $ ansible-playbook -i inventory/hosts.ini site.yml --start-at-task 'Open the port'
      resume after a failure, without redoing the slow part
    $ ansible-doc ansible.builtin.service
      every argument the module takes, with examples, offline
    
    --check
    dry run. Every module is asked what it would do. command and shell are skipped, because they cannot predict.
    --limit
    narrows the hosts without editing the inventory. The first thing to reach for when something goes wrong.
    web1 web2 web3 db1 Gathering Facts ok ok ok · Install nginx changed ok changed · Write the site config changed changed ok · Open the port changed ok skipped · Start nginx changed ok changed · Reload nginx (handler) changed changed · · Gathering Facts · · · ok Create the application database · · · changed

    That is the file the rest of this page runs. Below, it stops being source and starts being nineteen modules crossing four SSH connections.

Part II

What actually happens

That file, run. Nineteen modules built on your machine, thrown at four others, and every byte of the exchange open to inspection.

Gathering Facts ansible.builtin.setup Install nginx ansible.builtin.package Write the site config ansible.builtin.template Open the port ansible.posix.firewalld Start nginx ansible.builtin.service Reload nginx (handler) ansible.builtin.service Gathering Facts ansible.builtin.setup Create the application database community.postgresql.postgresql_db ok ok ok · changed ok changed · changed changed ok · changed ok skipped · changed ok changed · changed changed · · · · · ok · · · changed

Thirty-two cells, nineteen of them a module. A host the play does not target has no cell; web3 has one that was decided without opening a connection. Give every cell a duration and a worker and the grid becomes a schedule — which is the drawing below, and the only thing on this page that decides how long a run takes.

site.yml

- name: Web tier  hosts: webservers  become: true  vars:    nginx_port: 8080  handlers:    - name: Reload nginx      ansible.builtin.service:        name: nginx        state: reloaded  tasks:    # implicit: gather_facts     - name: Install nginx      ansible.builtin.package:        name: nginx        state: present     - name: Write the site config      ansible.builtin.template:        src: site.conf.j2        dest: /etc/nginx/conf.d/site.conf        mode: "0644"      notify: Reload nginx     - name: Open the port      ansible.posix.firewalld:        port: "{{ nginx_port }}/tcp"        permanent: true        state: enabled      when: ansible_facts.os_family == "RedHat"     - name: Start nginx      ansible.builtin.service:        name: nginx        state: started        enabled: true - name: Database  hosts: dbservers  become: true  tasks:     # implicit: gather_facts     - name: Create the application database      community.postgresql.postgresql_db:        name: app        state: present        encoding: UTF-8

inventory/hosts.ini

[webservers]
web1.example.net
web2.example.net
web3.example.net

[dbservers]
db1.example.net

[prod:children]
webservers
dbservers

[all:vars]
ansible_user=deploy
ansible_python_interpreter=auto_silent

ansible.cfg

[defaults]forks = 2strategy = lineargathering = implicit [ssh_connection]pipelining = Truessh_args = -o ControlMaster=auto -o ControlPersist=60s
1.2 ms 1.2 ms 1.4 ms 14 ms control node ansible-playbook site.yml web1 web1.example.net bare — every task has work to do web2 web2.example.net already converged — same tasks, different verdicts web3 web3.example.net a different distribution, and the third fork db1 db1.example.net another play, another region web1 Rocky 9.4 web2 Rocky 9.4 web3 Ubuntu 24.04 db1 Debian 12
0.00 s
forks
strategy
gathering
pipelining

7.06s 3.06s over

Real time at 1×. The upright hairlines are barriers: under linear no host starts a task until every host has finished the one before. Get it under 4.0s. Two of the thirty-two settings manage it, and editing the playbook is not one of them. Space plays, arrows nudge.

  1. Gathering Facts 0.00s
  2. Install nginx 1.71s
  3. Write the site config 4.62s
  4. Open the port 4.91s
  5. Start nginx 5.09s
  6. Reload nginx 5.55s · handler
  7. Gathering Facts 5.70s
  8. Create the application database 6.49s

PLAY RECAP

host ok changed unreachable failed skipped
web1 0 0 0 0 0
web2 0 0 0 0 0
web3 0 0 0 0 0
db1 0 0 0 0 0

Part III

Reference

The four things a run never shows you, because they are decisions taken before it starts.

One task, end to end

A module is a Python program built on your machine and thrown at an interpreter on theirs. The only question is how many times it has to cross, and one line of ansible.cfg answers it. Drawn to scale on db1's link — fourteen milliseconds each way.

control node db1 pipelining = False 4 round trips · 112 ms mkdir …/tmp/ put AnsiballZ_dnf.py python3 AnsiballZ_dnf.py rm -rf …/tmp/ control node db1 pipelining = True 1 round trip · 28 ms python3 ← AnsiballZ_dnf.py on stdin

The module does the same work either way; only the waiting changes. That is why the knob above barely moves a run across a datacentre and quadruples the overhead of one across an ocean — and why it is still off by default, because it needs no requiretty in sudoers.

Where a variable comes from

Twenty-two rungs, lowest first, and a total order: when two of them define the same name, the higher one wins. Reading the list teaches nobody the rule — so set nginx_port in more than one place and watch which survives.

Define nginx_port in…

  1. 1 command line values -u deploy, -b — not really variables
  2. 2 role defaults roles/x/defaults/main.yml — the floor, and where a role's knobs belong
  3. 3 inventory file group vars
  4. 4 inventory group_vars/all
  5. 5 playbook group_vars/all
  6. 6 inventory group_vars/*
  7. 7 playbook group_vars/* a playbook-adjacent file beats the inventory's
  8. 8 inventory file host vars
  9. 9 inventory host_vars/*
  10. 10 playbook host_vars/* host beats group, always
  11. 11 host facts / cached set_fact
  12. 12 play vars nginx_port: 8080 on this page
  13. 13 play vars_prompt
  14. 14 play vars_files
  15. 15 role vars roles/x/vars/main.yml — high, and the usual surprise
  16. 16 block vars
  17. 17 task vars
  18. 18 include_vars
  19. 19 set_fact / registered vars
  20. 20 role and include_role params
  21. 21 include params
  22. 22 extra vars -e — wins over everything, and cannot be overridden

Inventory, groups, role

Two directory layouts carry nearly all of Ansible's structure. A group is a folder name; a role is an interface made of folder names.

groups, and what they are for

  • webservers web1, web2, web3 the first play's target
  • dbservers db1 the second play's target
  • prod webservers, dbservers a group of groups — prod:children
  • all prod implicit, and always there; group_vars/all is the floor

Every group may hold group_vars/<name>.yml; every host may hold host_vars/<name>.yml. Host beats group, always.

a role is its layout

  • roles/webserver/
  • defaults/main.yml rung 2 — overridable
  • vars/main.yml rung 15 — hard to override
  • tasks/main.yml the entry point; loaded automatically
  • handlers/main.yml what notify can name
  • templates/site.conf.j2 rendered on the control node
  • files/ copied verbatim
  • meta/main.yml dependencies and argument_specs
  • library/ custom modules, found without configuration

Nothing here is configured. tasks/main.yml is loaded because of its name, and library/ is searched because of its name.

The five words a run can say

A verdict is a statement about the host, not about the task. Two of the five are decided before the module runs, and the other three are decided by what it found when it got there.

true false yes no already so acted could not a task when: on the control node reachable? SSH, not the module module runs on the managed host ok changed failed skipped unreachable

and the reason a verdict must be a prediction

  • ansible-playbook site.yml --check --diff every module is asked what it WOULD do
  • changed = would change the verdict becomes a forecast, which is what makes it reviewable
  • command, shell skipped in check mode unless check_mode: false — they cannot predict