包管理是什么

包管理,一个非常被大家熟悉,又非常让大家陌生的概念。

只记得在我刚开始接触 Linux 的时候,只听同学说,这个操作系统里,装什么软件都是敲命令,而不是像 Windows 一样,去各种神奇的天空软件站下载奇怪的安装包然后点下一步。当时我就惊讶于 Linux 的设计。当然,后来我也明白了,这没什么好惊讶的:我使用到的,其实只不过是一个包管理器而已。

后来我记得我购买了一台 Nexus 6P 。当时国内的许多安卓手机,还是需要分发 apk 文件才能享受到软件的。很多同学还需要在手机上安装一些更新助手,这些更新助手需要你手工操作半天才能对软件更新。而我在拥有 Nexus 6P 以后,我发现我只需要去 Google Play 商店里搜索软件,点击安装,剩下的我就什么都不用管了。

每个 App 都像是一个盒子,在商店里点安装,就像是把它抱回家里,打开就像是打开盒子,更新就像是把盒子里的东西换成新的,卸载就像是把盒子扔掉。应用商店就像是个大超市。

file

安装软件,或者说,作为一个开发者,分发自己开发的软件,听上去应该是一个非常成熟优雅的过程了。想象一下一位苹果的开发者,只需要在 Xcode 里写好代码,按下发布,随后大量的用户就可以在 App Store 里安装并享受他的软件。一个个 App 就像是一个个小盒子一样,可以轻松、统一的被下载、打开、更新、卸载、统计……

如果真的这个世界能有我所提到的那么优雅就好了。

有向无环图

回顾一下现在业界流行的包管理:winget, npm, nuget, apt, yum, dnf, pip, nix, homebrew, cargo, composer, gem, maven, gradle, snap, conda, pacman…… 它们的用法全部都是一样的:用户告诉它们我需要哪些包,它们帮用户准备好具有这个包的环境,随后就可以使用或开始开发了。

我们接下来讨论的内容,是通用的包管理的知识,不涉及到任何具体的包管理器。

file

作为开发者,每天都在面临一个基本问题:为什么在我的电脑上是好的,到了用户的电脑上就不好用了?

回答这个问题也简单。因为用户的电脑的环境和开发者的环境不一样。其区别可能在于:用户的电脑可能缺失了一些组件、库、环境变量、配置文件、操作系统版本、芯片指令集……开发者并没有测试那些可能的情况。

我们将上面的区别,也就是:组件、库、配置、芯片等,可以概括抽象成一个概念:依赖。每个包都有依赖。这些依赖可能是一个二进制,互联网,一个配置文件,一张 Nvidia 的显卡,或是一个特定的操作系统。

file

因此,不同的包,会产生截然不同的依赖。依赖本身也可能递归出来更多依赖。如果每个包是一个节点,每一个依赖关系是一条边,那么整个依赖关系就是一个图。这个图是有向的,而且一定是无环的。在数据结构中,有向无环图,称作 DAG。

所以,安装一个包并不是简单的:把一个盒子抱到家里,打开。我们抱回来的可能是整整一棵树。

基本需求

最典型的问题就是:如果两个包,A和B,都对 C 产生了依赖。那么当用户安装 A 时,我们显然需要安装 C。那么用户如果同时要安装 A 和 B,显然,我们没有必要安装两次 C。安装一次,可以非常有效的节省磁盘空间和带宽。

file

同样的,我们还有更多的问题。一个包的引入,显然不应该弄坏另外的包。包与包之间应当是隔离的。

对于用户来讲,他可能会想要升级一个特定的包,也可能想要降级一个特定的包。升级和降级都应当是允许的,并且不应当对系统造成破坏。

对于管理员来讲,他有需求需要查询当前上下文下,例如:一个包是怎么被带进来的?这些包谁吃的磁盘空间大,谁吃的磁盘空间小?这些包的发行者都是谁?是最新版吗?

对于开发者来讲,编译时和运行时依赖的区别是很重要的。虽然一般我们认为运行时依赖是编译时依赖的子集,并且这个假想一般不会有太大问题。以及:在我们自己开发过程中,假如我就是一位包作者,那么同名同版本的软件包很可能其实是不同源代码编译的。我如何可以正确在我自己编译阶段能够正确的引入这些包?

全局包管理 apt

