网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
只是因为如果不需要大家 build Docker image 了,那么 container 的存在就不容易被关注到了。
如果不想被上述蔽之,而要细究这个问题,那就待我一层一层剥开 Google 和 Facebook 的研发技术体系和计算技术体系。
Packaging
当我们提交一个分布式作业(job)到集群上去执行,我们得把要执行的程序(包括一个可执行文件以及相关的文件,比如 *.so,*.py)传送到调度系统分配给这个 job 的一些机器(节点、nodes)上去。
这些待打包的文件是怎么来的呢?当时是 build 出来的。在 Google 里有 Blaze,在 Facebook 里有 Buck。
感兴趣的朋友们可以看看 Google Blaze 的“开源版本”Bazel[3],以及 Facebook Buck 的开源版本[4]。
不过提醒在先:Blaze 和 Facebook Buck 的内部版都是用于 monolithic repo 的,而开源版本都是方便大家使用非 mono repos 的,所以理念和实现上有不同,不过基本使用方法还是可以感受一下的。
假设我们有如下模块依赖(module dependencies),用 Buck 或者 Bazel 语法描述(两者语法几乎一样):
python_binary(name=“A”, srcs=[“A.py”], deps=[“B”, “C”], …)
python_library(name=“B”, srcs=[“B.py”], deps=[“D”], …)
python_library(name=“C”, srcs=[“C.py”], deps=[“E”], …)
cxx_library(name=“D”, srcs=[“D.cxx”, “D.hpp”], deps=“F”, …)
cxx_library(name=“E”, srcs=[“E.cxx”, “E.hpp”], deps=“F”, …)
那么模块(build 结果)依赖关系如下:
A.py --> B.py --> D.so -\
-> C.py --> E.so --> F.so
如果是开源项目,请自行脑补,把上述模块(modules)替换成 GPT-3,PyTorch,cuDNN,libc++ 等项目(projects)。
当然,每个 projects 里包含多个 modules 也依赖其他 projects,就像每个 module 有多个子 modules 一样。
Tarball
最简单的打包方式就是把上述文件 {A,B,C}.py, {D,E,F}.so 打包成一个文件 A.zip,或者 A.tar.gz。
更严谨的说,文件名里应该包括版本号。比如 A-953bc.zip,其中版本号 953bc 是 git/Mercurial commit ID。
引入版本号,可以帮助在节点本地 cache,下次运行同一个 tarball 的时候,就不需要下载这个文件了。
请注意这里我引入了 package caching 的概念。为下文解释 Docker 预备。
XAR
ZIP 或者 tarball 文件拷贝到集群节点上之后,需要解压缩到本地文件系统的某个地方,比如:/var/packages/A-953bc/{A,B,C}.py,{D,E,F}.so。
一个稍显酷炫的方式是不用 Tarball,而是把上述文件放在一个 overlay filesystem 的 loopback device image 里。这样“解压”就变成了“mount”。
请注意这里我引入了 loopback device image 的概念。为下文解释 Docker 预备。
什么叫 loopback device image 呢?在 Unix 里,一个目录树的文件们被称为一个文件系统(filesystem)。
通常一个 filesystem 存储在一个 block device 上。什么是 block device 呢?
简单的说,但凡一个存储空间可以被看作一个 byte array 的,就是一个 block device。
比如一块硬盘就是一个 block device。在一个新买的硬盘里创建一个空的目录树结构的过程,就叫做格式化(format)。
既然 block device 只是一个 byte array,那么一个文件不也是一个 byte array 吗?
是的!在 Unix 的世界里,我们完全可以创建一个固定大小的空文件(用 truncate 命令),然后“格式化”这个文件,在里面创建一个空的文件系统。然后把上述文件 {A,B,C}.py,{D,E,F}.so 放进去。
比如 Facebook 开源的 XAR 文件[5]格式。这是和 Buck 一起使用的。
如果我们运行 buck build A 就会得到 A.xar . 这个文件包括一个 header,以及一个 squashfs loopback device image,简称 squanshfs image。
这里 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
网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
所以每个项目的 release 装进 Docker image 的一层 layer。与其前置多层合称为一个 image。
为什么 Google 和 Facebook 不需要 Docker
[外链图片转存中…(img-kWQdn96S-1715515947975)]
[外链图片转存中…(img-RJEr0hJg-1715515947975)]
网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!