把一台 Git 服务器拆开 左边一台机器里装着认证、接管道、跑 git 三件事和裸仓库 /srv/git/demo.git,三件事各有一个颜色标记;一个箭头把它拆开,右边变成接管道的 Workhorse/sshd、负责认证的 Rails、紫色的 Praefect 和三台各自在隔开的格子里跑 git 的 Gitaly,同色标记对应原来的职责,连线上的菱形标出拆开后的接缝。 一台机器 认证 接管道 跑 git /srv/git/demo.git

在一台 Linux 服务器上准备一个能 clone、能 push 的 Git 仓库,最少只要一条命令:

$ git init --bare /srv/git/app.git
$ ls -F /srv/git/app.git
HEAD branches/ config description hooks/ info/ objects/ refs/

再给同事开一个 SSH 账号,大家就可以 git clone git@git.lan:/srv/git/app.git 了。可是这个目录里一个源码文件都没有,它算什么仓库?一个目录加上 SSH,为什么就成了一台 Git 服务器?

GitLab 做的也是“让大家 clone 和 push”这件事,围绕仓库却长出了 Rails、Workhorse、gitlab-shell、Gitaly、Praefect 好几个组件。它们比这台最简单的服务器多做了什么,又为什么要拆成这样? 我们先把最简单的服务器搭起来,再从它和 GitLab 的差距出发,一段一段往下看。


一、从 bare 仓库到 Git 服务器

什么是 bare 仓库

平时 git clone 下来的仓库有两部分:我们编辑的那些文件叫工作区,.git/ 目录里存着对象、引用和配置,这才是仓库本身。bare 仓库就是只有后一半的仓库:没有工作区,原本藏在 .git/ 里的内容直接摊在目录顶层,config 里写着 bare = true,目录名按惯例以 .git 结尾。

服务器上为什么要用这种仓库?可以试着往一个普通仓库 push 它当前检出的分支:

$ git push origin HEAD
remote: error: refusing to update checked out branch: refs/heads/master
! [remote rejected] HEAD -> master (branch is currently checked out)

Git 在后面几行给了理由:push 改掉了分支指向的提交,对方的工作区和索引却还停在旧提交上,两边对不上。服务器上没有人在工作区里写代码,工作区只会带来这种不一致。去掉工作区,只存版本历史,正适合做大家共用的那一份。

一个目录怎么变成服务器

clone 的时候,客户端到底让服务器做了什么?把 GIT_SSH_COMMAND 换成一个只打印参数的脚本,就能看到 Git 要通过 SSH 执行的命令:

$ git clone git@git.lan:/srv/git/app.git
ssh git@git.lan git-upload-pack '/srv/git/app.git'

push 的时候,这条命令换成 git-receive-pack '/srv/git/app.git'。服务器端真正干活的只有这两个程序:upload-pack 和客户端协商出缺哪些对象,打成 pack 发过去;receive-pack 收下客户端送来的 pack,再更新引用。SSH 只负责登录和加密,把这两个进程的标准输入输出接到客户端上。

SSH 不是唯一的接法。git daemon 在 9418 端口上提供匿名的只读访问;git http-backend 是 Git 自带的 CGI 程序,挂在 nginx 或 Apache 后面,就能用 HTTPS clone 和 push。图 1 把三种接法放在一起:入口各不相同,最后都在同一个 bare 目录上启动同一个 git-upload-pack。

最简单的 Git 服务器 客户端用三种 URL 访问 git.lan:SSH 由 sshd 以 git 账号登录,git:// 由 git daemon 接住,HTTPS 由 nginx 加 git http-backend 接住。三条路最后都启动 git-upload-pack /srv/git/app.git,读写同一个 bare 仓库,里面只有 config、HEAD、objects、refs、hooks,没有工作区。 客户端 · 三种 URL 服务器 · git.lan SSH 端口 22 git@git.lan:/srv/git/app.git git:// 端口 9418 git://git.lan/app.git HTTPS 端口 443 https://git.lan/app.git sshd 共用账号 git ~git/.ssh/authorized_keys git daemon 只读 --base-path=/srv/git nginx + http-backend GIT_PROJECT_ROOT=/srv/git 入口只管登录、把管道接上, 干活的始终是右边同一个进程。 进程 git-upload-pack '/srv/git/app.git' 读对象、发 pack bare 仓库 /srv/git/app.git config bare = true HEAD ref: refs/heads/main objects/ pack/ info/ … refs/ heads/ tags/ hooks/ pre-receive …
图 1 · 三种入口只负责认证和接通管道,最后都在同一个没有工作区的 bare 目录上启动 git-upload-pack

