包构建/管理系统简史

01 Jun 2023 12661 words 43 minutes BY-SA 4.0
develop package

多年以后,面对开源社区的欢呼, Eelco Dolstra 工程师将会回想起他撰写博士毕业论文 的某个遥远的下午。

以下表格只列举了笔者熟悉的例子。

计算机语言 构建系统 包管理系统
C/C++ 为主 make vcpkg
C/C++ 为主 ninja /
C/C++ 为主 msbuild /
C/C++ 为主 Xcode build system /
C/C++ 为主 Bazel /
C/C++ 为主 autotools’ /
C/C++ 为主 meson’ /
C/C++ 为主 Blade’ /
C/C++ 为主 cmake’ /
python python -m build pip
perl Makefile.PL cpan
perl Build.PL cpanm
perl dist::zilla /
TeX l3build tlmgr

' 代表元构建系统,即生成其他构建系统配置的构建系统。另外 C/C++ 的包通常用软件发行版的包管理系统管理。

软件发行版 构建系统 包管理系统
Arch Linux’ makepkg pacman
Windows Msys2 makepkg-mingw pacboy
Android termux build.sh apt + dpkg
Android termux-pacman build.sh pacman
Gentoo’ ebuild emerger
Debian’ debmake apt + dpkg
Nix nix-build nix-env
NixOS’ nix-build nixos-rebuild
Nix Darwin nix-build darwin-rebuild
homebrew brew brew
openembedded bitbake dnf

' 代表操作系统。一般软件发行版的构建系统既可以当元构建系统(为某一特定计算机语言构建包时调用该计算机语言的构建系统)也可以直接当构建系统(直接调用编译器)。

包的管理

包管理器在 Linux 软件管理中扮演了重要角色。

– Steve Ovens evolution-package-managers

笔者认为引文的这段观点不够全面,因为无论是什么系统,只要涉及到依赖关系(某个操作系统里各种各样的软件,某种编程语言里形形色色的库),就务必需要引入一个管理依赖关系的角色,而不仅仅是 Linux 的软件管理。在下文中,考虑到 Windows Msys2 和 Android Termux 这样的环境,笔者不会使用“ Linux 发行版”这样的术语,而是会使用更一般的“软件发行版”的术语。

软件发行版包管理器和编程语言包管理器

从形式上看,软件发行版的包管理器和编程语言的包管理器最大的区别是路径是否硬编码:

软件发行版的包管理器中的路径通常是硬编码的。例如这是一个 pacman (ArchLinux, Windows Msys2, Android Termux-pacman 的包管理器) 的包,可以 sudo pacman -S ninja 或者下载这个包后通过 sudo pacman -U ninja-1.11.1-3-x86_64.pkg.tar.zst 安装它。

$ tar taf ninja-1.11.1-3-x86_64.pkg.tar.zst
.BUILDINFO
.MTREE
.PKGINFO
usr/
usr/bin/
usr/bin/ninja
usr/lib/
usr/lib/python3.11/
usr/lib/python3.11/site-packages/
usr/lib/python3.11/site-packages/ninja_syntax.py
usr/share/
usr/share/bash-completion/
usr/share/bash-completion/completions/
usr/share/bash-completion/completions/ninja
usr/share/doc/
usr/share/doc/ninja/
usr/share/doc/ninja/manual.asciidoc
usr/share/emacs/
usr/share/emacs/site-lisp/
usr/share/emacs/site-lisp/ninja-mode.el
usr/share/emacs/site-lisp/ninja-mode.elc
usr/share/licenses/
usr/share/licenses/ninja/
usr/share/licenses/ninja/COPYING
usr/share/vim/
usr/share/vim/vimfiles/
usr/share/vim/vimfiles/syntax/
usr/share/vim/vimfiles/syntax/ninja.vim
usr/share/zsh/
usr/share/zsh/site-functions/
usr/share/zsh/site-functions/_ninja

除去隐藏的元信息的文件,其他都会被直接安装在根目录下,例如 usr/bin/ninja

作为比较,这是一个 pip (python 的包管理器) 的包,可以 pip install ninja 或者下载这个包后通过 pip install ninja-1.11.1-py2.py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.whl 安装它。

