Skip to main content
RunBook Academy

AnsibleV · Inventory Design at Fleet ScaleFleet taxonomy

The four dimensions of a fleet

Intermediate⏱ ~16 minansible-core

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

Not yet marked complete on this device.

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:

DimensionThe question it answersExample values
EnvironmentWhat is the cost of getting this wrong?production, staging, development
Site / regionWhere does this machine physically live?eu_west, us_east, dc_ams1
Role / serviceWhat job does this machine do?web, database, cache, queue
LifecycleIs 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:

Read-only / Safethe shape of the fleet
$ 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.com

A 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:database is every host in either)
  • :& — intersection
  • :! — difference

The request said production and web and eu-west and live. That is three intersections:

Read-only / Saferesolve before you run
$ ansible -i inventory/production/hosts.yml 'web:&eu_west:&live' --list-hosts
  hosts (1):
  web-a1.example.com

And the “leave the draining ones alone” half is a difference:

Read-only / Safeexclusion is a first-class operator
$ ansible -i inventory/production/hosts.yml 'production:&web:!draining' --list-hosts
  hosts (1):
  web-a1.example.com

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

  1. One dimension per group name. If a group name contains two dimensions, you have started rebuilding the compound design. prod_web is the first step; prod_web_eu_live is where you end up.
  2. Group names are valid Python identifiers. Use underscores, not hyphens: eu_west, not eu-west. Ansible permits the hyphen in an inventory file, but a hyphenated group name cannot be referenced as groups.eu_west in Jinja and forces the bracket form everywhere.
  3. No hostnames encoded in group names. A group called web_a1_a2 documents the membership twice and will be wrong the moment a host is added.
  4. Environments are singular and unambiguous. Pick production or prod and 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

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

  2. Q2. Which of these belong in a group family rather than being derived from facts at run time? Select all that apply.

  3. Q3. Because a host appears under production, eu_west, web and live in the graph output, the inventory has duplicated that host four times.

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