在一台 Linux 服务器上准备一个能 clone、能 push 的 Git 仓库,最少只要一条命令:
$ git init --bare /srv/git/app.git$ ls -F /srv/git/app.gitHEAD 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 HEADremote: 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.gitssh 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。
这台服务器能用,但它管得很少。权限只有 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 放在一起,编号标出了六处接缝,后面一节讲一处。
六处接缝里,最早被拆开、也最根本的是第 ① 处:仓库和要用它的应用不在同一台机器上了。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。
图 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 把两边并排放在一起。
两边拿到的是同一组回答:用户是谁,仓库在哪台 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。
图 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 而不是请求方决定。
一个仓库的破坏关在一格里
一个超大仓库的 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,输出一边写进缓存文件一边发给自己的客户端;键相同的后来者从同一个文件里跟着读。
这三件事都发生在“跑 git”这一层的内部,没有改变 git 的行为,只是让一台机器可以安全地替很多人跑它。clone 这一侧到这里就走完了。push 的方向反过来:对象是用户送来的,进入仓库之前要检查,可检查的逻辑不在这台机器上。
六、跨机器的 push 检查
在最简服务器上,push 的检查就是仓库里的一个 pre-receive 钩子:receive-pack 先把新对象写进一个临时的隔离区,再把“旧值 新值 引用名”交给钩子,钩子拒绝,隔离区直接删掉10。钩子和仓库在同一台机器上,想读新对象,直接读就行。
到了 GitLab,保护分支、push 规则这些检查逻辑都在 Rails 里,钩子却是 git 在 Gitaly 机器上启动的子进程,这是图 2 的第 ⑤ 处接缝。更麻烦的是,Rails 检查时要看还没入库的新对象,而 Rails 自己不能读仓库,只能再请求 Gitaly。
于是 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,票数够了才放行。
图 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 目录出发:
- 一个目录怎么就成了 Git 服务器? bare 仓库只存版本历史;服务器只做三件事:认证、接管道、在仓库上跑
upload-pack或receive-pack。 - GitLab 的本质是什么? 还是这三件事,只是把 git 命令按仓库分散到很多台机器上去跑。多出来的组件都长在拆开的接缝上。
- ① 应用读不到仓库了怎么办? 仓库放在 NFS 上,打开一次要 30 秒;Gitaly 让 git 待在磁盘旁边,应用改成通过 gRPC 请它跑 git。
- ② 入口怎么知道能不能放行? Workhorse 和 gitlab-sshd 先问 Rails,拿到用户和仓库位置,再去找 Gitaly。
- ③ 管道跨了网络怎么办? sidechannel 在同一条连接上反向开一条流,把 git 的标准输入输出原样延长过去。
- ④ 一台机器替所有人跑 git 怎么办? 命令工厂决定参数和配置,cgroup 把每个仓库关进一格,相同的 pack 只算一次。
- ⑤ 检查逻辑在另一台机器上怎么办? git 的钩子回调 Gitaly,Gitaly 带着隔离区去问 Rails,Rails 再通过 Gitaly 读新对象。
- ⑥ 仓库只有一份怎么办? Praefect 让每台副本各跑一遍
receive-pack,在引用更新时投票,落后的副本用git fetch追平。 - 接下来改什么? 前面都没动“仓库是本地 bare 目录”这一层;WAL 和 MVCC 后端想改的正是它,目前还在半路上。
参考资料
Footnotes
-
Gitaly design收藏gitlab.com
doc/DESIGN.md:Rugged 的 P99 数据、Decisions 一节里“为什么不做成库”https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/DESIGN.md ↩ ↩2 ↩3 -
gitlab-org/gitlab收藏gitlab.com
workhorse/internal/、lib/api/internal/base.rb、lib/gitlab/git_access.rb,提交31a01d7ahttps://gitlab.com/gitlab-org/gitlab ↩ -
gitlab-org/gitlab-shell收藏gitlab.com
internal/sshd/、internal/command/uploadpack/,提交bbd07a4https://gitlab.com/gitlab-org/gitlab-shell ↩ -
Repository storage收藏docs.gitlab.comhttps://docs.gitlab.com/administration/repository_storage_paths/ ↩
-
Gitaly sidechannels收藏gitlab.com
doc/sidechannel.md;实现在internal/grpc/sidechannel/sidechannel.gohttps://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/sidechannel.md ↩ -
hashicorp/yamux收藏github.comhttps://github.com/hashicorp/yamux ↩
-
gitlab-org/gitaly收藏gitlab.com本文读的是 19.5.0-rc3,提交
77bee05(2026-10-14)https://gitlab.com/gitlab-org/gitaly ↩ -
Pack-objects cache design notes收藏gitlab.com
doc/design_pack_objects_cache.md;缓存键在service/hook/pack_objects.gohttps://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/design_pack_objects_cache.md ↩ -
Git object quarantine during git push收藏gitlab.com
doc/object_quarantine.mdhttps://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/object_quarantine.md ↩ -
Gitaly Cluster design收藏gitlab.com
doc/design_ha.md:从最终一致到强一致的设计https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/design_ha.md ↩ ↩2 -
githooks: reference-transaction收藏git-scm.comhttps://git-scm.com/docs/githooks#_reference_transaction ↩
-
Transactions收藏gitlab.com
doc/transactions.md,代码在internal/gitaly/storage/storagemgr/https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/transactions.md ↩ -
Gitaly CHANGELOG收藏gitlab.com19.4.0 的 Removed 一节https://gitlab.com/gitlab-org/gitaly/-/blob/master/CHANGELOG.md ↩
-
MVCC Gitaly RPC flow收藏gitlab.com
doc/mvcc_rpc_flow.md,文中说明描述的是 POC 分支https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/mvcc_rpc_flow.md ↩