$ unzip -l ninja-1.11.1-py2.py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.whl
Archive:  ninja-1.11.1-py2.py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.whl
  Length      Date    Time    Name
---------  ---------- -----   ----
        6  11-06-2022 00:46   ninja-1.11.1.dist-info/top_level.txt
      211  11-06-2022 00:46   ninja-1.11.1.dist-info/WHEEL
      142  11-06-2022 00:46   ninja-1.11.1.dist-info/AUTHORS.rst
     1033  11-06-2022 00:46   ninja-1.11.1.dist-info/RECORD
    10273  11-06-2022 00:46   ninja-1.11.1.dist-info/LICENSE_Apache_20
     5348  11-06-2022 00:46   ninja-1.11.1.dist-info/METADATA
       38  11-06-2022 00:46   ninja-1.11.1.dist-info/entry_points.txt
        0  11-06-2022 00:46   ninja/py.typed
       88  11-06-2022 00:46   ninja/__main__.py
     1783  11-06-2022 00:46   ninja/__init__.py
      498  11-06-2022 00:46   ninja/_version.py
     6948  11-06-2022 00:46   ninja/ninja_syntax.py
   302712  11-06-2022 00:46   ninja/data/bin/ninja
---------                     -------
   329080                     13 files

注意到差别了吗?路径没有硬编码意味着根本不会有形如 /usr 之类的东西。 因为编程语言的包管理器需要确保在软件发行版中都可以正常工作,所以 pip install ninja 会安装:(注意,在 Windows 上 ninja/data/bin/ninja 将是 ninja/data/bin/ninja.exe

如果有 ninja-1.11.1.dist-info/headers/ninja.h 或者 ninja-1.11.1.dist-info/scripts/ninja.pypip install 也会安装:

其他软件发行版不再赘述。

除了根据软件发行版决定某些安装路径之外, pip install 也允许用户决定前缀根据 --prefix 。 默认的前缀会根据用户管理员权限的有无分别为 /usr~/.local 。这又一次佐证了“最大的区别是路径是否硬编码”的观点。 此处读者可以先停下来想一想,为什么软件发行版的包管理器必须硬编码?

系统级包管理器和第三方包管理器

同样是软件发行版的包管理器,大多数 Linux 发行版预装的包管理器(Debian 系的 apt ,Redhat 系的 yum , Arch 系的 pacman , Gentoo 系的 emerge 等,称为系统级包管理器)和用户因为诸多原因(通常是预装的包管理器没有需要的软件)额外安装的包管理器(Homebrew (Linuxbrew) ,Nix (Nix-darwin) , Anaconda (Miniconda) , Flatpak , Snap 等,称为第三方包管理器)也有很大差异。包括但不限于:

回答之前的一个问题: 为什么软件发行版的包管理器必须硬编码?因为对这些包管理器管理的软件而言,如果不安装在指定路径下,软件就无法正常工作。例如一个用 C 编写的软件包 coreutils 中有一个程序 ls

在 GNU/Linux 上:

$ patchelf --print-interpreter $(which ls)
/lib/ld-linux-x86-64.so.2
$ patchelf --print-rpath $(which ls)
/lib

在 Android 上:

$ patchelf --print-interpreter $(which ls)
/system/bin/linker64
$ patchelf --print-rpath $(which ls)
/data/data/com.termux/files/usr/lib

在 NixOS 上:

$ patchelf --print-interpreter $(which ls)
/nix/store/yaz7pyf0ah88g2v505l38n0f3wg2vzdj-glibc-2.37-8/lib/ld-linux-x86-64.so.2
$ patchelf --print-rpath $(which ls)
/nix/store/p0ikbnq88v649sk7rdrwhdp8qaqqjill-acl-2.3.1/lib:/nix/store/w237hnxridnmjjwxfz1s1lfyppdzrrrb-attr-2.5.1/lib:/nix/store/19diy37d1q2mnvpmgaa9xkmjz830gmbj-gmp-with-cxx-6.2.1/lib:/nix/store/31hpxybx1h1ivhfrmc4xa8nw6g9y8b2i-openssl-3.0.8/lib:/nix/store/yaz7pyf0ah88g2v505l38n0f3wg2vzdj-glibc-2.37-8/lib

注意到了吗?这些 ls 都是动态链接的,运行时需要一个动态链接器 (interpreter) 去运行时路径 (rpath) 查找需要的动态链接库。而 interpreter 和 rpath 是以绝对路径的形式硬编码到二进制文件中的。如果包管理器安装软件包时不把这些路径硬编码到指定位置,这些 ls 根本无法正常工作。当然对静态链接的程序而言就无所谓了,但大多数软件出于减少硬盘和内存的占用的目的(动态链接库被多个软件重复使用从而减少软件的大小以及消除内存中库的重复加载)都会选择动态链接,从而使得软件发行版的包管理器必须硬编码。

包管理器的本质

版本约束

回到包管理器的讨论来。无论是什么包管理器,本质上都是一个 SAT 求解器。例如:

用户希望同时安装 pip install 'a>0.5' b c ,或者依次安装 pip install 'a>0.5' && pip install b && pip install c 。 请在满足用户要求的版本约束和依赖关系定义的版本约束的情况下尽可能安装版本最高的软件。

USE 约束

有的包管理器管理的依赖关系除了版本约束还有别的约束,比如 gentoo 引入 USEpackage.use 。例如:

USE 是全局的,例如 USE='f1 f3' 意味着所有软件都使用了 f1 和 f3 。 package.use 是局部的,例如 /etc/portage/package.use/my-use 中有内容 b f2 意味着所有版本的 b 都使用了 f2 。用户可以指定 USE , 请在满足用户要求的版本约束, USE 约束和依赖关系定义的版本约束和 USE 约束的情况下尽可能选择最少的 USE 并安装版本最高的软件。如果无解,包管理器需要给出用户可以修改哪些 USE 来继续安装。

通常 USE 可能会是:

因为 USE 的不同选择会导致构建的软件包数量繁多,通常 gentoo 只对某些构建耗时较长的软件包提供了构建好的二进制包(在某些 USE 下),对其他软件包需要用户手动构建,以致于有人吐槽

我装了 gentoo,学校派人来我们实验室调查是不是有人挖矿。

当然, gentoo 的卖点是选择和性能,不是耗电量

解决方案

众所周知,SAT 作为 NPC 问题,目前没有多项式时间算法。所以所有包管理器(nix 除外),实际上都是用的启发式算法。用户在安装软件前等待了很长的时间,最终得到的也不一定是最优解,或者得到的是无解。类似 ArchLinux 之类的发行版,干脆每个软件只提供一个最新的版本,减少求解空间。

另一个问题是多个不同版本的软件共存的问题。有 2 种可能用户需要安装多个不同版本的软件:

开源社区对房间里的大象也并非视而不见。例如:

面对这 2 个问题, nix 的创举是它作为一个包管理器,一举绕开了问题。为什么不能同时安装 python3.11.0python3.11.1 ?因为路径冲突了啊。现在 nix 把所有软件往形如 /nix/store/XXX-python-3.X.Y 的路径里安装,让每个软件都只能看到自己想要版本的依赖。

all

为此,我们要考虑另一个问题:一般所有的 GNU/Linux 发行版都遵循 FHS 标准,即希望所有的动态链接库都在 /lib (/usr/lib 的符号链接)下。即 rpath 就是 /lib 。同样,需要使用用户手册的软件都希望用户手册在 /usr/share/man 下,需要使用字体的软件希望字体在 /usr/share/fonts ,等等。怎么解决这个问题?使用符号链接吗?那如果我们提供从 /nix/store/XXX-python-3.11.0/lib/libpython3.11.so 指向 /usr/lib/libpython3.11.so 的链接,那 python3.11.1 该怎么办?

Nix 干脆不破不立——彻底废弃 FHS ,所有二进制库和可执行文件通过 patchelf 修改 interpreterrpath ,这也是为什么你会在之前看到 NixOS 上如此奇怪的 rpathinterpreter 的原因。对于其他软件也通过用 shell 脚本封装起来等方式确保可以正常工作。

当然, Nix 创始人 Eelco Dolstra 那 200 多页的博士毕业论文远不止笔者说的这么简单。他的博士论文主题是确定性系统,在这个以 Nix 为核心的系统中,通过使用 Nix Lang 来构建包从而确保一切包的构建都是可复现的——下面让我们谈谈包的构建。

包的构建

一般软件包的构建脚本分为 2 个过程:

Debian 系和 Redhat 系的包构建一般被称为分布式包构建系统,即每个项目的代码仓库中包含构建的代码(例如,通常会在一个叫 debian 的目录下包含所有构建需要的文件)。在 cmake 中可以通过 cpack -G DEBcpack -G RPM 获得对应的包。与之相对的是集中式包构建系统,存在一个集中的代码仓库,里面每个包都拥有一个属于自己的包构建脚本,例如:

下面给出更详细的介绍。

指令式包构建

指令式包构建对应编程语言中的指令式编程语言。以 GNU hello 为例。 GNU hello 是 GNU/Linux 中输出 hello world 的一个软件,它存在的意义是像所有 GNU/Linux 用户展示一个标准的 C 语言项目应该是什么样的。关于 C/C++ 构建系统,特别是 autotools 的 ./configure && make && make install,笔者在之前的博文里介绍过,不再赘述。

AUR

PKGBUILD:

# Maintainer: Michał Wojdyła < micwoj9292 at gmail dot com >
#Contributor: leo <leotemplin@yahoo.de>
pkgname=hello
pkgver=2.12.1
pkgrel=1
pkgdesc="Prints Hello World and more"
arch=(i686 x86_64)
url='https://gnu.org'
license=('GPL')

source=(https://ftp.gnu.org/gnu/hello/$pkgname-$pkgver.tar.gz)
md5sums=('5cf598783b9541527e17c9b5e525b7eb')

build(){
    cd "$pkgname-$pkgver"
    ./configure --prefix=/usr
    make
}
package(){
    cd "$pkgname-$pkgver"
    make DESTDIR="$pkgdir/" install
}

termux

build.sh:

TERMUX_PKG_HOMEPAGE=https://www.gnu.org/software/hello/
TERMUX_PKG_DESCRIPTION="Prints a friendly greeting"
TERMUX_PKG_LICENSE="GPL-3.0"
TERMUX_PKG_MAINTAINER="@termux"
TERMUX_PKG_VERSION=2.12.1
TERMUX_PKG_REVISION=1
TERMUX_PKG_SRCURL=https://ftp.gnu.org/gnu/hello/hello-${TERMUX_PKG_VERSION}.tar.gz
TERMUX_PKG_SHA256=8d99142afd92576f30b0cd7cb42a8dc6809998bc5d607d88761f512e26c7db20
TERMUX_PKG_DEPENDS="libiconv"
TERMUX_PKG_BUILD_IN_SRC=true

termux_step_pre_configure() {
    LDFLAGS+=" -liconv"
}

2 段脚本都使用 bash 编写,这是所有 GNU/Linux 上都确定一定安装的软件(Android 和 musl/Linux 不是 GNU/Linux)。 使用别的语言的包构建脚本也存在,例如 macOS 上 bash 不是预装的,所以出现了使用 tcl 的 mac Ports 和使用 ruby 的 homebrew ,或者一些编程语言的包管理器可以确保某种编程语言一定是安装好的,所以选择该编程语言。

可以看到 2 段脚本除最后一段外均为元信息的声明。 termux 甚至因为 autotools 在开源社区的广泛使用让缺省的 termux_step_make, termux_step_configure, termux_step_make_install 都为对应的指令。也可以通过类似 TERMUX_PKG_FORCE_CMAKE=true 的方式换其他构建系统。

总而言之,指令式包构建中实际的构建需要完成的命令是以可以执行的指令的形式直接编写在脚本中的。这带来了以下 2 个问题:

# ...
build(){
    cd "$pkgname-$pkgver" || return 1
    cmake -Bbuild -DCMAKE_INSTALL_PREFIX=/usr
    make -Cbuild
}

package(){
    cd "$pkgname-$pkgver" || return 1
    DESTDIR="$pkgdir" make -Cbuild install
}

这看上去可以正常工作,实际测试起来也可以——如果你没有 export CMAKE_GENERATOR=Ninja 的话。正确的代码是:

# ...
build(){
    cd "$pkgname-$pkgver" || return 1
    cmake -Bbuild -DCMAKE_INSTALL_PREFIX=/usr
    cmake --build build
}

package(){
    cd "$pkgname-$pkgver" || return 1
    DESTDIR="$pkgdir" cmake --install build
}

同样,在 perl 包构建时也要避免 $PERL5LIB, $PERL_MM_OPT, $PERL_LOCAL_LIB_ROOT 的影响。参见 ArchLinux Wiki:

build() {
    cd $pkgname-$pkgver || return 1
    unset PERL5LIB PERL_MM_OPT PERL_LOCAL_LIB_ROOT
    export PERL_MM_USE_DEFAULT=1 PERL_AUTOINSTALL=--skipdeps
    perl Makefile.PL
    make
}

check() {
    cd $pkgname-$pkgver || return 1
    unset PERL5LIB PERL_MM_OPT PERL_LOCAL_LIB_ROOT
    export PERL_MM_USE_DEFAULT=1
    make test
}

package() {
    cd $pkgname-$pkgver || return 1
    unset PERL5LIB PERL_MM_OPT PERL_LOCAL_LIB_ROOT
    make install INSTALLDIRS=vendor DESTDIR="$pkgdir"
}

所以,比起想方设法教育包构建脚本的编写者不要犯任何愚蠢的错误, 我们更希望犯下这些错误的包根本就不该被成功构建,换言之——不可复现(在某些用户的环境下能被成功构建,在某些用户的环境下不能)的包——本不该存在!

声明式包构建

数据描述语言

最初编程语言的包构建一般都是通过一个脚本语言完成,例如 python 的 setup.pyperlMakefile.PL ,但后来它们几乎通通都转向了数据描述语言:

数据描述语言显然功能不可能超过图灵完备的脚本语言,但所幸大多数时候也够用了,毕竟对某一种确定的编程语言而言,它的包构建步骤通常也是确定的,所以完全可以省略,像 Android termux 就默认所有项目都是用 C 编写的,自然构建方式要么 autotools 要么 cmake ,自然 build.sh 就不用写 termux_step_make, termux_step_configure, termux_step_make_install 这些函数了。如果忽略钩子函数,剩下的元信息完全可以用数据描述语言描述。

数据描述语言解决了以下问题:

至于可复现性问题,例如 python -m build 默认会用 venv 来创建虚拟环境,尽可能减少用户环境中的变量对 python 包构建的影响。但即便如此,仍然不能完全避免。注意到之前的 pip install ninja 了吗?这可是核心部分用 C 实现的 python 包,你以为逃得掉的 $CMAKE_GENERATOR 吗?

函数式编程语言

在复现性上, docker 是一个解决方案,但不能用户系统上所有软件都用 docker ,那样还不如鼓励开发者静态链接——为了驱逐这房间里的最后一只大象, Eelco Dolstra 在他的博士毕业论文里提出了一种引入函数式编程来实现包构建的系统。这就是 Nix ,简而言之, nix-instantiatedefault.nix “编译”为 /nix/store/XXX-*.drv, 一种数据描述语言,其中包含了构建一个包所有需要的一切,包括环境变量、依赖项、构建的指令,再通过 nix-store -r /nix/store/XXX-*.drv 来实际执行包构建。关于个中细节, Nix Pill 有更加入门级的介绍。在此不做赘述。

首先是犯下难以读取元信息之罪的指令式包构建系统,其次是犯下不可复现之罪的数据描述式包构建系统……至此,我们终于得到一个看上去还行的方案,但问题结束了吗? nix 已经问世 20 年,正如当年的 git 、 docker 等开源项目一样,提供 nar 托管服务的商业公司也已经存在——但没有人能判断未来会如何。毕竟:

一个人无法预见未来,这也许是一件好事。

—— 阿加莎·克里斯蒂 《无人生还》

https://user-images.githubusercontent.com/32936898/199681341-1c5cfa61-4411-4b67-b268-7cd87c5867bb.png https://user-images.githubusercontent.com/32936898/199681363-1094a0be-85ca-49cf-a410-19b3d7965120.png https://user-images.githubusercontent.com/32936898/199681368-c34c2be7-e0d8-43ea-8c2c-d3e865da6aeb.png