Git 巨型存儲庫的解決方案 頂 原

前言

通常來說,分佈式版本控制系統適合體積較小的存儲庫,分佈式版本控制系統 意味着存儲庫和工作目錄都放置在開發者自己的機器上,當開發者需要克隆一個巨大的存儲庫時,爲了獲得完整的拷貝,版本控制軟件不得不從遠程服務器上下載大量的數據。這是分佈式版本控制系統最大的缺陷之一。

這種缺陷並不會阻礙 git 的流行,自 2008 年以來,git 已經成爲事實上的版本控制軟件的魁首,諸如 GCC<sup>1</sup>,LLVM<sup>2</sup> 這樣的基礎軟件也已遷移到或者正在遷移到 git。那麼 git 如何解決這種大存儲庫的麻煩呢?

淺克隆和稀疏檢出

很早之前,我的構建 LLVM 的時候,都使用 svn 去檢出 LLVM 源碼,當時並不知道 git 能夠支持淺克隆。後來從事代碼託管開發,精通git 後,索性在 Clangbuilder<sup>3</sup> 中使用 git 淺克隆獲取 LLVM 源碼。

淺克隆意味着只克隆指定個數的 commit,在 git 克隆的時候使用 --depth=N 參數就能夠支持克隆最近的 N 個 commit,這種機制對於像 CI 這樣的服務來說,簡直是如虎添翼。

       --depth <depth>
           Create a shallow clone with a history truncated to the specified number of commits. Implies
           --single-branch unless --no-single-branch is given to fetch the histories near the tips of all branches.

與常規克隆不同的是,淺克隆可能需要多執行一次請求,用來協商 commit 的深度信息。

在服務器上支持淺克隆一般不需要做什麼事。如果使用 git-upload-pack 命令實現克隆功能時,對於 HTTP 協議要特殊設置,需要及時關閉 git-upload-pack 的輸入。否則,git-upload-pack 會阻塞不會退出。對於 Git 和 SSH 協議,完全不要考慮那麼多,HTTP協議是 Request--Respone 這種類型的,而 Git 和 SSH 協議則沒有這個限制。

而稀疏檢出指得是在將文件從存儲庫中檢出到目錄時,只檢出特定的目錄。這個需要設置 .git/info/sparse-checkout。稀疏檢出是一種客戶端行爲,只會優化用戶的檢出體驗,並不會減少服務器傳輸。

Git LFS 的初衷

Git 實質上是一種文件快照系統。創建提交時會將文件打包成新的 blob 對象。這種機制意味着 git 在管理大文件時是非常佔用存儲的。比如一個 1GB 的 PSD 文件,修改 10 次,存儲庫就可能是 10 GB。當然,這取決於 zip 對 PSD 文件的壓縮率。同樣的,這種存儲庫在網絡上傳輸,需要耗費更多的網絡帶寬。

對於 Github 而言,大文件耗費了他們大量的存儲和帶寬。Github 團隊於是在 2015 年推出了 Git LFS,在前面的博客中,我介紹瞭如何實現一個 Git LFS 服務器<sup>4</sup>,這裏也就不再多講了。

GVFS 的原理

好了,說到今天的重點了。微軟有專門的文件介紹了 《Git 縮放》<sup>5</sup> 《GVFS 設計歷史》<sup>6</sup>,相關的內容也就不贅述了。

GVFS 協議地址爲: The GVFS Protocol (v1)

GVFS 目前只設計和實現了 HTTP 協議,我將其 HTTP 接口整理如下表:

MethodURLBodyAccept
GET/gvfs/configNAapplication/json, gvfs not care
GET/gvfs/objects/{objectId}NAapplication/x-git-loose-object
POST/gvfs/objectsJson Objectsapplication/x-git-packfile; application/x-gvfs-loose-objects(cache server)
POST/gvfs/sizesJOSN Arrayapplication/json
GET/gvfs/prefetch[?lastPackTimestamp={secondsSinceEpoch}]NAapplication/x-gvfs-timestamped-packfiles-indexes

GVFS 最初要通過 /gvfs/config 接口去判斷遠程服務器對 GVFS 的支持程序,以及緩存服務器地址。獲取引用列表依然需要通過 GET /info/refs?service=git-upload-pack 去請求遠程服務器。

//https://github.com/Microsoft/GVFS/blob/b07e554db151178fb397e51974d76465a13af017/GVFS/FastFetch/CheckoutFetchHelper.cs#L47
            GitRefs refs = null;
            string commitToFetch;
            if (isBranch)
            {
                refs = this.ObjectRequestor.QueryInfoRefs(branchOrCommit);
                if (refs == null)
                {
                    throw new FetchException("Could not query info/refs from: {0}", this.Enlistment.RepoUrl);
                }
                else if (refs.Count == 0)
                {
                    throw new FetchException("Could not find branch {0} in info/refs from: {1}", branchOrCommit, this.Enlistment.RepoUrl);
                }

                commitToFetch = refs.GetTipCommitId(branchOrCommit);
            }
            else
            {
                commitToFetch = branchOrCommit;
            }

拿到引用列表後才能開始 GVFS clone。分析 POST /gvfs/objects 接口規範,我們知道,最初調用此接口時,只會獲得特定的 commit 以及 tree 對象。引用列表返回的都是 commit id。拿到 tree 對象後,就可以拿到 tree 之中的 blob id。通過 POST /gvfs/sizes 可以拿到需要獲得的對象的原始大小,通常而言,/gvfs/sizes 請求的對象的類型一般都是 blob,在 GVFS 源碼的 QueryForFileSizes 正是說明了這一點。實際上一個完整功能的 GVFS 服務器實現這三個接口就可以正常運行。

POST /gvfs/objects 請求類型:

{
	"objectIds":[
		"e402091910d6d71c287181baaddfd9e36a511636",
		"7ba8566052440d81c8d50f50d3650e5dd3c28a49"
	],
	"commitDepth":2
}
struct GvfsObjects{
    std::vector<std::string> objectIds;
    int commitDepth;
};

POST /gvfs/sizes

[
		"e402091910d6d71c287181baaddfd9e36a511636",
		"7ba8566052440d81c8d50f50d3650e5dd3c28a49"
]

對於 Loose Object,目前的 git 代碼託管平臺基本上都不支持啞協議了,GVFS 這裏支持 loose object 更多的目的是用來支持緩存,而 prefetch 的道理類似,像 Windows 源碼這樣體積的存儲庫,一般的代碼託管平臺優化策略往往無效。每一次計算 commit 中的目錄佈局都是非常耗時的,因此,GVFS 在設計之處都在儘量的利用緩存服務器。

使用 Libgit2

據我所知,國內最早實現 gvfs 服務器的是華爲開發者莊表偉,具體介紹在簡書上: 《GVFS協議與工作原理》。我在實現 gvfs 的過程也參考了他的實現。與他的基於 rack 用 git 命令行實現的服務器不同的是,我是使用 libgit2 實現一個 git-gvfs 命令行,然後被 git-srv 和 bzsrv 調用。採取這種機制一是使用 git 命令行需要多個命令的組合,無論是 git-srv 還是基於 go 的 bzsrv 還要處理各種各樣的命令,不利於細節屏蔽。二來是我對 libgit2 已經比較熟,並且也對 git 的存儲協議,pack 格式比較瞭解。

git-srv 是碼雲分佈式 git 傳輸的核心組件,無論是 HTTP 還是 SSH 還是 Git 協議,其傳輸數據都由其前端轉發到 git-srv,最後通過 git-* 命令實現,支持的命令有 git-upload-pack git-upload-archive git-receive-pack git-archive,如果直接使用 git 命令實現 gvfs 功能不吝於重寫 git-srv,很容易對線上的服務造成影響。簡單的方法就是使用 libgit2 實現一個 git-gvfs cli.

git-gvfs 命令的基本用法是:

git-gvfs GVFS impl cli
usage: [command] [args] gitdir
    config         show server config
    sizes           input json object ids, show those size
    pack-objects   pack input oid's objects
    loose-object   --oid; send loose object
    prefetch       --lastPackTimestamp; prefetch transfer

git-gvfs config 命令用於顯示服務器配置,在 brzo 或者 bzsrv 就可以被攔截,這裏保留。

git-gvfs sizes 命令對應 POST /gvfs/sizes 請求,請求體寫入到 git-gvfs 的 stdin ,git-gvfs 使用 nlohmann::json 解析請求,然後使用 git_odb 去查詢所有輸入對象的未壓縮大小。

pack-objects 命令對應 POST /gvfs/objects 請求,輸入的對象是 commit 時,使用 commitDepth 的長度回溯遍歷,取第一個 parent commit。如果對象的類型不是 blob,則向下解析,直到樹沒有子樹。構建 pack 可以使用 git_packbuilder,寫入文件使用 git_packbuilder_write,直接寫入 stdoutgit_packbuilder_foreach。爲了支持緩存,要先寫入磁盤,然後從磁盤讀取再寫入到 stdout

loose-object 即讀取鬆散對象寫入到標準輸出。

prefetch 對應 GET /gvfs/prefetch[?lastPackTimestamp={secondsSinceEpoch}]| 這裏核心是掃描 gvfs 臨時目錄。將所有某個時間點之後創建的 pack 文件打包成一個 pack。這裏需要對 pack 對象進行遍歷,最初的 pack 遍歷我是使用 Git Native Hook 的機制,但後來發現 odb 邊界導致性能不太理想,於是我使用 git_odb_new 新建 odb,然後使用 git_odb_backend_one_pack 創建 git_odb_backend 打開一個個的 pack 文件,使用 git_odb_add_backendodb_backend 添加到 odb,這時候就可以對 odb 進行遍歷,獲得所有的對象,要創建 packbuilder 需要 git_repositroy 對象,因此,可以使用 git_repository_warp_odb 創建一個 fake repo. 代碼片段如下:

class FakePackbuilder {
private:
  git_odb *db{nullptr};
  git_repository *repo{nullptr};
  git_packbuilder *pb{nullptr};
  std::vector<std::string> pks;
  bool pksremove{false};
  std::string name;
  /// skip self
  inline void removepkidx(const std::string &pk) {
    if (pk.size() > name.size() &&
        pk.compare(pk.size() - name.size(), name.size(), name) != 0) {
      auto idxfile = pk.substr(0, pk.size() - 4).append("idx");
      std::remove(pk.c_str()); ///
      std::remove(idxfile.c_str());
    }
  }

public:
  FakePackbuilder() = default;
  FakePackbuilder(const FakePackbuilder &) = delete;
  FakePackbuilder &operator=(const FakePackbuilder &) = delete;
  ~FakePackbuilder() {
    if (pb != nullptr) {
      git_packbuilder_free(pb);
    }
    if (repo != nullptr) {
      git_repository_free(repo);
    }
    if (db != nullptr) {
      git_odb_free(db);
    }
    if (pksremove) {
      for (auto &p : pks) {
        removepkidx(p);
      }
    }
  }
  std::vector<std::string> &Pks() { return pks; }
  const std::vector<std::string> &Pks() const { return pks; }
  /// packbuilder callback
  static int PackbuilderCallback(const git_oid *id, void *playload) {
    auto fake = reinterpret_cast<FakePackbuilder *>(playload);
    git_odb_object *obj;
    if (git_odb_read(&obj, fake->db, id) != 0) {
      return -1;
    }
    if (git_odb_object_type(obj) != GIT_OBJ_BLOB) {
      if (git_packbuilder_insert(fake->pb, id, nullptr) != 0) {
        git_odb_object_free(obj);
        return 1;
      }
    }
    git_odb_object_free(obj);
    return 0;
  }

  std::string Packfilename(const git_oid *id) {
    return std::string("pack-").append(git_oid_tostr_s(id)).append(".pack");
  }
  bool Repack(const std::string &gvfsdir, std::string &npk) {
    if (git_odb_new(&db) != 0) {
      fprintf(stderr, "new odb failed\n");
      return false;
    }
    for (auto &p : pks) {
      auto idxfile = p.substr(0, p.size() - 4).append("idx");
      git_odb_backend *backend = nullptr;
      if (git_odb_backend_one_pack(&backend, idxfile.c_str()) != 0) {
        auto err = giterr_last();
        fprintf(stderr, "%s\n", err->message);
        return false;
      }
      /// NOTE backend no public free fun ?????
      if (git_odb_add_backend(db, backend, 2) != 0) {
        // backend->free(backend);///
        if (backend->free != nullptr) {
          backend->free(backend);
        }
        return false;
      }
    }
    if (git_repository_wrap_odb(&repo, db) != 0) {
      fprintf(stderr, "warp odb failed\n");
      return false;
    }
    if (git_packbuilder_new(&pb, repo) != 0) {
      fprintf(stderr, "new packbuilder failed\n");
      return false;
    }
    if (git_odb_foreach(db, &FakePackbuilder::PackbuilderCallback, this) != 0) {
      return false;
    }
    if (git_packbuilder_write(pb, gvfsdir.c_str(), 0, nullptr, nullptr) != 0) {
      return false;
    }

    auto id = git_packbuilder_hash(pb);
    if (id == nullptr) {
      return false;
    }
    pksremove = true;
    name = Packfilename(id);
    npk.assign(gvfsdir).append("/").append(name);
    return true;
  }
};

上述 FakePackBuilder 還支持刪除舊的 pack,新的 pack 產生,舊的幾個 pack 文件就可以被刪除了。

在 git-gvfs 穩定後,或許會提供一個開源跨平臺版本。

GVFS 應用分析

GVFS 有哪些應用場景?

實際上還是很多的。比如,我曾經幫助同事將某客戶的存儲庫由 svn 遷移到 git,遷移的過程很長,最後使用 svn-fast-export 實現,轉換後,存儲庫的體積達到 80 GB。就目前碼雲的線上限制而言,這種存儲庫都無法上傳上去,而私有化,這種存儲庫同樣會給使用者帶來巨大的麻煩。如果使用 GVFS,這就相當於只下載目錄結構,淺表的 commit,然後需要時才下載所需的文件,好處顯而易見。隨着碼雲業務的發展,這種擁有歷史悠久的存儲庫的客戶只會越來越多,GVFS 或許必不可少了。

相關信息

在微軟的 GVFS 推出後,Google 開發者也在修改 Git 支持部分克隆<sup>7</sup>,用來改進巨型存儲庫的訪問體驗。代碼在 Github 上 <sup>8</sup> 目前還處於開發過程中。部分克隆相對於 GVFS 最大的不足可能是 FUFS。而 GVFS 客戶端僅支持 Windows 10 14393 也正是由於這一點,GVFS 正因這一點才被叫做 GVFS (Git Virtual Filesystem)。FUFS 能夠在目錄中呈現未下載的文件,在文件需要讀寫時,由驅動觸發下載,這就是其優勢。

最後

回過頭來一想,在支持大存儲庫的改造上,git 越來越不像一個分佈式版本控制系統,除了提交行爲還是比較純正。軟件的發展正是如此,功能的整合使得界限變得不那麼清晰。

鏈接

  1. Moving to git
  2. Moving LLVM Projects to GitHub
  3. Checkout LLVM use --depth
  4. Git LFS 服務器實現雜談
  5. Git at scale
  6. GVFS Design History
  7. Make GVFS available for Linux and macOS
  8. jonathantanmy/git
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章