GitLab 中 Git 对象去重的工作原理
当 GitLab 用户 fork 一个项目 时, GitLab 会创建一个新的 Project,并附带一个 Git 仓库,这个仓库是项目在 fork 时刻的副本。 如果一个大型项目经常被 fork,这可能导致 Git 仓库存储磁盘使用量快速增加。 为了解决这个问题,我们在 GitLab 中为 fork 添加了 Git 对象去重功能。 在本文档中,我们将描述 GitLab 如何实现 Git 对象去重。
Pool repositories
Understanding Git alternates
在 Git 层面,我们通过使用 Git alternates 来实现去重。 Git alternates 是一种机制,允许一个仓库从同一台机器上的另一个仓库借用对象。
要让仓库 A 从仓库 B 借用对象:
- 在特殊文件
A.git/objects/info/alternates中建立 alternates 链接, 写入一个解析到B.git/objects的路径。 - 在仓库 A 中运行
git repack,删除仓库 A 中也存在于仓库 B 的所有对象。
重新打包后,仓库 A 不再是自包含的,但仍包含自己的 refs 和配置。 A 中不在 B 中的对象仍然留在 A 中。为了使此配置正常工作,不能从仓库 B 中删除对象, 因为仓库 A 可能需要它们。
Do not run git prune or git gc in object pool repositories, which are
stored in the @pools directory. This can cause data loss in the regular
repositories that depend on the object pool.
危险在于 git prune,而 git gc 会调用 git prune。
问题是当 git prune 在池仓库中运行时,无法可靠地判断对象是否不再需要。
Git alternates in GitLab: pool repositories
GitLab 通过 创建特殊的 pool repositories 来组织这种对象借用, 这些仓库对用户是隐藏的。然后我们使用 Git alternates 让一组项目仓库从单个池仓库借用对象。 我们将这样的项目仓库集合称为一个池(pool)。池形成了从单个池借用的仓库的星形网络, 这类似于(但不等同于)用户 fork 项目时形成的 fork 网络。
在 Git 层面,池仓库使用 Gitaly RPC 调用创建和管理。 与典型仓库一样,关于哪些池仓库存在以及哪些仓库从它们借用的权威信息位于 Rails 应用层的 SQL 中。
总之,在 Git 层面,要在一组 GitLab 项目仓库中实现有效的对象去重,我们需要三样东西:
- 必须存在一个池仓库。
- 参与的项目仓库必须通过各自的
objects/info/alternates文件链接到池仓库。 - 池仓库必须包含参与项目仓库共有的 Git 对象数据。
Deduplication factor
GitLab 中 Git 对象去重的有效性取决于池仓库与其每个参与者之间的重叠量。 每次在源项目上运行垃圾回收时,源项目的 Git 对象都会迁移到池仓库。 随着垃圾回收的逐个运行,其他成员项目会从添加到池中的新对象中受益。
SQL model
GitLab 中的项目仓库没有自己的 SQL 表。它们通过 projects 表中的列间接标识。
换句话说,查找项目仓库的唯一方法是先查找其项目,然后调用 project.repository。
对于池仓库,我们重新开始。它们存在于自己的 pool_repositories SQL 表中。
这两个表之间的关系如下:
- 一个
Project最多属于一个PoolRepository(project.pool_repository) - 作为上述的自动结果,一个
PoolRepository有多个Project - 一个
PoolRepository正好有一个 “sourceProject"(pool.source_project)
Assumptions
- 池中的所有仓库必须在同一个 Gitaly 存储分片上。 Git alternates 机制依赖于跨多个仓库的直接磁盘访问,我们只能假设直接磁盘访问在 Gitaly 存储分片内是可能的。
- 从池中删除成员项目的唯一两种方式是:(1) 删除项目或 (2) 将项目移动到另一个 Gitaly 存储分片。
Creating pools and pool memberships
- 当创建池时,它必须有一个源项目。池仓库的初始内容是源项目仓库的 Git 克隆。
- 创建池的时机是当一个符合条件的(非私有、哈希存储、非 fork)GitLab 项目被 fork, 并且该项目还不属于任何池仓库时。fork 的父项目成为新池的源项目, fork 的父项目和子项目都成为新池的成员。
- 一旦项目 A 成为某个池的源项目,A 的所有未来符合条件的 fork 都将成为池成员。
- 如果 fork 源本身就是一个 fork,那么 resulting repository 既不会加入仓库, 也不会被用作新池仓库的种子。
例如:
假设 fork A 是某个池仓库的一部分,任何从 fork A 创建的 fork 都不是 fork A 所在池仓库的一部分。
假设 B 是 A 的 fork,并且 A 不属于任何对象池。 现在 C 被创建为 B 的 fork。C 不是池仓库的一部分。
Consequences
- 如果参与池的典型项目被移动到另一个 Gitaly 存储分片,其 “belongs to PoolRepository” 关系将被破坏。 由于在分片之间移动仓库的实现方式,我们在新的存储分片上获得项目仓库的一个全新的自包含副本。
- 如果池的源项目被移动到另一个 Gitaly 存储分片或被删除,“source project” 关系不会被破坏。 但是,除非源项目在同一个 Gitaly 分片上,否则池不会从源项目获取数据。
Consistency between the SQL pool relation and Gitaly
就 Gitaly 而言,SQL 池关系对 Gitaly 服务器上的状态做出了两种类型的声明: 池仓库的存在,以及仓库与池之间的 alternates 连接的存在。
Pool existence
如果 GitLab 认为池仓库存在(即根据 SQL 存在),但在 Gitaly 服务器上不存在, 那么它将由 Gitaly 动态创建。
Pool relation existence
这里有三种不同的问题可能出现。
1. SQL 说仓库 A 属于池 P,但 Gitaly 说 A 没有 alternate 对象
在这种情况下,我们错失了磁盘空间节省,但 A 上的所有 RPC 都能正常工作。
下次在 A 上运行垃圾回收时,alternates 连接将在 Gitaly 中建立。
这是由 GitLab Rails 中的 Projects::GitDeduplicationService 完成的。
2. SQL 说仓库 A 属于池 P1,但 Gitaly 说 A 在池 P2 中有 alternate 对象
在这种情况下,Projects::GitDeduplicationService 会抛出异常。
3. SQL 说仓库 A 不属于任何池,但 Gitaly 说 A 属于 P
在这种情况下,Projects::GitDeduplicationService 尝试使用 DisconnectGitAlternates RPC 来 “重新去重” 仓库 A。
Git object deduplication and GitLab Geo
当在 Geo 主节点上的 SQL 中创建池仓库记录时,这最终会在 Geo 从节点上触发一个事件。 Geo 从节点然后在 Gitaly 中创建池仓库。这导致了一种"最终一致"的情况, 因为随着每个池参与者被同步,Geo 最终会在从节点的 Gitaly 上触发垃圾回收, 在此阶段 Git 对象被去重。