Linux 包管理进化史:每个答案,都是上一个问题逼出来的(中篇)17认证网

正规官方授权
更专业・更权威

Linux 包管理进化史:每个答案,都是上一个问题逼出来的(中篇)

中篇 · 包管理的秩序:账本、铁律与版本共存

五、账本时代:数据库,与铁律的第一次开火

让我们把镜头拉回到盒子纪元。还记得吗?当 rpm 安装一个盒子时,它会把盒子里的元数据,登记进一台机器上的一个本地数据库(在 RPM 世界,它住在 /var/lib/rpm)。

多说一句:这份账本的底层存储格式,本身也演进过——早年用的是 Berkeley DB(你在老系统上会看到 /var/lib/rpm/Packages 这种文件),后来因为 BDB 上游维护停滞、许可证又对发行版不友好,RPM 团队把默认后端迁到了如今更稳健通用的 SQLite(/var/lib/rpm/rpmdb.sqlite)。连存放账本的”本子”本身,都在被更好的方案逼着替换——这又是一次微缩的进化。

随着系统里装的包从几个变成几百上千个,这个数据库的分量,才真正显现出来。

它是什么?它是这台机器”装了什么”的唯一真相源(single source of truth)。

但要真正讲透它,得先纠正一个几乎人人都有的模糊认识——整个包管理,其实牵着两本账,而不是一本。

一本,就是我们刚说的这个本地数据库 /var/lib/rpm,它记录”这台机器,已经装了什么“。

另一本,在远端的仓库服务器上,记录”世界上,存在哪些包“。

前者像你家的财产清单(我家里现在有哪些东西),后者像一份商品目录(商店里能买到哪些东西)。两本账记录的对象、所在的位置、服务的目的都不同,一定不能混为一谈。可偏偏它们记录的字段又长得很像,所以最容易让人糊涂。

要彻底看清它们,最好的办法就是把两本账都翻开,逐字段对照着看

5.1 远端账本:世界上有哪些软件

我们先从远端那本”商品目录”开始,因为它的结构更直白。

你配置的每个 yum 源,服务器上都有一个 repodata/ 目录。它的入口是一个永远以明文存在的小文件 repomd.xml——你可以把它理解成”清单的清单”,里面用一条条 <data type="..."> 记录,指向真正的元数据文件在哪、校验和是多少。其中 type="primary" 那一条,指向的就是整个仓库的”商品目录”——它的真身,是一个压缩过的文件:老仓库里通常是 primary.xml.gz(gzip),较新的仓库——比如 openEuler 24.03、以及新版 createrepo_c 的默认——用的是压缩率更高的 primary.xml.zst(zstd);而 Fedora 那一脉则偏爱 primary.xml.zck(zchunk——它内部其实也是 zstd,但把文件切成了小块,好处是 dnf update 时只需下载变化的那几块,省流量)。无论哪种,解压开,里面都是一段段这样的 XML。

下面这段,就是这样一段真实的 XML——它是用 createrepo_c(把一个装满 rpm 的目录”建成 yum 仓库”的工具,会扫描所有包、生成上面说的 repodata/ 索引;你做离线内网源时几乎必用它)为一个 nginx 包生成的条目,我原样贴出来:

<package type="rpm">
  <name>nginx</name>
<arch>x86_64</arch>
<version epoch="1" ver="1.20.1" rel="14.el9"/>
<checksum type="sha256" pkgid="YES">88fcf52b...d7f58</checksum>
<summary>A high performance web server and reverse proxy server</summary>
<size package="7199" installed="71" archive="860"/>
<location href="nginx-1.20.1-14.el9.x86_64.rpm"/>     <!-- 去哪下载 -->
<format>
    <rpm:provides>                                       <!-- 我能提供什么 -->
      <rpm:entry name="webserver"/>
      <rpm:entry name="nginx" flags="EQ" epoch="1" ver="1.20.1" rel="14.el9"/>
    </rpm:provides>
    <rpm:requires>                                       <!-- 我需要谁 -->
      <rpm:entry name="openssl-libs" flags="GE" epoch="0" ver="1.1.1"/>
      <rpm:entry name="pcre2"/>
      <rpm:entry name="zlib"/>
    </rpm:requires>
    <file>/usr/sbin/nginx</file>                         <!-- 我会铺哪些文件 -->
    <file>/etc/nginx/nginx.conf</file>
</format>
</package>

字段一目了然:名字、架构、版本(注意 epochverrel 是三个独立字段,这正是前面「大求解时代」里版本比较的依据)、校验和、它提供什么(provides)、它需要什么(requires)、它会往系统铺哪些文件(file)、以及——去哪下载它(location href)。 求解器之所以能解那道依赖数学题,靠的就是把仓库里所有包的 provides 和 requires 这两段读进来,相互匹配。

这里其实又藏着一段微缩的进化史,值得停下来看一眼。

如果你 ll 一个老仓库的 repodata/,会发现 primary 这份数据居然有两个版本并存:一个 primary.xml.gz(XML 版),一个 primary.sqlite.bz2(SQLite 版)。

为什么要存两份?因为早年 yum 嫌每次都解析 XML 太慢,于是仓库额外预生成了一份建好索引的 SQLite,让 yum 直接查、省去解析。这是典型的”用空间换时间”。可故事没完——后来 dnf 带着自己的求解库 libsolv 上场,它直接吃 XML 就够快,根本不稀罕那份预建的 sqlite。

于是新版 createrepo_c 做了个决定:默认不再生成 SQLite,只留 XML。 这就是为什么你在较新的 Fedora、RHEL、openEuler 仓库里,只剩一个 primary.xml(以 .gz/.zst/.zck 之一压缩),那个 .sqlite.bz2 不见了。

你品品:为提速而生的 SQLite 版,造出来、用了许多年,最后又被更聪明的下一代亲手淘汰,退回到只用 XML。这和我们后面会看到的”模块化兜一圈又退回普通 RPM”、以及前面”rpmdb 从 BDB 换到 SQLite”是同一种韵律——每一层优化,都在被它的下一层重新审视。

5.2 本地账本:你家的财产清单

看清了”商品目录”,现在翻开第二本账——本地这一端,/var/lib/rpm,你家的”财产清单”。

/var/lib/rpm(现在是 rpmdb.sqlite)里其实是两层东西:一层是一张张”索引表”(PackagesBasenames 等,就是前面我们扒出来的那些表名);另一层是每个包记录(Packages 表的 blob)内部、由 RPM 标签(tag)定义的几百个字段——包名、版本、文件清单这些”字段”,严格说是存在 blob 里的 RPM tag,而不是 sqlite 的列

/var/lib/rpm 为每个包记录的核心字段

序号
字段名称
作用
1
Name
包名,如 nginx。包的唯一标识主体
2
Epoch
纪元号,版本比较的最高优先级位(用于强制版本排序)
3
Version
上游软件版本号,如 1.20.1
4
Release
发行版打包版本号,如 14.el9(同一上游版本的第几次打包)
5
Arch
适配架构,如 x86_64aarch64
6
Summary / Description
一句话简介 / 详细描述
7
License
软件许可证,如 BSDGPLv2
8
URL
软件官方主页
9
Vendor / Packager
打包厂商 / 打包者信息
10
InstallTime
安装到本机的时间戳(财产清单独有,商品目录没有)

一个典型的目录/var/lib/rpm/包括

ll /var/lib/rpm -a
total 74260
drwxr-xr-x.  2 root root       91 Nov 16  2022 .
drwxr-xr-x. 41 root root     4096 Jun  2 13:42 ..
-rw-r--r--.  1 root root 76005376 Jun  2 13:42 rpmdb.sqlite
-rw-r--r--.  1 root root    32768 Jun  5 23:07 rpmdb.sqlite-shm
-rw-r--r--.  1 root root        0 Jun  2 13:42 rpmdb.sqlite-wal
-rw-r--r--.  1 root root        0 Apr 18  2023 .rpm.lock

当这个 nginx 真的装进系统后,你随时可以查它:

$ rpm -qi nginx              # 这个包的身份卡
Name        : nginx
Epoch       : 1
Version     : 1.20.1
Release     : 14.el9
Install Date: Fri Jun  5 2026     ← 注意:多了"安装时间"

$ rpm -ql nginx              # 它往系统铺了哪些文件
/etc/nginx/nginx.conf
/usr/sbin/nginx
/var/log/nginx

$ rpm -qf /usr/sbin/nginx    # 【关键】这个文件,到底归谁?
nginx-1.20.1-14.el9.x86_64

两本账翻完,把它们对照着看,一件很有意思的事浮现出来:它们记录的字段,大体上是同一套东西——名字、版本、依赖、文件清单,几乎重合。这也正是它们容易被混淆的原因。但仔细看,有两个决定性的差异,恰好暴露了各自的使命:

  • 仓库的”商品目录”里有 location(去哪下载),因为它要告诉你”世界上存在这个包,你可以来取”;
  • 本地的”财产清单”里有 Install Date(何时装的),以及一个商品目录永远不会有的能力——**rpm -qf 反查文件归属**,因为它要记录”这台机器此刻拥有什么、每个文件归谁”。