这台服务器能用,但它管得很少。权限只有 Unix 那一套:所有人共用 git 账号,谁的公钥在 authorized_keys 里,谁就能读写这台机器上的每一个仓库;想做到“张三只能读 app,李四不能推 main”,只能自己写钩子,或者借助 gitolite 这类工具,在 authorized_keys 里给每把公钥加一条强制命令,让登录先经过它的权限检查。仓库只能待在这块磁盘上:git 进程必须和目录在同一台机器上,机器坏了,仓库也就没了。网页、合并请求、CI 更是一概没有。

把三件事拆开

回头看图 1,这台服务器其实只做了三件事:认证(sshd 查 authorized_keys),接管道(把客户端的数据接到进程的标准输入输出上),跑 git(在仓库目录上执行 git-upload-pack 或 git-receive-pack)。三件事挤在同一台机器上,彼此之间只隔着一个管道。

GitLab 做的还是这三件事。它的本质可以压成一句话:在服务器端跑 git 命令,只是把这些命令分散到了很多台机器上。 有两点要说准。第一,分散的单位是整个仓库:每个仓库完整地放在某一台机器上,不同的仓库落在不同的机器上,单个仓库并没有被拆开。第二,跑的不只是 clone 和 push 用到的 upload-pack、receive-pack,网页上看文件、列分支、比较两个提交,背后也都是 git 命令。

可是三件事一旦分到不同的机器上,原来靠“同一台机器”免费得到的东西就都没了:应用想读仓库,仓库却不在本机;入口要放行一个请求,却不知道用户能不能读;管道要跨过网络;一台机器要替成千上万个用户跑 git;push 的检查逻辑在另一台机器上;仓库只有一份,机器坏了就没了。GitLab 多出来的每个组件,都长在这些接缝上。 图 2 把最简服务器和 GitLab 放在一起,编号标出了六处接缝,后面一节讲一处。

同样三件事,从一台机器拆到多台机器 左边是最简单的 Git 服务器:认证(sshd 和 authorized_keys)、接管道(SSH 的标准输入输出)、跑 git(git-upload-pack 读 /srv/git/app.git)都在同一台机器上。右边是 GitLab:入口 Workhorse 和 gitlab-sshd 先问 Rails 能不能、在哪(②);Rails 通过 gRPC 让 Gitaly 替它跑 git(①);pack 字节经 sidechannel 在入口和 Gitaly 之间传(③);Praefect 把写请求发给三台 Gitaly 副本并投票(⑥);每台 Gitaly 上跑着 git-upload-pack,机器被所有人共享(④),push 的钩子要回调 Rails(⑤)。 最简服务器 · 一台机器 GitLab · 拆到多台机器 认证 sshd · authorized_keys 接管道 SSH 的 stdin/stdout 跑 git git-upload-pack /srv/git/app.git 三件事挤在同一台机器上, 彼此之间只隔着一个管道。 入口 HTTPS · SSH Workhorse / gitlab-sshd ② 先问 Rails 认证 · 业务 /internal/allowed ③ pack 字节 走 sidechannel ① gRPC 替我跑 git Praefect ⑥ 写请求发给每个副本,投票决定提交 Gitaly 1 主 git-upload-pack @hashed/…/….git ④ 共享 · ⑤ 钩子 Gitaly 2 副本 git-upload-pack @hashed/…/….git 完整的一份 Gitaly 3 副本 git-upload-pack @hashed/…/….git 完整的一份 三件事都还在,只是分到了不同的机器上; 每个编号都是一处被拆开的地方,后面一节讲一处。
图 2 · GitLab 仍然只做认证、接管道、跑 git 这三件事,多出来的组件都长在把它们拆开的接缝上

六处接缝里,最早被拆开、也最根本的是第 ① 处:仓库和要用它的应用不在同一台机器上了。GitLab 并不是一开始就这么设计的,它是被一组监控数字逼出来的。


二、应用与仓库的分离:Gitaly

现象:CPU 很闲,请求很慢

GitLab 早期的部署很朴素:应用服务器上跑着 Rails、Sidekiq 和 gitlab-shell,所有机器挂载同一个 NFS 目录,仓库就是 NFS 上的一个个 bare 目录。Rails 要读仓库,就在本机用 Rugged(libgit2 的 Ruby 绑定)打开 NFS 上的目录。机器不够就加应用服务器,每台都挂上同一个 NFS。这时三件事其实还在“同一台机器”上,只不过这台机器的磁盘是通过网络借来的。

这套部署跑到 GitLab.com 的规模,出了一个怪现象。Gitaly 的设计文档里记着这样一组数字1:

Rugged::Repository.new P99 墙钟时间 峰值超过 30 s
CPU 时间 约 15 ms

打开一个仓库只是读几个文件,CPU 也确实只花了 15 毫秒,墙钟时间却能到 30 秒。差了三个数量级,说明进程几乎一直在等。

原因:git 默认磁盘就在身边

设计文档的判断是:时间花在了文件系统访问上1。git 假设存储访问是低延迟、高带宽的,打开仓库要读的 HEAD、config、packed-refs、pack 索引,生成 pack 时在几个 GB 文件里的随机读,在本地盘上都很便宜;放到 NFS 上,每一次都变成一次网络往返。一台 NFS 服务器出问题,所有应用服务器也跟着失效。

解决:把 git 搬回磁盘旁边,让应用远程调用

直觉的办法是把 git 访问封装成一个库,可库装在调用方进程里,调用方在哪,NFS 就得挂到哪,问题原样还在1。GitLab 的选择是反过来:git 不动,移动的是调用方。 在存放仓库的机器上跑一个常驻服务 Gitaly,只有它碰仓库目录;Rails、Workhorse、gitlab-shell 都不再打开仓库,改成通过 gRPC 请求 Gitaly 替它们执行 git2。

GitLab 拆分前后的 Git 访问路径 左边是早期部署:多台跑 Unicorn 和 Rugged 的应用服务器挂载同一个 NFS 目录,在本机直接读仓库。右边是今天:HTTP 由 Workhorse、SSH 由 gitlab-sshd 接住,先问 Rails 能不能、仓库在哪,再用 gRPC 直接找 Gitaly;只有 Gitaly 在本地磁盘上执行 git。 过去 · 应用服务器挂 NFS 现在 · git 进程搬到存储机器上 Unicorn + Rugged app-01 Rugged::Repository.new Unicorn + Rugged app-02 Rugged::Repository.new …还有 Sidekiq、gitlab-shell NFS 服务器 nfs-01 /var/opt/gitlab/git-data 所有机器挂同一个目录 P99 超过 30 秒,CPU 只用 15 毫秒: 时间花在等文件系统上。 Workhorse HTTP git-upload-pack gitlab-sshd SSH git-upload-pack 预授权 /allowed Rails · Puma 能不能读?仓库在哪个 storage? 网页和 API 的读请求 Gitaly gitaly-1 git upload-pack --stateless-rpc /var/opt/gitlab/git-data/repositories 本地磁盘,只有 Gitaly 碰它 带颜色的线是仓库数据走的路:右边它绕开了 Rails。
图 3 · 拆分之后,Rails 只回答“能不能、在哪里”,读写仓库的 git 进程只在 Gitaly 所在的机器上运行

图 3 右边就是今天的样子。从这里开始,Gitaly 的一个 RPC 基本就是一条 git 命令:clone 是 upload-pack,网页上看文件是 cat-file,列分支是 for-each-ref。GitLab 的应用层不再“读仓库”,只“请求别人在仓库上跑 git”。 这也是“分散地跑 git 命令”的字面意思:仓库按 storage 分散在很多台 Gitaly 上,Rails 记着每个仓库在哪一台,请求谁就由它决定。

可是请求 Gitaly 的不只有 Rails。clone 的流量从 Workhorse 和 gitlab-sshd 进来,它们自己不认识用户,怎么知道这次请求能不能放行、该去哪台 Gitaly?


三、认证与执行的分离:入口先问 Rails

在最简服务器上,认证和跑 git 是同一个 sshd 进程的前后两步:公钥对上了,就直接 exec git-upload-pack。到了 GitLab,知道用户和权限的是 Rails,跑 git 的是 Gitaly,接住客户端的又是第三个组件。图 2 的第 ② 处接缝就在这里。

GitLab 的做法是入口先问 Rails,再拿着答案去找 Gitaly。HTTPS 由 Workhorse 接住,它把同一个 URL 去掉请求体先发给 Rails 预授权3;SSH 由 gitlab-sshd(或 OpenSSH 加 authorized_keys 里的强制命令 gitlab-shell key-45)接住,用公钥换一个 key ID,再调 Rails 的 /internal/allowed4。图 4 把两边并排放在一起。

两个入口的鉴权请求和 Rails 的回答 HTTPS 入口:客户端 GET /acme/app.git/info/refs,Workhorse 把同一路径去掉请求体转给 Rails。SSH 入口:客户端 ssh git@gitlab.example.com git-upload-pack acme/app.git,gitlab-sshd 先用公钥换到 key 45,再 POST /api/v4/internal/allowed。两边从 Rails 拿到同一组字段:GL_ID、GL_REPOSITORY、storage_name、relative_path @hashed/73/47/…、Gitaly 地址和令牌。 HTTPS · Workhorse SSH · gitlab-sshd Rails 的回答(两边相同) 客户端请求 GET GET /acme/app.git/info/refs ?service=git-upload-pack Authorization: Basic ZGV2Oi… Workhorse → Rails PreAuthorize GET 同一路径,去掉请求体 Gitlab-Workhorse-Api-Request: JWT Rails 回 401 就原样转给 git,弹出密码框 客户端请求 ssh ssh git@gitlab.example.com git-upload-pack 'acme/app.git' env GIT_PROTOCOL=version=2 gitlab-sshd → Rails POST 公钥 → /internal/authorized_keys → key-id 45 /internal/allowed changes=_any Rails 返回 application/vnd.gitlab-workhorse+json · JSON GL_ID user-17 GL_REPOSITORY project-42 storage_name default relative_path @hashed/73/47/73475cb40a568e8da8a045ced110137e…8049.git GitalyServer.address tcp://gitaly-1.internal:8075 GitalyServer.token (共享令牌,省略) 项目 ID 42、用户 17、key 45 和主机名都是示例;relative_path 是 SHA-256("42") 的真实结果。
图 4 · 两个入口用不同的方式问同一个问题,Rails 给出同一组回答:用户是谁、仓库在哪台 Gitaly 的哪个路径

两边拿到的是同一组回答:用户是谁,仓库在哪台 Gitaly 的哪个路径。这个路径不是项目名,而是项目 ID 的 SHA-256 加两级目录,形如 @hashed/73/47/….git,改名、转移项目都不用移动目录5。Gitaly 不知道“项目”是什么,只认 storage 名和这条相对路径,在它下面就是一个普通的 bare 目录。

这样分工以后,Rails 只回答“能不能、在哪里”,不经手一个 pack 字节。入口拿到答案,就要把客户端的数据接到 Gitaly 上的 git 进程。原来那根本机管道,现在要跨过一跳网络。


四、管道跨过网络:sidechannel

在最简服务器上,SSH 直接把客户端接到 upload-pack 的标准输入输出上,pack 的字节一路原样流过。到了 GitLab,入口和 git 进程不在一台机器上,中间隔着 gRPC,这是图 2 的第 ③ 处接缝。

最直接的做法是把 pack 切成一块一块,装进 gRPC 的流式消息发回去,早期的 Gitaly 就是这样做的。问题在于每条 protobuf 消息都有编码、解码、分配内存的固定开销,一次 clone 要传几个 GB,这些开销会累积起来,限制一台 Gitaly 能提供的 fetch 流量6。

Gitaly 的办法是在同一条 TCP 连接上做多路复用7:gRPC 照常跑在一条流上,只传仓库、协议版本这些参数;需要传 pack 时,Gitaly 在同一条连接上反向打开一条新的流,把它直接当作 upload-pack 的标准输入和标准输出。这条流就叫 sidechannel。

sidechannel 的握手和数据流 Workhorse 在流 1 上发起 PostUploadPackWithSidechannel,metadata 里带 gitaly-sidechannel-id 7;Gitaly 在同一条 TCP 连接上反向打开一条 yamux 流,写入 sidechannel 和 7,Workhorse 回 ok;随后 HTTP 请求体经这条流进入 upload-pack 的标准输入,packfile 从同一条流回来;最后流 1 返回统计和状态。 一条 TCP 连接 · Workhorse ↔ gitaly-1 Workhorse HTTP 请求体 → 流 7 流 7 → HTTP 响应 Gitaly gitaly-1 git upload-pack stdin、stdout 都接在流 7 PostUploadPackWithSidechannel metadata gitaly-sidechannel-id: 7 流 1 打开新流:写入 "sidechannel" + int64 7 流 7 "ok" 流 7 0012command=fetch … want 612f4c1… … done CloseWrite():请求写完了 流 7 packfile 段:\x01PACK …(side-band 分帧) 流 7 Response{PackfileNegotiationStatistics} · OK 流 1 这些流共用 Workhorse 到 Gitaly 的一条 TCP 连接;流 2 是 Praefect 投票用的 backchannel,图里没画。
图 5 · gRPC 只传参数和结果,pack 的原始字节走 Gitaly 反向打开的那条 yamux 流

