当你git push时,Gitlab上发生了什么?
勇士,你可曾好奇过Git和Gitlab是如何工作的?现在,拿起你心爱的IDE,和我们一起踏上探索之旅吧!
基础知识
在开始旅程之前,我们需要做三分钟的知识储备,计时开始!
Git仓库内幕
使用了Git的项目都会在其根目录有个.git
文件夹(隐藏),它承载了Git保存的所有信息,下面是我们这次关注的部分:
.git
├── HEAD # 当前工作空间处于的分支(ref)
├── objects # git对象,git根据这些对象可以重建出仓库的全部commit及当时的全部文件
│ ├── 20 # 稀疏对象,基于对象hash的第一个字节按文件夹分片,避免某个目录有太多的文件
│ │ └── 7151a78fb5e2d99f1185db7ebbd7d883ebde6c
│ ├── 43 # 另一组稀疏对象
│ │ └── 49b682aeaf8dc281c7a7c8d8460f443835c0c2
│ └── pack # 压缩过的对象
└── refs # 分支,文件内容是commit的hash
├── heads
│ ├── feat
│ │ └── hello-world # 某个feature分支
│ └── main # 主分支
├── remotes
│ └── origin
│ └── HEAD # 本地记录的远端分支
└── tags # 标签,文件内容是commit的hash
Git服务端只会存储.git
文件夹内的信息(称为bare repository
,裸仓库),git clone
是从远端拉取这些信息到本地再重建仓库位于HEAD
的状态的操作,而git push
是把本地的ref及其相关commit对象、tree对象和文件对象发送到远端的操作。
Git在通过网络传输对象时会将其压缩,压缩后的对象称为packfile
.
Git传输协议
让我们按时间先后顺序理理git push
时发生了什么:
- 用户在客户端上运行
git push
- 客户端的Git的
git-send-pack
服务带上仓库标识符,调用服务端的git-receive-pack
服务 - 服务端返回目前服务端仓库各个ref所处的commit hash,每个hash记为40位hex编码的文本,它们长这样:
001f# service=git-receive-pack
000000c229859bcc73cdab4db2b70ed681077a5885f80134 refs/heads/main\x00report-status report-status-v2 delete-refs side-band-64k quiet atomic ofs-delta push-options object-format=sha1 agent=git/2.37.1.gl1
0000
我们可以看到,服务端的main
分支位于229859bcc73cdab4db2b70ed681077a5885f80134
(忽略前面的协议内容)。
- 客户端根据返回的ref情况,找出那些自己有但是服务端没有的commit,把即将变更的ref告知服务端:
009f0000000000000000000000000000000000000000 8fa91ae7af0341e6524d1bc2ea067c99dff65f1c refs/heads/feat/hello-world
上面这个例子中,我们正在推送一个新分支feat/hello-world
,它现在指向8fa91ae7af0341e6524d1bc2ea067c99dff65f1c
,由于它是个新分支,以前的指向记为0000000000000000000000000000000000000000
.
- 客户端将相关commit及其tree对象、文件对象打包压缩为packfile,发送到服务端,packfile是二进制:
report-status side-band-64k agent=git/2.20.10000PACK\x00\x00\x00\x02\x00\x00\x00\x03\x98\x0cx\x9c\x8d\x8bI
\xc30\x0c\x00\xef~\x85\xee\x85"[^$(\xa5_\x91m\x85\xe6\xe0\xa4\x04\xe7\xff]^\xd0\xcb0\x87\x99y\x98A\x11\xa5\xd8\xab,\xbdSA]Z\x15\xcb(\x94|4\xdf\x88\x02&\x94\xa0\xec^z\xd86!\x08'\xa9\xad\x15j]\xeb\xe7\x0c\xb5\xa0\xf5\xcc\x1eK\xd1\xc4\x9c\x16FO\xd1\xe99\x9f\xfb\x01\x9bn\xe3\x8c\x01n\xeb\xe3\xa7\xd7aw\xf09\x07\xf4\\\x88\xe1\x82\x8c\xe8\xda>\xc6:\xa7\xfd\xdb\xbb\xf3\xd5u\x1a|\xe1\xde\xac\xe29o\xa9\x04x\x9c340031Q\x08rut\xf1u\xd5\xcbMap\xf6\xdc\xd6\xb4n}\xef\xa1\xc6\xe3\xcbO\xdcp\xe3w\xb10=p\xc8\x10\xa2(%\xb1$U\xaf\xa4\xa2\x84\xa1T\xe5\x8eO\xe9\xcf\xd3\x0c\\R\x7f\xcf\xed\xdb\xb9]n\xd1\xea3\xa2\x00\xd3\x86\x1db\xbb\x02x\x9c\x01+\x00\xd4\xff2022\xe5\xb9\xb4 09\xe6\x9c\x88 01\xe6\x97\xa5 \xe6\x98\x9f\xe6\x9c\x9f\xe5\x9b\x9b 15:52:13 CST
\xa4d\x11\xa1\xe8\x86\xdeQ\x90\xb1\xe0Z\xfd\x7f\x91\x90\xc3\xd6\x17\xe8\x02&K\xd0
- 服务端解包packfile,更新ref,返回处理结果:
003a\x01000eunpack ok
0023ok refs/heads/feat/hello-world
Git传输协议可以由SSH或者HTTP(S)承载。
还是挺直接的,对吧?
Gitlab的组成部分
Gitlab是一个常用的Git代码托管服务,同时支持协作开发、任务跟踪、CI/CD等功能。
Gitlab的服务并不是一个单体,我们以大版本15为例,和git push有关的组件有下面这些:
- Gitlab:使用Ruby开发,分为两个部分, Gitlab的Web服务/API服务(下文记为Rails)以及任务队列/背景任务(下文记为Sidekiq)。
- Gitaly:使用Go开发,Gitlab的Git服务后端,负责Git仓库的存储和读写,将各种Git操作暴露为GRPC调用。早期Rails直接通过Git命令行操作NFS上的Git仓库,规模大了之后网络IO延迟感人,遂分解出了Gitaly.
- Workhorse:使用Go开发,作为Rails的前置代理,处理Git push/pull、文件下载/上传这类“缓慢”的HTTP请求。早期这些请求由Rails处理,它们会长时间占用可观的CPU和内存,为了服务稳定,Gitlab不得不将git clone的超时时间设为1分钟,但是这又带来了大仓库无法完整克隆的可用性问题。而goroutine的成本低很多,就被用来专门处理这类请求。
- Gitlab Shell:使用Go开发,用来响应和鉴权Git SSH连接,在用户Git客户端和Gitaly之间传递数据。
- Gitlab Runner:使用Go开发,负责CI/CD工作的执行。
- Gitlab的数据存储在Postgres中,使用Redis做缓存。Rails和Sidekiq直接连接数据库和缓存,其他组件经由Rails暴露的API进行数据读写。
开始git push!
三分钟过得真快!现在你已经掌握了基础,让我们踏上征途吧!
你喜欢SSH?
如果你的远端地址是[email protected]:user/repo.git
这样的,那么你在用SSH与Gitlab进行通讯。在你执行git push时,本质上,你的Git客户端的upload-pack服务在执行下列命令:
|
|
这里面有挺多问题值得说道的。
- 大家的用户名都叫
git
,服务端怎么分清谁是谁?(安能辨我是雄雌?) - ssh? 我可以在服务端上运行任意命令吗?
这两个问题由Gitlab Shell的gitlab-sshd来解决。它是个定制化的SSH Daemon,和一般的sshd讲同样的SSH协议,客户端没法分清它们。客户端在做SSH握手时会提供自己的公钥,gitlab-sshd会调用Rails的内部API GET /api/v4/internal/authorized_keys
查询公钥是否在Gitlab注册过并返回对应公钥ID(可定位到用户),同时校验SSH握手的签名是否由同一份公钥对应的私钥生成。
另外,gitlab-sshd限制了客户端可以运行的命令,其实,它在使用用户运行的命令来匹配自己应该运行哪个方法,没有对应方法的命令都会被拒绝。
可惜,看来我们是没法通过SSH在Gitlab的服务器上运行bash
或者rm -rf /
了。 ┑( ̄Д  ̄)┍
说点有趣的,早期Gitlab当真使用sshd来响应Git请求。为了解决上面这两个问题,他们这么写authorized_keys
:
# Managed by gitlab-rails
command="/bin/gitlab-shell key-1",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-
rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt1016k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7
Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=
command="/bin/gitlab-shell key-2",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-
rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt1026k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7
Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=
对,你没猜错,整个Gitlab的用户公钥都会被放到这个文件里,它可能会上百MB的大小!朴实无华!
command
参数覆盖了每次SSH客户端想运行的命令,让sshd启动gitlab-shell,启动参数是公钥ID. gitlab-shell可以在由sshd设定的环境变量SSH_ORIGINAL_COMMAND
获取到客户端原本想执行的命令,进而运行相关方法。
由于sshd在匹配authorized_keys
时用的是线性检索,在authorized_keys
很大时,先注册的用户(公钥在文件的前面)的匹配优先级会被后注册的用户高很多,换句话说,老用户的SSH鉴权要比新用户的快,而且是可察觉的快。(真·老用户福利)
如今gitlab-sshd依赖的Rails API背后是Postgres索引,这个bug(feature?)不复存在。
通过用户身份验证后,gitlab-sshd会检查用户对目标仓库是否有写权限(POST /api/v4/internal/allowed
),同时获知这个仓库在哪一个Gitaly实例,以及用户ID和仓库信息。
最后,gitlab-sshd会调用对应的Gitaly实例的SSHReceivePack
方法,在Git客户端(SSH)与Gitaly(GRPC)之间作为中继和翻译。
最后两步gitlab-shell的行为和gitlab-sshd是一样的。
从宏观视角看,经由SSH的git push是这样的:
- 用户执行
git push
- Git客户端通过SSH链接到gitlab-shell
- gitlab-shell使用客户端公钥调用
GET /api/v4/internal/authorized_keys
获得公钥ID,进行SSH握手 - gitlab-shell使用公钥ID和仓库地址调用
POST /api/v4/internal/allowed
,确认用户有到仓库的写权限 - API返回:Gitaly地址和鉴权token、repo对象、钩子回调信息(逻辑用户名
GL_ID
、逻辑项目名GL_REPOSITORY
) - gitlab-shell用上列信息调用Gitaly的
SSHReceivePack
方法,成为客户端和Gitaly的中继; - Gitaly在适当的工作目录运行
git-receive-pack
,并且预先设定好环境变量GITALY_HOOKS_PAYLOAD
,其中包含GL_ID
,GL_REPOSITORY
等 - 服务端Git尝试更新refs,运行Git hooks
- 完成
Gitaly和refs更新我们稍后会聊到。
你更喜欢HTTP(S)?
HTTP(S)的远端地址形如 https://gitlab.example.com/user/repo.git
. 和SSH不一样,HTTP请求是无状态的,而且总是一问一答。在你执行git push时,Git客户端会按顺序和两个接口打交道:
GET https://gitlab.example.com/user/repo.git/info/refs?service=git-receive-pack
:服务端会在body中返回目前服务端仓库各个分支所处的commit的hash.POST https://gitlab.example.com/user/repo.git/git-receive-pack
:客户端会在body中提交要更新的分支及其旧commit hash和新commit hash,同时附上所需的packfile. 服务端会在body中返回处理结果,以及我们老熟人"to create a merge request"提示:
003a\x01000eunpack ok
0023ok refs/heads/feat/hello-world
00000085\x02
To create a merge request for feat/hello-world, visit:
https://gitlab.example.com/user/repo/-/merge_requests/new?merge_request%5Bs0029\x02ource_branch%5D=feat%2Fhello-world
0000
上述两个请求会被Workhorse截获,每次它都做这两件事:
- 把请求原样发到Rails,后者会返回鉴权结果、用户ID、仓库对应Gitaly实例信息(有点怪,对吧?Rails的
info/refs
和git-receive-pack
接口居然是用来鉴权的,我猜这后面多少有些历史原因) - Workhorse根据上一步Rails返回的信息,建立与Gitaly的连接,在客户端和Gitaly之间充当中继。
总结一下,经由HTTP(S)的git push是这样的:
- 用户执行 git push
- Git客户端调用
GET https://gitlab.example.com/user/repo.git/info/refs?service=git-receive-pack
,带上对应的authorization header - Workhorse截获请求,原样发送请求到Rails,获得鉴权结果、用户ID、仓库对应Gitaly实例信息
- Workhorse根据上一步Rails的返回信息,调用Gitaly的GRPC服务
InfoRefsReceivePack
,在客户端和Gitaly之间充当中继 - Gitaly在适当的工作目录运行
git-receive-pack
,返回refs信息 - Git客户端调用
POST https://gitlab.example.com/user/repo.git/git-receive-pack
- Workhorse截获请求,原样发送请求到Rails,获得鉴权结果、用户ID、仓库对应Gitaly实例信息
- Workhorse根据上一步Rails的返回信息,调用Gitaly的GRPC服务
PostReceivePack
,在客户端和Gitaly之间充当中继 - Gitaly在适当的工作目录运行
git-receive-pack
,并且预先设定好环境变量GITALY_HOOKS_PAYLOAD
,其中包含GL_ID
,GL_REPOSITORY
等 - 服务端Git尝试更新refs,运行Git hooks
- 完成
Gitaly和Git Hooks
呼…说完了前面的连接层和权限控制,我们终于得以接近Gitlab的Git核心,Gitaly.
Gitaly这个名字其实是在玩梗,致敬了Git和俄罗斯小镇Aly,后者在2010年俄罗斯人口普查中得出的常住人口是0,Gitaly的工程师希望Gitaly的大部分操作的磁盘IO也是0.
软件工程师的梗实在是太生硬了,一般人恐怕吃不下……
Gitaly负责Gitlab仓库的存储和操作,它通过fork/exec运行本地的Git二进制程序,采用cgroups防止单个Git吃掉太多CPU和内存。仓库存储在本地,路径形如/var/opt/gitlab/git-data/repositories/@hashed/b1/7e/b17ef6d19c7a5b1ee83b907c595526dcb1eb06db8227d650d5dda0a9f4ce8cd9.git
,早期Gitlab/Gitaly也使用#{namespace}/#{project_name}.git
的形式,但是namespace
和project_name
都可以被用户修改,这带来了额外的运行开销。
git push对应Gitaly的SSHReceivePack
(SSH)和PostReceivePack
(HTTPS)方法,它们的底部都是Git的git-receive-pack,也就是说,最核心的refs和object更新由Git二进制来完成。git-receive-pack提供了钩子使得这个过程能够被Gitaly介入,这里面还牵扯Rails,一个单边的请求(不含返回)流程大概像下面这样:
graph LR
Gitaly[1,4. Gitaly<br>] --exec/fork--> git[2. Git<br>git-receive-pack]
git --运行hooks--> hooks[3. gitaly-hooks<br>二进制充当hook执行目标]
hooks --unix socket + GRPC + git和Gitly提供的参数--> Gitaly
Gitaly --API查询权限和簿记更新--> Workhorse[5. Workhorse]
Workhorse --请求原样代理到--> Rails[6. Rails]
Gitaly在启动git-receive-pack时会通过环境变量GITALY_HOOKS_PAYLOAD
传入一个Base64编码的JSON,其中有仓库信息、Gitaly Unix Socket地址和链接token、用户信息、要执行的哪些Hook(对于git push,总是下面这几个),并且设定Git的core.hooksPath
参数到Gitaly自己在程序启动时准备好的一个临时文件夹,那里的所有Hook文件都符号链接到了gitaly-hooks上。
gitaly-hooks在被git-receive-pack启动后从环境变量读取GITALY_HOOKS_PAYLOAD
,通过Unix Socket和GRPC连接回Gitaly,告知Gitaly目前执行的Hook,以及Git提供给Hook的参数。
pre-receive hook
这个钩子会在Git收到git push时触发一次,在调用gitlab-hooks时,Git会向其标准输入中写入变更信息,即“某个ref想从commit hash A更新到commit hash B”,一行一个:
<旧commit ref hash> SP <新commit ref hash> SP <ref名字> LF
其中SP
是空格,LF
是换行符。
上述信息回到Gitaly之后,Gitaly会依次调用Rails的两个接口:
POST /api/v4/internal/allowed
:这个接口之前在连接层鉴权时就调过,这次额外附上变更信息,Rails可以依据其进行更细粒度的判断,例如禁用force push,以及判断分支是否受保护等。POST /api/v4/internal/pre_receive
:通知Rails当前仓库即将有写更新,Rails对这个仓库的引用计数+1,这可以避免仓库的Git写操作被其他地方的重大变更打断。
如果POST /api/v4/internal/allowed
返回错误,Gitaly会将错误返回给gitaly-hooks,gitaly-hooks会在标准错误中写入错误信息并且退出,退出码非0. 错误信息会被git-receive-pack收集后再写入到标准错误,gitaly-hooks非0的退出码会使得git-receive-pack停止处理当前的git push而退出,退出码同样非0,控制权回到Gitaly,后者收集git-receive-pack的标准错误输出,回复GRPC响应到Workhorse/Gitlab-Shell.
细心的同学可能会问,Hooks在运行的时候,相关的object肯定已经上传到服务端了,这时停下来这部分悬空的object如何处理呢?
其实没有处理完的git push对应的object会被先写入到隔离环境中,它们独立存储在objects
下的一个子文件夹,形如incoming-8G4u9v
,这样如果Hooks认为这个push有问题,相关的资源就能容易地得到清理了。
update hook
这个钩子会在Git实际更新ref的前一刻触发,每个ref触发一次,入参从命令行参数传入:要更新的ref、旧commit hash、新commit hash。目前这个钩子不会与Rails互动。
Gitlab同时支持自定义Git Hooks,pre-receive hook, update hook和post-receive hook都支持,这个操作在gitlab-hooks通知Gitaly钩子运行时在Gitaly中完成。此刻就是触发自定义update hook的时候。
post-receive hook
在所有refs都得到更新后,Git会执行一次post-receive钩子,它获得的参数与pre-receive钩子相同。
Gitaly收到gitaly-hooks的提醒后,会调用Rails的POST /api/v4/internal/post_receive
,Rails会在这时干很多事:
- 返回提醒用户创建Merge Request的信息
- 将pre-receive中提到的仓库引用计数-1
- 刷新仓库缓存
- 触发CI
- 如果适用,发Email
其中有的操作是异步的,被交给SideKiq调度。
结语
现在,你已经从客户端到服务端走完了git push全程,真是一次伟大的旅程!
勇士,下图就是你的通关宝藏!
graph LR
push-ssh(git push via SSH)
push-http(git push via HTTP)
push-ssh --SSH--> gitlab-sshd
push-http --HTTP--> workhorse
gitlab-sshd --经由Workhorse代理:<br>查询公钥/验证写权限--> rails[Rails]
workhorse --GRPC--> gitaly[Gitaly]
workhorse --验证写权限--> rails
gitlab-sshd --GRPC--> gitaly
gitaly --fork/exec--> git
git --读取和写入--> disk[物理磁盘]
git --Hooks--> gitaly-hooks
gitaly-hooks --Unix socket + GRPC--> gitaly
gitaly --验证写权限<br>prerecive钩子<br>postreceive钩子--> rails
rails --传递工作--> sidekiq[SideKiq]
rails --更新缓存--> redis[Redis]
sidekiq --触发CI--> runner[Gitlab Runner]
发表说明
本文于2022年09月22日发表于极狐Gitlab公众号。
勘误说明
- 2022年12月4日,修正了图注:“黄金老用户的特别福利——超长git push时间”应为“新用户的特别福利——超长git push时间”。
参考资料
如果你想继续深入了解相关内容,下面的资料会是不错的起点: