AnsibleXL · Patch and Reboot ManagementPatch and Reboot Management
Patching Debian and Ubuntu
What you'll learn
- Refresh the apt cache without refreshing it on every host on every run
- Choose between upgrade safe, full and dist from what each is permitted to remove
- Explain what the default dpkg_options decides about a locally modified configuration file
- Diagnose and survive an apt lock held by unattended-upgrades
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
ansible.builtin.apt is a wrapper around a package manager that was
designed for an interactive administrator, and the module’s job is to
answer, in advance and without asking, every question apt would have
put on the terminal.
Most of those answers are defaults you did not choose. One of them decides what happens to a configuration file you edited by hand. This lesson is about which defaults are load-bearing.
Everything below was read from ansible-doc ansible.builtin.apt in a
virtualenv pinned to ansible-core 2.21.3, not from memory.
$ ansible-doc ansible.builtin.apt dpkg_options Add `dpkg' options to `apt' command. Defaults to `-o
"Dpkg::Options::=--force-confdef" -o
"Dpkg::Options::=--force-confold"'.
default: force-confdef,force-confold
lock_timeout How many seconds will this action wait to acquire a
lock on the apt db.
default: 60
upgrade If yes or safe, performs an aptitude safe-upgrade.
If full, performs an aptitude full-upgrade.
If dist, performs an apt-get dist-upgrade.
choices: [dist, full, 'no', safe, 'yes']
default: 'no'
state choices: [absent, build-dep, latest, present, fixed]
default: presentThe cache, and why refreshing it on every task is wrong
apt will not install a version it does not know exists, so the package
lists have to be current. update_cache: true runs the equivalent of
apt-get update.
The default is not to update the cache at all — the module leaves
update_cache unset rather than defaulting it to false, which amounts
to the same thing for a play but is worth knowing when you read the
documentation.
The naive fix is to set update_cache: true on every apt task. On a
300-host fleet with six package tasks, that is 1800 metadata refreshes
against your mirror inside one run, and a mirror that starts returning
503 halfway through.
- name: Refresh the package lists if they are older than an hour
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600cache_valid_time is in seconds and defaults to 0, which means
“always refresh”. Setting it makes the task idempotent in a useful
sense: a rerun within the hour does nothing and reports ok.
The documentation also records a convenience that is easy to trip over:
since Ansible 2.4, setting cache_valid_time explicitly implies
update_cache: yes. So this is a cache refresh even though it does not
say so:
- name: This refreshes the cache, despite appearances
ansible.builtin.apt:
name: nginx
state: present
cache_valid_time: 3600
state: latest versus upgrade
These are different operations and the difference is what gets touched.
state: latest with a name upgrades the named packages and their
dependencies. Nothing else on the host moves.
upgrade ignores name entirely and upgrades everything the system
has an update for. The documentation is explicit: “This does not
upgrade a specific package, use state=latest for that.”
For a patch run you almost always want upgrade, because the point of
patching is the set of packages you did not enumerate.
What each upgrade mode is permitted to remove
This is the distinction that matters, and the parameter names do not convey it.
| Value | Underlying operation | May remove installed packages? |
|---|---|---|
safe (and its alias yes) | aptitude safe-upgrade | No |
full | aptitude full-upgrade | Yes |
dist | apt-get dist-upgrade | Yes |
no (default) | nothing | — |
safe-upgrade upgrades packages only where doing so requires no
removals. If a package can only be upgraded by removing something else,
safe leaves it at the old version — which is conservative, and which
also means a safe upgrade can silently leave a security update
unapplied.
full-upgrade and dist-upgrade will resolve those cases by removing
whatever is in the way. On a well-maintained host that is usually an
obsolete transitional package. On a host that someone has been hand-
editing for four years it can be your monitoring agent.
- name: Apply all available updates
ansible.builtin.apt:
upgrade: safe
autoremove: false
fail_on_autoremove: true
lock_timeout: 300
register: apt_upgrade
- name: Record what changed, for the audit trail
ansible.builtin.debug:
var: apt_upgrade.stdout_lines
when: apt_upgrade.changedautoremove: false is explicit rather than redundant. The default is
already false, and writing it down states that removing orphaned
dependencies is a separate, deliberate maintenance action rather than
something a patch run does on the way past.
only_upgrade: patch what is there, install nothing new
only_upgrade: true upgrades a named package only if it is already
installed, and does nothing if it is not.
- name: Upgrade the packages we own, where they are installed
ansible.builtin.apt:
name:
- nginx
- openssl
- openssh-server
state: latest
only_upgrade: trueWithout only_upgrade, state: latest on a package that is absent
installs it. Run that task against a group that turned out to
include the database tier and you have just installed nginx on your
databases — a change that will pass every health check and confuse
somebody in four months.
The parameter that decides what happens to your edited config files
dpkg_options defaults to force-confdef,force-confold, and that
default answers the most consequential question a Debian upgrade asks.
When a package upgrade ships a new version of a configuration file that
you have modified locally, dpkg normally stops and asks. In a
non-interactive run it cannot ask, so the answer is pre-selected:
force-confold— keep the version currently on disk. Your modified file survives; the maintainer’s new file is written alongside it as.dpkg-distand ignored.force-confdef— where the package provides a default action, take it, and only fall back onconfoldwhen there is no default.
Together they mean: your local edits win, silently, every time.
The deeper fix is the one the configuration-management parts of this
course argue for throughout: if a file matters, Ansible should own it as
a template, and the .dpkg-dist question stops being interesting
because the desired content is asserted on every run rather than
inherited from whatever the package left behind.
The lock, and the neighbour that holds it
On Ubuntu and Debian, unattended-upgrades is enabled by default on
many installations, and it does exactly what you are trying to do, on
its own timer, holding the same dpkg lock.
$ ansible-playbook -i inventories/production patch.yml --limit webserversfatal: [web07.example.com]: FAILED! => {
"changed": false,
"msg": "Failed to lock apt for exclusive operation: Failed to lock directory /var/lib/dpkg/: E:Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 2841 (unattended-upgr)"
}The obvious diagnosis is that something is broken. Nothing is broken. Another patching mechanism got there first, and on a fleet it will hit a different subset of hosts every month, which is why the failure looks random.
Three responses, in increasing order of correctness:
Wait longer. lock_timeout defaults to 60 seconds. An
unattended-upgrades run that is downloading a 200 MB kernel will hold
the lock for considerably longer than that. Raising it to 300 turns most
of these into a slow success.
Wait for the lock explicitly, so the run log says what it waited for:
- name: Check whether unattended-upgrades holds the apt lock
ansible.builtin.command:
argv:
- fuser
- /var/lib/dpkg/lock-frontend
register: dpkg_lock
changed_when: false
failed_when: false
retries: 30
delay: 10
until: dpkg_lock.rc != 0
- name: Refuse to patch a host whose lock is still held
ansible.builtin.fail:
msg: >-
{{ inventory_hostname }} still has the dpkg lock held after five
minutes. Another package operation is in progress; this host is
excluded from the run rather than fought with.
when: dpkg_lock.rc == 0Stop competing. The correct long-term answer is that one thing
patches a host. If Ansible does it, unattended-upgrades should be
configured for security-only or disabled, and that decision should be
recorded in the same role that does the patching.
Knowledge check
Knowledge check · 4 questions
Q1. A security update ships a new /etc/ssh/sshd_config that disables a weak cipher. The host has a locally edited sshd_config. The patch play uses ansible.builtin.apt with default settings. What happens?
Q2. What is the practical difference between upgrade: safe and upgrade: full?
Q3. A play sets update_cache: true on all six of its apt tasks against a 300-host fleet. Which criticisms are valid? Select all that apply.
Q4. A patch task fails with "Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 2841 (unattended-upgr)" on 12 of 40 hosts. The correct diagnosis is that another patching mechanism is running on those hosts, not that the module or the mirror is broken.
Passing score: 75%. Answers are checked in this browser.