即使软件包已经卸载,只要 dpkg 仍保存着它的记录,dpkg-query -l 检查就可能返回退出码 0。直接把这个结果当成“已安装”,会让 Ansible Playbook 将 Debian 主机上已经移除的 nano 误判为存在。更可靠的方式是使用 package_facts 模块:读取软件包数据库,获得已安装软件包的字典,再通过 in 判断包名是否存在。
这份教程修正了早期版本使用 dpkg-query -l 的误判问题,依次介绍 package_facts、dpkg-query -W 与 rpm -q 命令查询、软件包模块的检查模式,以及只安装缺失软件包的 Playbook。下列 Playbook 和输出对应 2026 年 11 月的测试环境:ansible-core 2.21 控制一台 Debian 13 主机和一台 Rocky Linux 10 主机。
配置主机清单
本文的所有 Playbook 都以 servers 主机组为目标,因此先在 inventory(主机清单)中加入 Debian 系和 Red Hat 系主机。控制节点尚未安装 Ansible 时,可以先参考 Rocky Linux 与 Ubuntu 的 Ansible 安装指南。创建主机清单文件:
vim hosts
加入目标主机。示例主机名为 debian13 和 rocky10,使用时替换成自己的主机名或 IP 地址。
[servers]
debian13
rocky10
[servers:vars]
ansible_user=root
ansible_python_interpreter=auto_silent
auto_silent 保留 Python 解释器的自动发现功能,同时隐藏 ansible-core 每次对各台主机输出的解释器提示,避免这些信息淹没有用的检查结果。如果使用普通用户连接,在 [servers:vars] 下增加 ansible_become=true,让安装任务通过 sudo 提权运行。主机组、主机变量和不同环境的清单组织方式,可参考 Ansible 主机清单管理指南。
使用 package_facts 检查软件包是否已安装
package_facts 模块向目标主机的软件包管理器查询已安装软件包,支持 apt、dnf、rpm、zypper、pacman 等后端。结果保存在以包名为键的 ansible_facts.packages 中。只有已安装的软件包才会出现对应的键,因此 'nano' in ansible_facts.packages 可以直接得到真或假,无需为 Debian 系和 Red Hat 系分别编写检查任务。
创建 Playbook:
vim check_packages.yml
写入以下内容。第一项任务只在 Debian 系主机上运行,因为 package_facts 通过 python3-apt 库读取 dpkg 数据库,缺少该库时会失败。
---
- name: Check if packages are installed
hosts: servers
vars:
package_names:
- tar
- wget
- nano
- telnet
- tree
tasks:
- name: Install python3-apt so package_facts can read dpkg
ansible.builtin.apt:
name: python3-apt
state: present
when: ansible_facts['os_family'] == "Debian"
- name: Gather the package inventory
ansible.builtin.package_facts:
- name: Report each package
ansible.builtin.debug:
msg: "{{ item }} is {{ 'installed' if item in ansible_facts.packages else 'NOT installed' }}"
loop: "{{ package_names }}"
RPM 后端同样依赖 RPM 的 Python 绑定。Rocky Linux 的 python3-dnf 依赖这些绑定,因此一般已经具备;Fedora 41 及后续版本和 SUSE 默认可能没有安装,管理这些系统时,应增加安装 python3-rpm 的相应任务。
示例选择五个软件包,用于覆盖 dpkg 的常见状态。Rocky Linux 主机上只有 tar 已安装;Debian 主机的状态如下:
tar:已安装,状态为ii。wget:已安装,并通过apt-mark hold保持版本,状态为hi。nano:通过apt remove移除,保留配置文件,状态为rc。telnet:曾经安装,后来移除,状态为un。tree:从未安装,dpkg 不知道这个包名。
对两台主机运行 Playbook:
ansible-playbook -i hosts check_packages.yml
Debian 将 tar 和保持版本的 wget 判断为已安装,其余均为缺失,包括旧 dpkg-query -l 检查曾经误判为已安装的 nano。Rocky Linux 只将 tar 判断为已安装:
PLAY [Check if packages are installed] *****************************************
TASK [Gathering Facts] *********************************************************
ok: [debian13]
ok: [rocky10]
TASK [Install python3-apt so package_facts can read dpkg] **********************
skipping: [rocky10]
ok: [debian13]
TASK [Gather the package inventory] ********************************************
ok: [rocky10]
ok: [debian13]
TASK [Report each package] *****************************************************
ok: [debian13] => (item=tar) => {
"msg": "tar is installed"
}
ok: [rocky10] => (item=tar) => {
"msg": "tar is installed"
}
ok: [debian13] => (item=wget) => {
"msg": "wget is installed"
}
ok: [rocky10] => (item=wget) => {
"msg": "wget is NOT installed"
}
ok: [rocky10] => (item=nano) => {
"msg": "nano is NOT installed"
}
ok: [debian13] => (item=nano) => {
"msg": "nano is NOT installed"
}
ok: [debian13] => (item=telnet) => {
"msg": "telnet is NOT installed"
}
ok: [rocky10] => (item=telnet) => {
"msg": "telnet is NOT installed"
}
ok: [debian13] => (item=tree) => {
"msg": "tree is NOT installed"
}
ok: [rocky10] => (item=tree) => {
"msg": "tree is NOT installed"
}
PLAY RECAP *********************************************************************
debian13 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
rocky10 : ok=3 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
即使主机原先没有 python3-apt,安装该库的任务也可能显示 ok。原因是 apt 模块会先为自身安装 python3-apt,等到检查指定软件包时,该库已经存在。测试中的 Debian apt 历史日志记录了这一任务期间发生的安装。除了判断是否存在,还可以通过 ansible_facts.packages['tar'][0].version 获取已安装版本。
错误:Could not detect a supported package manager
如果目标主机缺少后端所需的 Python 库,例如 Debian、Ubuntu 上的 python3-apt,或 Fedora 41 及后续版本、SUSE 上的 python3-rpm,package_facts 可能报错:
Could not detect a supported package manager from the following list: ['apk', 'apt', 'pacman', 'pkg', 'pkg_info', 'portage', 'rpm', 'dnf', 'dnf5', 'yum', 'zypper', 'pkg5', 'pkgng', 'openbsd_pkg'], or the required Python library is not installed
这种情况在最小化系统和容器镜像中尤为常见。报错虽然将 apt 列在支持列表中,但真正缺少的可能是 Python 库,而非 apt 可执行文件。Debian 测试主机通过在 package_facts 前保留上述 ansible.builtin.apt 任务解决了问题;RPM 系主机安装 python3-rpm 后也可解决同类错误。
为什么 dpkg-query -l 会误判
dpkg-query -l 列出 dpkg 保存的软件包记录。只要找到记录,无论记录中的实际状态是什么,都可能返回退出码 0。通过 apt remove 卸载的软件包会保留 rc 记录,表示软件包已移除、配置文件仍在;曾经安装后移除的软件包也可能留下 un 记录,表示期望动作未知、当前未安装。
状态缩写的第一个字符代表期望动作,第二个字符代表实际状态。因此,保持版本且仍然安装的软件包显示为 hi。在 Debian 主机上检查已经移除的 nano:
dpkg-query -l nano; echo "exit code: $?"
输出前两个字符为 rc,明确表示 nano 未安装,但退出码仍然是 0,Ansible 因而可能将命令任务标记为成功:
Desired=Unknown/Install/Remove/Purge/Hold
| Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend
|/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad)
||/ Name Version Architecture Description
+++-==============-=============-============-============================================
rc nano 8.4-1+deb13u1 arm64 small, friendly text editor inspired by Pico
exit code: 0
测试覆盖了以下状态。只有 dpkg 从未见过的包名返回非零退出码,因此这个退出码回答的是“dpkg 是否认识该包名”,而不是“该软件包是否已安装”。
| 软件包状态 | dpkg 状态 | dpkg-query -l 退出码 |
|---|---|---|
| 已安装 | ii |
0 |
| 已安装且保持版本 | hi |
0 |
| 已移除,保留配置文件 | rc |
0 |
| dpkg 有记录,但未安装 | un |
0 |
| dpkg 从未见过 | 无 | 1 |
早期 Playbook 还有三个问题,修订前的测试分别复现了这些现象:
- Debian 和 Red Hat 任务都使用
register: package_check。被跳过的任务也会注册跳过结果,因此在 Debian 上,后面的 Red Hat 任务会覆盖真正的检查结果,package_check is succeeded实际检查到的是一次跳过。 - dpkg 不认识的包名会导致
command任务失败,终止该主机上的后续任务,使“缺失时安装”的分支没有机会执行。 - 在检查模式中,软件包存在或缺失都可能得到
succeeded,因为“如果实际执行就会安装”仍然是一种成功结果。
对应的修正分别是:为各任务使用不同的注册变量;查询任务设置 failed_when: false;检查 changed,而非 succeeded。下面两节给出具体实现。
直接查询 dpkg 和 rpm
有时需要软件包管理器自己的回答,例如不能在目标主机上增加 python3-apt。这时仍可使用命令查询,但必须读取实际状态,不能只相信退出码。
在 Debian 上,dpkg-query -W -f='${db:Status-Status}' 将软件包的实际状态输出为单词;保持版本的软件包同样输出 installed。不要检查 ${db:Status-Abbrev} 是否等于 ii,因为其三个字符分别是期望动作、实际状态和错误标志,保持版本的软件包会显示 hi,导致误判。
Red Hat 系主机上的 rpm -q 只有在软件包已安装时才返回 0。它也要求准确的软件包名:Rocky Linux 的包名是 vim-enhanced,因此 rpm -q vim 可能报告未安装。
创建 Playbook:
vim check_packages_cmd.yml
为两种查询分别注册变量;changed_when: false 防止只读查询显示为变更;failed_when: false 防止缺失软件包中断整个 Play。
---
- name: Check packages with dpkg-query and rpm
hosts: servers
vars:
package_names:
- tar
- wget
- nano
- telnet
- tree
tasks:
- name: Query dpkg on Debian family
ansible.builtin.command: dpkg-query -W -f='${db:Status-Status}' {{ item }}
loop: "{{ package_names }}"
register: dpkg_check
changed_when: false
failed_when: false
when: ansible_facts['os_family'] == "Debian"
- name: Query rpm on Red Hat family
ansible.builtin.command: rpm -q {{ item }}
loop: "{{ package_names }}"
register: rpm_check
changed_when: false
failed_when: false
when: ansible_facts['os_family'] == "RedHat"
- name: Report Debian results
ansible.builtin.debug:
msg: "{{ item.item }} is {{ 'installed' if item.stdout == 'installed' else 'NOT installed' }}"
loop: "{{ dpkg_check.results }}"
loop_control:
label: "{{ item.item }}"
when: ansible_facts['os_family'] == "Debian"
- name: Report Red Hat results
ansible.builtin.debug:
msg: "{{ item.item }} is {{ 'installed' if item.rc == 0 else 'NOT installed' }}"
loop: "{{ rpm_check.results }}"
loop_control:
label: "{{ item.item }}"
when: ansible_facts['os_family'] == "RedHat"
先对之前出现误判的 Debian 主机运行:
ansible-playbook -i hosts check_packages_cmd.yml --limit debian13
保持版本的 wget 被正确识别为已安装,已移除的 nano 和状态为 un 的 telnet 均识别为未安装,未知的 tree 也不会再中断执行:
PLAY [Check packages with dpkg-query and rpm] **********************************
TASK [Gathering Facts] *********************************************************
ok: [debian13]
TASK [Query dpkg on Debian family] *********************************************
ok: [debian13] => (item=tar)
ok: [debian13] => (item=wget)
ok: [debian13] => (item=nano)
ok: [debian13] => (item=telnet)
ok: [debian13] => (item=tree)
TASK [Query rpm on Red Hat family] *********************************************
skipping: [debian13] => (item=tar)
skipping: [debian13] => (item=wget)
skipping: [debian13] => (item=nano)
skipping: [debian13] => (item=telnet)
skipping: [debian13] => (item=tree)
skipping: [debian13]
TASK [Report Debian results] ***************************************************
ok: [debian13] => (item=tar) => {
"msg": "tar is installed"
}
ok: [debian13] => (item=wget) => {
"msg": "wget is installed"
}
ok: [debian13] => (item=nano) => {
"msg": "nano is NOT installed"
}
ok: [debian13] => (item=telnet) => {
"msg": "telnet is NOT installed"
}
ok: [debian13] => (item=tree) => {
"msg": "tree is NOT installed"
}
TASK [Report Red Hat results] **************************************************
skipping: [debian13] => (item=tar)
skipping: [debian13] => (item=wget)
skipping: [debian13] => (item=nano)
skipping: [debian13] => (item=telnet)
skipping: [debian13] => (item=tree)
skipping: [debian13]
PLAY RECAP *********************************************************************
debian13 : ok=3 changed=0 unreachable=0 failed=0 skipped=2 rescued=0 ignored=0
同一 Playbook 对 rocky10 运行时,会报告 tar 已安装、其余四个软件包缺失,与 package_facts 的结果一致。由于 YAML 大约多出一倍,通常只在事实收集模块无法运行时采用这种方式。经常按 os_family 分支处理任务时,可参考 Ansible os_family 与发行版事实值列表。
使用软件包模块的检查模式
在 check mode(检查模式)中,以 state: present 调用 ansible.builtin.package,可以查询软件包管理器“实际执行时会做什么”,而不进行安装。缺失的软件包返回 changed,因为模块原本需要安装它;已安装的软件包返回 ok。因此应检查 is changed,不能检查 is succeeded。
Debian 的 apt 模块在检查模式中同样依赖 python3-apt,但不会在该模式下为自己安装此库,而是报出 python3-apt must be installed to use check mode。应先运行一次前面的 package_facts Playbook,或者自行安装这个库。
vim check_mode_packages.yml
写入以下 Playbook,检查 tar、保持版本的 wget 和缺失的 nano:
---
- name: Check packages with the package module in check mode
hosts: servers
vars:
package_names:
- tar
- wget
- nano
tasks:
- name: Ask the package manager what it would install
ansible.builtin.package:
name: "{{ item }}"
state: present
check_mode: true
loop: "{{ package_names }}"
register: pkg_check
- name: Report each package
ansible.builtin.debug:
msg: "{{ item.item }} is {{ 'NOT installed' if item is changed else 'installed' }}"
loop: "{{ pkg_check.results }}"
loop_control:
label: "{{ item.item }}"
运行检查:
ansible-playbook -i hosts check_mode_packages.yml
Debian 上保持版本的 wget 返回 ok,因此算作已安装;Rocky Linux 则需要安装 wget 和 nano。这次检查没有在任何一台主机上实际安装软件包:
PLAY [Check packages with the package module in check mode] ********************
TASK [Gathering Facts] *********************************************************
ok: [debian13]
ok: [rocky10]
TASK [Ask the package manager what it would install] ***************************
ok: [rocky10] => (item=tar)
ok: [debian13] => (item=tar)
changed: [rocky10] => (item=wget)
ok: [debian13] => (item=wget)
changed: [rocky10] => (item=nano)
changed: [debian13] => (item=nano)
TASK [Report each package] *****************************************************
ok: [debian13] => (item=tar) => {
"msg": "tar is installed"
}
ok: [rocky10] => (item=tar) => {
"msg": "tar is installed"
}
ok: [debian13] => (item=wget) => {
"msg": "wget is installed"
}
ok: [rocky10] => (item=wget) => {
"msg": "wget is NOT installed"
}
ok: [debian13] => (item=nano) => {
"msg": "nano is NOT installed"
}
ok: [rocky10] => (item=nano) => {
"msg": "nano is NOT installed"
}
PLAY RECAP *********************************************************************
debian13 : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
rocky10 : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
检查模式会为每个软件包分别调用包管理器,因此比 package_facts 慢。当你还想确认软件包能否从当前配置的软件仓库中安装时,它更有价值;package_facts 本身无法回答这一点。
只安装缺失的软件包
大多数情况下,检查软件包状态是为了安装缺失的软件包。可以通过 reject('in', ...) 将目标列表与 ansible_facts.packages 对比,输出缺失列表以便审计,再将整份列表交给一个软件包任务,让包管理器在一次事务中处理。更多 when 与过滤器用法见 Ansible 条件判断与循环教程。
vim install_missing.yml
写入以下 Playbook,其中继续复用前面的 python3-apt 安装任务:
---
- name: Install packages that are missing
hosts: servers
vars:
package_names:
- tar
- nano
- tree
tasks:
- name: Install python3-apt so package_facts can read dpkg
ansible.builtin.apt:
name: python3-apt
state: present
when: ansible_facts['os_family'] == "Debian"
- name: Gather the package inventory
ansible.builtin.package_facts:
- name: Work out which packages are missing
ansible.builtin.set_fact:
missing_packages: "{{ package_names | reject('in', ansible_facts.packages) | list }}"
- name: Show the missing packages
ansible.builtin.debug:
var: missing_packages
- name: Install the missing packages
ansible.builtin.package:
name: "{{ missing_packages }}"
state: present
when: missing_packages | length > 0
运行:
ansible-playbook -i hosts install_missing.yml
两台主机都缺少 nano 和 tree,各自通过一次安装任务完成变更:
PLAY [Install packages that are missing] ***************************************
TASK [Gathering Facts] *********************************************************
ok: [debian13]
ok: [rocky10]
TASK [Install python3-apt so package_facts can read dpkg] **********************
skipping: [rocky10]
ok: [debian13]
TASK [Gather the package inventory] ********************************************
ok: [rocky10]
ok: [debian13]
TASK [Work out which packages are missing] *************************************
ok: [debian13]
ok: [rocky10]
TASK [Show the missing packages] ***********************************************
ok: [debian13] => {
"missing_packages": [
"nano",
"tree"
]
}
ok: [rocky10] => {
"missing_packages": [
"nano",
"tree"
]
}
TASK [Install the missing packages] ********************************************
changed: [debian13]
changed: [rocky10]
PLAY RECAP *********************************************************************
debian13 : ok=6 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
rocky10 : ok=5 changed=1 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
再次运行时,两台主机的 missing_packages 都变为空列表,安装任务被跳过,汇总中的 changed=0。这体现了幂等性:重复执行不会产生不必要的变更。
实际上,state: present 本身就会跳过已安装的软件包。如果只需要“缺失时安装”,一个软件包任务即可,无需提前查询。需要根据安装状态生成报告、选择分支或跳过工作时,package_facts 才更有价值,例如只在仍安装旧软件包的主机上执行迁移。类似模式可参考 Ansible 速查表;set_fact 和注册变量的详细用法见 Ansible 变量教程。










