本文來自:
李振楠 極狐(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?
如果你的遠端地址是 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;
- 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 <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