构建的本质
让我们先忘掉nix,假设我们现在要设计一个究极的构建系统,能构建计算机领域的任何东西。为了实现这个伟大的目标,我们首先要思考一下,什么是“构建”
拿最简单的hello world举例。我们写完了hello world的源码,这个源码是如何变为产物的?
$ gcc hello.c -o hello
似乎很简单,只要有一个源码,有一个构建工具,就可以输出最终产物:
ELF是构建产物,那压缩包呢?似乎也是一个道理,只不过构建依赖中有压缩工具而已
压缩包是构建产物,那容器镜像呢?好像也一样,无非也就是多引入几个工具,里面包含一个rootfs,生成一个特殊格式的压缩包而已
容器镜像是构建产物,操作系统呢?好像也差不多,只不过是依赖多了一些。
可以说掌握了构建的本质,就掌握了能够构建所有东西的理论基础
hello world!
你可能说,问题才没那么简单!让我们拿最简单的hello world程序举例
#include <stdio.h>
int main() {
printf("hello world\n");
return 0;
}
stdio.h哪里来呢,这个应该算作什么呢?然后我们看一下hello的动态链接情况
$ ldd hello
linux-vdso.so.1 (0x00007ffcda3d9000)
libc.so.6 => /lib64/libc.so.6 (0x00007b5716402000)
/lib64/ld-linux-x86-64.so.2 (0x00007b5716606000)
libc.so.6以及ld-linux-x86-64.so.2算作什么呢?似乎不只运行阶段,我们构建的时候也需要这些文件
好好好,让我们弥补一下:
在上面这张图中,我将gcc也算作了构建依赖,使用bash脚本作为构建器,其中可能使用mkdir之类的命令,所以说可能也需要coreutils作为构建依赖,实际问题可能会更复杂,但总体来说都能总结为(源码+依赖 -> 产物)
究极的构建系统
很好,很好,很好。现在我们既理解了构建的本质,也有了hello world的实际例子,理论与实践相结合,现在离目标的“究极构建系统”就差一个程序员了。
Meta Builder (v0.0.1)
实现似乎也很简单,把构建过程封装成一个函数,成果物就是函数的返回值:
// ...
seed = downloadUrl("https://example.com/bootstrap-seed.tar.gz")
(gcc,make,glibc,autoconf,m4,bash,coreutils) := mkPackage(seed)
// 源码来源无所谓,只要能返回值类型一致就可以
hello_src = downloadUrl("mirror://gnu/hello/hello-2.12.2.tar.gz")
hello := mkPackage(hello_src,gcc,bash,coreutils,make,autoconf)
// 当然,动态链接库依赖也没问题
libanything_src = downloadUrl("...")
libanything = mkPackage(libanything_src,...)
anything_src = downloadUrl("...")
anything = mkPackage(anything_src,libanything,...)
// 究极构建系统,当然要可以自举了
bootstrap_seed = mkPackage(...)
以上代码描绘了这样一套系统:
- 只需要一个seed,便构建出gcc、glibc等基础包
- 利用这些基础包作为构建依赖,可以构建出其他包
- 构建出的包可以进一步作为其他包的依赖,进而构建出所有包
- 当然,也支持构建seed,完成自举
在v0.0.1版本Meta Builder中,究极构建系统的大厦已经基本建成,目前飘在这个大厦上方的只有这四朵乌云:
- 多版本共存,这也是一直以来计算机领域中的顽疾问题
- 如何单独构建某一个具体的包,递归构建其所有依赖(从seed开始),并获得其构建产物
- 构建缓存,当依赖的包已经构建过,可以直接获取构建缓存,而不用对依赖包进行构建
- 运行时依赖如何确定
不过不需要担心,这些问题都可以被解决
Meta Builder (v0.0.2)——多版本共存问题
要解决多版本共存问题,可能要先明确一下到底什么是“版本”:
- 版本号不同,当然是不同的“版本”
- 版本号相同,构建选项不同,应该也算作不同的“版本”,毕竟软件表现可能有差异
- 版本号相同,构建选项相同,但是依赖“版本”不同,有可能也会造成软件表现有差异?
- 版本号相同,构建选项相同,依赖“版本”相同,似乎可以确定是同一个版本
所以说,“版本”的本质,其实应该是反应了软件功能的差异。例如v1.0.0和v1.0.1之间可能只是一些不影响功能的缺陷修复,但实际上“版本号”这个东西没有任何强制规定,在日积月累的“约定俗成”之下,版本号的含义也百花齐放(此处为讽刺)
这样看下来,似乎只要软件行为可能产生不一致性,我们就可以将其界定为不同“版本”,但是这个可能产生不一致性的边界在哪里呢?依赖的版本不一致可能会导致软件行为不一致,依赖的依赖呢?似乎很难明确界定一个边界。那我们如何解决这个问题呢?
很好办,我们给每一个包算一个唯一哈希值,就像默克尔树那样表达依赖,指定一个唯一哈希对应的包作为依赖,只要有一丁点儿不一样,就直接算不同版本😀
// hello_1.hash = hash(hello_src.hash,gcc.hash,bash.hash,coreutils.hash,options_1.hash)
hello_1 := mkPackage(hello_src,gcc,bash,coreutils,make,autoconf,options_1)
// 构建参数也可以视为“构建依赖”,指定其他构建参数
hello_2 := mkPackage(hello_src,gcc,bash,coreutils,make,autoconf,options_2)
// 指定gcc15
hello_3 := mkPackage(hello_src,gcc15,bash,coreutils,make,autoconf,options_2)
这样以来,假如你需要一个开启了特定参数的依赖包构建当前的包,也不会影响其他软件的构建,进而解决多版本共存问题
什么?你说源码的版本怎么办:
hello_src = downloadUrl(
"mirror://gnu/hello/hello-2.12.2.tar.gz",
"5a9a996dc292cc24dcf411cee87e92f6aae5b8d13bd9c6819b4c7a9dce0818ab",
)
hello := mkPackage(hello_src,gcc,bash,coreutils,make,autoconf)
静态文件的话,就拿文件哈希值当作版本😀
Meta Builder (v0.0.3)——单独构建一个具体的包
在前面我们用了类似go的语法来描述我们的构建系统原型,但是我们似乎只能一次性声明所有的包,总不能这个东西执行完,就要先把所有包构建出来吧。
所以说第一步,我们应该将包的“构建过程描述”,和“实际构建”分离开来。如何做到这一点呢?
好办的,兄弟,好办的。我们让mkPackage函数生成一个副产物,存到磁盘中,将整个构建过程序列化为一个具体的文件。路径,就直接拿这个文件的哈希值当作文件路径吧。
// 生成/store/${hash(hello_src.hash,gcc.hash,bash.hash,coreutils.hash,options_1.hash)}.bld文件 (bld for build)
hello := mkPackage(hello_src,gcc,bash,coreutils,make,autoconf,options)
等等,好像有哪里不太对。这个作为文件路径的哈希值好像和文件版本的哈希值是一个东西,现在他表示的是构建过程,那产物路径在哪?
我们简单一点/store/${hash}.bld表示构建过程/store/${hash}表示构建结果目录。这样以来:
- 知道构建过程(
${hash}.bld)就知道构建结果在哪(${hash}) - mkPackage的实现中,可以直接拿
${hash}调用依赖,例如${gcc.hash}/bin/gcc - 构建过程具有唯一值,那依赖关系也很好表示和处理
我们大概假设一个.bld文件的内容:
{
"build": {
"builder": "/store/209c81367d2b664eeba51777afa6bea8/bin/bash",
"args": [
"/store/c97c9b8fb3fc7390dfe77a9a3b0b870e/build.sh"
]
}
"env": {
"PATH": "/store/ca604b8e17ea170f3d1b295f5584e19a/bin:/store/a8e741f21465b3a506c3929c310bc13f/bin"
}
"deps": [
"ca604b8e17ea170f3d1b295f5584e19a",
"a8e741f21465b3a506c3929c310bc13f",
"209c81367d2b664eeba51777afa6bea8",
"c97c9b8fb3fc7390dfe77a9a3b0b870e",
"..."
]
}
这样以来,我们只需要生成所有包的.bld的文件,之后就可以根据这个文件内容来构建任何包,只需要拥有JSON解析功能,就可以直接调用builder,传递对应的参数来完成构建。
至于依赖问题,我们只需要沿着这个依赖关系,将整棵依赖树用相同的方式构建出来即可。
当然这仍然有缺点,毕竟如果我们实现非常多的包(例如有100w),那就会产生对应数量的
.bld文件。但是实际上如果我们只想构建一个很简单的程序,不需要生成所有.bld文件,只需要生成所需要的.bld文件就可以了。但这个问题可以通过设计一个专门的"build"语言来解决,所以因为篇幅问题,就不在这里重点讨论了
Meta Builder (v0.0.4)——构建缓存!!!
到了这儿,构建缓存的设计似乎就水到渠成了,因为所有的包都有一个唯一标识,任何能够满足kv存储需求的服务都可以视作我们的缓存服务。
而且,不只是构建产物的缓存,构建过程也可以做缓存。想象一个场景,你假设你根本没有生成.bld文件的源码,只有一个magic hash,那我们可以利用缓存机制构建出来对应的包吗?
答案是肯定的,过程如下:
- 查询构建缓存中是否有哈希值对应的包,如果有则不需要构建,直接下载即可
- 如果没有缓存,则下载哈希值对应的
.bld文件 - 递归解析
.bld文件的依赖项,同样的,如果有构建缓存则下载构建缓存,如果没有构建缓存则下载对应的.bld文件 - 完成所有依赖项的准备,执行"builder",完成构建
阿基里斯之踵
也许你发现了我们这个“究极构建系统”的盲点所在,似乎我们一直在讨论构建的依赖,但是一直没有提及运行的依赖。在我们所描述的构建过程当中,所有构建依赖都有对应的全局唯一路径,很好理解,只要这个路径存在即可。
但软件构建其实只完成了一半的工作,运行时依赖哪里来?
仔细看我们的构建/运行时依赖,似乎可以分为三派:
- 第一类依赖,例如mv、ls、tar,仅在构建时需要,运行时实际不需要
- 第二类依赖,例如像是glibc之类的so库,在构建和运行时均需要
- 第三类依赖,程序在运行阶段额外调用的包,例如bash脚本需要调用bash、某个特定的程序可能依赖netstat程序获取网络情况,这类依赖在构建时不需要,仅在运行时需要
可以看到,构建依赖/运行时依赖之间存在某种不对称性,这个不对称性的来源主要是上面所说的第一类依赖和第三类依赖带来的。如果能有办法弥合这个缺陷,似乎我们的“究极构建系统”就完整了。
针对第三类依赖,我们可以强制在构建时,将自己的运行时依赖也作为参数传递,这样依赖我们至少可以获得一个运行时依赖的超集。
但这样似乎还不够,总不能让用户运行时必须带着所有构建/运行时依赖吧。
这样吧,我出一个主意(其实不是我出的)。在把第三类依赖也作为参数传递的基础上,把构建产物打一个tar包,不压缩,然后strings一下,找到所有形似/store/${hash}的字符串,把hash值对应的包作为运行时依赖。
什么?你说可能会额外找到其他符合/store/${hash}模式的字符串?没事儿,额外多一个依赖总比把构建依赖整个包进来要好
什么?你说可能会漏?漏的话,额外在产物里放一个txt文件,里面存落下的运行时依赖信息😀
欢迎来到nix的世界
太完美了,究极构建系统的大厦已经完全落成。解决了目前构建系统的所有痛点,可以多版本共存,运行时依赖自动解算,再也不会出现奇怪的运行时问题,完全可复现(毕竟所有包都有自己唯一的路径)。
很好,问得好,这样完美的构建系统哪里下载呢?有的,兄弟,有的,https://github.com/NixOS/nix
上面提到解决各种问题的思路,其实都来自于nix,概念基本一致,具体细节有些出于简化考虑,所有会有些许出入:
.bld实际对应.drv,也就是nix的derivation序列化到磁盘中的文件,实际nix没有以json格式存储该文件- nix中的包,产物路径中的哈希值和
.drv文件路径中的哈希值不一致,nix将产物路径存储到.drv文件内 mkPackage实际上约等于Derivation,实际上nix的stdenv.mkDerivation接口会更好用- 从seed生成标准编译环境的过程做了简化,省去了stdenv的概念