图 5 是一次 HTTPS clone 在这条连接上的往来。对 git 进程来说,它看到的仍然是一对普通的标准输入输出,和在最简服务器上被 SSH 接上时没有区别;sidechannel 做的事,就是把那根本机管道原样延长到另一台机器。push 方向的数据量小得多,仍然走普通的 gRPC 消息。

管道接上了,Gitaly 要真正启动 git。在最简服务器上,这台机器只属于一个团队;现在它要替所有用户、所有仓库跑 git。


五、共享的执行机器

这是图 2 的第 ④ 处接缝。一台 Gitaly 每秒要启动大量 git 进程,参数来自陌生人的请求,仓库可能是任何人上传的,同一时刻还可能有几十个请求打在同一个仓库上。最简服务器上不用操心的事,在这里都成了问题。

参数和配置由 Gitaly 决定

直接拼一条命令行去执行是危险的:一个以 - 开头的分支名可能被当成选项,仓库自己的 config 能设置钩子和别名,一次读请求可能触发自动 gc。所以 Gitaly 里启动 git 的入口只有一个,命令工厂8:子命令必须在白名单里,位置参数不许以 - 开头,配置全部用 -c 写在命令行上,仓库自己的 config 改不掉;连 git 二进制本身都是 Gitaly 自带的,与系统里装了什么无关。图 6 下方就是最终执行的那条命令,它仍然是一条普通的 git 命令,只是每个参数都由 Gitaly 而不是请求方决定。

命令工厂把一个 RPC 变成什么样的 git 进程 左上是 PostUploadPackWithSidechannel 请求里的仓库字段,右上是 ExecCommandFactory.newCommand 依次做的事,下方是最终执行的进程:bundled git 的符号链接、--git-dir、一串 -c 配置、upload-pack --stateless-rpc、环境变量,以及由仓库路径的 crc32 决定的 cgroup repos-450。 gitaly-1 收到的请求与命令工厂 最终执行的进程(示例) 请求 PostUploadPackWithSidechannel storage_name default relative_path @hashed/73/47/7347…8049.git git_protocol version=2 ExecCommandFactory.newCommand 1 白名单 commandDescriptions["upload-pack"] 2 -c:全局 → 子命令 → 调用方 → config.toml 3 gitaly-git-prev 链接成 git-exec-*.d/git 4 crc32("default/@hashed/…") % 500 = 450 git upload-pack pid 18823 argv[0] /var/opt/gitlab/gitaly/run/git-exec-0451.d/git → gitaly-git-prev --git-dir /var/opt/gitlab/git-data/repositories/@hashed/73/47/7347…8049.git -c gc.auto=0 -c core.useReplaceRefs=false -c core.fsync=objects,… -c uploadpack.allowFilter=true -c uploadpack.packObjectsHook=…/gitaly-hooks upload-pack --stateless-rpc --end-of-options …/7347….git env GIT_EXEC_PATH=…/git-exec-0451.d GIT_PROTOCOL=version=2 cgroup /sys/fs/cgroup/gitaly/gitaly-4211/repos-450
图 6 · 同一个仓库路径决定了 --git-dir,也决定了进程被放进哪个 cgroup

一个仓库的破坏关在一格里

一个超大仓库的 pack-objects 可能吃光整台机器的内存。Gitaly 用 Linux 的 cgroup 把进程分组限额:按仓库路径的哈希把每个仓库固定分到 N 个子 cgroup 中的一个(图 6 里的 repos-450),一个仓库最多用完自己那一格的内存,被杀掉的只是它自己的进程。

相同的 pack 只算一次

最典型的拥挤来自 CI:一次 push 触发一条流水线,几十个 job 几乎同时 clone 同一个提交,每个请求都会启动一次 pack-objects,把同样的对象压缩同样的一遍。排队只能保护机器,不能减少工作量。

关键的观察是:这些请求交给 pack-objects 的标准输入完全相同,同一个提交、同样的空 have。Gitaly 让 upload-pack 把启动 pack-objects 这一步交回给自己,用标准输入算一个缓存键9。第一个请求真正启动 pack-objects,输出一边写进缓存文件一边发给自己的客户端;键相同的后来者从同一个文件里跟着读。

