当你git push时,Gitlab上发生了什么?

      ☕ 16 分钟
🏷️
  • #git
  • #Gitlab
  • 勇士,你可曾好奇过GitGitlab是如何工作的?现在,拿起你心爱的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数据模型
    红色部分由refs提供,其余部分全部由objects提供,commit对象(黄色)指向保存文件结构的tree对象(蓝色),后者再指向各个文件对象(灰色) 图/Pro Git on git-scm.com

    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组件关系 图/GitLab architecture overview on docs.gitlab.com

    开始git push!

    三分钟过得真快!现在你已经掌握了基础,让我们踏上征途吧!

    你喜欢SSH?

    如果你的远端地址是[email protected]:user/repo.git这样的,那么你在用SSH与Gitlab进行通讯。在你执行git push时,本质上,你的Git客户端的upload-pack服务在执行下列命令:

    1
    
    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时间
    两个有经验的开发者正在等待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://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截获,每次它都做这两件事:

    1. 把请求原样发到Rails,后者会返回鉴权结果、用户ID、仓库对应Gitaly实例信息(有点怪,对吧?Rails的info/refsgit-receive-pack接口居然是用来鉴权的,我猜这后面多少有些历史原因)
    2. Workhorse根据上一步Rails返回的信息,建立与Gitaly的连接,在客户端和Gitaly之间充当中继。

    总结一下,经由HTTP(S)的git push是这样的:

    1. 用户执行 git push
    2. Git客户端调用GET https://gitlab.example.com/user/repo.git/info/refs?service=git-receive-pack,带上对应的authorization header
    3. Workhorse截获请求,原样发送请求到Rails,获得鉴权结果、用户ID、仓库对应Gitaly实例信息
    4. Workhorse根据上一步Rails的返回信息,调用Gitaly的GRPC服务InfoRefsReceivePack,在客户端和Gitaly之间充当中继
    5. Gitaly在适当的工作目录运行git-receive-pack,返回refs信息
    6. Git客户端调用POST https://gitlab.example.com/user/repo.git/git-receive-pack
    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 Logo
    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的形式,但是namespaceproject_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的时候。

    一张钩子的图片
    图中的这个钩子和计算机科学有着历史悠久的联系……咳咳,好吧我编不下去了,我只是担心你看到这里已经要睡着了,找张图片让你放松一下 图/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全程,真是一次伟大的旅程!

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

    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时间”。

    参考资料

    如果你想继续深入了解相关内容,下面的资料会是不错的起点:


    nanmu42
    作者
    nanmu42
    用心构建美好事物。

    目录