一句话:商品目录面向”未来可能装什么”,财产清单面向”现在已经装了什么”。

你可能会冒出一个很自然的疑问:这两份数据库,是不是就对应”rpm 命令查的那份”和”dnf 命令查的那份”?

不是。 它们的分界不在”用哪个命令”,而在”在哪台机器、记录什么”:

  • 本地数据库 /var/lib/rpm 在你自己机器上,是全系统共用的财产清单。rpm 读它(rpm -qf 查归属就是在读它),dnf 也读它——因为 dnf 必须先知道”系统现状”,才能算出”该装什么、会不会撞车”。
  • 仓库元数据(就是那个 primary.xml,以 gz/zst/zck 之一压缩)在远端源服务器上(用到时才缓存到本地 /var/cache/dnf/),主要是 dnf 在用。rpm 这个单包工具压根不碰仓库,它只管你手里那个孤立的 .rpm 盒子。

所以真正的分野是:rpm 只盯着本地这一份,而 dnf 两份都握在手里。 这恰恰是 dnf 比 rpm “聪明”的根源——它一手拿着远端的”商品目录”算出该装哪些包,一手拿着本地的”财产清单”核对会不会和已装的东西冲突。

图片

记住这句”dnf 两份都握着”。几段之后那场 nginx 惨案,正是它把两份一对照、当场撞出来的

而那个 rpm -qf——”这个文件到底归谁”——看似平平无奇,却是整个系统账本的命根子。它之所以总能给出一个确定的答案,靠的是一条从盒子纪元就埋下、并请你记在心里的铁律,从未被破坏过:

系统里的任何一个文件,在同一时刻,只能属于一个包。

现在你该明白这条铁律为什么非守不可了:如果 /usr/sbin/nginx 这个文件同时属于包 A 和包 B,那当你卸载 A 时,这个文件删还是不删?删了,B 就坏了;不删,A 就没卸干净。财产清单一旦允许”一物多主”,整个系统的账就乱了,可信度归零。所以数据库把这条铁律当成生命线来守。

它怎么守?靠的是一道在真正动手之前的安全演习

5.3 事务:动手之前,先在沙盘上推演一遍

当你 dnf install 某个东西,求解器算出完整方案后,dnf并不会立刻往你的磁盘上写文件。它会先做一件极其聪明的事——**事务测试(transaction test)**。

它把所有即将安装、升级、删除的包,在内存里”空跑”一遍:模拟这些文件铺到系统上之后,会不会有哪个文件撞上了已有的主人?会不会有依赖在最后一刻断裂?这就像一场实弹演习前,先在沙盘上把整个流程推演一遍,确认万无一失,才下令真正开火。

这背后,其实是一种非常深刻的工程思想——事务的原子性,也就是数据库领域常说的 ACID 里的那个 “A”:要么全部成功,要么全部不发生,绝不允许出现”装了一半”的残废状态。

dnf 对待你的系统,就像数据库对待一笔转账:宁可整笔回滚,也绝不留下一个账目不平的烂摊子。

这个设计的价值,只有在出事的那一刻才会显现。而现在,就是出事的那一刻。

5.4 铁律开火:一场真实的 nginx 惨案

让我把一个真实的事故摆在你面前。

一台 Rocky Linux 9 的机器,系统自带的官方源里,已经装了一个 nginx,版本 1.20.1。它的主程序和配置,实际是由一个叫 nginx-core 的包提供的——也就是说,/usr/sbin/nginx/etc/nginx/nginx.conf 这些文件,此刻的合法主人是 nginx-core,这笔账清清楚楚记在数据库里。

这时,运维同学为了用上更新的特性,配置了 nginx 官方仓库,然后敲下 yum install nginx。求解器一比较版本,从官方仓库里挑中了一个高得多的版本:1.31.1,包的尾巴带着 .ngx 标记——这是 nginx 官方打包体系的印记,和系统那套 .el9(发行版官方打包)是两套互不相识的体系

包下载好了,GPG 密钥也导入了,一切看起来都在顺利推进。然后,在最后那道事务测试里,沙盘推演发现了致命问题:

Error: Transaction test error:
  file /usr/sbin/nginx from install of nginx-2:1.31.1-1.el9.ngx.x86_64
       conflicts with file from package nginx-core-1:1.20.1-14.el9.x86_64
  file /etc/nginx/nginx.conf ... conflicts with ...
  ...

翻译过来就是:那个 .ngx 的新 nginx,想把自己的主程序也放到 /usr/sbin/nginx、配置也放到 /etc/nginx/nginx.conf——可这些位置,**已经名花有主了,主人是 nginx-core**。

铁律,开火了。

这里你可能会冒出一个合理的反问:升级软件不是天天发生吗?从 nginx 1.20 升到 1.22,新版不也要占用 /usr/sbin/nginx 这个被旧版占着的路径吗?为什么平时升级不冲突,这次就撞了?

答案藏在一个 RPM 的关键机制里——**Obsoletes(废弃声明)**。

正常的同源升级,新版包的元数据里会明确写一句:”我 Obsoletes(取代)某某旧包。”这相当于一份正式的所有权交接书:RPM 一看,哦,这是合法的接班,旧包让位、新包接管那批文件,所有权平稳过户——不算冲突。这就是为什么 dnf upgrade 平时顺顺当当。

可这次的两个 nginx,来自两套互不相识的打包体系:旧的 nginx-core 是发行版官方打的(.el9),新的是 nginx 官网自己打的(.ngx)。那个 .ngx 包的元数据里,**根本没有声明”我要 Obsoletes 掉 nginx-core“**——它压根不知道 nginx-core 的存在。于是在 RPM 眼里,这不是”合法接班”,而是”两个陌生的包,同时来抢同一批文件”。没有交接书,就是硬冲突。而”一个文件只能属于一个包”这条生命线不容侵犯,于是数据库当场判定冲突,整个事务被中止

请注意这场事故最精彩的地方:它没有造成任何损坏。

因为冲突是在”沙盘推演”阶段被发现的,真正的磁盘写入根本还没开始。系统里的 nginx 还是原来那个完好的 1.20.1,一个文件都没被动过。这正是事务原子性的胜利——

这不是”装坏了”,而是”dnf 拦住了一次会把系统装坏的操作”。它在保护你。

那个让无数人第一次见到时一头雾水的报错,本质上不是故障,而是那条贯穿三十年的铁律,在尽职尽责地守护着你系统账本的完整性。

5.5 一个连带的暗坑:你改过的配置,升级后去哪了?

既然聊到了”文件归属”,顺手揭开一个几乎每个运维都踩过、却很少有人讲清的暗坑。

想象一下:某个软件的配置文件 /etc/xxx/xxx.conf 是它的包带来的,归这个包所有。可你上线后,亲手改过这个配置(调了端口、加了参数)。现在新版本来了,新包里也带着一份它自己的、全新的 xxx.conf。问题来了:你改过的那份,和新包自带的那份,该听谁的?

RPM 的处理堪称细腻。打包者会给配置文件标记一个属性,最常见的是 %config(noreplace)——”升级时别覆盖用户改过的”。于是 RPM 这样裁决:

  • 如果你没动过这个配置,直接用新版的,无声替换;
  • 如果你改过,RPM 不敢擅自覆盖你的心血,于是保留你的原文件不动,把新版那份改名存成 xxx.conf.rpmnew 放在旁边——意思是”这是新版的样板,你自己看着要不要合并”。
  • 反过来,在某些 %config(不带 noreplace)或卸载场景下,它会把你的旧文件备份成 **xxx.conf.rpmsave**。

这就解释了那个经典困惑:**”我明明升级了,怎么新功能没生效?”**——很可能是新配置静静躺在 .rpmnew 里,而系统还在用你那份旧的。也解释了另一个反向的惊吓:”我的配置怎么被改回去了?”——也许该看看有没有 .rpmsave。养成升级后 find /etc -name '*.rpmnew' -o -name '*.rpmsave' 扫一眼的习惯,能躲掉无数玄学故障。这一切的根子,依然是那本账本在恪尽职守:它清楚每个配置文件归谁、谁动过,于是在”尊重你的修改”和”交付新版”之间,小心翼翼地两头都不得罪。

5.6 惨案背后,一个无解的新问题

事故平息了,但它留下了一个发人深省的问题。

回头看:这位运维同学其实只想做一件再正常不过的事——在同一台机器上,用上一个更新版本的 nginx。 这个诉求过分吗?一点都不。