三个 CI 任务同时 clone 同一个提交 三个 CI 任务从不同 IP 请求同一个提交,Gitaly 为每个请求启动 upload-pack,upload-pack 通过 gitaly-hooks 回调 PackObjectsHookWithSidechannel;三个请求算出相同的缓存键 9f2c41e0…,只有第一个请求在 cgroup repos-450 里启动 git pack-objects,另外两个从同一个缓存文件边写边读。下方标出两层并发限制:按 RPC 和仓库排队,按客户端 IP 限制 pack-objects。 CI 任务(IP 为示例) gitaly-1 CI #1 10.0.3.11 upload-pack → gitaly-hooks CI #2 10.0.3.12 upload-pack → gitaly-hooks CI #3 10.0.3.13 upload-pack → gitaly-hooks 每个请求各有一对进程 streamcache 条目 key 9f2c41e0… 生产者 CI #1 的请求 读者 #1、#2、#3 max_age 300s 文件 …/9f2c41e0… git pack-objects 只跑一次 --stdout --revs … cgroup repos-450 两层并发限制 RPC 级 按仓库路径排队 pack-objects 按客户端 IP 队满或等太久 → ResourceExhausted 键 = sha256(仓库、参数、协议 + 完整的标准输入); 标准输入就是协商出来的“要哪些、排除哪些”。
图 7 · 三份相同的标准输入算出同一个缓存键,pack-objects 只跑一次,其余请求从正在写的缓存文件里跟读

这三件事都发生在“跑 git”这一层的内部,没有改变 git 的行为,只是让一台机器可以安全地替很多人跑它。clone 这一侧到这里就走完了。push 的方向反过来:对象是用户送来的,进入仓库之前要检查,可检查的逻辑不在这台机器上。


六、跨机器的 push 检查

在最简服务器上,push 的检查就是仓库里的一个 pre-receive 钩子:receive-pack 先把新对象写进一个临时的隔离区,再把“旧值 新值 引用名”交给钩子,钩子拒绝,隔离区直接删掉10。钩子和仓库在同一台机器上,想读新对象,直接读就行。

到了 GitLab,保护分支、push 规则这些检查逻辑都在 Rails 里,钩子却是 git 在 Gitaly 机器上启动的子进程,这是图 2 的第 ⑤ 处接缝。更麻烦的是,Rails 检查时要看还没入库的新对象,而 Rails 自己不能读仓库,只能再请求 Gitaly。

一次 push 在 Gitaly 和 Rails 之间的往返 Gitaly 执行 git receive-pack(①),新对象先写进 objects/incoming-Xy3k;receive-pack 调 pre-receive 钩子 gitaly-hooks(②),gitaly-hooks 经内部 socket 回调 Gitaly 的 PreReceiveHook(③);Gitaly 带着变更和隔离区目录 POST /internal/allowed(④);Rails 的检查需要读新对象,于是调 Gitaly 的读 RPC 并带上隔离区目录(⑤);通过后再调 /internal/pre_receive,引用更新完成后 post-receive 调 /internal/post_receive。 gitaly-1 Rails Gitaly HookService PostReceivePack ← Workhorse PreReceiveHook ← gitaly-hooks ① exec git receive-pack 子进程 core.hooksPath gitaly-hooks 新对象写到 objects/incoming-Xy3k ② pre-receive gitaly-hooks pre-receive 385ce2c 612f4c1 refs/heads/main GIT_OBJECT_DIRECTORY …/incoming-Xy3k ③ ④ ⑤ POST /internal/allowed changes=385ce2c 612f4c1 refs/heads/main relative_path=@hashed/73/47/7347….git git_object_directory=objects/incoming-Xy3k Gitlab::Checks::ChangesAccess 保护分支、push 规则、文件大小…… 读 RPC Repository{…, incoming-Xy3k} 之后的两次回调 /pre_receive ReferenceCounter +1 /post_receive Sidekiq PostReceive 隔离区目录名 Xy3k 为示例;钩子被拒时,receive-pack 直接删掉整个 incoming 目录。
图 8 · 隔离区目录跟着 /allowed 进了 Rails,又跟着读 RPC 回到 Gitaly,Rails 才看得见还没入库的对象

