我教会了一个桶说Git

Hacker News Top 工具

摘要

作者演示了如何使用billy文件系统抽象,使Tigris对象存储桶表现得像一个文件系统,从而让go-git直接将其视为git仓库服务器。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/06/24 16:52

# 我教一个桶说 Git | Tigris 对象存储 来源:https://www.tigrisdata.com/blog/objgit/ *一只蓝色老虎冒险者站在迷雾山山谷的石庙壁架上,一只手释放出一股发光的魔法,注入一扇刻满符文的石门,远处金色的寺庙在发光。* 如果我只是把一个 Git 服务器指向一个对象存储桶,会怎样? 回到当初我把 **agent sandbox** 移植到 Go([https://www.tigrisdata.com/blog/agent-sandbox-go/](https://www.tigrisdata.com/blog/agent-sandbox-go/))的时候,我把所有东西都构建在 **billy**([https://pkg.go.dev/github.com/go-git/go-billy/v6](https://pkg.go.dev/github.com/go-git/go-billy/v6))之上,billy 是 Go 的一个文件系统抽象。这个项目的全部秘诀就是教会一个 Tigris 桶模拟文件系统,使得 shell 解释器及其工具无法分辨差异。**billy** 是让整个伪装得以实现的关键层。 在一切正常工作之后,我得知我对 billy 的使用远远超出了它的正常用途。它最初是为 **go-git**([https://pkg.go.dev/github.com/go-git/go-git/v6](https://pkg.go.dev/github.com/go-git/go-git/v6))设计的,go-git 是一个纯 Go 实现的 git 协议和数据格式。它根本不依赖 `/usr/bin/git` 这个二进制文件。billy 文件系统接口上的每个方法纯粹是因为 go-git 需要它。 这给了我一个可怕的主意:我已经有一个能像文件系统一样嘎嘎叫的桶了,而 go-git 的母语就是“文件系统”。这东西能 Just Work™ 吗?让我们试试看。 ## Git 一直就是一个对象存储 如果你剥离掉用户界面,一个 git 仓库无非是 4 样东西: - **对象**(Objects),即数据的压缩二进制块。大多数仓库里的对象都是文件。 - **树**(Trees),即映射到其他对象的对象。简而言之:树就是文件夹。 - **提交**(Commits),即指向一棵树及其父提交的对象。这能让你确定哪些文件属于一个逻辑变更集。 - **引用**(Refs),即分支和标签,它们是可变的小指针,指向对象堆中的某个点。 > **注意**:在我开始做这个之前,我一直以为 git 只存储对空文件夹所做的补丁,并通过这种方式重建仓库的历史。它并不是这样。它实际上会保留完整的文件内容,这也解释了为什么大型二进制 blob 会严重扰乱工具的运行。在日常使用 git 时,diff 思维模型没问题;但在存储层——也就是本文讨论的层面——它就是错的。 举个例子,假设我刚创建了一个新的 git 仓库,并提交了一个 README.md。`.git` 文件夹的树结构看起来像这样: ``` $ tree .git.git .git ├── COMMIT_EDITMSG ├── config ├── HEAD ├── index ├── objects │ ├── 5e │ │ └── b8151eb669aa4467b6dea2c4bce19183cd0b41 │ ├── 6a │ │ └── 6a8ecfcae2632152486aca3d9150ef83dedd66 │ ├── f4 │ │ └── d2487a1c6d742c8037c0296ddf80625190bd80 │ ├── info │ └── pack └── refs ├── heads │ └── main └── tags ``` 如你所见,有三个对象。其中一个是提交 `5eb8151eb669aa4467b6dea2c4bce19183cd0b41`,下一个是树,最后一个就是 README 文件。`main` 分支也指向那个提交: ``` $ cat .git/refs/heads/main 5eb8151eb669aa4467b6dea2c4bce19183cd0b41 ``` 有趣的是,这里面有一半是内容寻址的。这些内容寻址的部分*一旦提交就永远不会改变*。Git 对象非常适合 Tigris 的内部模型,因为它们只追加存储,就像 Tigris 所构建的基本模型一样([https://www.tigrisdata.com/blog/append-only-storage/](https://www.tigrisdata.com/blog/append-only-storage/))。而经常变化的东西是引用,它们会被更新指向最新提交。不过这些文件*非常小*,Tigris 可以毫不费力地处理它们。 然而,当我们在服务器上托管 git 仓库时,我们就会制造单点故障。我们的 git 仓库托管在单个机器上,而这些机器会(也一定会)出问题。整个实现依赖于 git 对象与文件系统对象 1:1 对应,因为每个人(包括 GitHub)都会调用 git 二进制程序来实际存储文件。托管 git 仓库成了我们无状态云原生环境中最有状态的服务之一。当然,git 理论上也是去中心化的,但我们大多数人最终都把它用成了一个大型单一存储,而且它的运行时间还不靠谱:GitHub。 公平地说,GitHub 的运营规模是我们任何人都无法想象的。他们从成立之初就一直在突破极限,甚至需要让 Engine Yard 不断为他们构建更大的服务器来处理负载。他们只能用挂载的大型文件系统来做所有事情,因为 git 的工具链没有给他们其他选择。 ## 超越人类理解的恐怖闹剧 现在,假设这种怪异让你很困扰,以至于想做点什么。要构建一个**不**把所有东西存储在本地文件系统上的 git 服务器,你必须以某种方式“说” git,而传统的选择其实都不怎么样: - 如果你调用 git 二进制程序,那么你的“库”就是 git 进程的 argv,错误处理就是屏幕抓取输出。在内部,git 用无数个子命令来实现功能,而不是把它全部暴露为库。代码库靠关键的 `die()` 调用维持,而 `die()` 会杀死进程。 - 如果你用 `libgit` 链接到 git 内部,你就会继承“出问题时 `die()`”的行为,然后你的应用程序就会开始随机崩溃。这对运行时间不利。 - 如果你尝试用 `libgit2`(那个重写版——实际上是个库),你必须面对它受 GPL 困扰(带有链接例外,解释给你的律师听试试),每次你对 git 做任何事都要跳转到 C(非常频繁),开发已经停滞,Go 绑定已被归档,而且尽管它声称不依赖本地文件系统,但实际还是依赖。 听起来希望渺茫,对吧?你可能可以用 WebAssembly 之类的东西来封装这种疯狂(假设你有一个实现 `fork()`/`exec()` 或 `posix_spawn()` 的好方法),但假如有一个纯 Go 库能为我们处理这一切呢?这就引出了 **go-git**([https://pkg.go.dev/github.com/go-git/go-git/v6](https://pkg.go.dev/github.com/go-git/go-git/v6)),一个从头开始的纯 Go 实现的 git 协议和内部结构。它不依赖 cgo 或 `/usr/bin/git`,也不假设仓库存储在本地文件系统中。它的存储接口是针对 billy 编写的,而 billy 正是我已经教会说 Tigris 的那个接口。我想要一个只存在于桶里的 git 服务器,而所有部件都摆在那里,呼唤着我。 ## 哦不,它居然能工作 于是我拼凑出了 **objgit**([https://github.com/tigrisdata/objgit](https://github.com/tigrisdata/objgit)),一个由对象存储支持的 git 服务器。我为了让它能启动而添加的唯一文件系统调用是 `MkdirAll`。我把 `transport`([https://pkg.go.dev/github.com/go-git/go-git/v6/plumbing/transport](https://pkg.go.dev/github.com/go-git/go-git/v6/plumbing/transport))包挂接到一个 socket 上,实现了纯文本的 `git` 协议,然后把它连接到桶上,并推送了我目前正在工作的仓库。 令我完全震惊的是,它居然能工作。Git 推送、拉取、日志、指责、标签,全套功能都正常。我不用自己实现 git,我只是极端地“把方钉塞进圆孔”,直到它塞进去。回过头来看,这种做法虽然烦人但很合理。一个裸仓库就是文件系统上的那四种东西;把文件系统换成对象存储,其他一切 Just Working™ 就完全合乎逻辑了。Git 的磁盘格式*就是*它的数据库模式,只要你足够逼真地模拟 open/stat/rename,整个伪装就能保持工作,因为 API 就是我们为了晚上能睡着而对自己说的谎言。 经过大量 hacking,我最终得到了大概这样的功能列表: - 通过三种传输协议进行推送和拉取:HTTP、经典的 `git://` 和 SSH - 首次推送时自动创建仓库 - 完全没有做身份验证,因为这只是一个实验,身份验证又烦又复杂 - 集成 Prometheus 指标,以便优化文件系统层 全部来自一个 Go 二进制文件,没有本地状态,甚至生成的 SSH 密钥也存储在桶里。你可以在 Kubernetes 集群中运行它,唯一需要的可变存储是用于智能 git 克隆时乐观缓存的临时文件。本文的其余部分将介绍从“哦不,它居然能工作”到接近可用的过程。 (像生活中最好的东西一样)必须说明:这是一个实验。它没有经过全面的测试或验证正确性。如果它裂成两半,你两半都能拿到。请不要把你公司的单体仓库迁移到这个上面,然后着火时给我发邮件。 ## 唯一存活下来的那个 POSIX 惯用法 Git 对持久性偏执到了极点,而它的整个策略是一个你在很多地方都能看到的 Unix 惯用法:将新数据写入临时文件,然后确保其正确后,再用 `rename(2)` 把它移动到最终位置。POSIX 保证 rename 是原子的,所以读者要么看到旧文件,要么看到新文件,而不会看到两者之间的中间状态。Packfile(对象包)在上传时作为临时文件落地,然后移动到它们的永久位置。引用则被写入锁定的临时文件,然后重命名覆盖引用。整个流程一路都是 rename。 传统的对象存储没有将 rename 作为一个原子操作。S3 的解决方案恰恰会产生那种中间状态:`CopyObject` 到新位置,然后 `DeleteObject` 旧位置。这使得 Git 哲学中最重要的惯用法土崩瓦解。 幸运的是,Tigris 有一个扩展:`RenameObject`([https://www.tigrisdata.com/docs/objects/object-rename](https://www.tigrisdata.com/docs/objects/object-rename))。使用方法是向 `CopyObject` 调用添加额外的 `X-Tigris-Rename: true` 头,这样它就不会在客户端上复制再删除,而是在服务器上移动元数据。一趟往返,没有数据移动,Unix 惯用法与桶完美对应。Objgit 的 `Rename` 实现非常简单: ```go // internal/s3fs/basic.go // RenameObject 是 Tigris 扩展,原地重命名(无数据复制), // 所以我们不需要单独的 CopyObject + DeleteObject。 copySource := fs3.bucket + "/" + src _, err := fs3.client.RenameObject(ctx, &s3.CopyObjectInput{ Bucket: &fs3.bucket, CopySource: &copySource, Key: &dst, }) ``` 同一代码路径中还隐藏着第二个更隐蔽的违反点。当 go-git 写入一个临时文件时,它会创建那个临时文件,然后**立刻**开始打开它进行读取,以便构建包索引。在任何一个对象存储系统中,你都不能对一个活跃的对象同时进行读写——你只能在读或写,不能同时进行。我最后通过有点作弊的方式绕过了这个问题:将新写入的 pack 文件的内容缓冲在内存中,以便这种“胆小鬼游戏”能够继续。我可能得改为把这个 pack 缓存写入文件系统,因为尝试推送 `gcc.git` 时我耗尽了 RAM。至少,所有的东西都足够一致地撒谎,让 git 不在乎,所以赢了! ## 死于一千次 `stat()` 调用 在解决了正确性问题之后,我尝试将 `golang/go`([https://github.com/golang/go](https://github.com/golang/go))仓库推送到 objgit,看看需要多久。它确实能工作,但花了*很长时间*。利用我之前提到的 Prometheus 指标,我发现它在进行天文数字级别的 `HeadObject` 调用。一些阻塞分析指出,git 库使用 `stat()` 调用来检测文件是否存在。流程是这样的: - 客户端有对象 x - 检查对象 x 是否存在 - 检查是否有任何包包含对象 x - 如此无限循环 在本地文件系统上这还算可以,因为这些系统调用能在*微秒*级完成,而不是从我的办公室到最近的 Tigris 区域所需的几十毫秒(请扩展到渥太华,我会非常感激的)。更糟的是,我发现我正在使用的传输层(SSH——经典的 `git://` 共享相同的代码路径)在推送时会将每个 packfile 爆炸成*松散对象*。每次松散对象写入的成本是两次往返:`stat()` 检查文件是否存在,然后 `open()`/`write()` 实际把数据放入 Tigris。这使得一个 10 万对象的 packfile 需要 20 万次对象存储调用。就算每次延迟是 10 毫秒,那也要等超过半个小时,而大部分响应只是说“404 not found”。缓存在这里也帮不上忙,读缓存可以吸收重复读取,但这是一条 *写入* 10 万个路径的洪流,这些路径可能从未被读取过,也可能永远不再被看到。 只有两种传输层有这个问题的原因是死锁故事。git 库的快路径通过 `PackfileWriter` 整体存储传入的包,从连接复制直到 `io.EOF`。在 HTTP 上没问题:请求体结束,EOF 到达,各回各家。在 `git://` 和 SSH 上,连接是持久 socket,客户端保持打开状态,礼貌地等待服务器状态报告。EOF 永远不会来。复制永远等待,客户端永远等待,你发明了一个有两个参与者的分布式死锁。最初的解决方法是在这些传输层上隐藏 `PackfileWriter` 功能,这样 go-git 就会退回到流解析器,把每个对象写成松散形式。于是就产生了 stat 风暴。 所以解决方案是彻底不再依赖 EOF。Packfile 是自定界的:头部说明有多少对象,尾部校验和标记结束,所以一个 packfile 扫描器可以遍历流并在尾部停止,同时一个 `TeeReader` 将这些字节精确镜像到 `PackfileWriter`。这使得其余的伪装得以实现,git 库也开心了。这让推送变成了两次上传:一个 packfile 及其索引,而不是一堆毫无意义的往返。 ## 克隆呢? 我修复了推送后,开始处理读取路径。为了模拟 `ReadAt`,我使用了范围查询的 `GetObject` 请求,这样 git 库就可以从 packfile 中读取单个对象。我对这个 hack 很满意,但有一个问题:延迟诅咒再次降临。克隆一个只有 318 个对象和 200KiB 的 packfile 的简单仓库,在我杀死它之前产生了超过 8,500 次 `GetObject` 调用。一个 git 客户端克隆仓库会成千上万次随机读取仓库的 packfile,一遍又一遍地遍历对象和候选 delta 基。在本地磁盘上你永远不会注意到,因为页面缓存轻松搞定。当每次调用都是一次 HTTP 请求时,200KiB 的仓库变成了几十兆字节的往返流量。一个 20MiB 的仓库实际上无法提供服务。 换句话说,我居然把缓存原本要解决的工作负载给“取消缓存”了。修复方法利用了 git 的一个赠品:pack 文件是不可变的,并且是内容寻址的。`pack-*.pack` 只要存在就*永远不会改变*。这使得它们可以轻松地缓存到更快的本地介质,比如文件系统。不需要任何失效逻辑。我让 objgit 将包下载到本地临时文件夹,并从那里提供读服务。为了安全起见,我还添加了最近最少使用(LRU)缓存,这样我的临时文件夹就不会意外膨胀。这意味着 pack 文件的第一次请求会慢一些,但之后的所有操作都是文件系统速度了。是的,这又依赖了本地磁盘,但只是作为一个可以随时丢弃的缓存。我认为用无状态理想换取能在合理时间内完成的克隆是值得的。 ## 为什么这么多 `ListObjectsV2`,蝙蝠侠?

相似文章

Git 由什么构成?(2022)

Lobsters Hottest

深入教程,解释 Git 的内部结构,包括对象、哈希以及 Git 如何存储数据,并提供 Go 和 shell 命令示例。

Gitolite

Lobsters Hottest

Gitolite 是一个用于在中央服务器上托管 Git 仓库的工具,具有细粒度的访问控制和许多其他强大功能。