接下来,我们的重点就是讨论行业里的解决方案了。我们先看看 Debian 的世界里最流行的包管理:APT。

有的同学或许没有进行过大型 C、C++ 项目的开发,误以为 apt 是用来安装软件的。虽然 apt 可以安装软件,但它本质上就是一个包管理。包管理的本质,就是用于部署软件包。

例如:在 Linux 里,如果你的 C++ 程序需要 include 一个例如 libcurl 的头文件,那么你需要这个文件存在于 /usr/include/ 目录下。或者你开发了一个视频格式转换器,它需要调用 ffmpeg,那么你一定需要 ffmpeg 存在于 /usr/bin/ 目录下。这些文件,就是软件包。

此时有一层非常简单明确的关系:这就是一个依赖。你的程序依赖了 libcurl 或 ffmpeg。因此,apt 能够正确理解这件事,它会先帮你安装好 libcurl 或 ffmpeg,然后再帮你安装你的程序。

file

听上去简单,但是我们和需求对比对比就知道了。虽然我们能够解决 A和B,都对 C 产生了依赖 时,如果其他软件也想安装 ffmpeg,它们会很快意识到,ffmpeg 已经被你的应用安装好了。你的系统中只会有一份 ffmpeg。这可以有效的降低磁盘占用。

当然,apt 也足够聪明,如果两份软件都依赖了 ffmpeg,那么只有当两份软件全部卸载后,ffmpeg 才会被卸载。这是因为 apt 会维护一个引用计数。只有引用计数为 0 时,软件包才会被卸载。

除了 apt,包括:yum,pacman 等,它们都是全局包管理器。全局包管理器的特点是:它们会把软件包安装到系统的全局目录下。这样,所有的用户都可以使用这个软件包。

这样的好处是:所有的用户都可以使用这个软件包。这样的坏处也是:所有的用户都可以使用这个软件包。

无法解决的升级问题

为什么这是个坏处?自然,它第一个问题就是:假如 A 和 B 对 C 的要求不一样,例如一个要 2.0 版本,一个要 3.0 版本,那么这个问题在全局包管理器里是无法解决的。全局包管理只能检查 A 和 B 的历史版本,找到第一个能够解决冲突的解,然后安装这个版本。

上面这个过程听起来好像很容易。只需要找到一个无冲突的解就可以了。但是,这是个 NP 完全问题。这意味着,这个问题是无法在多项式时间内解决的。这个问题的复杂度和旅行商问题一样。目前,全局包管理都是使用伪解算器(pseudo-solver)来解决这个问题。

甚至说,这个问题中就不存在最优解。例如 A 的新版本需要使用老版本的 C,而 B 的新版本又需要使用新版本的 C。此时你要么升级 A,要么升级 B。根本没有所谓的最优解。伪解算器只是帮你得到一个可行解。

破坏性的升级降级

全局包管理的问题不仅仅在于依赖冲突问题。全局包管理还有一个问题:破坏性的升级降级。

在现实世界中,很可能两个不同的组件会提供同一份文件。例如 Docker 和 Docker Desktop 同时会提供 Docker,而一个是基于 containerd,一个是基于虚拟化。更可怕的是,他们甚至可能在安装时会写入同一个文件,在卸载时又都会想删除这个文件。

这种情况下,每一次安装、升级、降级、卸载,本质上都是破坏性的,都是不可回滚的,都不是原子性的。文件很可能被覆写。

file

举个例子,如果你通过 Ubuntu 的 do-release-upgrade 命令,例如从 22.04 升级到 24.04,接下来你就会迎来一场 50 分钟,大约 100 道题的考试。考试的内容就是包管理不停问你:一个包升级了,但是它的配置文件被篡改过了。现在怎么办?这种痛苦每个 Linux 用户应该都感受过。

分治

上面的全局包管理的核心问题在于:一旦有包冲突,就会非常麻烦。而一台电脑平时可能干的事情太多了:开发前端、后端、运维、游戏、办公、学习……这些事情很可能会带来大量的包而产生冲突。

肯定很多人会想,既然全用一个管会冲突,我们分治一下,例如:将一个巨大无比的项目拆成十几个小项目,然后让每个小项目跑在一个隔离的环境里,比如文件夹,容器甚至虚拟器里,让它的包影响不了别人,这个问题不就解决了?

