AnsibleV · Inventory Design at Fleet ScaleFleet taxonomy
The four dimensions of a fleet
What you'll learn
- Name the four grouping dimensions a production fleet needs and keep them independent
- Explain why compound group names collapse dimensions into an unusable combinatorial set
- Express a precise target as an intersection of group families rather than a glob
- Prove what a pattern resolves to before running anything against it
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Someone hands you a change request: patch the production web servers in eu-west that are currently in service, and leave the ones being drained alone.
Whether that sentence is a two-minute job or a two-hour archaeology exercise is decided entirely by how the inventory was structured, years before the request arrived. This lesson is about making it the two-minute job.
The request is a conjunction, so the inventory must be too
Read the request again and notice its shape. It names four independent properties of a host:
| Dimension | The question it answers | Example values |
|---|---|---|
| Environment | What is the cost of getting this wrong? | production, staging, development |
| Site / region | Where does this machine physically live? | eu_west, us_east, dc_ams1 |
| Role / service | What job does this machine do? | web, database, cache, queue |
| Lifecycle | Is this machine in service right now? | building, burn_in, live, draining |
These four are orthogonal. Knowing a host is web tells you nothing
about whether it is production. Knowing it is eu_west tells you nothing
about whether it is draining. Every host has exactly one value on each
axis, and the values on one axis never constrain another.
That is the whole design. Each dimension becomes its own group family, a host joins one group from each family, and a target is expressed as the intersection of the families you care about.
What the alternative looks like
The design that grows by accident, one urgent request at a time, is the compound group name:
prod-eu-web-live
prod-eu-web-draining
prod-eu-db-live
prod-us-web-live
staging-eu-web-live
...
Each name encodes all four dimensions at once. It reads perfectly and it fails on contact with the second request.
Count what it costs. An estate with 3 environments, 2 regions, 11 service roles and 5 lifecycle stages has 3 + 2 + 11 + 5 = 21 groups under the orthogonal design. Under compound naming the same estate needs up to 3 × 2 × 11 × 5 = 330 group names, most of which will hold one or two hosts, and every one of which is a string somebody has to type correctly.
The deeper problem is not the count. It is that a compound name is a string, so the only way to ask “all production web servers, any region” is to match on the string:
--limit 'prod-*-web-*'
A glob is not a set. It cannot express “and not draining”, it silently
matches prod-eu-webhook-live that somebody added last quarter, and it
depends on a naming convention that no tool enforces. The first time
someone writes prod-* intending production web servers and gets the
database tier as well, the design has failed in the way it was always
going to.
The orthogonal inventory
Here is the same fleet expressed as four families. Hosts appear more than once — that is the point, not a mistake.
# inventory/production/hosts.yml
all:
children:
# --- environment ---------------------------------------------
production:
hosts:
web-a1.example.com:
web-a2.example.com:
db-a1.example.com:
# --- site ----------------------------------------------------
eu_west:
hosts:
web-a1.example.com:
web-a2.example.com:
db-a1.example.com:
# --- role ----------------------------------------------------
web:
hosts:
web-a1.example.com:
web-a2.example.com:
database:
hosts:
db-a1.example.com:
# --- lifecycle -----------------------------------------------
live:
hosts:
web-a1.example.com:
db-a1.example.com:
draining:
hosts:
web-a2.example.com:
ansible-inventory --graph prints the families back, and this is the
first thing to run against any inventory you did not write:
$ ansible-inventory -i inventory/production/hosts.yml --graph@all:
|--@ungrouped:
|--@production:
| |--web-a1.example.com
| |--web-a2.example.com
| |--db-a1.example.com
|--@eu_west:
| |--web-a1.example.com
| |--web-a2.example.com
| |--db-a1.example.com
|--@web:
| |--web-a1.example.com
| |--web-a2.example.com
|--@database:
| |--db-a1.example.com
|--@live:
| |--web-a1.example.com
| |--db-a1.example.com
|--@draining:
| |--web-a2.example.comA host appearing under four groups is a host whose four properties are each recorded once, in one place, in a form a tool can reason about.
Targeting is now set arithmetic
With families in place, the original change request stops being an archaeology exercise. Ansible’s pattern syntax gives you three operators:
:— union (also written as a comma;web:databaseis every host in either):&— intersection:!— difference
The request said production and web and eu-west and live. That is three intersections:
$ ansible -i inventory/production/hosts.yml 'web:&eu_west:&live' --list-hosts hosts (1):
web-a1.example.comAnd the “leave the draining ones alone” half is a difference:
$ ansible -i inventory/production/hosts.yml 'production:&web:!draining' --list-hosts hosts (1):
web-a1.example.comCompare that with --limit 'prod-*-web-*'. The intersection form is
checkable: every token in it is a group that either exists or does not,
and ansible-inventory --graph will tell you which. A typo in a group
name produces an empty result, loudly. A typo in a glob produces a
different result, quietly.
Naming rules that survive contact with a fleet
Four conventions do most of the work. None of them are enforced by Ansible, which is why they belong in a review checklist.
- One dimension per group name. If a group name contains two
dimensions, you have started rebuilding the compound design.
prod_webis the first step;prod_web_eu_liveis where you end up. - Group names are valid Python identifiers. Use underscores, not
hyphens:
eu_west, noteu-west. Ansible permits the hyphen in an inventory file, but a hyphenated group name cannot be referenced asgroups.eu_westin Jinja and forces the bracket form everywhere. - No hostnames encoded in group names. A group called
web_a1_a2documents the membership twice and will be wrong the moment a host is added. - Environments are singular and unambiguous. Pick
productionorprodand never use both. Two spellings of the same environment is the single most common way a host ends up in one and not the other.
Where the dimensions live on disk
Group families work best when the file layout mirrors them, because the layout is what a reviewer sees first:
inventory/
production/
hosts.yml # membership only
group_vars/
production.yml # environment-wide settings
web.yml # role settings
eu_west.yml # site settings: proxies, NTP, DNS
live.yml # lifecycle settings: monitoring on
staging/
hosts.yml
group_vars/
...
Membership in hosts.yml, behaviour in group_vars/. The separation
matters because the two change for different reasons and by different
people: membership changes when the fleet changes, and behaviour changes
when policy changes. Mixing them into one file means every provisioning
event touches the same file as every policy decision.
Knowledge check
Knowledge check · 4 questions
Q1. An estate has 3 environments, 2 regions, 11 service roles and 5 lifecycle stages. What is the strongest argument against naming groups like prod-eu-web-live?
Q2. Which of these belong in a group family rather than being derived from facts at run time? Select all that apply.
Q3. Because a host appears under production, eu_west, web and live in the graph output, the inventory has duplicated that host four times.
Q4. A scheduled nightly job targets web:&eu_wset:&live - a group name with transposed letters. What happens?
Passing score: 75%. Answers are checked in this browser.