Ubuntu nodes can have the unattended-upgrades package installed without actively running upgrades. The package state alone is not enough. Check the config, service, timers, logs, and package history before deciding whether a node is safe.
For production Kubernetes nodes, the desired state is usually:
unattended-upgrades package absent or inert
unattended-upgrades.service inactive and disabled
apt-daily.timer disabled or masked
apt-daily-upgrade.timer disabled or masked
/etc/apt/apt.conf.d/20auto-upgrades set to 0
patching handled through controlled maintenance windows
Audit First
Use multiple signals. An empty unattended-upgrades log is useful, but it does not prove the feature is disabled.
dpkg -l unattended-upgrades
cat /etc/apt/apt.conf.d/20auto-upgrades 2>/dev/null || true
systemctl is-enabled unattended-upgrades.service apt-daily.timer apt-daily-upgrade.timer
systemctl is-active unattended-upgrades.service apt-daily.timer apt-daily-upgrade.timer
systemctl list-timers | grep apt || true
sudo journalctl -u unattended-upgrades --since "7 days ago" --no-pager
sudo ls -lh /var/log/unattended-upgrades/ 2>/dev/null || true
Interpret the state carefully:
| Signal | Meaning |
|---|---|
| package installed | The capability exists, but may be inactive |
20auto-upgrades has Unattended-Upgrade "1" |
Automatic upgrades are enabled |
apt-daily-upgrade.timer enabled |
The system may trigger unattended upgrade activity |
| empty unattended-upgrades log | It may not have run recently, but verify timers and config |
| journal entries during an incident window | Correlate with package, service, storage, and kubelet events |
Disable Cleanly
Prefer stopping and disabling systemd units before removing packages. Do not start with kill -9 unless a process is stuck and you have already tried a clean stop.
sudo systemctl stop unattended-upgrades.service apt-daily.service apt-daily-upgrade.service 2>/dev/null || true
sudo systemctl disable unattended-upgrades.service apt-daily.service apt-daily-upgrade.service 2>/dev/null || true
sudo systemctl stop apt-daily.timer apt-daily-upgrade.timer 2>/dev/null || true
sudo systemctl disable apt-daily.timer apt-daily-upgrade.timer 2>/dev/null || true
sudo systemctl mask apt-daily.timer apt-daily-upgrade.timer 2>/dev/null || true
Then make the config inert:
sudo tee /etc/apt/apt.conf.d/20auto-upgrades >/dev/null <<'EOF'
APT::Periodic::Update-Package-Lists "0";
APT::Periodic::Download-Upgradeable-Packages "0";
APT::Periodic::AutocleanInterval "0";
APT::Periodic::Unattended-Upgrade "0";
EOF
If the operating model is to remove the package entirely:
sudo apt-get remove --purge -y unattended-upgrades
sudo rm -f /etc/apt/apt.conf.d/50unattended-upgrades
Inject The Intent In Packer
The durable place to express this policy is the node image, not only the remediation playbook. If Packer builds the Ubuntu template used for Kubernetes nodes, add a provisioner that makes the template’s intent explicit: automatic apt activity is off before any clone ever joins a cluster.
Place this after the base package installation stage and before final template cleanup. That lets the build install required packages first, then freeze the update policy into the image baseline.
build {
sources = ["source.vsphere-iso.ubuntu"]
provisioner "shell" {
inline = [
"while [ ! -f /var/lib/cloud/instance/boot-finished ]; do echo 'Waiting for cloud-init...'; sleep 2; done",
"sudo apt-get update",
"sudo DEBIAN_FRONTEND=noninteractive apt-get install -y open-vm-tools openssh-server lvm2 xfsprogs",
]
}
provisioner "shell" {
inline = [
"set -eu",
"echo 'Disabling unattended apt activity for Kubernetes node template...'",
"sudo systemctl stop unattended-upgrades.service apt-daily.service apt-daily-upgrade.service 2>/dev/null || true",
"sudo systemctl disable unattended-upgrades.service apt-daily.service apt-daily-upgrade.service 2>/dev/null || true",
"sudo systemctl stop apt-daily.timer apt-daily-upgrade.timer 2>/dev/null || true",
"sudo systemctl disable apt-daily.timer apt-daily-upgrade.timer 2>/dev/null || true",
"sudo systemctl mask apt-daily.timer apt-daily-upgrade.timer 2>/dev/null || true",
"sudo tee /etc/apt/apt.conf.d/20auto-upgrades >/dev/null <<'EOF'\nAPT::Periodic::Update-Package-Lists \"0\";\nAPT::Periodic::Download-Upgradeable-Packages \"0\";\nAPT::Periodic::AutocleanInterval \"0\";\nAPT::Periodic::Unattended-Upgrade \"0\";\nEOF",
"sudo apt-get remove --purge -y unattended-upgrades || true",
"sudo rm -f /etc/apt/apt.conf.d/50unattended-upgrades",
"systemctl is-enabled apt-daily.timer apt-daily-upgrade.timer 2>/dev/null || true",
"test ! -e /etc/apt/apt.conf.d/50unattended-upgrades"
]
}
provisioner "shell" {
inline = [
"sudo apt-get autoremove -y",
"sudo apt-get clean",
"sudo rm -rf /var/lib/apt/lists/*"
]
}
}
If the organization prefers keeping the package installed but inert, omit the apt-get remove --purge line and keep the config plus masked timers. The important part is that the image declares the operational boundary: Kubernetes node patching is owned by maintenance automation, not by background apt timers.
For a more modular Packer layout, keep the policy in a dedicated script and call it from the template:
provisioner "file" {
source = "scripts/disable-unattended-upgrades.sh"
destination = "/tmp/disable-unattended-upgrades.sh"
}
provisioner "shell" {
inline = [
"sudo install -m 0755 -o root -g root /tmp/disable-unattended-upgrades.sh /usr/local/sbin/disable-unattended-upgrades",
"sudo /usr/local/sbin/disable-unattended-upgrades",
"sudo rm -f /usr/local/sbin/disable-unattended-upgrades"
]
}
That keeps the Packer HCL readable while still making the image build fail if the policy script fails. Do not leave this as a wiki-only instruction. If the template is the source of truth for node operating-system behavior, the unattended-upgrades state belongs in the template build.
Ansible Pattern
In Ansible, avoid capturing newline-separated PIDs and passing them directly to kill. A task like this is unsafe when pgrep returns more than one process:
shell: "kill -9 {{ apt_get_pid.stdout }}"
Use services first, then verify, then process cleanup only as a last resort.
vars:
apt_timers:
- apt-daily.timer
- apt-daily-upgrade.timer
apt_services:
- apt-daily.service
- apt-daily-upgrade.service
- unattended-upgrades.service
tasks:
- name: Stop and mask automatic apt timers
ansible.builtin.systemd:
name: "{{ item }}"
state: stopped
enabled: false
masked: true
no_block: true
loop: "{{ apt_timers }}"
ignore_errors: true
tags: [unattended, disable, timers]
- name: Stop and disable unattended upgrade services
ansible.builtin.systemd:
name: "{{ item }}"
state: stopped
enabled: false
no_block: true
loop: "{{ apt_services }}"
ignore_errors: true
tags: [unattended, disable, services]
- name: Disable apt periodic configuration
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
owner: root
group: root
mode: "0644"
content: |
APT::Periodic::Update-Package-Lists "0";
APT::Periodic::Download-Upgradeable-Packages "0";
APT::Periodic::AutocleanInterval "0";
APT::Periodic::Unattended-Upgrade "0";
tags: [unattended, disable, config]
If process cleanup is still needed, separate discovery from action and handle an empty result safely:
- name: Find lingering apt or unattended-upgrade processes
ansible.builtin.shell: |
pgrep -f '(apt-get.*update|/usr/bin/apt|apt.systemd.daily|unattended-upgrade|/usr/bin/dpkg)' || true
register: apt_lingering
changed_when: false
failed_when: false
tags: [unattended, cleanup]
- name: Force kill lingering apt processes only as a last resort
ansible.builtin.command: "kill -9 {{ item }}"
loop: "{{ apt_lingering.stdout_lines | map('trim') | select('match', '^\\d+$') | list }}"
when: apt_lingering.stdout | length > 0
ignore_errors: true
tags: [unattended, cleanup]
That pattern works when zero, one, or many PIDs are returned. It also avoids failing when the discovery task is run and finds nothing.
Purge With Lock Awareness
If the package must be purged, wait briefly for apt and dpkg locks before invoking the apt module:
- name: Wait for apt and dpkg locks to clear
ansible.builtin.shell: |
for i in $(seq 1 30); do
if fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1; then
sleep 2
else
exit 0
fi
done
exit 0
changed_when: false
failed_when: false
tags: [unattended, purge]
- name: Purge unattended-upgrades
ansible.builtin.apt:
name: unattended-upgrades
state: absent
purge: true
autoremove: true
force_apt_get: true
register: purge_unattended
retries: 5
delay: 10
until: purge_unattended is succeeded
tags: [unattended, purge]
Verify The Desired State
Verification should not depend on previous tasks having run in the same play. That matters when using --tags verify.
systemctl is-enabled apt-daily.timer apt-daily-upgrade.timer unattended-upgrades.service || true
systemctl is-active apt-daily.timer apt-daily-upgrade.timer unattended-upgrades.service || true
cat /etc/apt/apt.conf.d/20auto-upgrades 2>/dev/null || true
dpkg -l unattended-upgrades 2>/dev/null || true
Expected output for a disabled-but-installed model:
APT::Periodic::Unattended-Upgrade "0";
apt-daily.timer masked
apt-daily-upgrade.timer masked
unattended-upgrades.service inactive or disabled
Expected output for a purged model:
package not installed
timers disabled or masked
no unattended-upgrades journal activity after the change
Related article: Ubuntu Unattended Upgrades Are Kubernetes Node Changes.