可经典的打包模型,却结构性地做不到。原因恰恰还是那条铁律:无论 1.20 还是 1.31,它们都想占用 /usr/sbin/nginx 这同一个文件路径。而一个路径只能有一个主人。所以在经典模型里,同一个软件,同一时刻,整个系统只能存在一个版本。新旧两个版本,天生水火不容。

这在过去不是大问题。但时代变了。

如今的企业级发行版,生命周期动辄长达十年(比如 RHEL 一个大版本要维护到地老天荒)。可十年里,nginx、Python、MySQL 这些软件,早就迭代了好几个大版本。于是一个尖锐的矛盾浮出水面:

系统的底座要十年不变,以求稳定;可上面跑的应用,却需要不断用上新版本,以求先进。一个要慢,一个要快,而经典模型里”一个软件只能有一个版本”的铁律,逼着你只能二选一。

这是一个靠前面所有机制——盒子、仓库、求解器、数据库——都无法解决的新问题。它不是”怎么把包弄进来”的问题,而是一个全新的维度:怎么让同一个软件的多个大版本,在同一个系统里和平共存、还能按需切换?

要回答它,我们必须想办法绕开、甚至部分推翻那条统治了一切的铁律。

而这,正是下一段历史登场的理由。

RHEL 8 给出的回应分两层:先是一个新的仓库框架——应用流(Application Stream,即 AppStream),专门用来容纳”可以有多个版本”的上层应用;然后,为了在这个框架里真正实现”同一软件多版本共存”,它又配上了一种叫**模块化(Modularity)**的具体打包技术。

请记住这个”框架 + 实现”的分层——它是这一章结尾那个意味深长的反转的关键。

图片

六、共存纪元:模块化,绕开铁律的第一次尝试

我们先把那个矛盾,逼到它最尖锐的形态,这样你才能体会解法的精妙。

矛盾的核心是:发行版想给你一个版本的 Python(比如 3.6),并承诺为它提供长达十年的安全维护;可你的新项目,偏偏要 Python 3.9。在经典模型里,这是死局——因为 python3 这个命令、它的库文件,路径就那么几个,两个版本一定会撞车,而铁律说一个文件只能一个主人。

那么,Red Hat 在 RHEL 8 里给出的解法是什么?

它的思路不是去打破铁律(那会动摇整个 RPM 体系的根基),而是非常聪明地绕开它。绕开的办法,是引入一个全新的概念层次,叠在经典的”包”之上。我们一层层来看。

首先,它把仓库劈成了两半。

RHEL 8 之后,你会发现系统的软件来源,分成了两个性质截然不同的仓库:

  • BaseOS:操作系统的底座。内核、systemd、基础工具链……这些东西只有一个版本,稳定如磐石,十年不动。这是你脚下的地基,绝不能晃。
  • AppStream(Application Stream,应用流):跑在底座上的应用软件,Python、Node.js、数据库、nginx……这里,允许多版本共存。

这个切分本身就是一个深刻的设计哲学:把”必须稳定的”和”需要灵活的”在物理上彻底分开。 nginx 日志里看到的 baseosappstream 这两个仓库名,根源就在这里。地基归地基,应用归应用,两者不同的节奏,被装进了两个不同的盒子。

其次,在 AppStream 这个框架里,它又配上了一套具体的实现技术——”模块(Module)”和”流(Stream)”。

光把应用单独拎出来还不够,还得解决”同一个应用、多个版本”的共存问题。于是有了两个关键概念:

  • 模块(Module):可以理解为”某个应用的整套打包方案”。比如 nginx 是一个模块,python 是一个模块。它把一个应用相关的一堆 RPM 包,捆成一个有意义的整体来管理。
  • 流(Stream):这才是点睛之笔。一个模块下面,可以有多条”流”,每条流代表一个大版本线。比如 nginx 模块下面有 1.20 流、1.22 流;python 模块下面有 3.6 流、3.9 流。

于是,那个原本无解的诉求,现在有了优雅的表达方式:

dnf module enable nginx:1.22     # 我选 nginx 的 1.22 这条流
dnf module install nginx

你不再是”安装某个版本的包”,而是**”在多条流里,选定一条,然后顺着它走”**。系统保证:同一个模块,同一时刻只有一条流是激活的。你想换版本,就切换到另一条流。

发现没有?铁律其实根本没有被破坏。

在任何一个确定的时刻,系统里 nginx 依然只有一个版本占用着 /usr/sbin/nginx——铁律安然无恙。模块化做的,不是让两个版本同时挤在一个路径上(那是不可能的),而是在更高的层次上,管理”此刻该让哪条流来占用这些路径”,并让切换变得干净、可控、有据可查。

