AnsibleXXXV · Large Fleet ArchitectureLarge fleet architecture
Inventory structure that survives growth
What you'll learn
- Design group structure as a small number of orthogonal dimensions rather than an ad-hoc list
- Apply the rule that decides whether something deserves a group or is merely a fact
- Predict which group_vars file wins when two same-depth groups set the same variable
- Generate fact-derived groups with the constructed inventory plugin instead of maintaining them by hand
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
Inventory at forty hosts is a file. Inventory at four thousand hosts is a data model, and the difference shows up about eighteen months in, when somebody asks a simple question — “which hosts run the payments API in Amsterdam and are still in service?” — and nobody can answer it from the inventory.
The structure that gets you there is not complicated. It is a small number of dimensions, applied consistently, with a rule about what is allowed to become a group.
Dimensions, not a list
Every group in a healthy large inventory belongs to exactly one of a handful of dimensions, and every host has exactly one value in each:
| Dimension | Answers | Example groups |
|---|---|---|
| Role | What does this machine do? | role_web, role_api, role_db |
| Environment | Which lifecycle stage? | env_prod, env_stage, env_dev |
| Location | Where does it physically live? | site_ams, site_lon, site_fra |
| Lifecycle | What is its operational status? | life_build, life_active, life_decom |
Four dimensions, perhaps a dozen groups each. Compare that with the
alternative that grows organically: webservers, prod-web,
prod-web-ams, web-ams-new, web-migrated, web-old-kernel — each
one added by someone solving one afternoon’s problem, none of them
orthogonal to any other, and none of them safe to delete because
something might target it.
The reason dimensions work is that they compose. You never need
prod-web-ams as a stored group, because you can ask for it:
# every production web host in Amsterdam that is still in service
ansible-inventory -i inventory/ \
--graph 'role_web:&env_prod:&site_ams:!life_decom'
# the same set, as the target of a run
ansible-playbook -i inventory/ site.yml \
--limit 'role_web:&env_prod:&site_ams:!life_decom' --list-hostsA pattern is a query. Once the dimensions exist, most of the groups people ask for are queries that never needed to be stored.
The rule for what becomes a group
Two questions, and a group needs a yes to at least one:
- Does anybody target it? Is there a run whose blast radius is exactly this set?
- Does anybody set variables on it? Is there a
group_varsfile that belongs to precisely this population?
If the answer to both is no, what you have is a fact about hosts,
not a group. has_nvme, kernel_6_1, bought_2021, patched_march
are facts. They belong in host variables or in gathered facts, and they
are reachable in a conditional or by a pattern over a constructed group.
Same-depth groups: alphabetical order decides
Here is the behaviour that makes group proliferation dangerous rather
than merely untidy. Give one host three groups at the same depth, each
with a group_vars file setting the same variable:
# inventory/hosts.yml
all:
children:
role_web:
hosts: {web01: null}
env_prod:
hosts: {web01: null}
site_ams:
hosts: {web01: null}
# group_vars/role_web.yml -> workers: 10
# group_vars/env_prod.yml -> workers: 20
# group_vars/site_ams.yml -> workers: 30$ ansible-playbook -i inventory/hosts.yml show-workers.ymlTASK [ansible.builtin.debug] ***************************************************
ok: [web01] => {
"workers": 30
}
TASK [ansible.builtin.debug] ***************************************************
ok: [web01] => {
"group_names": [
"env_prod",
"role_web",
"site_ams"
]
}site_ams wins. Not because location is more specific than role, and
not because of the order the groups appear in the file — because groups
at the same depth are merged in alphabetical order and the last one
applied wins.
Read that consequence carefully: your choice of dimension prefix is a
precedence decision. Rename site_ams to dc_ams and the winner
becomes role_web, with no other change to the repository. Nothing
warns you.
$ ansible-playbook -i inventory/hosts.yml show-workers.ymlTASK [ansible.builtin.debug] ***************************************************
ok: [web01] => {
"workers": 10
}Depth beats alphabet
Alphabetical order only breaks ties within a depth. A child group always beats its parent, regardless of name:
all:
children:
env_prod: # group_vars/env_prod.yml -> log_level: warn
children:
env_prod_payments: # group_vars/env_prod_payments.yml -> log_level: info
hosts:
pay01.example.com:pay01 gets log_level: info. That is a hierarchy expressing a real
specialisation, and it is predictable. Use children where the
relationship is genuinely “a narrower kind of”, and keep unrelated
dimensions flat and non-overlapping.
Generate fact-derived groups; do not maintain them
The legitimate need behind the group-per-fact trap — “I want to target
every host still on the old OS” — has a proper mechanism. The
ansible.builtin.constructed inventory plugin builds groups from host
variables and facts every time the inventory is parsed, so membership
cannot rot.
plugin: ansible.builtin.constructed
strict: true
keyed_groups:
- key: os_major
prefix: os
- key: role
prefix: role
groups:
needs_upgrade: os_major | int < 13$ ansible-inventory -i inventory/01-hosts.yml -i inventory/02-constructed.yml --graph@all:
|--@ungrouped:
|--@needs_upgrade:
| |--web01
|--@os_12:
| |--web01
|--@role_web:
| |--web01
| |--web02
|--@os_13:
| |--web02
| |--api01
|--@role_api:
| |--api01Change os_major on web01 and the groups follow on the next parse. No
migration task, no stale membership, no group that is a claim about last
March.
Auditing the shape
Two commands answer “is this inventory still the shape we designed?”.
# how many groups exist, per dimension prefix?
ansible-inventory -i inventory/ --list \
| python3 -c 'import json,sys,collections
d=json.load(sys.stdin)
c=collections.Counter(k.split("_")[0] for k in d if k not in ("_meta","all"))
print(c.most_common())'
# hosts that landed in no group - usually a typo or a missed onboarding step
ansible-inventory -i inventory/ --graph ungroupedA prefix count that shows four dimensions with a dozen groups each is a healthy inventory. A count with a long tail of one-off prefixes is the group-per-fact trap in progress, and it is far cheaper to arrest at thirty groups than at four hundred.
Knowledge check
Knowledge check · 4 questions
Q1. Host web01 belongs to three same-depth groups - role_web, env_prod and site_ams - and each has a group_vars file setting workers. Which value does web01 get, on ansible-core 2.21.3?
Q2. A colleague adds ansible_group_priority: 100 to group_vars/role_web.yml so that role variables beat location variables. What happens?
Q3. Which of these belong in a group rather than being expressed some other way? Select all that apply.
Q4. With the constructed inventory plugin, leaving strict at its default is safe because a broken expression will fail the inventory parse.
Passing score: 75%. Answers are checked in this browser.