The problem
Ansible can manage four hosts or four thousand, but before it can do anything it needs a list: which machines exist, how to reach them, and how they are grouped ("the web servers", "everything in production"). That list is the inventory. Get it wrong and the right playbook runs on the wrong machines - which in production is worse than not running at all.
What you need to know already: the previous lesson (control node, managed nodes, idempotency), 1.17 (SSH keys and known_hosts), 4.3 (users and groups), 7.4 (reading command output in columns).
An inventory in INI format
The simplest inventory is a text file in INI style. One host per line, groups in brackets:
# inventory.ini
[lb]
lb-1
[web]
web-1
web-2
[db]
db-1
[prod:children] # a group made of groups
lb
web
db
- A group is a name in
[brackets]; every host listed under it is a member. A host can be in many groups. [prod:children]makesproda parent group: its members are the members oflb,webanddb.- Two groups always exist: all (every host) and ungrouped (hosts listed before any section, in no group of their own).
- Ranges save typing:
web-[1:2]isweb-1andweb-2,db-[01:10]keeps the zero.
Each line can also carry host variables as key=value pairs, and a [group:vars] section sets variables for a whole group:
[web]
web-1 ansible_host=10.0.5.11 nginx_port=8080
web-2 ansible_host=10.0.5.12
[web:vars]
ansible_user=learner
The ansible_ variables are the connection variables - how to reach the host:
ansible_host the address to connect to (default: the inventory name)
ansible_user the SSH user (default: remote_user in ansible.cfg, else your name)
ansible_port the SSH port (default 22)
ansible_ssh_private_key_file which key to offer
ansible_connection ssh (default), local (run on the control node itself)
ansible_python_interpreter the Python on the host
A gotcha with real consequences: on a host line, values are read as Python literals, so nginx_port=8080 is the number 8080 and debug=False is a boolean. In a [group:vars] section every value is a string: debug=False there is the non-empty string "False", which is true in a condition. Put typed variables in YAML files instead (group_vars/, next lesson but one).
The same in YAML
The other common format is YAML. It nests: all has children (groups), groups have hosts and vars:
# inventory.yml
all:
children:
web:
hosts:
web-1:
nginx_port: 8080
web-2:
db:
hosts:
db-1:
prod:
children:
web:
db:
Both formats describe the same thing; teams pick one and stick to it. YAML is easier when variables get structured (lists, dictionaries), INI is easier to read at a glance.
Where Ansible looks
Every command takes -i FILE (or a directory of inventory files, or a comma list: -i web-1,web-2, - the trailing comma matters). Without -i, Ansible uses the inventory setting of its configuration file, and without that /etc/ansible/hosts, which does not exist on this box:
$ cd ~/oncall-lab/labs/1a-ansible/try/inventory
$ ANSIBLE_CONFIG=/dev/null ansible all --list-hosts
[WARNING]: Unable to parse /etc/ansible/hosts as an inventory source
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'
[WARNING]: Could not match supplied host pattern, ignoring: all
hosts (0):
Those two warnings mean "you have no inventory". Ansible always has one host, the implicit localhost, but it is not part of all.
ansible.cfg
The configuration file is ansible.cfg, an INI file. Ansible reads exactly one, the first it finds:
- the file named in
$ANSIBLE_CONFIG; ./ansible.cfgin the current directory;~/.ansible.cfg;/etc/ansible/ansible.cfg.
So a project directory with its own ansible.cfg gets its own settings whenever you run Ansible from there. A typical project file is short:
[defaults]
inventory = inventory.ini
interpreter_python = auto_silent
(Ansible refuses ./ansible.cfg when the directory is world-writable, with a warning, because anyone could plant settings there - keep project directories 755.)
Any setting can also come from an ANSIBLE_* environment variable, which beats the file. ansible-config dump --only-changed prints what differs from the defaults and where each value came from - the first thing to run when "my setting is ignored":
$ ansible-config dump --only-changed
CONFIG_FILE() = /home/learner/oncall-lab/labs/1a-ansible/try/inventory/ansible.cfg
DEFAULT_HOST_LIST(/home/learner/oncall-lab/labs/1a-ansible/try/inventory/ansible.cfg) = ['/home/learner/oncall-lab/labs/1a-ansible/try/inventory/inventory.ini']
INTERPRETER_PYTHON(/home/learner/oncall-lab/labs/1a-ansible/try/inventory/ansible.cfg) = 'auto_silent'
A misspelt key in ansible.cfg (host_key_cheking) is silently ignored. dump shows what really took effect.
Reading the inventory back
ansible-inventory shows what Ansible understood:
$ ansible-inventory --graph
@all:
|--@ungrouped:
|--@prod:
| |--@lb:
| | |--lb-1
| |--@web:
| | |--web-1
| | |--web-2
| |--@db:
| | |--db-1
$ ansible-inventory --host web-1
{
"nginx_port": 8080
}
--graph draws the group tree (@ marks a group). --host prints the variables a host gets from the inventory. --list dumps everything as JSON (-y for YAML).
Patterns: choosing hosts
Every command takes a pattern that selects hosts from the inventory:
all or * every host
web a group
web-1 one host
web:db union: in web OR in db
prod:&web intersection: in prod AND in web
web:!web-2 exclusion: in web but NOT web-2
web-* a glob over host names
~web-\d a regular expression (starts with ~)
web[0] the first host of the group
--limit (-l) narrows any run further with another pattern. It is the safety habit for changes: run on one host first (-l web-1), look, then the rest. --list-hosts shows what a pattern matches without touching anything:
$ ansible 'prod:!db' --list-hosts
hosts (3):
lb-1
web-1
web-2
Ad-hoc commands
ansible (without -playbook) runs one module on the hosts a pattern selects - an ad-hoc command. The shape is ansible PATTERN -m MODULE -a "ARGUMENTS".
The first one everybody runs:
$ ansible all -m ping
lb-1 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.14"
},
"changed": false,
"ping": "pong"
}
web-1 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.14"
},
"changed": false,
"ping": "pong"
}
web-2 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.14"
},
"changed": false,
"ping": "pong"
}
db-1 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.14"
},
"changed": false,
"ping": "pong"
}
ping is not ICMP ping. It logs in over SSH, runs a tiny Python module and returns "pong". A SUCCESS proves the whole chain: name resolution, SSH, the key, a login shell and Python. The output format of ad-hoc runs:
web-1 | SUCCESS => {...} ran, nothing changed (JSON result)
web-1 | CHANGED => {...} ran and changed something
web-1 | CHANGED | rc=0 >> a command: its exit code, then its output
web-1 | FAILED! => {...} the module failed
web-1 | UNREACHABLE! => {...} Ansible could not connect at all
Without -m, the module is command: it runs a program with arguments, with no shell in between. Pipes, redirection, * and ; are passed to the program as plain arguments:
$ ansible web -a 'uname -r'
web-1 | CHANGED | rc=0 >>
7.0.0-31-generic
web-2 | CHANGED | rc=0 >>
7.0.0-31-generic
When you need shell features, use the shell module (-m shell -a 'ps aux | grep nginx'). Prefer command when you can: no surprises from quoting and globbing.
Two more flags you will use constantly:
-b(--become): run as root through sudo on the host. Without it, the module runs as your SSH user, and installing a package fails with a permission error.-f N(--forks): how many hosts in parallel (default 5).
$ ansible web -b -a 'whoami'
web-1 | CHANGED | rc=0 >>
root
web-2 | CHANGED | rc=0 >>
root
The exit code of ansible itself: 0 when every host was fine, 2 when a host failed, 4 when a host was unreachable.
Host keys and the interpreter warning
The very first connection to a host shows the same known_hosts question ssh asks (1.17), once per host. Three ways to handle it, from best to worst:
- Pre-trust the keys when the hosts are built:
ssh-keyscan web-1 >> ~/.ssh/known_hostsfrom the provisioning, or answeryesonce. host_key_checking = Falseinansible.cfg(orANSIBLE_HOST_KEY_CHECKING=False): convenient in a throwaway lab, and it means Ansible would happily talk to a machine pretending to be your server.- Ignoring the prompt in a script: the run fails with
Host key verification failed.
Then, once per host per run:
[WARNING]: Host 'web-1' is using the discovered Python interpreter at '/usr/bin/python3.14', but future
installation of another Python interpreter could cause a different interpreter to be discovered.
Ansible looked for a Python on the host and found /usr/bin/python3.14. The warning says "this choice could change if someone installs another Python". Either pin it (ansible_python_interpreter=/usr/bin/python3 in the inventory) or tell Ansible discovery is fine: interpreter_python = auto_silent in ansible.cfg.
What you can now do
- Write an INI or YAML inventory with groups, children and host variables.
- Know where Ansible finds its inventory and its one
ansible.cfg, and check both withansible-inventory --graphandansible-config dump --only-changed. - Select hosts with patterns and
--limit, and preview with--list-hosts. - Run ad-hoc commands with
ping,command,shelland-b, and read their output.