这就是模块化的精髓:它没有正面推翻”一个文件只能属于一个包”那条物理铁律,而是在它之上,搭了一层”版本线的开关”。铁律管的是”此刻谁在位”,模块化管的是”该让谁上位、怎么换人”。

这是一种极高明的工程智慧:当一条底层规则无法撼动时,不要硬碰它,而是在更高的抽象层上,为它加一个调度器。

6.1 多养一本账的代价

要看清模块化到底值不值,得先知道它为了实现”共存”,在 /var/lib/rpm 这本老账本之外,额外背上了什么。它引入了两份新数据:一份在仓库侧,叫 modules.yaml(随仓库元数据下发、缓存在 /var/cache/dnf/ 下),用 YAML 定义”有哪些模块、每个模块有哪些流、每条流含哪些包、有哪些 profile 组合”;另一份在本地侧,是 /etc/dnf/modules.d/ 目录下的一堆 .module 文件,记录”这台机器为每个模块启用了哪条流”。关键的分野就在这里:老账本 /var/lib/rpm 记的是”实际装了哪个包、文件归谁”,而这两份新账记的是”我想用哪条版本线”——前者是事实,后者是偏好,分属完全不同的层。

平心而论,凭着这两份新账,模块化确实换来了三样像样的便利。其一是版本线锁定:启用 nginx:1.22 后,dnf update 只会在 1.22 这条线内部收安全补丁,绝不会擅自跳到 1.24 这样的大版本,等于自动替你挡住了”被偷偷升级”的坑。其二是整组打包切换:一句 dnf module install postgresql:13/server,就能拿到该版本配套的一整组包和预设好的安装组合,既不必自己拼凑,也不会新旧混装。其三是收窄求解:它等于告诉求解器”这软件只准在这条流里选”,让依赖结果更可预测。这些便利不能说不真实。

但把这三样便利放到天平另一头,代价立刻显得过重。多出来的 /etc/dnf/modules.d/ 这本意愿账,必须时刻和 /var/lib/rpm 这本事实账保持对齐,于是”模块流”与”普通包”裂成两套并行世界,交界处滋生出大量难以排查的故障,认知和运维成本陡增。

更要命的是,那三样好处没有一样非它不可:

  1. 锁版本,一个 versionlock 插件就够;

  2. 整组安装,本就有元包(meta package)能一键带全套;

  3. 收窄求解,普通用户根本无感。

模块采用的技术和朴素的”卸旧装新”没有任何区别,铁律一刻没被突破,它并没有解决任何旧机制解决不了的真问题,只是把已有的手段,换了一套更复杂、还要多养一本账的方式重新包装了一遍。

6.2 共存的秘密,全在路径上

说到这里,你心里可能升起一个尖锐的反问:既然铁律这么死板,一个路径只能一个主人,那为什么我的系统里,明明能同时躺着好几个版本的同一个库——libfoo.so.1libfoo.so.2 安然共存,从不打架?它们不是”同一个软件的多个版本”吗,怎么就不冲突?

答案藏在一个不起眼却极聪明的设计里:soname(共享库的版本化命名)。共享库的开发者早就预见了这个矛盾,于是约定:把”主版本号”直接编进文件名。于是 libfoo.so.1 和 libfoo.so.2 从一开始就是两个不同的文件名、占着两个不同的路径——铁律压根没被触犯,它们当然能共存。需要 1.x 的老程序去链接 .so.1,需要 2.x 的新程序去链接 .so.2,各取所需,互不干扰。

这下你就看穿了一件事:为什么库能轻松多版本共存,而 nginx 那样的可执行程序却不能? 因为库从设计之初就把版本编进了文件名、主动给自己分了路径。

而 /usr/sbin/nginx 这个可执行文件的路径是写死的、不带版本的——两个版本必然抢同一个名字。

共存能不能实现,从来取决于一件事:它们占的是不是同一个路径。 soname 是”在文件名层面自己分路径”的优雅解法,模块化是”在更高层调度谁来占路径”的笨重解法,而我们后面会看到的 Nix,则是”干脆给所有东西都分配独立路径”的终极解法——殊途同归,都是在和那条铁律周旋。

6.3 但这一次,解法本身成了新的麻烦

按我们这部进化史一以贯之的剧情,你现在应该已经预感到了:模块化解决了多版本共存,但它自己,又带来了新的问题。而且这一次的问题,争议大到最后动摇了它自己的命运。

