Skip to main content
RunBook Academy

← All break/fix scenarios in Ansible

intermediatePackages~30 min

Break/Fix: the patch run fails on a third of the fleet with a lock error and succeeds when rerun

Reported symptoms

  • The scheduled patch run fails on 60 to 80 of 200 hosts with a package manager lock error
  • Rerunning the same play twenty minutes later succeeds on every previously failed host
  • The failing hosts are different each week and correlate with nothing in the inventory
  • The Debian hosts and the RHEL hosts fail at similar rates, which ruled out a distribution-specific bug
  • No human is logged in on the failing hosts at the time
  • Because the rerun always works, the incident was closed twice as transient

Evidence

  • · The task failure message names the package database lock and the process holding it
  • · `ps -o pid,etimes,cmd -p <holder-pid>` on a failing host shows an unattended upgrade or a metadata refresh, started minutes before the run
  • · `systemctl list-timers | grep -Ei "apt|dnf|unattended"` shows a distribution timer firing inside the patch window
  • · `systemctl show apt-daily.timer -p RandomizedDelaySec` shows a randomised delay measured in hours
  • · `ansible-doc ansible.builtin.apt` gives `lock_timeout` a default of 60 seconds; `ansible.builtin.dnf` defaults to 30
  • · The playbook sets neither, so both defaults apply
  • · The run starts at 03:00 and the distribution timers are scheduled into a window that overlaps it
Diagnosis and resolutionclick to reveal

Root cause

The fleet is being patched by two systems at once. The distribution ships a timer that refreshes package metadata and, on the Debian hosts, applies security updates unattended; the Ansible patch play runs on its own schedule. Both need the package database lock and only one can hold it. Which hosts collide is decided by the randomised delay the distribution timers apply to spread load, which is why the failing set changes every week and correlates with nothing - the randomisation is the pattern. Ansible waits for the lock, but only for as long as the module's `lock_timeout` allows, and the defaults are short relative to how long a metadata refresh or an unattended upgrade takes. When the wait expires the task fails and the host is left partway through a patch cycle. The rerun succeeds because by then the other system has finished, which is exactly the behaviour that made the incident look transient and got it closed twice. The deeper problem is not the lock at all: two uncoordinated systems are changing packages on the same machines, so even a successful run leaves the estate in a state neither system fully describes.

Remediation

Decide which system owns patching and disable the other, rather than tuning the collision. If Ansible owns it, mask the distribution timers through the same configuration management that runs the patch play, so the setting is described in the repository and survives a rebuild. If unattended upgrades own security patching by policy, then the Ansible play must not also install packages and should restrict itself to verifying and reporting. Whichever way the decision goes, raise `lock_timeout` to a value that reflects how long the other work legitimately takes, add a pre-flight check that refuses to start while a package transaction is in progress, and repair the hosts that were left partway through a patch cycle before doing anything else.

Verification

The check that matters is a run with the collision deliberately arranged: on one test host, start a long metadata refresh, then run the patch play and confirm the pre-flight check refuses to start rather than failing halfway through. A verification that only ever runs on a quiet host cannot distinguish a fixed system from a lucky one. Beyond that, confirm the distribution timers are inactive with `systemctl is-enabled` and `systemctl list-timers` on a sample, confirm no host has a partially applied transaction, and run the patch play on three consecutive schedules with zero lock failures before declaring it fixed.

Prevention

One system owns package state on a host. Two systems patching the same machine is a correctness problem before it is a locking problem, because neither one can describe what is installed. Mask the distribution timers explicitly and manage that masking as configuration, so a rebuilt host does not silently reacquire a second patching system. Set `lock_timeout` deliberately rather than accepting a default chosen for interactive use, and add a pre-flight guard that refuses to run while a transaction is in progress - failing before the work starts is much cheaper than failing halfway through it. Finally, stop closing intermittent failures as transient: a failure whose victim set is randomised is a scheduling collision, and the randomisation is the evidence rather than the noise.

