Ansible
A run is a grid: tasks down, hosts across, and every cell one module built on your machine and run on theirs.
A run is a grid: tasks down, hosts across, and every cell one module built on your machine and run on theirs.
Part I
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
PLAY RECAP *********************************** web1 : ok=0 changed=0 unreachable=0 failed=0 skipped=0
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
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
PLAY [Web tier] ****************************** TASK [Gathering Facts] *********************** ok: [web1] ok: [web2] ok: [web3] TASK [Install nginx] ************************* changed: [web1] ok: [web2] changed: [web3] PLAY RECAP *********************************** web1 : ok=2 changed=1 unreachable=0 failed=0 skipped=0 web2 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 web3 : ok=2 changed=1 unreachable=0 failed=0 skipped=0
PLAY [Web tier] ****************************** TASK [Gathering Facts] *********************** ok: [web1] ok: [web2] ok: [web3] TASK [Install nginx] ************************* changed: [web1] ok: [web2] changed: [web3] TASK [Start nginx] *************************** changed: [web1] ok: [web2] changed: [web3] PLAY RECAP *********************************** web1 : ok=3 changed=2 unreachable=0 failed=0 skipped=0 web2 : ok=3 changed=0 unreachable=0 failed=0 skipped=0 web3 : ok=3 changed=2 unreachable=0 failed=0 skipped=0
PLAY [Web tier] ****************************** TASK [Gathering Facts] *********************** ok: [web1] ok: [web2] ok: [web3] TASK [Install nginx] ************************* changed: [web1] ok: [web2] changed: [web3] TASK [Write the site config] ***************** changed: [web1] changed: [web2] ok: [web3] TASK [Open the port] ************************* changed: [web1] ok: [web2] skipping: [web3] TASK [Start nginx] *************************** changed: [web1] ok: [web2] changed: [web3] PLAY RECAP *********************************** web1 : ok=5 changed=4 unreachable=0 failed=0 skipped=0 web2 : ok=5 changed=1 unreachable=0 failed=0 skipped=0 web3 : ok=4 changed=2 unreachable=0 failed=0 skipped=1
PLAY [Web tier] ****************************** TASK [Gathering Facts] *********************** ok: [web1] ok: [web2] ok: [web3] TASK [Install nginx] ************************* changed: [web1] ok: [web2] changed: [web3] TASK [Write the site config] ***************** changed: [web1] changed: [web2] ok: [web3] TASK [Open the port] ************************* changed: [web1] ok: [web2] skipping: [web3] TASK [Start nginx] *************************** changed: [web1] ok: [web2] changed: [web3] PLAY RECAP *********************************** web1 : ok=5 changed=4 unreachable=0 failed=0 skipped=0 web2 : ok=5 changed=1 unreachable=0 failed=0 skipped=0 web3 : ok=4 changed=2 unreachable=0 failed=0 skipped=1
PLAY [Web tier] ****************************** TASK [Gathering Facts] *********************** ok: [web1] ok: [web2] ok: [web3] TASK [Install nginx] ************************* changed: [web1] ok: [web2] changed: [web3] TASK [Write the site config] ***************** changed: [web1] changed: [web2] ok: [web3] TASK [Open the port] ************************* changed: [web1] ok: [web2] skipping: [web3] TASK [Start nginx] *************************** changed: [web1] ok: [web2] changed: [web3] RUNNING HANDLER [Reload nginx] *************** changed: [web1] changed: [web2] PLAY RECAP *********************************** web1 : ok=6 changed=5 unreachable=0 failed=0 skipped=0 web2 : ok=6 changed=2 unreachable=0 failed=0 skipped=0 web3 : ok=4 changed=2 unreachable=0 failed=0 skipped=1
PLAY [Web tier] ****************************** TASK [Gathering Facts] *********************** ok: [web1] ok: [web2] ok: [web3] TASK [Install nginx] ************************* changed: [web1] ok: [web2] changed: [web3] TASK [Write the site config] ***************** changed: [web1] changed: [web2] ok: [web3] TASK [Open the port] ************************* changed: [web1] ok: [web2] skipping: [web3] TASK [Start nginx] *************************** changed: [web1] ok: [web2] changed: [web3] RUNNING HANDLER [Reload nginx] *************** changed: [web1] changed: [web2] PLAY [Database] ****************************** TASK [Gathering Facts] *********************** ok: [db1] TASK [Create the application database] ******* changed: [db1] PLAY RECAP *********************************** web1 : ok=6 changed=5 unreachable=0 failed=0 skipped=0 web2 : ok=6 changed=2 unreachable=0 failed=0 skipped=0 web3 : ok=4 changed=2 unreachable=0 failed=0 skipped=1 db1 : ok=2 changed=1 unreachable=0 failed=0 skipped=0
01
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.
02
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 web1 means. -m ping, package, copy, service — around a thousand of them ship in collections. -a key=value pairs here and as YAML everywhere else. -b 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.
03
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: ansible.builtin.package: ansible.builtin ships with Ansible; others come from Galaxy. state: present hosts: become: -b from step 2, set once for every task in the play instead of per command. tasks: Gathering Facts 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.
04
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 ok 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:.
05
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 --limit narrows it at the command line without editing the file, which is what the last two steps were doing. web2 says ok Play = hosts + tasks. The same task, the same file, three machines — and three different answers, because web2 already had nginx.
06
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 enabled: true changed covering both. Order is the file's order. A play is a column of task rows, each run against every host.
07
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: nginx_port: 8080. {{ nginx_port }} mode: "0644" 0644 as an octal number and the file lands with the wrong permissions. when: template: .j2 file locally, compares it with what is on the host, and transfers only if they differ. Facts describe the host; variables describe your intent; when joins them. One file, three different runs.
08
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 -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.
09
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:. notify: Reload nginx changed. An ok task notifies nothing. That is why web3, whose config already matched, does not reload. Notified on changed, de-duplicated, and flushed at the end of the play — not where you wrote it.
10
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 command and shell are skipped, because they cannot predict. --limit 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
That file, run. Nineteen modules built on your machine, thrown at four others, and every byte of the exchange open to inspection.
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 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.
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
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.
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…
-u deploy, -b — not really variables roles/x/defaults/main.yml — the floor, and where a role's knobs belong group_vars/all group_vars/all group_vars/* group_vars/* a playbook-adjacent file beats the inventory's host_vars/* host_vars/* host beats group, always set_fact nginx_port: 8080 on this page vars_prompt vars_files roles/x/vars/main.yml — high, and the usual surprise include_vars set_fact / registered vars include_role params include params -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.
changed: false changed: true — and a handler may be queued when was false on the control node ignore_errors or a rescue block 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