第一个麻烦是复杂度的暴涨。经典模型里,你只要想”装哪个包”。模块化之后,你得先想”启用哪个模块、激活哪条流”,再想”装哪个包”。多出来的这一层,让无数管理员栽了跟头——尤其是当模块的流和普通仓库里的包发生纠缠时,求解器会给出一些极其反直觉的结果。你或许在nginx 配置里见过的 module_hotfixes=true 吗?那行配置存在的意义,恰恰就是为了告诉 dnf:对这个第三方仓库,请绕过模块化的某些限制——它本身就是模块化带来的复杂度,逼出来的一块补丁。

第二个麻烦更微妙:模块和经典包,是两套并行的世界,它们的交界处充满了陷阱。 一个被模块”接管”的应用,和一个从普通仓库来的同名包,该听谁的?这种边界上的模糊,带来了大量难以排查的故障。

顺带说一个和”版本控制”一脉相承的实用机制。模块化用”锁定一条流”来固定大版本,但如果你不用模块、只是单纯想钉死某个包的版本,不让它被 dnf update 偷偷升级,有一个更直接的工具:versionlock。装上 dnf-plugin-versionlock,一句 dnf versionlock add nginx,就给这个包上了把锁——之后任何升级都会绕开它,直到你亲手解锁。这是把”我不要这次升级”的意愿,从”每次小心翼翼”变成”一次声明、长期生效”。生产环境里那些”绝不能动”的关键组件,值得用它焊死。

这些麻烦累积到什么程度呢?程度是:Red Hat 最终亲手把这套机制送进了历史。 而且退得干净利落、有明确的时间表——从 RHEL 9 开始,官方就逐步停止创建新模块;到了 RHEL 10,模块化作为一种打包技术被彻底放弃,官方文档的措辞不留余地:RHEL 10 不再分发任何模块化内容。

那么,多版本共存的需求消失了吗?并没有。这里要特别说清一件事,否则容易误解:被放弃的,只是”模块化”这一种实现技术,而不是 AppStream 这个框架。 AppStream 活得好好的,RHEL 10 里它依然是那个容纳上层应用的仓库——变的只是它”内部怎么提供多版本”。

那么,RHEL 10 改用什么来填充这个框架了呢?Red Hat 给出的答案,说出来你可能会愣一下——回归”带版本号的普通 RPM 包”,直接用 dnf install 安装。 也就是说,框架(AppStream)留下了,但它最初那套精巧的内部实现(模块、流),兜了整整一大圈,最后被官方亲口判定:太复杂、太难维护、得不偿失,我们还是回到最朴素的老办法吧。

请你品一品这个结局。模块化——一个曾被寄予厚望、作为 RHEL 8 旗舰特性隆重推出的实现技术,不到两个大版本,就被它的创造者判定为”不值得”,然后亲手拆掉,让那个它本想服务的框架,改回用最朴素的方式运转。这在软件史上并不常见,却也最诚实。它把这部进化史的灵魂,赤裸裸地摆在了你面前:

没有任何一个解法是终点。每一个答案,都只是”在当时的约束下所能找到的最不坏的那个”;而它带来的新问题,终将催生下一次进化——哪怕那次进化,是退回到起点。

那么,有没有一种更彻底的思路?一种不在那条铁律之上修修补补,而是从根上让”一个路径一个主人”这个前提本身不再成立的办法?

如果连”所有软件都共享同一个 /usr“这个延续了三十年的大前提,都可以被推翻呢?

这,就是我们这部进化史最后,也是最激进的一跳。

七、终章前夜:那条铁律,能不能从根上被废掉?

让我们做一次彻底的回溯。

从蛮荒时代到模块化,这一路所有的进化,无论看起来多么不同,其实都站在同一块地基上——这块地基,我们从盒子纪元起就反复在敲打:

所有软件,共享同一个文件系统;系统里的每一个路径,同一时刻只能有一个主人。

/usr/sbin/nginx 只有一个,/etc/nginx/nginx.conf 只有一个。rpm 的卸载、dnf 的求解、数据库的账本、事务测试的冲突检查、乃至模块化那套精巧的”版本线开关”——全部,都是在这块”共享的、可变的、全局唯一的文件系统”之上做文章。它们或修补、或调度、或绕行,但从没有一个,敢去动这块地基本身

那么,一个近乎异端的问题浮现了:

如果这块地基,本身就是错的呢?

