最近,一位朋友分享了关于亚马逊内部构建系统的设计要点,这也让我对于软件打包这事有了新的认识。
根据推测,亚马逊的构建系统“Brazil”在原理上有点类似 Nix/NixPkgs,也就是基于几乎一切现有包的声明、具备完全的可重现能力。但是,大家不仅可以选择为软件包的各个版本创建独立的快照,还能指定一组软件包 semver(语义版本),在创建新的不可变 build 时通过单元测试强制保证其彼此兼容,这样得到了能够放心使用的最终更新。亚马逊,真有你的!跟 Nix 类似,Brazil 还具备以下特性:
同时在系统上安装两个软件包版本,根据实际环境选择需要的版本。
针对开发/调试环境对软件包做本地覆盖。
提供二进制版本,确保一切均可复现。
而且这些“都能实现”!我这位在亚马逊工作的朋友对此高度评价,觉得软件构建从未如此简单。其实这真的很难相信:
主 build 驱动会用 Perl 脚本生成大量 Makefiles。整个构建系统只由最小 Perl 脚本引导,而此脚本会假设环境中仅包含最基本的 Perl deps 和 GCC,然后下载所有其他依赖项。
……但人家说能实现,那就是能实现喽!
大多数软件并非如此
在开始讨论之前,我们先明确解释几个要用到的术语:
软件包:软件的原子单元,包括库、应用程序等等。每个软件包又包含:接口版本:这些标识符用于让其他软件了解某个软件包是否支持某些功能。理想状态下会以 semver 兼容的方式存在,但实际操作中往往不一定。添加额外的调试记录或修复安装 bug 之类不会影响到消费者使用的操作,不会改变接口版本。Build 版本:这些标识符与软件包生成的二进制文件中的差异一一对应,用于区分“我添加过额外调试记录或修复安装 bug 的库”和“还没调试/修复过的库”。也能反映不同 build 版本之间依赖项方面的差异。
依赖项:软件包在构建及/或运行时所依赖的另一软件包。通常使用接口版本来指定,但也可以在 build 版本中指定。
版本集:由已知能够良好协同运行的各软件包 build 版本所建立的集合。它的意义在于证明各 build 版本间能够良好协同,之所以不指向接口版本,是为了避免搞乱 semver。
环境:指当我们想要使用某个软件包时,所有能够对其产生影响的其他软件包的总合。
据我所知,目前有两种常见方法来分发软件包并创建运行环境。除此之外当然还有其他,而且很多方法难以准确分类。这里我们就先讨论最典型的情况。
共享一切
有一个中央版本集,其中包含所有软件包,通常需要测试各软件包间能否良好协作。
在任意给定时间,每个包只能安装一个 build 版本。如果想要同时拥有不同的 build 版本,则需要创建不同的包或为包指定别名。
这就是软件环境的典型模型。Arch Linux、RHEL、pip、npm、Homebrew、Forge 等等,但凡是包管理器,使用的就很可能是这种模型。虽然它们在更新频率、semver 固定原理和所负责的工作方面各有差异,但我列出的所有示例都具有上述共通特征。
现在,我要坦率地讲,这套模型相当差劲。不是我要尬黑,但能够正式安装的软件包只能有一个版本确实太少。如果想在中央版本集之外保留一个包含某个依赖项的 build 版本,那只有以下三种办法:
重新命名这个依赖项,再进行全局安装。
在包管理器的控制范围之外“安装”这个依赖项。
直接放弃。
第一个选项太蠢了,因为这意味着我们得自己把接口/build 版本指定为包名称,而这类版本区分的工作本来是该由包管理器负责的。选项二也很蠢,代表我们虽然有了好用的包管理器,但还是得使用 CMakeLists.txt 和 shell 脚本对它做滚动更新。选项三更不行,毕竟咱搞开发的不能轻言放弃😤
有时候,我们可以允许软件包拥有自己的依赖项范围,毕竟不是所有东西都得全局化。坦率地讲,目前这种糟糕的本地安装支持实在让人无法接受。所以下面,咱们再来看看事情的另一个极端:
完全不共享
如果某个包有依赖项,可以用这种方式以自包含的形式将这些依赖项放进环境当中。目前有多种办法可以让单独安装的软件包融入同一环境。但如果没有包管理器的支持,这些办法要么缺乏可扩展性(这还是最好的情况),要么就是引发令人恼火的错误。奇怪的是,Windows 和 MacOS 等消费级操作系统居然将此作为默认方法。更奇怪的是,最近 Docker、Snap、Flatpak 等容器化技术的普及也使得 Linux 软件开始以这种模式进行分发。为什么会这样?
我猜测这种模式之所以流行开来,是因为它更利于产出比较一致的软件。Linux 发行版长期面临的头号难题,就是“在我的机器上明明能跑啊”和“在我的发行版上明明能跑啊”这种不一致冲突。如果共享一切,那么只要在全局版本集之外进行尝试,甚至是在随时间推移而开展的同一发行版之间,软件包的构建都可能出现令人沮丧的意外。正因为如此,具有虚拟环境的特定语言包管理器都会选择完全不共享的方式,Docker 大受欢迎的原因也在于此。全局环境不可避免存在“幽灵”,这些无形的依赖项会随时侵扰构建过程,因此隔离一切并驱散“幽灵”是实现可复现性的前提。
当然这里也要强调,“不共享”方法也有自己的缺点。要求软件包把所有依赖项都捆绑进来、建立起内部的“共享一切”小环境会导致体积快速膨胀。反正我自己是不太想在机器上重复安装 5 个 Tensorflow 或者 PyTorch 副本的,但我又不想把所有一次性 AI 项目都塞进同一个全局 Python 环境,所以情况就很尴尬了。
有没有更好的方法?
下面咱们捋一援理想构建系统的基本要求:
可稳定复现的构建:如果远程系统能够成功构建,那我们的本地系统也应该可以。
本地覆盖:不仅可以在本地构建软件包,还能根据需求对包内容进行随意替换。
远程托管的二进制版本:这样就不必每次想要安装软件时,都劳烦自己本地的 CPU 和硬盘。
不设全局版本集:允许在系统上安装同一软件包的多个版本(包括主要版本、次要版本、不同补丁),而且均采用可稳定复现的构建基础。
Semver 和哈希固定:启用依赖项共享(如果支持),并在必要时提供精确的复现性。
很明显,前面介绍的两种常见方法都满足不了要求,甚至可以说还差得远!也就是说,目前的软件包分发机制存在根本缺陷,导致我们身陷困境。
迎难而上
在对“共享一切”和“完全不共享”有了深入了解之后,现在我们就能体会到亚马逊 Brazil 的妙处了。它不仅允许隔离各软件包并分别指定其依赖项,而且一切都能稳定复现,甚至能够让各包共享具有相同接口版本的依赖项!这也太棒了,但亚马逊到底是怎么做到的?
技术挑战
这里我们不打算太过深入,但其实没有现成方案的原因并不是做不到。各种主流操作系统已经能把不同层级的环境妥善隔离开来,为什么软件包这边就不行?
社会挑战
所以最大的问题可能跟技术无关,而更多来自人们的漠不关心。开发者、发行版贡献者大都觉得“我为什么要改变自己构建软件的方式?目前的方案对我的用例来说已经足够了!”
就个人而言,我也曾经在跟预期环境略有区别的环境中构建过不少软件,而且深受其害。每个包各不相同,拥有自己的脚本、命令行标志、环境变量和 build 目录,而这一切都让工作充满了不确定性。正如 Brazil 项目下一位评论者的留言:
根据个人经验,Brazil 的打包概念之所以没能普及,就是因为之前的问题还没严重到改变的临界点。亚马逊有 Brazil,可以用它轻松搞定 Gem、NPM 包、*.so 或者 JAR 等依赖项。所以哪怕要经历一番痛苦(特别是在导入新的构建系统时),问题也总能得到解决。而且在打包完成后,这事就过去了。
只有那帮闲着没事干的书呆子才愿意为此专门构建生态系统。Gentoo、NixPkgs、Guix、AUR 的软件包维护者们各自举起自己的神器,想让整个软件世界臣服在自己脚下。于是乎,在同一系统之内“一切都正常运作”,但对我们这些不幸要在系统之间往来跨越的软件开发者来说,迎来的就是一场无休止的噩梦。啥都可能出问题,啥都没法顺利实现,而且没人愿意真的拿出时间和精力搞一套整体解决方案。又不是不能解决,忍着得了……
亚马逊是怎么做的
简而言之,他们选择花钱解决问题。这笔钱,来自在包构建时浪费在每个依赖项传递、浪费在确保接口版本符合 semver 标准上的计算成本。也来自浪费在托管软件完整历史记录(源代码加二进制文件)以防止旧有 build 版本丢失的存储成本上。最重要的是,亚马逊愿意支持开发人员把自己想用的所有软件都移植进这个构建系统。
所以,这种方法只适用于像亚马逊这样的科技巨头,毕竟对他们来说这点投入绝对物有所值。但我们其他人呢?
我们能不能学两招?
老实说,我也不知道。也许 NixPkgs 和 Guix 都比较接近我想要的效果,能在一定程度上满足我对理想构建系统的要求(当然,semver 固定这类没钱就不可能实现的要求除外)。我用得不多,所以还没有资格评价二者的使用体验。但一方面我听说过关于 NixPkgs 的抱怨,另一方面我几乎没听人提起过 Guix,这两种情况似乎都不太妙。
作为个人,我也没那个能力去迎难而上。我已经习惯了生活在噩梦的阴影下,用修修补补的方式把自己的 Windows 开发环境维持起来,这种情况在短时间内也不太可能改变。但我觉得,应该有一整个技术社区去迎难而上,这样即使我手头的 Arch 安装还是问题多多,但下一次 Linux 安装就能拥有稳定的可复现性。希望更多人能和我有同样的期待。
原文链接:
https://cohost.org/PolyWolf/post/2613009-software-packaging-a
评论