用 NFS 将 Git 提交挂载为文件夹:项目开发问题与用途全揭秘
用 NFS 将 Git 提交挂载为文件夹
前几天有人想到一个问题:有没有人做过一种 FUSE 文件系统,能把 Git 仓库里的每个提交都当作一个文件夹呢?结果发现还真有,比如 giblefs、GitMounter,还有适用于 Plan 9 的 git9。
不过,在 Mac 上使用 FUSE 挺麻烦的,需要安装内核扩展,而且出于安全考虑,Mac OS 似乎越来越难安装内核扩展了。另外,开发者对文件系统的组织方式也有一些和这些项目不同的想法。
所以,开发者尝试在 Mac OS 上用 FUSE 之外的方法挂载文件系统,开发了一个名为 git-commit-folders 的项目。它(至少在开发者的计算机上)同时支持 FUSE 和 NFS,还有一个不太完善的 WebDav 实现。
这个项目还处于实验阶段(开发者不确定它到底是一款实用的软件,还是一个用来探索 Git 工作原理的有趣玩具),但编写过程很有趣,开发者自己在小仓库里用着也挺顺手。下面就来分享一下开发者在编写过程中遇到的一些问题。
目标:展示提交如何类似文件夹
开发者做这个项目的主要原因,是想让大家对 Git 的底层工作原理有更直观的认识。毕竟,Git 提交实际上和文件夹非常相似——每个 Git 提交都包含一个目录列表,列出了其中的文件,而且这个目录还可以包含子目录等等。
只不过,为了节省磁盘空间,Git 提交实际上并不是以文件夹的形式实现的。
在 git-commit-folders 里,每个提交都对应一个文件夹。如果想查看旧提交,直接浏览文件系统就行了!比如,查看博客的初始提交,结果如下:
$ ls commits/8d/8dc0/8dc0cb0b4b0de3c6f40674198cb2bd44aeee9b86/
README
几个提交之后,情况变成这样:
$ ls /tmp/git-homepage/commits/c9/c94e/c94e6f531d02e658d96a3b6255bbf424367765e9/
_config.yml config.rb Rakefile rubypants.rb source
分支是符号链接
在 git-commit-folders 挂载的文件系统中,只有提交对应的是真正的文件夹,其他的(如分支、标签等)都是指向提交的符号链接,这和 Git 的底层工作方式是一致的。
$ ls -l branches/
lr-xr-xr-x 59 bork bazil-fuse -> ../commits/ff/ff56/ff563b089f9d952cd21ac4d68d8f13c94183dcd8
lr-xr-xr-x 59 bork follow-symlink -> ../commits/7f/7f73/7f73779a8ff79a2a1e21553c6c9cd5d195f33030
lr-xr-xr-x 59 bork go-mod-branch -> ../commits/91/912d/912da3150d9cfa74523b42fae028bbb320b6804f
lr-xr-xr-x 59 bork mac-version -> ../commits/30/3008/30082dcd702b59435f71969cf453828f60753e67
lr-xr-xr-x 59 bork mac-version-debugging -> ../commits/18/18c0/18c0db074ec9b70cb7a28ad9d3f9850082129ce0
lr-xr-xr-x 59 bork main -> ../commits/04/043e/043e90debbeb0fc6b4e28cf8776e874aa5b6e673
$ ls -l tags/
lr-xr-xr-x - bork 31 Dec 1969 test-tag -> ../commits/16/16a3/16a3d776dc163aa8286fb89fde51183ed90c71d0
这当然不能完全解释 Git 的工作原理(Git 远不止“提交就像文件夹”这么简单),但开发者希望能让“每个提交就像一个包含旧版本代码的文件夹”这个概念更加具体。
为什么这可能有用?
在介绍实现细节之前,先来谈谈把每个 Git 提交都当作文件夹的文件系统有什么用。开发者很多项目最后都没怎么用(比如 dnspeep),但这个项目在开发过程中开发者还真用了不少。
目前发现的主要用途有:
- 查找被删除的函数:可以运行 grep someFunction branch_histories/main/*/commit.go 来找到它的旧版本。
- 快速查看另一个分支上的文件,复制其中的一行,比如 vim branches/other-branch/go.mod。
- 在每个分支中搜索函数,比如 grep someFunction branches/*/commit.go。
这些操作都是通过指向提交的符号链接来完成的,而不是直接引用提交。
这些方法都不是最高效的(可以用 git show、git log -S 或者 git grep 来实现类似的功能),但开发者个人总是记不住这些命令的语法,对开发者来说,浏览文件系统感觉更简单。git worktree 也能让开发者同时检出多个分支,但对开发者来说,仅仅为了查看一个文件就设置整个工作树,感觉有点麻烦。
接下来,要讲讲遇到的一些问题。
问题 1:WebDav 还是 NFS?
Mac OS 原生支持的两种文件系统是 WebDav 和 NFS。开发者不确定哪个更容易实现,所以就两个都试了试。
一开始,WebDav 看起来更容易,而且 golang.org/x/net 有一个 WebDav 实现,设置起来挺简单的。
但这个实现不支持符号链接,开发者觉得可能是因为它使用了 io/fs 接口,而 io/fs 目前还不支持符号链接。不过这个问题好像正在解决中。所以开发者放弃了 WebDav,决定专注于 NFS 实现,使用 go-nfs 这个 NFSv3 库。
也有人提到 Mac 上有 FileProvider,但开发者没深入研究。
问题 2:如何保持所有实现同步?
开发者要实现三种不同的文件系统(FUSE、NFS 和 WebDav),但不清楚如何避免大量的代码重复。
开发者的朋友 Dave 建议先写一个核心实现,然后编写适配器(比如 fuse2nfs 和 fuse2dav),把它转换为 NFS 和 WebDav 版本。具体来说,开发者需要实现三个文件系统接口:
- 用于 FUSE 的 fs.FS
- 用于 NFS 的 billy.Filesystem
- 用于 WebDav 的 webdav.Filesystem
所以开发者把所有核心逻辑都放在 fs.FS 接口里,然后编写了两个函数:
- func Fuse2Dav(fs fs.FS) webdav.FileSystem
- func Fuse2NFS(fs fs.FS) billy.Filesystem
这些文件系统都有相似之处,所以转换起来不算太难,但要修复的小问题可不少。
问题 3:不想列出所有提交
有些 Git 仓库有数千甚至数百万个提交。开发者最初的想法是让 commits/ 看起来是空的,就像这样:
$ ls commits/
$ ls commits/80210c25a86f75440110e4bc280e388b2c098fbd/
fuse fuse2nfs go.mod go.sum main.go README.md
这样,每个提交都可以通过直接引用访问,但无法列出它们。这在 FUSE 里没问题,但在 NFS 里行不通。开发者猜是因为如果告诉 NFS 一个目录是空的,它就会认为这个目录真的是空的,这也合理。
最后开发者这样解决这个问题:
- 像.git/objects 那样,按提交哈希的前两个字符来组织提交(这样 ls commits 会显示 0b 03 05 06 07 09 1b 1e 3e 4a),并且采用两级目录结构,比如 18d46e76d7c2eedd8577fae67e3f1d4db25018b0 会存放在 commits/18/18df/18d46e76d7c2eedd8577fae67e3f1d4db25018b0。
- 只在开始时列出一次所有打包提交的哈希,把它们缓存在内存中,之后只更新松散对象。因为仓库里的提交大多是打包的,而且 Git 不常重新打包提交。
在有大约 100 万个提交的 Linux 内核仓库上,这个方法效果还不错。在开发者的机器上,初始加载可能需要一分钟,之后只需要进行快速的增量更新。
每个提交哈希只有 20 字节,缓存 100 万个提交哈希也不过 20MB,不算什么。
开发者觉得更聪明的做法是懒加载提交列表——Git 会按提交 ID 对打包文件进行排序,所以可以很容易地用二分查找找到以 1b 或 1b8c 开头的所有提交。但开发者用的 Git 库在这方面支持不太好,因为列出 Git 仓库里的所有提交是个挺奇怪的需求。开发者花了几天时间尝试实现,但没达到想要的性能,就放弃了。
问题 4:“不是目录”
开发者总是遇到这个错误:
"/tmp/mnt2/commits/59/59167d7d09fd7a1d64aa1d5be73bc484f6621894/": Not a directory (os error 20)
一开始开发者被这个错误搞得晕头转向,后来发现这其实意味着在列出目录时出错了,而 NFS 库处理这个错误的方式就是返回“不是目录”。这种情况出现了很多次,开发者每次都得去排查问题。
还有很多类似的奇怪错误。开发者还遇到过 cd: system call interrupted,这挺让人郁闷的,但最后发现只是程序里的其他 bug。
最后开发者意识到,可以用 Wireshark 查看所有来回传输的 NFS 数据包,这让调试变得容易了一些。
问题 5:inode 编号
一开始,开发者不小心把所有目录的 inode 编号都设成了 0。这可不行,因为如果在一个所有目录 inode 编号都是 0 的目录里运行 find,它会抱怨文件系统有循环然后放弃,这也很合理。
开发者通过定义一个 inode(string) 函数来解决这个问题,这个函数会对字符串进行哈希运算得到 inode 编号,然后用树 ID 或 blob ID 作为要哈希的字符串。
问题 6:过时的文件句柄
开发者总是遇到“Stale NFS file handle”错误。问题在于,开发者需要能够将一个不透明的 64 字节 NFS“文件句柄”映射到正确的目录。
开发者用的 NFS 库的工作方式是,为每个文件生成一个文件句柄,并使用固定大小的缓存来存储这些引用。对于小仓库来说,这样没问题,但如果文件太多,缓存就会溢出,就会开始遇到过时的文件句柄错误。
这仍然是个问题,开发者还不知道怎么解决。开发者不明白真正的 NFS 服务器是怎么处理这个问题的,也许它们有一个很大的缓存?
NFS 文件句柄有 64 字节(注意是字节,不是比特),挺大的,所以很多时候其实可以直接把整个文件路径编码在句柄里,而不用缓存。也许开发者之后会试试实现这个方法。
问题 7:分支历史
目前,branch_histories/ 目录只列出每个分支的最新 100 个提交。开发者不确定该怎么处理这个问题——要是能列出分支的完整历史就好了。也许开发者可以采用和 commits/ 目录类似的子文件夹技巧。
问题 8:子模块
Git 仓库有时会有子模块。开发者对这方面不太了解,所以目前就直接忽略了。这算是个 bug。
问题 9:NFSv4 更好吗?
开发者用 NFSv3 构建了这个项目,因为当时能找到的唯一 Go 库是 NFSv3 库。完成之后,开发者发现 buildbarn 项目里有一个 NFSv4 服务器。用它会不会更好呢?
开发者不确定这到底是不是个问题,也不清楚使用 NFSv4 会有多大优势。开发者也有点犹豫要不要用 buildbarn 的 NFS 库,因为不清楚他们是否希望别人使用。
就这些啦!
可能还有一些开发者忘记的问题,但目前能想到的就这些了。开发者不确定会不会修复 NFS 过时文件句柄的问题,或者“在 Linux 内核仓库上启动需要一分钟”的问题,谁知道呢!
感谢开发者的朋友 vasi,他给开发者讲解了很多关于文件系统的知识。