这个想法是对的。分治的思想是:将一个大问题拆成小问题,然后分别解决。而实际上,这也正是业界的主流做法:将单体应用改造成微服务。每个微服务都跑在隔离的环境中,而避免自己的环境影响了别人。

file

Python 里的 venv 和 conda,Node.js 里的 nvm,Java 里的 maven,都是这种思路的体现。它们都是将依赖的环境隔离开来,避免了冲突。

分治最大的缺点

对于 macOS 用户,他们一定非常熟悉上面的解决方案。 macOS 里有一个非常有名的文件夹:Applications。这个文件夹里存放了所有的 App。每个 App 都是一个独立的文件夹,里面包含了这个 App 的所有文件。这样,每个 App 都是一个独立的环境,不会影响其他 App。一样是这个思路。

这个解决方案的好处是:每个 App 都是独立的,不会影响其他 App。虽然使用这种分治的思想可以有效减少冲突的可能,但是它的代价也非常大,那就是:假如 A 和 B 同时依赖了 C,那么 C 必须至少存在两份。这样,磁盘空间的占用会非常大。

或许你还没什么感觉。有人统计过,一个普通用户的 Windows 系统中可能就包含了超过 30 份 Electron。这是因为你只要安装了一个使用了 Electron 开发的软件,它就需要为你额外安装一次 Electron。虽然 Electron 不大,普通用户还能忍一忍,但是对于高密度的计算来说就不可接受了。

file

想象一下一个基于微服务的数据中心,每个服务都跑在一个完整的 Linux 隔离环境里,那么这意味着有多少个服务,可能就需要维护多少份 Linux 镜像。虽然容器技术已经能够对反复使用的镜像进行去重,但是这个问题还是存在的。去重这件事本身也有很大的代价,我们回头聊到文件系统再详细展开说说。

Monolithic

file

上面的概念里,将所有自己依赖的东西,全部打包到一起的思想,一定程度上可以缓解这个问题。这个思想我们可以称为:Monolithic。

虽然彻底解决冲突问题很难,但是如果我将所有的依赖都打包到一起,那么我就可以保证我的依赖是正确的。这也是 Windows 和 macOS 的主流做法。

file

而现阶段数据中心的主流做法,也正是拆成微服务以后,将每个部分都单体化,在各个部分之间使用最简单的依赖管理来解决。

例如:每个单体都额外声明自己对其他单体的依赖关系、允许的版本范围等。

所以,Monolithic 和微服务,听上去是两个截然不同的设计思路,但实际上,它是将一个复杂的问题进行了分治,分别解决了两个问题。如果你愿意,你甚至可以拆不止两层:三层四层都可以。

Nix

除了上面的两种思路,还有一种思路:Nix。

file

Nix 是一个包管理器,它的特点是:它是一个函数式的包管理器。这意味着,Nix 里的包是不可变的。一旦一个包被安装,它就不会被改变。这样,Nix 就可以保证:一个包的安装不会影响到其他包。

Nix 的另一个特点是:它对于包的存储是平坦的。系统里有一个目录叫作 /nix/store,所有的包都会被安装到这个目录下。每个包只能出现在一个目录下。这样,Nix 就可以保证:安装一个包,不会影响到其他包。

file

但是,这和苹果的 Application 不一样,Nix 可不是 Monolithic。nix/store 里一个包可没它的依赖!

Nix 的构建过程是确定性的(PPT第12页)。这意味着,给定相同的输入,Nix 总是生成相同的输出。这通过在沙箱中构建包来实现,确保构建过程中只看到显式指定的依赖项,而不是系统的全局状态(PPT第15页)。这种方法避免了因环境不同导致的构建结果不一致问题。

在 Nix 中,最小的构建单元称为 derivation(PPT第13页)。每个 derivation 是一个描述如何构建包的函数,所有输入必须显式指定,并且在沙箱中构建。这样,Nix 可以确保每个 derivation 的输出是确定的,并且可以预测其结果。

Nix Store 是一个只读的存储,只有通过 Nix 命令才能修改(PPT第14页)。每个 derivation 的输出路径包含一个基于其内容的哈希值,这确保了任何输入或构建过程的变化都会反映在哈希值中,从而避免了包的冲突。

file