Reported symptoms

The monthly patch run has failed on part of the fleet three months running. Each time between 60 and 80 hosts out of 200 report a package manager lock error and the play aborts on those hosts.

Each time, the on-call engineer reran the play against the failed hosts about twenty minutes later and it succeeded completely.

The ticket has been closed twice with the word “transient”.

What was checked and ruled out:

  • Not distribution-specific: the Debian and RHEL hosts fail at similar rates.
  • Not a bad mirror: the successful hosts in the same run used the same mirrors.
  • Not human interference: nobody is logged in at 03:00.
  • Not inventory-correlated: the failing hosts share no group, rack, region, role or build date.

The only thing anyone noticed was that the failing set was different each month, and that observation was treated as further evidence that the problem was random.

Evidence provided

Read-only / Safeboth distributions, same shape of failure
$ grep -m2 -A3 'FAILED' logs/patch-2026-08-11.log
fatal: [app041]: FAILED! => {"changed": false, "msg": "Failed to lock apt for exclusive operation: Failed to lock directory /var/lib/apt/lists/: E:Could not get lock /var/lib/apt/lists/lock. It is held by process 2841 (apt-get)"}
fatal: [db017]: FAILED! => {"changed": false, "msg": "Failed to obtain dnf lock, held by process 3120 (dnf)"}
Read-only / Safesomething else has been patching this host for seven minutes
$ ansible app041 -i inventory -b -m ansible.builtin.command -a 'ps -o pid,etimes,cmd -p 2841'
  PID ETIMES CMD
2841    418 /usr/bin/apt-get --quiet --force-confold ... dist-upgrade
Read-only / Safea second patching system, shipped by the distribution
$ ansible app041 -i inventory -b -m ansible.builtin.command -a 'systemctl list-timers --all --no-pager'
NEXT                        LEFT     LAST                        UNIT
Wed 2026-08-12 03:22:41 UTC  11h      Tue 2026-08-11 02:57:03 UTC apt-daily.timer
Wed 2026-08-12 06:14:02 UTC  14h      Tue 2026-08-11 06:11:55 UTC apt-daily-upgrade.timer
Read-only / Safetwelve hours of randomised delay - this is where the randomness comes from
$ ansible app041 -i inventory -b -m ansible.builtin.command -a 'systemctl show apt-daily.timer -p OnCalendar -p RandomizedDelaySec'
OnCalendar={ OnCalendar=*-*-* 06,18:00:00 }
RandomizedDelaySec=43200
Read-only / Safeno lock_timeout is set, so the module defaults apply
$ grep -n -B2 -A6 'Apply security updates' patch.yml
22:- name: Apply security updates
23:  ansible.builtin.package:
24:    name: '*'
25:    state: latest
26:  register: patch_result

Work the evidence before reading on

The failure names the process holding the lock. That process is not Ansible.

  1. Look at the timer’s randomised delay and the size of the window it spreads work across. Roughly what fraction of 200 hosts would you expect to be inside a 15-minute patch run at any given moment?
  2. The failing set changes every month and correlates with nothing. What kind of cause produces a victim set that is different every time and correlated with nothing?
  3. The rerun succeeds twenty minutes later. What has changed in those twenty minutes, on the host?

Before continuing: the run “succeeded” on the other 130 hosts. Given that a second system is also installing packages on those hosts, what exactly does that success tell you about their package state?

Root cause

1. Two systems are patching the same machines

The distribution ships timers that refresh package metadata and, on the Debian hosts, apply security upgrades without being asked. They are enabled by default on a fresh install, they run as root, and nothing about them appears in the Ansible repository.

The Ansible patch play does the same job on its own schedule. Neither system knows the other exists.

2. The lock decides who loses, and randomisation decides who collides

The package database lock is exclusive by design - two package transactions on one host would corrupt the database, so the lock is correct and necessary.

