AnsibleXLIV · Failure Modes and Partial Fleet FailureFailure Modes and Partial Fleet Failure
Fighting the automatic updater for the package lock
What you'll learn
- Recognise the signature of package-lock contention in a partial fleet failure
- State the lock_timeout defaults for apt, dnf and dnf5 and what each is worth
- Explain why raising lock_timeout is a symptom fix rather than a solution
- Write the automatic-updater precondition as a documented, verifiable gate
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
Of the thirty hosts that failed in the signature scenario, the single most likely cause is not a bug in the play, a drifted config or a missing package. It is that another automated system was patching the same host at the same time.
The error is unmistakable once you have seen it once:
$ grep -A 3 'fatal:' /var/log/ansible/patch-2026-08-11.log | head -12fatal: [app-047]: 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: [app-112]: FAILED! => {"changed": false, "msg": "Failed to obtain the DNF
lock. Another app is currently holding the yum lock; waiting for it to exit...
The other application is: PackageKit"}Illustrative output
The signature: it clusters, it does not scatter
This is the diagnostic that separates lock contention from everything else, and it is the reason the lesson exists.
Random failures scatter. If thirty hosts fail because of a bad package
in a repository, they will be spread across your serial batches in
proportion to how many of them run that distribution. If thirty hosts
fail because of hardware or a flaky network, they scatter too.
Lock contention clusters by time. The automatic updater is driven by
a timer — apt-daily-upgrade.timer on Debian family, a dnf-automatic
timer or PackageKit refresh on RHEL family. The timer fires within a
randomised window, and the hosts that collide with your run are the
hosts whose window overlapped the batch you were on at that moment.
Batching makes this worse in a way that is worth stating plainly. A
serial: 20 run over 300 hosts takes as long as it takes, and each
batch occupies a different slice of the clock. The batch that happens to
run at 02:31, when the timer window opens, fails. The batches on either
side do not. From the recap it looks like a mysterious cluster of
identical hosts behaving differently; from the timer it is obvious.
The lock_timeout options, and what each is actually worth
Both package modules can wait for the lock. Verified with ansible-doc
against ansible-core 2.21.3:
$ ansible-doc ansible.builtin.apt | grep -A 6 lock_timeout ; ansible-doc ansible.builtin.dnf5 | grep -A 4 lock_timeout lock_timeout How many seconds will this action wait to acquire a
lock on the apt db.
Sometimes there is a transitory lock and this will
retry at least until timeout is hit.
default: 60
type: int
lock_timeout This is currently a no-op as dnf5 does not provide an
option to configure it.
Amount of time to wait for the dnf lockfile to be
freed.
default: 30
type: int| Module | Default | Reality |
|---|---|---|
ansible.builtin.apt | 60 seconds | Waits, and retries transitory locks |
ansible.builtin.dnf | 30 seconds | Waits for the dnf lockfile |
ansible.builtin.dnf5 | 30 seconds | Documented as a no-op — dnf5 provides no way to configure it |
Raising the timeout is not the fix
The obvious move is lock_timeout: 600. It will reduce your failure
count, and it is still the wrong answer, for four reasons.
It makes the run slower and less predictable. Each contended host
now blocks for up to ten minutes. With serial: 20, one contended host
delays its entire batch, and a maintenance window with a hard end time
becomes a window you cannot size.
It does not remove the second writer. Waiting means you patch after the automatic updater. The updater has now installed a set of packages you did not choose, at a version you did not pin, possibly restarting services, and your play then runs on top of that. The run reports success. What was installed on that host is not what your play specified.
It converts a loud failure into a quiet inconsistency. A locked host that fails is on your failed list. A locked host that waited and then succeeded looks identical to a host that had no contention at all — and the two ended up with different package sets.
It does not help at all on dnf5. See above.
The precondition, written as a gate
If Ansible owns patching, the automatic updater being off is a documented precondition of the change, in the same category as “the host is in the load balancer” or “there is a current backup”. Preconditions are asserted, not hoped for.
- name: Patch preconditions
hosts: patch_targets
gather_facts: true
tasks:
- name: Ask systemd about every automatic-update unit
ansible.builtin.command:
argv:
- systemctl
- is-enabled
- '{{ item }}'
register: updater_state
changed_when: false
failed_when: false
loop:
- apt-daily.timer
- apt-daily-upgrade.timer
- dnf-automatic.timer
- dnf-automatic-install.timer
- packagekit.service
- name: Refuse to patch a host that has a second package-state owner
ansible.builtin.assert:
that:
- updater_state.results
| selectattr('stdout', 'eq', 'enabled')
| list | length == 0
fail_msg: >-
An automatic updater is enabled here. Patching would race a
second package transaction. Resolve before the window opens.
success_msg: 'This host has a single owner for package state.'The permanent fix belongs in the host build, not in the patch play: the automatic updater is masked at provisioning time, in the same role that configures everything else, and the assertion above exists to catch the hosts where that did not happen.
- name: Ansible owns package state on this fleet
ansible.builtin.systemd_service:
name: '{{ item }}'
enabled: false
state: stopped
masked: true
loop:
- apt-daily.timer
- apt-daily-upgrade.timer
when: ansible_facts['os_family'] == 'Debian'Masking rather than disabling is deliberate: a disabled unit can be
re-enabled by a package upgrade shipping a new preset, which is exactly
the kind of quiet reversal that produces a cluster of failures six
months later with no change to blame.
Knowledge check
Knowledge check · 4 questions
Q1. Thirty of 300 hosts failed a patch run. Which observation most strongly indicates package-lock contention rather than a bad package or a network fault?
Q2. Why is raising lock_timeout to 600 the wrong response to lock contention? Select all that apply.
Q3. Setting lock_timeout on the dnf5 module has the same effect as setting it on the dnf module.
Q4. A base role disables apt-daily-upgrade.timer with enabled: false and state: stopped. Six months later a cluster of hosts fails a patch run with lock contention. What is the most likely explanation?
Passing score: 75%. Answers are checked in this browser.