Nix 还支持二进制缓存(PPT第19页)。因为 Nix 可以在构建前计算出输出哈希值,所以它可以轻松地缓存构建结果,并通过文件服务器提供这些缓存。这大大加快了包的安装速度,因为用户可以直接从缓存中获取预构建的包,而不必每次都重新构建。也就是对于 Nix 来说,下载包的过程非常有趣:你自己编译和下载是一定能够得到相同的结果的。因此,你自己本地就变成了 Nix 的一个缓存。

file

file

Nix 的安装和卸载过程是原子的。安装包时,如果其依赖项已经存在于 Nix Store 中,则直接使用,否则会构建并存储在 Nix Store 中。卸载包时,通过垃圾回收机制移除不再需要的包。

NixOS 是基于 Nix 的操作系统,它将包管理与系统配置结合起来(PPT第26页)。在 NixOS 中,系统的配置也是通过 Nix 来管理的。这意味着,用户可以声明式地描述系统的配置,而 NixOS 会根据这些配置自动构建和部署系统(PPT第28页)。

总的来说,Nix 通过其独特的函数式包管理模型,解决了传统包管理中的许多问题。它的确定性构建、包的隔离与共享、原子的安装和卸载以及强大的二进制缓存,使其成为一个非常强大的包管理工具。

Nuget

Nuget 是微软为 .NET 开发而设计的包管理。作为一个包管理,它一样有和其他包管理相同的问题。但是 Nuget 的解决方法也非常巧妙。它更多的是类似 Nix 的思路。虽然 Nuget 并没有保证包的不可变性,但是它也是使用了动态链接的方式来解决包的冲突问题。而在一个单独的项目内,Nuget 是一个 Monolithic 的包管理器。

例如,一个 .NET 应用程序需要引用一个包,它会向 nuget 索要。Nuget 会从官方的服务器中下载到一个全局的路径,例如 ~/.nuget/packages/。最终它存放的路径里是包含包名和版本的,因此一个包的多个版本不会冲突。

而项目在真正使用时,Nuget 会提供给它它需要的那个版本的包。这既可以避免反复重复下载,又可以解决依赖冲突问题。它的思路既类似 Nix,将所有包都放在一个集中的地方让大家一起用,又类似 Monolithic,将所有包都打包在一起,避免了冲突。

当然,对于一个单独的 .NET 项目,它是不可能依赖 A 和 B,而 A 和 B 又依赖不同版本的 C 的。最终打包的阶段,这么做会产生严重的错误。我们有时会使用 Binding Redirect 来缓解这个问题,让运行时能够凑合容忍这个不一样版本的包。但是这个问题从根本上是无法解决的。

其他包管理

上面我们讨论了 apt,dnf,pip,npm,maven,nix,nuget 等。它们都各有优缺点,但是它们的核心思想是一样的:将依赖的包安装到一个地方,然后让用户使用。

还有一些东西看起来像包管理,但其实不是,因为它们并没有解决依赖问题。例如:winget。为 winget 上架一个包,只需要提供一个下载地址即可。winget 充其量只是个下载器,帮你索引了一大堆包的下载地址,然后你自己去下载安装吧!它彻底无法解决依赖问题。至于卸载、升级、降级,也都是这些包本身的安装程序自己去解决的。

snap,flatpak 也是一种解决依赖冲突的方法。它们使用 squashfs 文件系统,将所有的依赖都打包在一个文件里。这样,一个 snap 包就是一个完整的环境。这样的好处是:snap 包是不可变的,不会影响到其他包。这样的坏处自然你也非常清楚:浪费空间,包重复下载,配置文件仍然可能冲突。

总结

了解到这里,我们不得不面对一个不是那么令人愉快的事实:包管理实际上是一个我们暂时没有找到万能解决方案的东西。虽然 Nix 算是一个很不错的方案,但是以其不符合直觉的特点没有那么流行。在业界,更多的情况下,我们还是在构建 Monolithic 的包管理,再结合 Docker、虚拟机,使用分治的思路来解决依赖冲突问题。

看起来一个简单的从应用商店下东西的过程,其背后的复杂性是非常大的。我们每天在手机里横扫屏幕,浏览自己安装的软件列表时,我们面对的只是现代软件工程为我们包装好的一个美丽的图景:一个我只需要无脑逛商店,系统会帮我解决一切的图景。而某一天,或许我们真的开始自己分发软件时,很快就会意识到:这个世界并不是那么简单。