于是 push 绕了一圈(图 8):Gitaly 让 receive-pack 的钩子统一指向自己的 gitaly-hooks;钩子回调 Gitaly,Gitaly 带着变更和隔离区目录调 Rails 的 /internal/allowed;Rails 检查时发出的读 RPC 都带着这个隔离区目录,Gitaly 上的 git 才看得见还没入库的对象。检查通过后,对象并进仓库、引用更新,post-receive 再通知 Rails 去触发 CI、合并请求和 webhook。

绕了一圈,本质没变:检查仍然挂在 git 自己的钩子上,只是钩子的另一头接到了另一台机器。 这一切都发生在一台 Gitaly 上。这台机器坏了怎么办?


七、单机仓库的副本:Praefect

最简服务器上,仓库只有一份,靠备份兜底。早期 GitLab 的冗余由 NFS 提供;离开 NFS 以后,每个仓库只在一台 Gitaly 的本地盘上,这台机器就是单点,这是图 2 的第 ⑥ 处接缝11。

GitLab 的答案仍然是“多跑几遍 git”。Praefect 是放在 Gitaly 前面的代理,每个仓库在几台 Gitaly 上各存一份完整的副本。读请求挑一份最新的副本去跑;写请求则原样转发给每一台,每台各自执行一遍 receive-pack。

难点在于让几份副本对“这次 push 到底算不算数”达成一致。最早的 Praefect 先写主副本、再异步复制,主副本在复制完成前坏掉,最近的 push 就丢了11。现在的做法是在引用更新的那一刻投票:git 的 reference-transaction 钩子12在引用锁好、还没写入时被调用一次,标准输入就是这次要改的全部引用。每台 Gitaly 把它的哈希当作一票投给 Praefect,票数够了才放行。

Praefect 对一次 push 的加权投票 Praefect 为一个主副本和两个从副本登记事务:gitaly-1 三票,gitaly-2 和 gitaly-3 各一票,阈值四票。gitaly-1 和 gitaly-2 在 reference-transaction 的 prepared 阶段都投出 sha1(stdin) = 12b66d2…,合计四票,提交;gitaly-3 解包失败没有投票,被记为落后。 Praefect 事务 4182(示例) gitaly-1(主) 3 票 secondaries + 1 gitaly-2 1 票 gitaly-3 1 票 阈值 4 (2+1) + ⌈2/2⌉ 12b66d2… 3 + 1 = 4 → 提交 prepared 没有票 gitaly-1 主 sha1(stdin) = 12b66d2… prepared 投票 → 等 结果 VoteCommitted gitaly-2 从 sha1(stdin) = 12b66d2… prepared 投票 → 等 结果 VoteCommitted gitaly-3 从 unpack 失败(示例) prepared 没到这一步 结果 记为落后 每台 Gitaly 各自执行 receive-pack;钩子的 stdin 是 “385ce2c… 612f4c1… refs/heads/main”。
图 9 · 主副本 3 票加任意一个从副本 1 票才到阈值 4:主副本单独凑不够,从副本联合起来也凑不够

图 9 是一主两从的情况:主副本 3 票,从副本各 1 票,阈值 4,必须主副本加至少一半从副本投出同一个哈希。没投上票的副本被记为落后,Praefect 用一个递增的代数记下谁落后,落后的副本不接读请求,后台的复制任务让它从最新的副本 git fetch 一遍追平。修复副本,用的还是 git 自己的 fetch。

这套机制能保证一致,代价也摆在那里:每次 push 要在几台机器上各跑一遍,每个仓库不论冷热都占几份磁盘,副本的位置和代数还要一个数据库来照看。


八、底层的下一步

回头看,前面六处接缝都没有动最底下那一层:仓库是某台机器本地盘上的一个 bare 目录,git 命令在它上面运行。 副本之所以要投票、要修复,也是因为“真相”分散在几块本地盘上。GitLab 正在改的,恰好是这一层。

当前的 Gitaly 源码里有两套新代码。一套是每个分区一份预写日志(WAL):修改先写成日志再应用,配合事务和快照隔离,原本打算用 Raft 复制日志来取代 Praefect 的投票13;但在我们读的 19.5.0-rc3 里,它没有接入服务,Raft 配置也在 19.4.0 被删掉了14。另一套是 MVCC 后端:一个仓库的一个版本是一份 manifest,列出构成这个版本的 pack 和引用文件,git 在 manifest 描述的视图上运行,写完用比较并交换把指针改到新版本15。它已经接进服务,但指针仍然存在本地盘上;把指针放进对象存储的实现写好了,还没有调用方。

如果这条路走通,“仓库”就不再是一块本地盘上的目录,而是对象存储里的一组文件加一个版本指针,本地盘只是缓存。到那时,“在仓库旁边跑 git”这件事本身才真正被改写。


