关于git:当你-git-push-时极狐GitLab上发生了什么

3次阅读

共计 9820 个字符,预计需要花费 25 分钟才能阅读完成。

本文来自:
李振楠 极狐(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 时产生了什么:

  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(疏忽后面的协定内容)。

  1. 客户端依据返回的 ref 状况,找出那些本人有然而服务端没有的 commit,把行将变更的 ref 告知服务端:
009f0000000000000000000000000000000000000000 8fa91ae7af0341e6524d1bc2ea067c99dff65f1c refs/heads/feat/hello-world

下面这个例子中,咱们正在推送一个新分支 feat/hello-world,它当初指向 8fa91ae7af0341e6524d1bc2ea067c99dff65f1c,因为它是个新分支,以前的指向记为 0000000000000000000000000000000000000000。

  1. 客户端将相干 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
  1. 服务端解包 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 是这样的:

  1. 用户执行 git push;
  2. Git 客户端通过 SSH 链接到 gitlab-shell;
  3. gitlab-shell 应用客户端公钥调用 GET /api/v4/internal/authorized_keys 取得公钥 ID,进行 SSH 握手;
  4. gitlab-shell 应用公钥 ID 和仓库地址调用 POST /api/v4/internal/allowed,确认用户有到仓库的写权限;
  5. API 返回:Gitaly 地址和鉴权 token、repo 对象、钩子回调信息(逻辑用户名 GL_ID、逻辑我的项目名 GL_REPOSITORY);
  6. gitlab-shell 用上列信息调用 Gitaly 的 SSHReceivePack 办法,成为客户端和 Gitaly 的中继;
  7. Gitaly 在适当的工作目录运行 git-receive-pack,并且事后设定好环境变量 GITALY_HOOKS_PAYLOAD,其中蕴含 GL_ID, GL_REPOSITORY 等;
  8. 服务端 Git 尝试更新 refs,运行 Git hooks;
  9. 实现。

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 是这样的:

  1. 用户执行 git push;
  2. Git 客户端调用 GET https://jihulab.example.com/u…,带上对应的 authorization header;
  3. Workhorse 截获申请,原样发送申请到 Rails,取得鉴权后果、用户 ID、仓库对应 Gitaly 实例信息;
  4. Workhorse 依据上一步 Rails 的返回信息,调用 Gitaly 的 GRPC 服务 InfoRefsReceivePack,在客户端和 Gitaly 之间充当中继;
  5. Gitaly 在适当的工作目录运行 git-receive-pack,返回 refs 信息;
  6. Git 客户端调用 POST https://jihulab.example.com/u…;
  7. Workhorse 截获申请,原样发送申请到 Rails,取得鉴权后果、用户 ID、仓库对应 Gitaly 实例信息;
  8. Workhorse 依据上一步 Rails 的返回信息,调用 Gitaly 的 GRPC 服务 PostReceivePack,在客户端和 Gitaly 之间充当中继;
  9. Gitaly 在适当的工作目录运行 git-receive-pack,并且事后设定好环境变量 GITALY_HOOKS_PAYLOAD,其中蕴含 GL_ID, GL_REPOSITORY 等;
  10. 服务端 Git 尝试更新 refs,运行 Git hooks;
  11. 实现。

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 <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 的时候。

图 /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 全程,真是一次平凡的旅程!

壮士,下图就是你的通关宝藏!

参考资料

如果你想持续深刻理解相干内容,上面的材料会是不错的终点:

  • Pro Git – Chapter 10: Git Internals
  • How Gitaly fits into GitLab
  • Gitaly – Git Hooks
  • Git Hooks
  • Rails internal APIs
正文完
 0