AnsibleXXXVI · Drift and ConvergenceDrift and convergence
Check mode as a drift detector
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
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
ansible-playbook -i inventory/ site.yml \
--limit env_prod \
--check --diffNote 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:
$ ansible-playbook -i inventory/ site.yml --limit env_prod --check --diffPLAY 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=0Illustrative 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.
$ 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: 0changed 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:
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.
Trending it
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:
| Metric | What a rise means |
|---|---|
Hosts with changed > 0 | Drift is spreading across the fleet |
Total changed across the fleet | Drift is deepening, possibly on few hosts |
changed count for a specific task | One 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.
# 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- 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 > 0Note 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
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?
Q2. You want machine-readable output from a check run on a stock ansible-core 2.21.3 controller. What is actually available?
Q3. Which of these are accurate limits of dpkg --verify, per its own man page? Select all that apply.
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.