本文来自:
李振楠 极狐(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
图: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 传输协议
让我们按时间先后顺序理理 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 进行数据读写。
简明的极狐GitLab 组件关系
图/极狐GitLab architecture overview on docs.gitlab.cn
开始 git push!
三分钟过得真快!现在你已经掌握了基础,让我们开始征途吧!
你喜欢SSH?
如果你的远端地址是 [email protected]:user/repo.git 这样的,那么你在用 SSH 与 极狐GitLab 进行通讯。在你执行 git push 时,本质上,你的 Git 客户端的 upload-pack 服务在执行下列命令:
ssh -x [email protected] "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;
- 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://jihulab.example.com/u... 和 SSH 不一样,HTTP 请求是无状态的,而且总是一问一答。在你执行 git push 时,Git 客户端会按顺序和两个接口打交道:
- GET https://jihulab.example.com/u...:服务端会在body中返回目前服务端仓库各个分支所处的commit的hash。
- POST https://jihulab.example.com/u...:客户端会在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 截获,每次它都做这两件事:
- 把请求原样发到 Rails,后者会返回鉴权结果、用户 ID、 仓库对应 Gitaly 实例信息(有点怪,对吧?Rails 的 info/refs 和 git-receive-pack 接口居然是用来鉴权的,我猜这后面多少有些历史原因);
- Workhorse 根据上一步 Rails 返回的信息,建立与 Gitaly 的连接,在客户端和 Gitaly 之间充当中继。
总结一下,经由 HTTP(S) 的 git push 是这样的:
- 用户执行 git push;
- Git客户端调用GET https://jihulab.example.com/u...,带上对应的authorization header;
- Workhorse 截获请求,原样发送请求到 Rails,获得鉴权结果、用户 ID、仓库对应 Gitaly 实例信息;
- Workhorse 根据上一步 Rails 的返回信息,调用 Gitaly 的 GRPC 服务 InfoRefsReceivePack,在客户端和 Gitaly 之间充当中继;
- Gitaly 在适当的工作目录运行 git-receive-pack,返回 refs 信息;
- Git客户端调用POST https://jihulab.example.com/u...;
- 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,一个单边的请求(不含返回)流程大概像下面这样:
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 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 的时候。
图/Vishal Jadhav on Unsplash
图中的这个钩子和计算机科学有着历史悠久的联系……咳咳,好吧我编不下去了,我只是担心你看到这里已经要睡着了,找张图片让你放松一下~
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 全程,真是一次伟大的旅程!
勇士,下图就是你的通关宝藏!
参考资料
如果你想继续深入了解相关内容,下面的资料会是不错的起点: