Skip to main content
RunBook Academy

AnsibleXLIV · Failure Modes and Partial Fleet FailureFailure Modes and Partial Fleet Failure

Fighting the automatic updater for the package lock

Advanced⏱ ~24 minansible-playbookansible-doc

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

Not yet marked complete on this device.

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:

Read-only / Safewhat lock contention looks like in a run log
$ grep -A 3 'fatal:' /var/log/ansible/patch-2026-08-11.log | head -12
fatal: [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:

Read-only / Safethe documented lock_timeout defaults — read from ansible-doc on 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
ModuleDefaultReality
ansible.builtin.apt60 secondsWaits, and retries transitory locks
ansible.builtin.dnf30 secondsWaits for the dnf lockfile
ansible.builtin.dnf530 secondsDocumented 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.

Read-only / Safeassert the precondition before touching any package
- 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.

Configuration changemasking the updater in the base role, where it belongs
- 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

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

  2. Q2. Why is raising lock_timeout to 600 the wrong response to lock contention? Select all that apply.

  3. Q3. Setting lock_timeout on the dnf5 module has the same effect as setting it on the dnf module.

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