如果”一个路径一个主人”这件事,根本不必成立呢?如果可以让 nginx 1.20 和 1.31 真正地、物理地同时存在于系统里,谁也不挤占谁的路径呢?

那样的话,前面三十年里所有的痛苦——依赖冲突、版本互斥、升级把配置覆盖、装坏了难回滚——会不会有相当一部分,从根上就不存在了?

这不是空想。已经有人这么干了,而且给出了好几种截然不同的答案。它们共同构成了包管理进化史当下最前沿、也最激动人心的一跳。我们不展开细节(那足够再写一整篇),只看它们各自是怎么对那条铁律下手的:

第一种思路:让每个包住进自己的地址,从此不再有”同一个路径”。 这是 Nix 与 Guix 的世界。它们彻底抛弃了”软件都装进 /usr“的传统,转而给每一个包、每一个版本,都分配一个由内容哈希决定的、独一无二的安装路径。nginx 1.20 住在它自己的哈希目录里,1.31 住在另一个——它们从不共享任何路径,于是”一个路径一个主人”这条铁律,在这里直接失去了用武之地:根本不存在需要争夺的公共路径。版本共存不再是需要精巧调度的难题,而是天经地义的默认状态。代价是:整套思维方式都要重建,学习曲线陡峭。

第二种思路:干脆给每个应用发一个独立的世界。 这是容器(Docker 那一脉)的答案。它不去解决”如何在一个系统里共存”,而是反问:为什么非要挤在一个系统里?给每个应用打包一个自带依赖的、隔离的运行环境,让它在自己的小世界里独享一套文件系统。铁律依然在每个容器内部成立,但容器与容器之间,老死不相往来——冲突自然也就无从谈起。

第三种思路:把整个系统冻成一块只读的铁板。 这是不可变操作系统(immutable OS)的方向。它釜底抽薪地拿掉了铁律里最危险的那个词——”可变”。既然”共享可变全局态”是万恶之源,那就让系统盘只读,更新时不再是”在原地改改补补”,而是整体地、原子地换上一个新镜像,要回滚就整体换回旧的。系统状态从此干净、可预测、可复现。

你看出这三种思路的共同点了吗?它们不再像前辈那样,小心翼翼地在铁律之上修补、调度、绕行。它们做的是更狠的事——把铁律赖以成立的那个前提(“共享的、可变的、全局唯一的文件系统”)本身,给拆了。

这是三十年来,这部进化史第一次,把矛头指向了它自己的地基。

八、尾声:进化没有终点

我们从一句最朴素的 make install 出发,走到了今天。

回望整条路,你会发现它惊人地遵循着同一种韵律:手工编译的混沌,逼出了盒子;盒子的孤立,逼出了仓库与求解;求解的迟钝,逼出了更强的引擎;而要管好这一切,逼出了账本与那条铁律;铁律带来的版本死锁,逼出了模块化;模块化的复杂,又让那套实现退回原点;直到今天,有人开始追问——能不能把铁律的地基本身换掉。

每一个答案,都精确地长在上一个问题的伤口上。没有一步是凭空设计的,每一步都是被前一步的疼痛逼出来的。

所以,回到我们最初的那个问题:为什么你每天敲的那句 yum install,背后是这样一台层层叠叠、精密又笨重的机器?

因为它不是被”设计”出来的,而是被”逼”出来的。它身上每一道看似多余的褶皱——epoch 字段、事务测试、模块的流、.rpmnew 文件、那条文件归属的铁律——都是某一次真实疼痛留下的疤痕。读懂了这些疤痕,你就读懂了:你面对的从来不是一堆需要背诵的命令,而是一部仍在继续书写的、关于”如何与复杂性共处”的历史。

而它还远没有写完。Nix 会不会成为主流?容器会不会进一步吞并传统包管理?不可变 OS 会不会成为服务器的新常态?没有人知道。我们唯一能确定的是那条贯穿全文的铁律——不是”一个文件只能属于一个包”那条,而是更高的那一条:

每一个答案,都只是下一个问题的开始。

这,就是 Linux 包管理进化史;这,也是一切工程演化的宿命。

转自:张先生的深夜课堂

版权申明:内容来源网络,版权归原创者所有,如有侵权请联系删除

想了解更多干货,可通过下方扫码关注

可扫码添加上智启元官方客服微信👇

未经允许不得转载:17认证网 » Linux 包管理进化史:每个答案,都是上一个问题逼出来的(中篇)
分享到:0

评论已关闭。

400-663-6632
咨询老师
咨询老师
咨询老师