Ansible waits for it, but not indefinitely. ansible.builtin.apt defaults lock_timeout to 60 seconds and ansible.builtin.dnf defaults to 30. Neither is long enough for a dist-upgrade or a full metadata refresh, which routinely take several minutes.

The distribution timers apply a large randomised delay specifically so that a fleet does not hit its mirrors simultaneously. That randomisation is precisely what produces a different set of collisions every month. The victim set is not noise around a hidden pattern - the randomisation is the pattern, and it was the strongest available clue.

3. The rerun was not a fix, it was a second draw

Twenty minutes later the other transaction has finished, so the lock is free and the run succeeds. Nothing was repaired; the collision simply did not happen the second time.

That is why the incident was closed as transient twice, and it is the most expensive mistake in the whole scenario, because it left the underlying condition in place for three months.

Resolution

  1. Repair the hosts that were left partway through a patch cycle before anything else. A failed patch run can leave packages downloaded but not configured; on Debian hosts check dpkg --audit and complete any interrupted configuration deliberately.
  2. Decide who owns patching, as a policy decision rather than a technical one. Two systems patching the same host cannot both be right, and tuning the timeout only changes which one usually wins.
  3. If Ansible owns patching, mask the distribution timers through the same configuration management, so the decision is written in the repository and survives a rebuild. Masking rather than disabling prevents a dependency from starting them again.
  4. If unattended upgrades own security patching by policy, remove package installation from the Ansible play entirely and keep it as a verification and reporting play. A play that only asserts cannot collide.
  5. Raise lock_timeout to reflect how long legitimate other work takes - several minutes, not the interactive default - so a brief overlap waits rather than fails.
  6. Add a pre-flight check that refuses to start while a package transaction is in progress. Failing before the run is far cheaper than failing partway through it, and it produces a clear message instead of a lock error.
  7. Move the Ansible patch window away from the distribution timer window as defence in depth, accepting that a twelve-hour randomised delay means separation alone is not a fix.
  8. Reopen and correct the two tickets closed as transient, with the timer evidence attached, so the next intermittent failure is not closed the same way.

Verification

  1. The guard fires under a deliberately arranged collision. On one test host, start a long metadata refresh, then run the patch play: the pre-flight check must refuse to start and say why. This is the check that can fail, and running the play on a quiet host proves nothing.
  2. The distribution timers are gone where they should be. systemctl list-timers --all on a sample shows no package-related timers, and systemctl is-enabled apt-daily.timer reports masked.
  3. The masking survives a converge. Re-run the configuration management and confirm the timers are still masked; a host-level mask that the next converge undoes is not a fix.
  4. No host is mid-transaction. dpkg --audit returns nothing on Debian hosts and the equivalent RPM verification is clean, across the hosts that failed in the last three runs.
  5. Three consecutive scheduled runs report zero lock failures. One clean run is not evidence about a collision that happens by chance.
  6. The patch report accounts for every package change. Compare installed versions before and after a run against what the run reported; unexplained differences mean a second system is still active somewhere.
  7. A rebuilt host comes up with the timers masked. Build one from the pipeline and check, because this is the path by which a second patching system silently returns.

Prevention

  • One system owns package state on a host, and the choice is written down. This is a correctness requirement, not a preference: two patching systems make the estate undescribable long before they make it fail.
  • Mask the distribution timers as managed configuration, not as a one-time host change. Rebuilt hosts reacquire defaults.
  • Set lock_timeout deliberately. The module defaults are sensible for a human at a terminal and short for a fleet run competing with a background transaction.
  • Guard before you work. A pre-flight task that refuses to start while another transaction is running turns a mid-run failure into a clean refusal with a readable message.
  • Never close an intermittent failure as transient without characterising the victim set. Same hosts every time, different hosts correlated with load, and different hosts correlated with nothing are three different diagnoses, and the third one means look at the schedule.
  • Make systemctl list-timers --all a standard early step when investigating anything periodic. It is cheap and it enumerates every other system that thinks it owns the machine.