用 Ansible 准确检查 Linux 软件包是否已安装

即使软件包已经卸载,只要 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 变量教程。

Avatar photo

关于 木子

Email: muzi@vip.rockylinux.cn 微信:VebinLee QQ: 2306867585
Founder of the Rocky Linux Chinese community, MVP、VMware vExpert、TVP, advocate for cloud native technologies, with over ten years of experience in site reliability engineering (SRE) and the DevOps field. Passionate about Cloud Computing、Microservices、CI&CD、DevOps、Kubernetes, currently dedicated to promoting and implementing Rocky Linux in Chinese-speaking regions.
用一杯咖啡支持我们,我们的每一篇[文档]都经过实际操作和精心打磨,而不是简单地从网上复制粘贴。期间投入了大量心血,只为能够真正帮助到您。
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