网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
这里 squashfs 是一个开源文件系统。感兴趣的朋友们可以参考这个教程[6],创建一个空文件,把它格式化成 squashfs,然后 mount 到本地文件系统的某个目录(mount point)里。
待到我们 umount 的时候,曾经加入到 mount point 里的文件,就留在这个“空文件”里了。
我们可以把它拷贝分发给其他人,大家都可以 mount 之,看到我们加入其中的文件。
因为 XAR 是在 squashfs image 前面加上了一个 header,所以没法用 mount -t squashf 命令来 mount,得用 mount -t xar 或者 xarexec -m 命令。
比如,一个节点上如果有了 /packages/A-953bc.xar,我们可以用如下命令看到它的内容,而不需要耗费 CPU 资源来解压缩:
xarexec -m A-953bc.xar
这个命令会打印出一个临时目录,是 XAR 文件的 mount point。
分层
如果我们现在修改了 A.py,那么不管是 build 成 tarball 还是 XAR,整个包都需要重新更新。
当然,只要 build system 支持 cache,我们是不需要重新生成各个 *.so 文件的。
但是这个不解决我们需要重新分发 .tar.gz 和 .xar 文件到集群的各个节点的麻烦。
之前节点上可能有老版本的 A-953bc87fe.{tar.gz,xar} 了,但是不能复用。为了复用 ,需要分层。
对于上面情况,我们可以根据模块依赖关系图,构造多个 XAR 文件。
A-953bc.xar --> B-953bc.xar --> D-953bc.xar -\
-> C-953bc.xar --> E-953bc.xar --> F-953bc.xar
其中每个 XAR 文件里只有对应的 build rule 产生的文件。比如,F-953bc.xar 里只有 F.so。
这样,如果我们只修改了 A.py,则只有 A.xar 需要重新 build 和传送到集群节点上。这个节点可以复用之前已经 cache 了的 {B,C,D,E,F}-953bc.xar 文件。
假设一个节点上已经有 /packages/{A,B,C,D,E,F}-953bc.xar,我们是不是可以按照模块依赖顺序,运行 xarexec -m 命令,依次 mount 这些 XAR 文件到同一个 mount point 目录,既可得到其中所有的内容了呢?
很遗憾,不行。因为后一个 xarexec/mount 命令会报错 —— 因为这个 mount point 已经被前一个 xarexec/mount 命令占据了。
下面解释为什么文件系统 image 优于 tarball。
那退一步,不用 XAR 了,用 ZIP 或者 tar.gz 不行吗?可以,但是慢。我们可以把所有 .tar.gz 都解压缩到同一个目录里。
但是如果 A.py 更新了,我们没法识别老的 A.py 并且替换为新的,而是得重新解压所有 .tar.gz 文件,得到一个新的文件夹。而重新解压所有的 {B,C,D,E,F}.tar.gz 很慢。
Overlay Filesystem
有一个申请的开源工具 fuse-overlayfs。它可以把几个目录“叠加”(overlay)起来。
比如下面命令把 /tmp/{A,B,C,D,E,F}-953bc 这几个目录里的内容都“叠加”到 /pacakges/A-953bc 这个目录里。
fuse-overlayfs -o \
lowerdir=“/tmp/A-953bc:/tmp/B-953bc:…” \
/packages/A-953bc
而 /tmp/{A,B,C,D,E,F}-953bc 这几个目录来自 xarcexec -m /packages/{A,B,C,D,E,F}-953bc.xar。
请注意这里我引入了 overlay filesystem 的概念。为下文解释 Docker 预备。fuse-overlayfs 是怎么做到这一点的呢?
当我们访问任何一个文件系统目录,比如 /packages/A 的时候,我们使用的命令行工具(比如 ls )调用 system calls(比如 open/close/read/write) 来访问其中的文件。
这些 system calls 和文件系统的 driver 打交道 —— 它们会问 driver:/packages/A 这个目录里有没有一个叫 A.py 的文件呀?
如果我们使用 Linux,一般来说,硬盘上的文件系统是 ext4 或者 btrfs。也就是说,Linux universal filesystem driver 会看看每个分区的文件系统是啥,然后把 system call 转发给对应的 ext4/btrfs driver 去处理。
一般的 filesystem drivers 和其他设备的 drivers 一样运行在 kernel mode 里。
这是为什么一般我们运行 mount 和 umount 这类操作 filesystems 的命令的时候,都需要 sudo。而 FUSE 是一个在 userland 开发 filesystem driver 的库。
fuse-overlayfs 这命令利用 FUSE 这个库,开发了一个运行在 userland 的 fuse-overlayfs driver。
当 ls 命令询问这个 overlayfs driver /packages/A-953bc 目录里有啥的时候,这个 fuse-overlayfs driver 记得之前用户运行过 fuse-overlayfs 命令把 /tmp/{A,B,C,D,E}-953bc 这几个目录给叠加上去过,所以它返回这几个目录里的文件。
此时,因为 /tmp/{A,B,C,D,E}-953bc 这几个目录其实是 /packages/{A,B,C,D,E,F}-953bc.xar 的 mount points,所以每个 XAR 就相当于一个 layer。
像 fuse-overlayfs driver 这样实现把多个目录“叠加”起来的 filesystem driver 被称为 overlay filesystem driver,有时简称为 overlay filesystems。
Docker Image and Layer
上面说到用 overlay filesystem 实现分层。用过 Docker 的人都会熟悉一个 Docker image 由多层构成。
当我们运行 docker pull <image-name> 命令的时候,如果本机已经 cache 了这个 image 的一部分 layers,则省略下载这些 layers。这其实就是用 overlay filesystem 实现的。
Docker 团队开发了一个 filesystem(driver)叫做 overlayfs —— 这是一个特定的 filesystem 的名字。
顾名思义,Docker overlayfs 也实现了“叠加”(overlay)的能力,这就是我们看到每个 Docker image 可以有多个 layers 的原因。
Docker 的 overlayfs 以及它的后续版本 overlayfs2 都是运行在 kernel mode 里的。
这也是 Docker 需要机器的 root 权限的原因之一,而这又是 Docker 被诟病容易导致安全漏斗的原因。
有一个叫 btrfs 的 filesystem,是 Linux 世界里最近几年发展很迅速的,用于管理硬盘效果很好。
这个 filesystem 的 driver 也支持 overlay。所以 Docker 也可以被配置为使用这个 filesystem 而不是 overlayfs。
不过只有 Docker 用户的电脑的 local filesystem 是 btrfs 的时候,Docker 才能用 btrfs 在上面叠加 layers。
所以说,如果你用的是 macOS 或者 Windows,那肯定没法让 Docker 使用 btrfs 了。
不过如果你用的是 fuse-overlayfs,那就是用了一副万灵药了。只是通过 FUSE 在 userland 运行的 filesystem 的性能很一般,不过本文讨论的情形对性能也没啥需求。
其实 Docker 也可以被配置使用 fuse-overlayfs。Docker 支持的分层 filesystem 列表在这里 Docker storage drivers[7]。
为什么需要 Docker Image
总结上文所述,从编程到可以在集群上跑起来,我们要做几个步骤:
-
**编译:**把源码编译成可执行的形式。
-
**打包:**把编译结果纳入一个“包”里,以便部署和分发
-
**传输:**通常是集群管理系统(Borg、Kubernetes、Tupperware 来做)。如果要在某个集群节点上启动 container,则需要把“包”传输到此节点上,除非这个节点曾经运行过这个程序,已经有包的 cache。
-
**解包:**如果“包”是 tarball 或者 zip,到了集群节点上之后需要解压缩;如果“包”是一个 filesystem image,则需要 mount。
把源码分成模块,可以让编译这步充分利用每次修改只改动一小部分代码的特点,只重新编译被修改的模块,从而节省时间。
为了节省 2,3 和 4 的时间,我们希望“包”是分层的。每一层最好只包含一个或者几个代码模块。这样,可以利用模块之间的依赖关系,尽量复用容纳底层模块的“层”。
在开源的世界里,我们用 Docker image 支持分层的特点,一个基础层可能只包括某个 Linux distribution(比如 CentOS)的 userland programs,如 ls、cat、grep 等。
在其上,可以有一个层包括 CUDA。再其上安装 Python 和 PyTorch。再再之上的一层里是 GPT-3 模型的训练程序。
这样,如果我们只是修改了 GPT-3 训练程序,则不需要重新打包和传输下面三层。
**这里的逻辑核心是:**存在“项目”(project)的概念。每个项目可以有自己的 repo,自己的 building system(GNU make、CMake、Buck、Bazel 等),自己的发行版本(release)。
所以每个项目的 release 装进 Docker image 的一层 layer。与其前置多层合称为一个 image。
为什么 Google 和 Facebook 不需要 Docker
经过上述这么多知识准备,请我们终于可以点题了。
因为 Google 和 Facebook 使用 monolithic repository,使用统一的 build system(Google Blaze 或者 Facebook Buck)。
虽然也可以利用“项目”的概念,把每个项目的 build result 装入 Docker image 的一层。但是实际上并不需要。
利用 Blaze 和 Buck 的 build rules 定义的模块,以及模块之间依赖关系,我们可以完全去打包和解包的概念。
没有了包,当然就不需要 zip、tarball、以及 Docker image 和 layers 了。
直接把每个模块当做一个 layer 既可。如果 D.so 因为我们修改了 D.cpp 被重新编译,那么只重新传输 D.so 既可,而不需要去传输一个 layer 其中包括 D.so。
于是,在 Google 和 Facebook 里,受益于 monolithic repository 和统一的 build 工具。
我们把上述四个步骤省略成了两个:
-
**编译:**把源码编译成可执行的形式。
-
**传输:**如果某个模块被重新编译,则传输这个模块。
Google 和 Facebook 没在用 Docker
上一节说了 monolithic repo 可以让 Google 和 Facebook 不需要 Docker image。
现实是 Google 和 Facebook 没有在使用 Docker。这两个概念有区别。
我们先说“没在用”。历史上,Google 和 Facebook 使用超大规模集群先于 Docker 和 Kubernetes 的出现。当时为了打包方便,连 tarball 都没有。
对于 C/C++ 程序,直接全静态链接,根本没有 *.so。于是一个 executable binary file 就是“包”了。
直到今天,大家用开源的 Bazel 和 Buck 的时候,仍然可以看到默认链接方式就是全静态链接。
Java 语言虽然是一种“全动态链接”的语言,不过其诞生和演进扣准了互联网历史机遇,其开发者发明 jar 文件格式,从而支持了全静态链接。
Python 语言本身没有 jar 包,所以 Blaze 和 Bazel 发明了 PAR 文件格式(英语叫 subpar),相当于为 Python 设计了一个 jar。开源实现在这里[8]。
类似的,Buck 发明了 XAR 格式,也就是我上文所说的 squashfs image 前面加了一个 header。其开源实现在这里[9]。
Go 语言默认就是全静态链接的。在 Rob Pike 早期的一些总结里提到,Go 的设计,包括全静态链接,基本就是绕坑而行,绕开 Google C/C++ 实践中遇到过的各种坑。
熟悉 Google C++ style guide 的朋友们应该感觉到了 Go 语法覆盖了 guide 说的“应该用的 C++ 语法”,而不支持 guide 说的 “不应该用的 C++ 的部分”。
简单的说,历史上 Google 和 Facebook 没有在用 Docker image,很重要的一个原因是,其 build system 对各种常见语言的程序都可以全静态链接,所以可执行文件就是“包”。
但这并不是最好的解法,毕竟这样就没有分层了。哪怕我只是修改了 main 函数里的一行代码,重新编译和发布,都需要很长时间,十分钟甚至数十分钟,要知道全静态链接得到的可执行文件往往大小以 GB 计。
所以全静态链接虽然是 Google 和 Facebook 没有在用 Docker 的原因之一,但是并不是一个好选择。
所以也没被其他公司效仿。大家还是更愿意用支持分层 cache 的 Docker image。
完美解法的技术挑战
完美的解法应该支持分层 cache(或者更精确的说是分块 cache)。所以还是应该用上文介绍的 monolithic repo 和统一 build system 的特点。
但是这里有一个技术挑战,build system 描述的模块,而模块通常比“项目”细粒度太多了。
以 C/C++ 语言为例,如果每个模块生成一个 .so 文件,当做一个“层”或者“块”以便作为 cache 的单元,那么一个应用程序可能需要的 .so 数量就太多了。
启动应用的时候,恐怕要花几十分钟来 resolve symbols 并且完成链接。
所以呢,虽然 monolithic repo 有很多好处,它也有一个缺点,不像开源世界里,大家人力的把代码分解成“项目”。
每个项目通常是一个 GitHub repo,其中可以有很多模块,但是每个项目里所有模块 build 成一个 *.so 作为一个 cache 的单元。
因为一个应用程序依赖的项目数量总不会太多,从而控制了 layer 的总数。
好在这个问题并非无解。既然一个应用程序对各个模块的依赖关系是一个 DAG,那么我们总可以想办法做一个 graph partitioning,把这个 DAG 分解成不那么多的几个子图。
仍然以 C/C++ 程序为例,我们可以把每个子图里的每个模块编译成一个 .a,而每个子图里的所有 .a 链接成一个 *.so,作为一个 cache 的单元。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!
由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新
多的几个子图。
仍然以 C/C++ 程序为例,我们可以把每个子图里的每个模块编译成一个 .a,而每个子图里的所有 .a 链接成一个 *.so,作为一个 cache 的单元。
[外链图片转存中…(img-OGbSAChS-1715814506374)]
[外链图片转存中…(img-9MmWZD4I-1715814506374)]
[外链图片转存中…(img-pacPzvA4-1715814506375)]
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!
由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新