Linux 基础体系 · 第 12/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux 软件包管理:APT、DNF、仓库、签名、依赖与可重复安装

软件包管理并不只是“执行一条安装命令”。在现代 Linux 中,一次安装通常同时涉及:

  1. 本地软件包数据库;
  2. 仓库索引和软件包文件;
  3. 数字签名与信任根;
  4. 依赖、冲突和版本选择;
  5. 文件解包、配置脚本和服务生命周期;
  6. 缓存、锁、日志以及失败后的恢复;
  7. 发行版版本、CPU 架构和仓库快照。

理解这些边界,才能解释“仓库里明明有这个包却装不上”“签名验证成功但软件仍有漏洞”“同一份脚本今天能装、下周不能装”等问题。

一、软件包管理的分层模型

1. 软件包、底层数据库与高层解析器

一个软件包通常包含:

  • 文件路径,例如 /usr/bin/curl
  • 元数据,例如包名、版本、架构;
  • 依赖和冲突声明;
  • 安装前、安装后脚本;
  • 配置文件处理规则;
  • 文件校验信息。

Debian/Ubuntu 一类系统使用 .deb 包,底层数据库和解包工具主要由 dpkg 提供;Red Hat、Fedora、Rocky Linux、AlmaLinux 等系统使用 .rpm 包,底层数据库和事务工具由 RPM 体系提供。

APT 和 DNF 位于更高层:

APT / DNF
  ├── 读取仓库元数据
  ├── 解析依赖与冲突
  ├── 选择软件包版本
  ├── 下载软件包
  └── 调用 dpkg 或 rpm 执行本地事务

因此,以下两类操作不能混为一谈:

dpkg -i example.deb
rpm -Uvh example.rpm

它们主要负责处理已经拿到的本地包,不会像 APT 或 DNF 那样完整地从仓库解析并补齐依赖。直接使用底层工具安装包,可能得到“包已经解包,但依赖未满足”的中间状态。

高层命令通常是:

# Debian/Ubuntu
sudo apt update
sudo apt install curl

# Fedora/RHEL 系
sudo dnf makecache
sudo dnf install curl

apt 更适合交互式使用;自动化脚本通常使用 apt-get,因为其行为和输出接口相对更面向脚本。dnf 同时用于交互和自动化,现代 Fedora 使用 DNF 5,RHEL 系发行版中仍可能存在 DNF 4;常用的安装、查询和升级命令大体兼容,但高级插件和配置不能假定完全一致。

2. “已安装”不是单一状态

一个软件包至少可能处于以下状态:

仓库中可见
  ↓ 下载
已缓存但未安装
  ↓ 解包
文件已写入,但配置可能未完成
  ↓ 配置完成
已安装
  ↓ 升级/删除
新版本已安装,或旧文件被移除

Debian 系统还会记录 unpackedhalf-configuredtriggers-pending 等中间状态。可以检查:

dpkg --audit

如果没有输出,通常表示没有被 dpkg 识别出的未完成状态;有输出时,应先处理它,而不是继续大规模安装。

在 RPM 系统中,可以检查数据库和事务历史:

rpm -qa | head
sudo dnf history

软件包安装也可能修改服务状态。某些包的安装后脚本会调用 systemctl preset、启动服务、创建用户、生成缓存或注册触发器。发行版通常会通过策略抑制服务在某些安装环境中自动启动,但这不是所有发行版、包和场景都统一保证的。

因此,安装一个包不是纯粹的文件复制操作,而是对系统状态的特权变更。


二、仓库:索引、包文件与发行版策略

1. 仓库提供什么

软件包仓库至少包含两类数据:

仓库
  ├── 元数据索引:有哪些包、版本、架构、依赖、校验值
  └── 软件包文件:.deb 或 .rpm

APT 仓库通常由 ReleaseInReleaseRelease.gpg 描述发行版组件和索引文件;包索引进一步列出包名、版本、架构、依赖和包文件的哈希值。

RPM 仓库通常包含 repodata/repomd.xml,它索引了 primary、filelists、other 等元数据文件及其校验值。RPM 包本身还携带包级别的签名和摘要信息。

APT 的仓库配置通常位于:

