勇士,你可曾好奇过 Git 和极狐 GitLab 是如何工作的?现在,拿起你心爱的 IDE,和我们一起踏上探索之旅吧!
在开始旅程之前,我们需要做三分钟的知识储备,计时开始!
使用了 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
图:Pro Git on git-scm.com
图注:红色部分由 refs 提供,其余部分全部由 objects 提供,commit 对象(黄色)指向保存文件结构的 tree 对象(蓝色),后者再指向各个文件对象(灰色)
Git 服务端只会存储 .git
文件夹内的信息(称为 bare repository
,裸仓库),git clone
是从远端拉取这些信息到本地再重建仓库位于 HEAD
的状态的操作,而 git push
是把本地的 ref 及其相关 commit 对象、tree 对象和文件对象发送到远端的操作。
Git 在通过网络传输对象时会将其压缩,压缩后的对象称为 packfile
。
让我们按时间先后顺序理理 git push
时发生了什么:
1. 用户在客户端上运行 git push;
2. 客户端的 Git 的 git-send-pack
服务带上仓库标识符,调用服务端的 git-receive-pack
服务;
3. 服务端返回目前服务端仓库各个 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
(忽略前面的协议内容)。
4. 客户端根据返回的 ref 情况,找出那些自己有但是服务端没有的 commit,把即将变更的 ref 告知服务端:
009f0000000000000000000000000000000000000000 8fa91ae7af0341e6524d1bc2ea067c99dff65f1c refs/heads/feat/hello-world
上面这个例子中,我们正在推送一个新分支 feat/hello-world
,它现在指向 8fa91ae7af0341e6524d1bc2ea067c99dff65f1c
,由于它是个新分支,以前的指向记为 0000000000000000000000000000000000000000
。
5. 客户端将相关 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
6. 服务端解包 packfile,更新 ref,返回处理结果:
003a\x01000eunpack ok
0023ok refs/heads/feat/hello-world
Git 传输协议可以由 SSH 或者 HTTP(S) 承载。
还是挺直接的,对吧?
极狐 GitLab 是一个常用的 Git 代码托管服务,同时支持协作开发、任务跟踪、CI/CD 等功能。
极狐 GitLab 的服务并不是一个单体,我们以大版本 15 为例,和 git push 有关的组件有下面这些:
简明的极狐 GitLab 组件关系
图/极狐 GitLab architecture overview on docs.gitlab.cn
三分钟过得真快!现在你已经掌握了基础,让我们开始征途吧!
如果你的远端地址是 git@jihulab.example.com:user/repo.git
这样的,那么你在用 SSH 与 极狐 GitLab 进行通讯。在你执行 git push 时,本质上,你的 Git 客户端的 upload-pack 服务在执行下列命令:
ssh -x git@jihulab.example.com "git-receive-pack 'user/repo.git'"
这里面有挺多问题值得说道的:
大家的用户名都叫 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 鉴权要比新用户的快,而且是可察觉的快。(真·老用户福利)
黄金老用户的特别福利——超长 git push 时间
图/xkcd-excuse.com
如今 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
;GET /api/v4/internal/authorized_keys
获得公钥 ID,进行 SSH 握手;POST /api/v4/internal/allowed
,确认用户有到仓库的写权限;GL_REPOSITORY
);SSHReceivePack
方法,成为客户端和 Gitaly 的中继;git-receive-pack
,并且预先设定好环境变量 GITALY_HOOKS_PAYLOAD
,其中包含 GL_ID
, GL_REPOSITORY
等;
Gitaly 和 refs 更新我们稍后会聊到。
HTTP(S)的远端地址形如 https://jihulab.example.com/user/repo.git.
和 SSH 不一样,HTTP 请求是无状态的,而且总是一问一答。在你执行 git push 时,Git 客户端会按顺序和两个接口打交道:
GET https://jihulab.example.com/user/repo.git/info/refs?service=git-receive-pack
:服务端会在 body 中返回目前服务端仓库各个分支所处的 commit 的 hash。POST https://jihulab.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://jihulab.example.com/user/repo/-/merge_requests/new?merge_request%5Bs0029\x02ource_branch%5D=feat%2Fhello-world
0000
上述两个请求会被 Workhorse 截获,每次它都做这两件事:
info/refs
和 git-receive-pack
接口居然是用来鉴权的,我猜这后面多少有些历史原因);
总结一下,经由 HTTP(S) 的 git push 是这样的:
GET https://jihulab.example.com/user/repo.git/info/refs?service=git-receive-pack
,带上对应的 authorization header;InfoRefsReceivePack
,在客户端和 Gitaly 之间充当中继;\POST https://jihulab.example.com/user/repo.git/git-receive-pack
;PostReceivePack
,在客户端和 Gitaly 之间充当中继;git-receive-pack
,并且预先设定好环境变量 GITALY_HOOKS_PAYLOAD
,其中包含 GL_ID
, GL_REPOSITORY
等;
呼…说完了前面的连接层和权限控制,我们终于得以接近极狐 GitLab 的 Git 核心,Gitaly。
Gitaly Logo 图/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,一个单边的请求(不含返回)流程大概像下面这样:
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 的参数。
这个钩子会在 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 有问题,相关的资源就能容易地得到清理了。
这个钩子会在 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 的时候。
图/Vishal Jadhav on Unsplash
图中的这个钩子和计算机科学有着历史悠久的联系……咳咳,好吧我编不下去了,我只是担心你看到这里已经要睡着了,找张图片让你放松一下~
在所有 refs 都得到更新后,Git 会执行一次 post-receive 钩子,它获得的参数与 pre-receive 钩子相同。
Gitaly 收到 gitaly-hooks 的提醒后,会调用 Rails 的 POST /api/v4/internal/post_receive
,Rails 会在这时干很多事:
其中有的操作是异步的,被交给 SideKiq 调度。
现在,你已经从客户端到服务端走完了 git push 全程,真是一次伟大的旅程!
勇士,下图就是你的通关宝藏!
如果你想继续深入了解相关内容,下面的资料会是不错的起点: