基於 Git Namespace 的存儲庫快照方案 頂 原

前言

Git 是一種分佈式的版本控制系統,分佈式版本控制系統的一大特性就是遠程存儲庫和本地存儲庫都包含存儲庫的完整數據。 而集中式的版本控制系統只有在中心服務器上纔會包含存儲庫完整的數據,本地所謂的存儲庫只是遠程服務器特定版本的 checkout。當中心服務器故障後,如果沒有備份服務器,那麼集中式的版本控制系統存儲庫的數據絕大部分就會被丟失。這很容易得出分佈式版本控制系統的代碼要必集中式的版本控制系統更加安全。

但是,安全並不是絕對的,尤其當 Git 被越來越多的人使用後,用戶也會需要 Git 吸收集中式版本控制系統的特性來改進用戶體驗,這種情形下,Git 分佈式版本控制系統的安全性也就面臨挑戰。終端用戶獲取的不是完整的數據,爲了保證存儲庫的安全仍然需要備份或者鏡像遠程服務器上的存儲庫。(用戶可以使用淺表克隆,單分支克隆或者使用 git vfs(GVFS) 之類的技術加快 git 訪問。)

Git 給開發者非常大的自由,git 可以修改 commit 重新提交,也可以強制推送<sup>1</sup>引用到遠程服務器,覆蓋特定的引用,不合理的使用強制推送是非常危險的,這很容易造成代碼丟失,對於企業存儲庫來說,合理的快照能夠代碼丟失後減小代碼資產的損失。(但這並不是說絕對禁止強制推送<sup>2</sup>)

在 Gitee 提供了企業版後,我們也經常接收到用戶對於代碼資產安全的反饋,爲了利用有限的資源提供更加安全的服務,存儲庫備份與快照方案的設計也就非常重要了。

術語及組件

NameFeature
Git Native Hook基於 C++ 編寫的原生鉤子,大文件檢查,分支權限,消息及同步隊列,Git 原生鉤子的深度優化
Blaze基於 Go 編寫的備份,GC,存儲庫刪除隊列服務
Git Diamond基於 Asio 編寫的內部 git 協議傳輸服務器
Git Snapshot基於 C++ 編寫的快照和恢復工具

Gitee 目前備份方案

Gitee 的企業存儲庫有兩個安全策略保證其安全,分別是 DGIT 觸發式同步和 rsync 定期快照方案。

DGIT 觸發式同步的組件有:Git Native HookRedisBlazeGit Diamond。GNK(Git Native Hook) 作爲符號鏈接存在與服務器上存儲庫目錄下的 hooks 之中,當用戶推送代碼後,GNK 會被觸發,在執行完授權和大文件檢測後,將同步事件寫入到 Redis 隊列被 Blaze 讀取,Blaze 獲取到當前服務器的 Slave 機器,將當前存儲庫使用 git push --mirror 的方式同步到 Slave 上,傳輸協議爲 git://Slave 機器上的接收端爲 Git DiamondGit Diamond 是專門的 Git 內部傳輸服務端。

rsync 快照方案字面意思非常容易理解,即定期運行 rsync 將企業存儲庫快照到企業備份服務器上的特定目錄。而本文的優化主要也就是是取代 rsync 的快照方案。

舉一個簡單的例子: 使用 rsync 方式備份以一個平均大小爲 1GB 的存儲庫來計算,90天一個週期,每 7 天備份一次,需要耗費的存儲空間爲 12.9 GB。按照尊享版 100GB 空間,使用率 20% 計算:

12.9 GB*20=258 GB

一百個尊享版企業就需要 25.2TB 存儲空間。

我們可以看到基於 rsync 的快照方案是非常佔用存儲空間的。實際上,此方案不僅佔用存儲空間在同步的時候還很耗時,按照目前的方案,每一次快照都需要重新完整的獲取存儲庫的所有數據,還是以前面 1GB 存儲庫爲例,12.9個週期內,內網流量消耗爲 ~13 GB

基於 rsync 的快照方案也成了 Gitee 企業備份的痛點。在前段時間,我突然想到可以使用基於 Git Namespace 快照使用企業快照,這樣一來可以大幅度的節省存儲空間,於是開始開發實現,也就有了本文。

Git 引用快照方案

Git 存儲庫的資產主要是對象和引用,對象實際上是按照哈希存儲到存儲庫中的,在之前的備份方案中,在同一個存儲庫的不同備份中,存在有大量重複的對象,這些對象佔用了存儲空間。我們只要在不同的快照中複用這些對象即可。git 存儲庫中複用對象可以使用 GIT_NAMESPACE 隔離模擬成不同的存儲庫,也可以使用對象借用,借用對象可能使 Bitmap 的優化失敗,並且多次快照可能會形成借用目錄的鏈式依賴帶來問題,因此在技術選型上也就選擇了基於 GIT_NAMESPACE 來實現快照。

使用引用快照方案能夠節省大量的存儲,下面有兩個快照間隔的對比:

90 天週期內,7天一次快照,存儲庫取平均值 1GB:

方案存儲消耗存儲庫數據流量消耗引用數目(平均值 1W)存儲佔比
rsync~13 GB13 GB1 * 13 W100%
git snapshot1 GB1 GB13 W~7.8%
git snapshot (double)2 GB2 GB13 W * 2~15.5%

90 天週期內,每天快照一次,存儲庫取平均值 1GB:

方案存儲消耗存儲庫數據流量消耗引用數目(平均值 1W)存儲佔比
rsync90 GB90 GB1 * 90 W100%
git snapshot1 GB1 GB90 W~1.1%
git snapshot (double)2 GB2 GB90 W * 2~2.2%

Git Snapshot 原理

在快照存儲庫時,如果存儲庫不存在,我們先需要將存儲庫以鏡像的方式克隆下來,命令行如下(git clone 支持目錄遞歸創建):

git clone [email protected]/oscstudio/git.git --mirror --bare /home/git/enbk/os/oscstudio/git.git

如果存儲庫存在,則是用 fetch,命令行如下:

git fetch [email protected]/oscstudio/git.git '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' '+refs/fetches/*:refs/fetches/*' '+refs/pull/*:refs/pull/*' '+refs/pull/*:refs/pull/*' '+refs/git-as-svn/*:refs/git-as-svn/*' --prune

其中不同前綴的引用實際類型不一樣:

PrefixType
refs/heads/常規分支
refs/tags標籤
refs/fetches/PR 功能相關
refs/pull/PR 功能相關
refs/git-as-svn/svn 功能相關

Subversion 相比,Git 創建分支非常輕量級,而在 Git 中,分支實際上對應的是以 refs/heads/ 開頭的引用。那麼引用的創建同樣應該是輕量級的。所以我們只需要將 fetch 命令中的引用創建基於名稱空間的快照即可:

快照變換

refname ---> refs/namespaces/yyyy-MM-dd/refname

創建 Git 引用可以使用 git 命令行也可以使用 libgit2。命令行創建引用如下:

$ git update-ref refs/namespaces/2018-11-18/refs/heads/master 1a410efbd13591db07496601ebc7a059dd55cfe9

使用 libgit2 API: git_reference_create 創建引用代碼如下:

git_reference *ref=nullptr;
if(git_reference_create(&ref,repo,"refs/namespaces/2018-11-18/refs/heads/master",id,1,"cretae")!=0){
    auto err=giterr_last();
    fprintf(stderr,"create error %s\n",err->kmessage);
    return false;
}

當引用數目比較少時無論是使用命令行還是使用 API 都是不錯的選擇,但像 Gitee 的源碼存儲庫 Gitlab 這樣的項目引用超過 1W 個時,無論是 API 還是命令行都將變得無比緩慢。這很容易理解,創建一次快照需要創建同等數目的引用,libgit2 創建引用需要在磁盤上創建同等數目的鬆散引用,在創建引用前需要創建 refname.lock 這樣的文件避免與其他 git 進程或者 API 發生訪問衝突。這樣下來創建文件的系統調用也就是難以接受的。而 git 命令行在這點上也是無能爲力的。最開始我直接使用 libgit2 API,當創建到第十次快照時,一次快照需要花費幾分鐘,這肯定時不能接受的。

專門的企業快照機器實際上並不需要運行其他業務,也就不用擔心創建 Git 引用的安全問題,這時候做個取捨就可以實現優化,比如直接創建引用,引用存儲在磁盤上分爲 loose referencespacked references。如果還是創建鬆散引用勢必需要創建數量巨多的文件,這樣一來優化非常優先,因此我們應該直接編寫 packed-refspacked-refs 格式即 $COMMITID SP $REFNAME LF,非常簡單。我們按照要求格式化輸出即可。

# pack-refs with: peeled fully-peeled sorted
$COMMITID $REFNAME\n

創建 packed-refs 之前先使用 git pack-refs --all --prune 命令將原有的引用打包到一起,然後掃描舊的 packed-refs 寫入到新的 packed-refs ,成功後替換文件即可。

這裏需要注意幾點:

  1. packed-refs 引用名需要按照字母排序,否則引用會找不到。
  2. 使用內存映射技術減少拷貝非常必要。
  3. 可以使用 C++17 std::string_view 這樣的容器方便解析。存儲在容器中也可以使用 string_view
  4. HEAD 也需要快照,我們的做法時將其放在 .git/_enbk/$NS 路徑下。
  5. 爲了保證引用被刪除時感知不到,可以先從遠程服務器運行 ls-remote 獲得引用列表,寫入快照時刪除不存在的即可。爲了支持檢測,可以使用 absl::flat_hash_set 之類的無序支持異構查找容器。

經測試,快照的時間減少幅度非常大,但引用數量達到 24W 時,快照時間爲 300 多毫秒(機械硬盤)。

./bin/git-snapshot bk git@localhost:oschina/gitlab.git -n 2018-11-30 -V
username: (none)
use privatekey: /home/example/.ssh/id_rsa
url: git@localhost:oschina/gitlab.git
remote git@localhost:oschina/gitlab.git has 10721 refs
克隆到純倉庫 '/home/git/repositories/snapshot/os/oschina/gitlab.git'...
remote: 枚舉對象: 497921, 完成.
remote: 對象計數中: 100% (497921/497921), 完成.
remote: 壓縮對象中: 100% (99609/99609), 完成.
remote: 總共 497921 (差異 393964),複用 497921 (差異 393964)
接收對象中: 100% (497921/497921), 211.43 MiB | 51.58 MiB/s, 完成.
處理 delta 中: 100% (393964/393964), 完成.
snapshot write 21442 refs
apply /home/git/repositories/snapshot/os/oschina/gitlab.git HEAD to refs/heads/master
ls-remote time: 88 ms
prepare time:   12684 ms
snapshot time:  17 ms
total time:     12789 ms

存儲庫從快照恢復

恢復存儲庫的時候使用名稱空間隔離,然後 clone 到特定的目錄即可。命令如下:

git -c protocol.ext.allow=always clone 'ext::git --namespace=yyyy-MM-dd %s /path/bk/dir.git' /path/save/dir.git --mirror 

總結

在研究此方案時,需要對 Git 的相關技術細節非常清楚,比如,筆者在編寫 packed-refs 時,最開始未瞭解到 packed-refs 引用是按字母排序的,直接寫入導致一些引用異常,寫入失敗。

其他

  1. Git 服務器並不知道用戶是否是強制推送,除非開啓了 receive.denyNonFastForwards=truereceive-pack 通過 commit 回溯掃描檢查才能知道用戶是否強制推送(這通常會減緩遠程服務器的響應速度,並且與一次性推送的 commit 數目密切相關)。

  2. 合理的強推有時候是必要的,比如使用強推刪除涉密信息,修改提交內容,合併提交等等, 筆者在給 Git 貢獻代碼時就多次使用強推。 PR: http: add support selecting http version 中也強推了好幾次。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章