Skip to main content
RunBook Academy

AnsibleXXXVI · Drift and ConvergenceDrift and convergence

Check mode as a drift detector

Intermediate⏱ ~22 minansible-playbookdpkg

What you'll learn

  • Run a production playbook as a scheduled drift report and read the output correctly
  • Explain why the exit code cannot be used to detect drift in a check run
  • Turn would-change counts into a metric that can be trended per host and per task
  • Supplement check mode with package verification for the surface it cannot see

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.

You already own a drift detector. It is the playbook you use to configure production, run with --check --diff instead of for real.

Every task in it asks a host about a resource and reports whether the host matches. That is precisely what a drift report is. The work is not in building the detector; it is in scheduling it, reading its output correctly, and being honest about what it cannot see.

The scheduled drift run

Read-only / Safethe drift report itself
ansible-playbook -i inventory/ site.yml \
--limit env_prod \
--check --diff

Note the severity badge. A --check run is READ-ONLY only if no task in the play carries check_mode: false — a task with that keyword executes for real during a check run. The next lesson deals with that properly; for now, treat “is this play safe to check-run?” as a question you must have answered before scheduling it.

The output is a normal play recap, where changed now means would change:

Read-only / Safea drift report, read as counts
$ ansible-playbook -i inventory/ site.yml --limit env_prod --check --diff
PLAY RECAP *********************************************************************
web014.example.com         : ok=180  changed=0    unreachable=0    failed=0    skipped=6    rescued=0    ignored=0
web015.example.com         : ok=180  changed=0    unreachable=0    failed=0    skipped=6    rescued=0    ignored=0
web016.example.com         : ok=177  changed=3    unreachable=0    failed=0    skipped=6    rescued=0    ignored=0
api047.example.com         : ok=179  changed=1    unreachable=0    failed=0    skipped=6    rescued=0    ignored=0

Illustrative output

Two hosts converged, two not. web016 has three declared attributes that no longer match; api047 has one. That is a drift report, and it took a flag.

The exit code is not the signal

This trips up the first attempt at automating it, every time.

Read-only / Safea check run that found drift exits 0
$ ansible-playbook -i inventory.ini site.yml --check --diff; echo "exit: $?"
PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0

exit: 0

changed is not a failure. It never has been, in check mode or out of it, and a scheduled job that tests $? to decide whether the fleet has drifted will report “no drift” forever.

Parsing the recap with what core ships

Being precise about what is available, because the usual advice is wrong for ansible-core.

ansible-doc -t callback -l on 2.21.3 lists exactly five callback plugins: default, junit, minimal, oneline and tree. There is no json stdout callback in core — attempting ANSIBLE_STDOUT_CALLBACK=json produces [ERROR]: Could not load 'json' callback plugin. It lives in a collection, and if you want structured output you have to install one.

With core only, you parse the recap. It is crude and it works:

Read-only / Safedrift report to a machine-readable summary, core only
set -uo pipefail

REPORT=$(mktemp)
ansible-playbook -i inventory/ site.yml --limit env_prod --check --diff \
| tee "$REPORT"

awk '/PLAY RECAP/{recap=1; next}
   recap && /changed=[1-9]/ {
     host=$1
     for (i=1; i<=NF; i++) if ($i ~ /^changed=/) { split($i, a, "="); c=a[2] }
     printf "drift %s %s\n", host, c
     n++
   }
   END { exit (n > 0 ? 1 : 0) }' "$REPORT"

If you can install collections on the controller, a structured stdout callback is a better foundation than parsing text, and an automation platform gives you per-task results as a product feature. Say which one you are using in the runbook, because the parsing is the fragile part.

A single drift report is a to-do list. A series of them is a health metric, and the series is where the value is.

Three quantities worth recording per run:

MetricWhat a rise means
Hosts with changed > 0Drift is spreading across the fleet
Total changed across the fleetDrift is deepening, possibly on few hosts
changed count for a specific taskOne resource is being fought over — usually a vendor agent, or a genuine exception nobody recorded

The third is the most actionable and the one people never collect. A task that reports “would change” on the same forty hosts every night is not drift being detected; it is drift being observed and ignored, and it is almost always one of three things: a resource something else also manages, a declaration that is wrong, or a legitimate deviation with no mechanism to record it.

What check mode cannot see, and the tools that can

Check mode compares the declared surface. Category-4 drift — changes in areas the automation declares nothing about — is invisible to it by construction.

The complementary technique is to compare the host against something that is not your declaration: the package manager’s own record of what it installed.

Read-only / Safepackage verification on Debian and RPM systems
# Debian/Ubuntu: md5sum check against the dpkg database
dpkg --verify

# a single package
dpkg --verify nginx-common

# RPM systems: a wider set of attributes
rpm -Va
Read-only / Safepackage verification as a fleet-wide ad-hoc sweep
- name: verify installed packages against the package database
hosts: env_prod
gather_facts: true
tasks:
  - name: run package verification
    ansible.builtin.command:
      cmd: dpkg --verify
    register: pkgverify
    changed_when: false
    failed_when: false
    when: ansible_facts['pkg_mgr'] in ['apt']

  - name: report hosts with modified package files
    ansible.builtin.debug:
      msg: "{{ pkgverify.stdout_lines }}"
    when: pkgverify.stdout | default('') | length > 0

Note that this play uses ansible.builtin.command, which means it behaves differently under --check — the next lesson is entirely about that, and this task is one of the cases it covers.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A nightly job runs the site playbook with --check --diff and alerts if the exit code is non-zero. It has been green for a year. What is wrong?

  2. Q2. You want machine-readable output from a check run on a stock ansible-core 2.21.3 controller. What is actually available?

  3. Q3. Which of these are accurate limits of dpkg --verify, per its own man page? Select all that apply.

  4. Q4. A task that reports "would change" on the same forty hosts every night is usually not drift being detected, but drift being observed and ignored.

Passing score: 75%. Answers are checked in this browser.