/etc/apt/sources.list
/etc/apt/sources.list.d/*.list
/etc/apt/sources.list.d/*.sources

RPM 系统通常使用:

/etc/yum.repos.d/*.repo

查看实际配置时,不应只看一份文件:

grep -R --line-number --no-filename \
  -E '^[[:space:]]*(deb|deb-src|Types:|URIs:|Suites:|Components:|baseurl=|mirrorlist=|enabled=)' \
  /etc/apt/sources.list /etc/apt/sources.list.d \
  /etc/yum.repos.d 2>/dev/null

这里使用了引用和空白字符类,避免把路径中的特殊字符交给 Shell 重新解释。生产脚本不应直接拼接未经验证的仓库 URL。

2. updatemakecacheinstall 的区别

APT:

sudo apt update
sudo apt install curl

第一条命令下载并验证仓库索引,更新本地可用版本信息;它不会安装升级软件包。第二条命令基于当前索引选择版本、下载并安装包。

DNF:

sudo dnf makecache
sudo dnf install curl

makecache 使仓库元数据进入或刷新本地缓存。DNF 在很多情况下会自动刷新元数据,因此显式调用它主要用于诊断、预热缓存或构建流程。

查询候选版本:

# Debian/Ubuntu
apt-cache policy curl
apt-cache madison curl

# Fedora/RHEL 系
dnf list --showduplicates curl
dnf info curl

典型的 APT 输出可能类似:

curl:
  Installed: 7.88.1-10+deb12u5
  Candidate: 7.88.1-10+deb12u6
  Version table:
     7.88.1-10+deb12u6 500
        500 http://deb.debian.org/debian bookworm/main amd64 Packages
 *** 7.88.1-10+deb12u5 100
        100 /var/lib/dpkg/status

这里:

  • Installed 是当前已安装版本;
  • Candidate 是 APT 按优先级和版本策略选择的候选版本;
  • 500 是仓库优先级;
  • 100 /var/lib/dpkg/status 表示本地已安装状态来源。

Candidate 不等于“最新版本”的绝对定义,它是当前源、组件、架构、优先级和 pinning 策略共同计算出的候选版本。

3. 仓库不是一个永恒不变的目录

仓库可以变化:

  • 维护者发布新版本;
  • 旧版本被清理;
  • 元数据过期;
  • 镜像同步到不同时间点;
  • 同一个发行版代号的包集合继续更新;
  • 第三方仓库更改依赖关系或签名密钥。

因此,“使用 Debian 12”或“使用 RHEL 9”通常只确定了发行版大范围,不足以确定某次安装会得到哪些精确包。若需要复现,必须进一步固定时间点、仓库快照或包文件集合。


三、签名、哈希与信任链

1. 哈希解决完整性,签名解决来源认证

设软件包内容为字节序列 PP,其摘要为:

h=H(P)h = H(P)

其中 HH 是哈希函数。下载后重新计算 H(P)H(P'),如果:

H(P)=hH(P') = h

则说明下载结果与被记录的内容一致,能够发现传输损坏或内容替换。

但如果攻击者同时替换了包文件和索引中的哈希值,单独使用哈希不能判断哪个版本是真实的。因此需要数字签名。

签名过程可以抽象为:

σ=SignKprivate(M)\sigma = Sign_{K_{private}}(M)

验证过程为:

VerifyKpublic(M,σ)=trueVerify_{K_{public}}(M, \sigma) = true

其中:

  • MM 是被签名的仓库元数据或包数据;
  • private key 是仓库发布者持有的私钥;
  • public key 是系统信任的公钥;
  • σ\sigma 是签名。

签名验证回答的是:

这份数据是否由与该公钥对应的私钥签发,并且内容未被修改?

它不回答:

  • 软件包是否没有漏洞;
  • 软件包是否没有恶意功能;
  • 发布者是否值得信任;
  • 当前版本是否适合本机;
  • 依赖是否能够满足。

2. APT 的常见验证路径

APT 通常验证仓库发布的 InRelease,或验证 Release 与分离签名 Release.gpg。元数据中再包含具体包索引的哈希;包索引中包含 .deb 文件的哈希。

简化的数据流是:

仓库签名密钥
      ↓ 验证
InRelease / Release.gpg
      ↓ 包含索引哈希
Packages 索引
      ↓ 包含 .deb 哈希
具体 .deb 文件

查看某个包的来源与候选版本:

apt-cache policy nginx
apt-cache show nginx

现代 APT 不应把所有第三方密钥放进一个全局信任目录后让所有仓库共享信任。更窄的做法是为仓库指定密钥环,例如:

deb [signed-by=/etc/apt/keyrings/vendor.gpg] \
    https://packages.example.invalid/debian bookworm main

/etc/apt/keyrings 是常见的管理员维护位置;密钥文件必须通过可信渠道获得,并核对指纹。仅仅通过 HTTPS 下载一个 .gpg 文件并不自动构成可信导入。

apt-key 在现代 Debian 系列中已被弃用。原因不是签名机制失效,而是传统全局信任模型使一个仓库密钥可能被用于验证其他仓库,扩大了失陷影响范围。

3. RPM/DNF 的常见验证路径

RPM 包通常包含包级签名。DNF 还可以验证仓库元数据,配置项常见为:

gpgcheck=1
repo_gpgcheck=1
gpgkey=https://...

不同发行版和仓库对 repo_gpgcheck 的启用方式可能不同,不应仅凭某个示例推断系统已经验证了所有层次。可以查询实际生效配置:

dnf config-manager --dump 2>/dev/null | grep -E \
  '^(gpgcheck|repo_gpgcheck|gpgkey|sslverify)'

也可以检查已安装 RPM 的签名摘要:

rpm -q --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}\n' curl
rpm -Kv /var/cache/dnf/*/packages/*.rpm 2>/dev/null

缓存路径和输出格式可能因 DNF 版本而变,因此诊断时应以命令实际输出为准。

4. 签名失败时不要绕过验证

常见错误包括:

NO_PUBKEY ...
The following signatures couldn't be verified
GPG check FAILED

正确的诊断顺序是:

  1. 检查系统时间;
  2. 检查仓库 URL、发行版代号和架构;
  3. 检查密钥是否过期、是否发生轮换;
  4. 从发行版官方公告或可信介质获取新密钥;
  5. 核对新密钥指纹;
  6. 清理损坏的元数据并重新获取;
  7. 记录变更原因和验证证据。

不应使用“关闭 GPG 检查”作为常规修复。这样做相当于把“经过信任链验证的仓库”降级成“只要能下载就接受”,攻击者若控制 DNS、镜像、代理或仓库路径,就可能替换软件包。


四、依赖解析:从声明到事务

1. 依赖的形式化表示

把每个软件包版本表示为一个候选对象 pp。包的依赖可以写成:

A 依赖 B >= 2.0
A 依赖 (C 或 D)
A 与 E 冲突

安装请求形成约束集合 CC。解析器需要找到一个选择集合 SS,使得:

  1. 用户请求的包在 SS 中;
  2. 对每个依赖约束,至少有一个满足条件的包在 SS 中;
  3. 所有冲突约束都不被违反;
  4. 同一包的架构、版本和替代关系保持一致;
  5. 选择结果符合仓库优先级、架构和发行版策略。

可抽象为:

SCS \models C

这不是简单的“按依赖列表递归下载”。一个依赖可能引出另一个依赖,而多个包可能要求同一个包的互不兼容版本。

2. 完整算例

假设仓库提供:

A-1.0  依赖 B >= 2.0,依赖 C
B-1.5
B-2.0  依赖 D = 1
B-3.0  冲突 E
C-1.0  依赖 D >= 1
D-1
D-2
E-1.0

用户请求安装 A。推导过程如下:

初始请求:
S = {A-1.0}

读取 A 的依赖:
S = {A-1.0, B>=2.0, C}

满足 B>=2.0:
候选为 B-2.0 或 B-3.0

选择 B-2.0:
B-2.0 要求 D=1
C-1.0 要求 D>=1
两者交集为 D=1

得到:
S = {A-1.0, B-2.0, C-1.0, D-1}

这是一个可满足解。

如果系统中已经安装 E-1.0,解析器尝试选择 B-3.0 时会发现:

B-3.0 冲突 E

于是可能选择 B-2.0,也可能在其他约束下报告无解。解析器的目标不是永远保留所有已安装包,而是在允许升级、删除或替换的范围内寻找满足约束的事务。

如果用户明确固定:

A 依赖 B >= 2.0
用户又要求 B = 3.0
系统已有 E,且 E 不能删除

约束集合可能无解。此时“再多试几次安装”不会解决数学上的冲突,必须改变版本、仓库、冲突包或软件设计。

3. 版本约束不是字符串比较

版本号比较由包管理器定义,不能简单用 Shell 的字符串排序推断。例如:

sort -V

只是一个通用版本排序工具,不等价于 Debian 的 dpkg 版本规则,也不等价于 RPM 的 EVR(epoch-version-release)比较。

Debian 版本可能包含 epoch、上游版本、Debian 修订号:

2:1.4.7-3

RPM 常见表示为:

name-version-release.arch

其中 epoch 可能不直接显示在普通包文件名中,却参与比较。生产脚本不应自行解析版本字符串来决定升级,而应调用包管理器的查询和事务能力。

4. 查询依赖与模拟事务

APT:

apt-cache depends nginx
apt-cache rdepends nginx

sudo apt-get -s install nginx
sudo apt-get -s dist-upgrade

-s 只模拟,不写入系统。输出中应重点看:

  • 将安装哪些包;
  • 将升级哪些包;
  • 将删除哪些包;
  • 是否出现 kept back
  • 是否有来自非预期仓库的包。

DNF:

dnf repoquery --requires nginx
dnf repoquery --whatrequires openssl-libs
sudo dnf install --assumeno nginx
sudo dnf upgrade --assumeno

--assumeno 通常会展示事务并默认拒绝执行;不同 DNF 版本的输出细节可能不同。

生产变更前,模拟不是形式动作。例如升级核心库时,若模拟结果包含:

Removing: systemd
Removing: openssh-server

就必须停止并检查仓库优先级、模块流、排除规则和发行版版本,而不是添加 -y 继续执行。


五、安装、升级与删除的真实生命周期

1. 一个安装事务发生了什么

一次典型流程如下:

flowchart TD
    A[读取本地配置与已安装状态] --> B[读取仓库元数据]
    B --> C[验证仓库签名和索引摘要]
    C --> D[解析依赖、冲突和版本]
    D --> E{是否得到可行事务}
    E -- 否 --> F[报告依赖或策略错误]
    E -- 是 --> G[下载软件包]
    G --> H[验证包摘要与包签名]
    H --> I[获取包管理器锁]
    I --> J[解包文件]
    J --> K[执行配置脚本与触发器]
    K --> L[更新本地数据库]
    L --> M[必要时处理服务状态]

关键点有两个。

第一,仓库元数据验证和软件包文件验证处于不同层。索引签名通过,不代表下载过程不需要再次验证包文件摘要。

第二,事务通常不是完整的文件系统原子提交。包管理器会尽量保持数据库和文件状态一致,但断电、磁盘写满、安装脚本失败、管理员中止或底层文件系统故障都可能留下部分完成状态。

2. 安装前后的验证

安装前先检查:

# Debian/Ubuntu
apt-get -s install nginx

# Fedora/RHEL 系
dnf install --assumeno nginx

安装:

sudo apt-get install nginx
# 或
sudo dnf install nginx

安装后验证包状态和服务:

# Debian/Ubuntu
dpkg-query -W -f='${Package} ${Version} ${Status}\n' nginx
systemctl status nginx --no-pager
systemctl is-enabled nginx
systemctl is-active nginx

# Fedora/RHEL 系
rpm -q nginx
systemctl status nginx --no-pager
systemctl is-enabled nginx
systemctl is-active nginx

is-enabledis-active 是两个不同问题:

  • is-enabled:系统启动时是否配置为启用;
  • is-active:当前进程是否正在运行。

软件包安装成功并不保证服务启动成功。例如配置文件语法错误、端口已被占用、SELinux 拒绝访问、证书文件权限错误,都可能使包安装成功而服务处于 failed 状态。

诊断服务启动失败:

systemctl status nginx --no-pager -l
journalctl -u nginx -b --no-pager

-b 限定当前启动周期;若要查看上一次启动,需要使用相应的 boot ID,而不是假设所有发行版都保留同样数量的日志。

3. 升级和删除不是简单的覆盖与删除

APT 中:

sudo apt upgrade
sudo apt full-upgrade

upgrade 通常避免为了完成升级而删除包;full-upgrade 允许更大范围地调整依赖关系,可能安装新包、删除旧包。不同 APT 版本的细节以本机帮助和模拟结果为准。

删除:

sudo apt remove nginx
sudo apt purge nginx
sudo apt autoremove
  • remove 通常删除程序文件但保留部分系统配置;
  • purge 进一步删除由包管理器标记的配置文件;
  • autoremove 删除被标记为自动安装、且当前不再被依赖的包。

DNF 中:

sudo dnf upgrade
sudo dnf remove nginx
sudo dnf autoremove

不能把 autoremove 当作“安全清理所有无用文件”。它依赖包管理器记录的安装原因和当前依赖图;手工部署的文件、应用自己的缓存、日志和数据目录通常不由它管理。

删除软件包也不等于删除所有数据。数据库、/var/lib 下的应用数据、手工创建的配置和日志,往往有意保留。生产卸载前必须确认数据保留策略。

4. 锁与并发

APT 和 DNF 都会对本地数据库或事务过程加锁。若自动更新服务正在运行,手工执行安装可能看到:

Could not get lock ...
Another app is currently holding the dnf lock

正确做法是确认持锁进程和任务状态:

ps -ef | grep -E '[a]pt|[d]pkg|[d]nf|[y]um'
systemctl list-timers --all | grep -Ei 'apt|dnf|yum'

不要随意删除 lock 文件。锁文件通常只是状态表现,真正的持锁进程仍可能在写数据库。应等待任务完成,或在确认其已异常退出后按发行版文档恢复。

自动化中,多个并发任务同时执行安装是错误设计。应在调度层串行化变更,并记录事务日志。


六、可重复安装:从“同一命令”到“同一输入”

1. 可重复安装的定义

设一次安装由输入集合决定:

I=(D,A,R,K,P,C)I = (D, A, R, K, P, C)

其中:

  • DD:发行版及版本;
  • AA:CPU 架构;
  • RR:仓库地址、组件和时间快照;
  • KK:仓库信任密钥及其指纹;
  • PP:软件包精确版本集合;
  • CC:安装配置、环境变量和交互答案。

如果两次安装的 II 不同,即使命令都写成:

apt-get install nginx

也不应期待得到相同结果。

可重复性至少有三个层级:

  1. 依赖可重复:得到相同的软件包版本集合;
  2. 文件可重复:包文件摘要完全一致;
  3. 系统状态可重复:配置、服务状态、用户、生成文件也一致。

包管理器主要负责前两层的一部分,第三层还需要配置管理、初始化脚本和服务编排。

2. 仅固定顶层包名为什么不够

假设今天执行:

sudo apt-get install app

得到:

app=1.0
libx=2.1
liby=4.3

一周后,仓库发布了 libx=2.2,且 app=1.0 的依赖允许 libx >= 2.1。同一命令可能得到:

app=1.0
libx=2.2
liby=4.3

顶层包名没有变化,依赖闭包却变化了。

因此,严格复现需要固定整个依赖闭包,或者使用在固定时间点可访问的仓库快照。

3. APT 的精确版本安装

先查看版本:

apt-cache policy nginx

如果仓库中存在精确版本,可以执行:

sudo apt-get install nginx=1.22.1-9+deb12u5

依赖包也可能需要显式固定,否则解析器会在允许的约束内选择其他版本。更可靠的方式是同时固定仓库快照,并在安装后导出实际清单:

dpkg-query -W -f='${binary:Package}\t${Version}\n' \
  | sort > installed-packages.tsv

其中 ${binary:Package} 能保留某些架构信息;不要只导出包名后假定所有架构状态都无关紧要。

若要下载而不安装:

mkdir -p ./deb-cache
apt-get download nginx

apt-get download 通常以当前目录为目标,不需要 root,但它不是完整环境重建方案:依赖不会自动全部下载,包的来源和版本仍由当前索引决定。构建离线安装集合时,应使用专门的依赖下载流程,并在隔离环境验证闭包完整性。

APT 还有 pinning 和 preferences 机制,可以影响候选版本,但 pinning 不是时间快照。仓库继续变化时,仍可能出现新的候选包或依赖解。

4. DNF 的精确版本安装

查询可用版本:

dnf list --showduplicates nginx

通常可以使用完整 NEVRA 形式指定包:

sudo dnf install nginx-1.24.0-2.fc40.x86_64

NEVRA 表示:

Name-Epoch:Version-Release.Arch

实际包名是否需要写 epoch、仓库是否仍保留该版本,应以 dnf list --showduplicates 和仓库元数据为准。

DNF 的 versionlock 能力通常通过插件提供,是否预装、命令名称和配置位置取决于发行版。例如某些系统提供:

sudo dnf install 'dnf-command(versionlock)'
sudo dnf versionlock add nginx

不能把这组命令无条件写入所有 RPM 系统的安装脚本。更通用的可重复策略是:

  • 使用发行版或内部仓库快照;
  • 记录并审查完整事务;
  • 固定完整包清单;
  • 在同一发行版和架构的干净环境中验证;
  • 对长期维护系统使用发行版规定的版本锁定机制。

5. 容器构建中的一个常见边界

以下写法能减少索引与包文件不一致的时间窗口:

RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates curl \
 && rm -rf /var/lib/apt/lists/*

三条命令放在同一构建层中,是因为 apt-get update 产生的索引可能在下一层缓存中变旧。rm -rf /var/lib/apt/lists/* 只清理索引缓存,不是安全验证,也不会把系统变成可重复构建。

要获得更强重复性,还需:

  • 固定基础镜像摘要,而不是只写可变标签;
  • 使用固定仓库快照;
  • 固定包版本或锁文件;
  • 保存构建清单;
  • 定期主动更新并重新验证,而不是永久冻结漏洞修复。

“完全固定”和“持续获得安全更新”是两个需要显式权衡的目标。生产环境通常采用“可复现的构建输入 + 受控的定期更新”,而不是永远使用第一次构建的包。


七、仓库优先级、第三方源与混源风险

多个仓库可能同时提供同名包。最终选择受以下因素影响:

  • 包版本;
  • 仓库优先级;
  • 已安装状态;
  • 架构;
  • pinning 或 exclude;
  • 模块流或发行版组件策略;
  • 包是否允许替换、升级或降级。

在 Debian 系统中,APT preferences 可以调整优先级;在 RPM 系统中,.repo 文件中的 priorityexclude、模块策略等可能影响选择。第三方仓库若提供系统核心库,可能导致大范围替换。

查询来源:

# Debian/Ubuntu
apt-cache policy openssl

# Fedora/RHEL 系
dnf repoquery --qf '%{name}-%{version}-%{release}.%{arch} %{repoid}' openssl

生产上不应为了安装一个应用而随意混用不同发行版代号、不同主版本或不兼容的第三方仓库。例如把面向某个发行版大版本构建的 RPM 强行安装到另一个大版本,可能表现为:

  • 依赖名称存在但 ABI 不兼容;
  • 核心库被替换;
  • 服务启动失败;
  • SELinux 策略与文件标签不匹配;
  • 后续系统升级无法解析。

如果确实需要第三方软件,优先选择与当前发行版和版本明确匹配的仓库,并限制仓库能影响的包范围。对关键生产机,应先在相同镜像、相同仓库配置和相同架构的预生产环境执行模拟事务。


八、脚本化安装:Shell 错误处理不能替代包管理器事务

1. 不要用字符串拼接包名

错误示例:

packages="curl nginx"
sudo apt-get install -y $packages

未引用的 $packages 会发生词拆分和通配符展开。如果变量来自外部输入,可能被拆成额外参数,甚至展开当前目录文件。

更安全的 Bash 写法:

#!/usr/bin/env bash
set -Eeuo pipefail

packages=(curl nginx ca-certificates)

sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive \
  apt-get install -y --no-install-recommends "${packages[@]}"

这里:

  • 数组保留每个包名的边界;
  • "${packages[@]}" 将每个元素作为独立参数;
  • set -e 不能处理所有错误语义,但能避免许多未检查失败继续执行;
  • -E 配合 trap 让错误处理函数在函数调用中保留;
  • -u 会把未定义变量当作错误;
  • pipefail 让管道中前面的失败不被最后一个成功命令掩盖。

可以增加诊断:

trap 'rc=$?; printf "failed: line=%s rc=%s command=%q\n" \
  "$LINENO" "$rc" "$BASH_COMMAND" >&2' ERR

set -e 有明确边界:条件判断中的命令、while/until 条件、部分管道和逻辑组合的行为与直觉不同。脚本应显式检查关键命令返回值,不能认为打开 set -e 后所有失败都能被正确处理。

2. 交互、重启和配置文件必须显式设计

自动化安装可能遇到:

  • 配置文件冲突提示;
  • 服务启动;
  • 服务重启;
  • 时区或语言环境问题;
  • 需要接受许可协议;
  • 需要重启主机的内核或核心库更新。

DEBIAN_FRONTEND=noninteractive 只能减少部分 Debian 配置交互,不能替代应用配置,也不应永久写入全局环境。使用 -y 只表示自动回答确认,不代表事务风险已经被审查。

升级前模拟:

if ! apt-get -s install nginx; then
    echo "dependency simulation failed" >&2
    exit 1
fi

对于 DNF:

if ! dnf install --assumeno nginx; then
    echo "dependency simulation failed" >&2
    exit 1
fi

模拟命令本身成功,只说明解析器可以生成事务,不保证下载、解包、配置脚本和服务启动都成功。

3. 管道中的错误不能被吞掉

错误示例:

apt-cache policy nginx | grep Candidate

如果 apt-cache 失败而 grep 正常退出,默认情况下整个管道可能返回成功。使用:

set -o pipefail
apt-cache policy nginx | grep -F 'Candidate:'

或者显式保存状态:

output=$(apt-cache policy nginx)
printf '%s\n' "$output" | grep -F 'Candidate:'

脚本还应记录:

  • 发行版和架构;
  • 启用的仓库;
  • 目标包及精确版本;
  • 模拟事务结果;
  • 实际安装清单;
  • 包管理器日志;
  • 服务状态和健康检查结果。

这些信息比“脚本返回 0”更能解释生产故障。


九、失败路径与恢复方法

1. 索引过期、源不可达或发行版代号错误

APT 常见表现:

404 Not Found
Release file ... is not valid yet
The repository ... does not have a Release file

DNF 常见表现:

Failed to download metadata
All mirrors were tried

诊断顺序:

cat /etc/os-release
uname -m
date -u
getent hosts deb.debian.org

然后确认仓库路径是否对应当前发行版代号、主版本和架构。不要把“网络可达”误认为“仓库正确”;能连上一个服务器不代表该服务器提供当前系统可用的签名元数据。

2. 依赖破损或中断事务

Debian 系:

sudo dpkg --configure -a
sudo apt-get -f install

dpkg --configure -a 尝试配置已解包但尚未配置的包;apt-get -f install 尝试修复依赖。两者都可能执行包脚本或安装/删除包,生产环境执行前应先保存模拟结果并确认磁盘空间。

先检查:

df -h
df -i
sudo dpkg --audit

RPM 系:

sudo dnf check
sudo dnf history
sudo dnf history info <ID>

如果事务因磁盘写满中断,直接重试可能扩大问题。应先释放明确可释放的空间,确认数据库状态,再根据事务历史恢复或回滚。

3. 服务失败不一定是包失败

例如:

sudo apt-get install nginx
systemctl is-active nginx

可能得到:

active

也可能得到:

failed

第二种情况需要区分:

  • 包是否已正确安装;
  • 配置文件是否有效;
  • 端口是否冲突;
  • 依赖服务是否运行;
  • 文件权限和所有者是否正确;
  • SELinux/AppArmor 是否拒绝;
  • 服务是否被策略禁止启动。

检查配置和日志:

sudo nginx -t
sudo journalctl -u nginx -b --no-pager
sudo ss -ltnp

对于启用 SELinux 的系统:

getenforce
sudo ausearch -m avc -ts recent

在 AppArmor 系统中,应查看内核日志和 profile 状态。不要为了让服务启动而直接禁用 SELinux/AppArmor;应先确认拒绝事件是否真实对应目标服务,再修正策略、文件标签或服务配置。

4. 回滚并不等于“恢复到安装前”

某些 RPM 系统支持基于 DNF 历史的事务回滚:

sudo dnf history
sudo dnf history info <ID>

但回滚受限于:

  • 旧包是否仍在仓库或缓存中;
  • 配置文件是否被修改;
  • 安装脚本的副作用;
  • 数据库 schema 是否已经迁移;
  • 服务是否写入了不可逆数据;
  • 依赖关系是否仍然可满足。

APT 的 remove 或重新安装旧版本也不能自动恢复所有配置和应用数据。真正可靠的恢复方案通常需要镜像、快照、配置备份和应用级备份共同参与。


十、软件包管理与主机安全

软件包管理是主机安全基线的一部分,但不是完整的漏洞治理系统。

1. 安装来源和最小化

安装包前应回答:

  • 它来自哪个仓库;
  • 仓库密钥如何获得并核对;
  • 是否真的需要该包;
  • 是否包含不必要的推荐依赖;
  • 它是否会启用或暴露网络服务;
  • 包是否带有安装脚本;
  • 更新策略是什么。

Debian 系可以在确认依赖影响后使用:

sudo apt-get install --no-install-recommends package-name

这会减少一部分非必需包,但可能使某些软件功能不可用,不能盲目作为全局开关。

包最小化还应结合服务清理:

systemctl list-unit-files --state=enabled
ss -lntup

关闭服务不能替代卸载包;卸载包也不能保证已创建的用户、数据和防火墙规则全部消失。

2. 漏洞修复与可重复性

“固定版本”有两个相反风险:

  • 不固定:构建结果随仓库变化;
  • 永久固定:无法获得安全修复。

更合理的流程是:

固定快照构建
  ↓ 生成并保存清单
扫描漏洞与测试
  ↓
选择新的仓库快照
  ↓
重新构建、比较差异、分批发布

漏洞扫描结果也要结合发行版回移补丁策略判断。发行版可能保留上游旧版本号,但将安全修复回移到该版本;因此不能只用上游版本字符串判断是否存在漏洞,应结合发行版安全公告、包修订号和本机实际包状态。

3. 安装脚本是特权代码

.deb.rpm 的维护脚本通常以 root 权限运行。仓库签名能确认包来自被信任的发布者,却不会降低脚本权限,也不会保证脚本逻辑符合本地变更审批。

因此,第三方包进入生产环境前,应检查:

  • 包的来源和签名;
  • 包内容;
  • 维护脚本;
  • 服务单元;
  • 默认监听地址和端口;
  • 创建的用户和权限;
  • SELinux/AppArmor 兼容性;
  • 升级和卸载行为。

可以查看已安装包的文件清单:

# Debian/Ubuntu
dpkg -L nginx

# RPM 系
rpm -ql nginx

但“文件清单”不包含所有运行时副作用。服务启动、用户创建、数据库迁移和生成文件仍需通过系统日志、包脚本和应用文档验证。


十一、一个可审计的安装流程

下面的 Bash 示例面向 Debian/Ubuntu,重点是展示检查、模拟、安装和验证的顺序;它不自动修改仓库配置,也不适合直接套用于所有发行版。

#!/usr/bin/env bash
set -Eeuo pipefail

trap 'rc=$?; printf "ERROR line=%s rc=%s command=%q\n" \
  "$LINENO" "$rc" "$BASH_COMMAND" >&2' ERR

packages=(ca-certificates curl)
log_dir=/var/log/package-change
mkdir -p "$log_dir"

printf '%s\n' '== system ==' | tee "$log_dir/context.txt"
cat /etc/os-release | tee -a "$log_dir/context.txt"
uname -m | tee -a "$log_dir/context.txt"

printf '%s\n' '== refresh metadata =='
sudo apt-get update 2>&1 | tee "$log_dir/apt-update.log"

printf '%s\n' '== simulate transaction =='
sudo apt-get -s install --no-install-recommends "${packages[@]}" \
  | tee "$log_dir/apt-simulate.log"

printf '%s\n' 'Review the simulation result before continuing.'
read -r -p 'Install now? [y/N] ' answer
if [[ "$answer" != y && "$answer" != Y ]]; then
    printf '%s\n' 'aborted'
    exit 0
fi

sudo apt-get install -y --no-install-recommends "${packages[@]}" \
  2>&1 | tee "$log_dir/apt-install.log"

printf '%s\n' '== installed versions =='
dpkg-query -W -f='${binary:Package}\t${Version}\t${Status}\n' \
  "${packages[@]}" | tee "$log_dir/installed.tsv"

每一步的成立条件如下:

  1. packages 是数组,避免包名发生 Shell 词拆分;
  2. apt-get update 先刷新索引,避免使用过期候选信息;
  3. -s 生成事务但不写入系统;
  4. 人工确认模拟结果,防止意外删除或升级;
  5. 安装日志通过 tee 保存,但命令返回状态仍受 pipefail 约束;
  6. 最终从 dpkg 数据库读取实际状态,而不是把命令输出当作事实。

如果把确认步骤改成无人值守,必须将模拟结果、仓库配置和允许的变更范围放入 CI/CD 审批,而不是简单把 read 删除并保留 -y


十二、常见误解与边界

误解一:apt update 已经完成升级

错误。它主要更新索引。需要升级时使用 apt upgrade 或明确的 apt-get install package=version,并先检查模拟事务。

误解二:HTTPS 已经足够,所以可以关闭包签名验证

错误。HTTPS 保护特定连接,但信任范围、代理、镜像和仓库端点仍可能出问题。包管理器的签名验证提供了独立于传输层的发布认证和内容校验。

误解三:签名通过就代表软件安全

错误。签名只说明内容由受信发布者签发且未被篡改。漏洞、恶意发布、配置错误和供应链失陷仍需通过发行版安全公告、漏洞扫描、代码审查和运行时限制处理。

误解四:包管理器会自动记录所有系统变化

错误。它通常记录包文件、版本、依赖和部分配置,但不会完整记录手工编辑、应用数据、数据库迁移、运行时生成文件和所有服务副作用。

误解五:卸载包会删除所有相关内容

错误。配置、数据、日志、用户和手工创建的文件可能被保留。删除前应查看包清单、服务单元和应用数据目录。

误解六:固定一个顶层包版本就能复现环境

错误。依赖包、仓库元数据、发行版修订级别、架构、配置交互和安装脚本都会影响最终状态。可重复安装需要固定完整输入集合,通常还需要仓库快照或内部制品仓库。

误解七:-y 等于自动化已经安全

错误。-y 只自动回答确认,不能审查包来源、删除列表、服务重启、配置覆盖和漏洞风险。自动化的关键是可预测输入、模拟、审计和失败恢复。

软件包管理的核心不是记住 apt installdnf install 的语法,而是建立一条可验证的数据链:

仓库配置
  → 签名元数据
  → 包索引与版本选择
  → 依赖约束求解
  → 包文件摘要与签名验证
  → 特权安装脚本
  → 本地数据库与服务状态
  → 清单、日志和恢复手段

当这条链中的每一层都被明确记录,APT、DNF、仓库、签名、依赖和可重复安装才会从“安装命令”变成可审计、可诊断、可恢复的 Linux 系统工程。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。