小结

从一个 bare 目录出发:

  1. 一个目录怎么就成了 Git 服务器? bare 仓库只存版本历史;服务器只做三件事:认证、接管道、在仓库上跑 upload-pack 或 receive-pack。
  2. GitLab 的本质是什么? 还是这三件事,只是把 git 命令按仓库分散到很多台机器上去跑。多出来的组件都长在拆开的接缝上。
  3. ① 应用读不到仓库了怎么办? 仓库放在 NFS 上,打开一次要 30 秒;Gitaly 让 git 待在磁盘旁边,应用改成通过 gRPC 请它跑 git。
  4. ② 入口怎么知道能不能放行? Workhorse 和 gitlab-sshd 先问 Rails,拿到用户和仓库位置,再去找 Gitaly。
  5. ③ 管道跨了网络怎么办? sidechannel 在同一条连接上反向开一条流,把 git 的标准输入输出原样延长过去。
  6. ④ 一台机器替所有人跑 git 怎么办? 命令工厂决定参数和配置,cgroup 把每个仓库关进一格,相同的 pack 只算一次。
  7. ⑤ 检查逻辑在另一台机器上怎么办? git 的钩子回调 Gitaly,Gitaly 带着隔离区去问 Rails,Rails 再通过 Gitaly 读新对象。
  8. ⑥ 仓库只有一份怎么办? Praefect 让每台副本各跑一遍 receive-pack,在引用更新时投票,落后的副本用 git fetch 追平。
  9. 接下来改什么? 前面都没动“仓库是本地 bare 目录”这一层;WAL 和 MVCC 后端想改的正是它,目前还在半路上。

参考资料

Footnotes

  1. Gitaly designGitLab收藏gitlab.comdoc/DESIGN.md:Rugged 的 P99 数据、Decisions 一节里“为什么不做成库”https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/DESIGN.md ↩ ↩2 ↩3

  2. gRPCgrpc.io收藏grpc.iohttps://grpc.io/ ↩

  3. gitlab-org/gitlabGitLab 源码收藏gitlab.comworkhorse/internal/、lib/api/internal/base.rb、lib/gitlab/git_access.rb,提交 31a01d7ahttps://gitlab.com/gitlab-org/gitlab ↩

  4. gitlab-org/gitlab-shellGitLab 源码收藏gitlab.cominternal/sshd/、internal/command/uploadpack/,提交 bbd07a4https://gitlab.com/gitlab-org/gitlab-shell ↩

  5. Repository storageGitLab 文档收藏docs.gitlab.comhttps://docs.gitlab.com/administration/repository_storage_paths/ ↩

  6. Gitaly sidechannelsGitLab收藏gitlab.comdoc/sidechannel.md;实现在 internal/grpc/sidechannel/sidechannel.gohttps://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/sidechannel.md ↩

  7. hashicorp/yamuxGitHub收藏github.comhttps://github.com/hashicorp/yamux ↩

  8. gitlab-org/gitalyGitLab 源码收藏gitlab.com本文读的是 19.5.0-rc3,提交 77bee05(2026-10-14)https://gitlab.com/gitlab-org/gitaly ↩

  9. Pack-objects cache design notesGitLab收藏gitlab.comdoc/design_pack_objects_cache.md;缓存键在 service/hook/pack_objects.gohttps://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/design_pack_objects_cache.md ↩

  10. Git object quarantine during git pushGitLab收藏gitlab.comdoc/object_quarantine.mdhttps://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/object_quarantine.md ↩

  11. Gitaly Cluster designGitLab收藏gitlab.comdoc/design_ha.md:从最终一致到强一致的设计https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/design_ha.md ↩ ↩2

  12. githooks: reference-transactionGit 文档收藏git-scm.comhttps://git-scm.com/docs/githooks#_reference_transaction ↩

  13. TransactionsGitLab收藏gitlab.comdoc/transactions.md,代码在 internal/gitaly/storage/storagemgr/https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/transactions.md ↩

  14. Gitaly CHANGELOGGitLab收藏gitlab.com19.4.0 的 Removed 一节https://gitlab.com/gitlab-org/gitaly/-/blob/master/CHANGELOG.md ↩

  15. MVCC Gitaly RPC flowGitLab收藏gitlab.comdoc/mvcc_rpc_flow.md,文中说明描述的是 POC 分支https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/mvcc_rpc_flow.md ↩