Skip to main content
RunBook Academy

AnsibleXXXV · Large Fleet ArchitectureLarge fleet architecture

Inventory structure that survives growth

Advanced⏱ ~23 minansible-inventory

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

Not yet marked complete on this device.

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:

DimensionAnswersExample groups
RoleWhat does this machine do?role_web, role_api, role_db
EnvironmentWhich lifecycle stage?env_prod, env_stage, env_dev
LocationWhere does it physically live?site_ams, site_lon, site_fra
LifecycleWhat 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:

Read-only / Safecomposing dimensions in a pattern
# 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-hosts

A 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:

  1. Does anybody target it? Is there a run whose blast radius is exactly this set?
  2. Does anybody set variables on it? Is there a group_vars file 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:

Read-only / Safethree same-depth groups, one 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
Read-only / Safewhich one wins?
$ ansible-playbook -i inventory/hosts.yml show-workers.yml
TASK [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.

Read-only / Safeansible_group_priority, set in the inventory source
$ ansible-playbook -i inventory/hosts.yml show-workers.yml
TASK [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:

Read-only / Safehierarchy for the values that genuinely nest
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.

Read-only / Safeinventory/02-constructed.yml
plugin: ansible.builtin.constructed
strict: true
keyed_groups:
- key: os_major
  prefix: os
- key: role
  prefix: role
groups:
needs_upgrade: os_major | int < 13
Read-only / Safethe groups exist without anyone maintaining them
$ 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:
|  |--api01

Change 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?”.

Read-only / Safeinventory shape audit
# 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 ungrouped

A 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

  1. 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?

  2. Q2. A colleague adds ansible_group_priority: 100 to group_vars/role_web.yml so that role variables beat location variables. What happens?

  3. Q3. Which of these belong in a group rather than being expressed some other way? Select all that apply.

  4. 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.