本文作者:李博文 - CODING 后端开发工程师
对于现代版本控制系统而言,传输协议与代码托管平台的关系更为密切,只要支持了该版本控制系统的传输协议才意味着平台支持这个版本控制系统,要支持 Git,代码托管平台也就需要了解 Git 的传输协议。
和版本控制系统的不断发展类似,Git 的传输协议也是在不断发展以适应新的情况。谈到 Git 传输协议,我们最常用的是智能协议,除了智能协议,Git 还有本地协议,哑协议( Dump Protocol ),以及有线协议( Wire Protocol/v2 Protocol )。本地协议通常指通过文件系统路径或者 file://
协议路径访问本机上的存储库的协议,该协议本质上是通过命令调用将其他目录的存储库拷贝到指定目录,这类协议的用处较少,其中有一个细节需要讲清楚,基于文件系统路径的克隆,也就是非 file://
协议克隆,会将源存储库的对象,这里通常是 .pack
文件通过硬链接的方式共享,这实际上是利用了 Git 对象的只读特性,也就是只能删除和新增而不能修改,另外,两个目录并不在同一个分区则不支持硬链接,也就不能使用硬链接共享对象。
哑协议旨在为服务端没有 Git 服务时提供只读的 Git Over HTTP 访问支持,正因为不支持写操作,目前几乎所有的公共代码托管平台均已经不在支持哑协议了。
既然哑协议不堪重任,那么也只能另起炉灶设计一个好的协议了,这就有了智能协议,但随着 Git 被广泛使用,智能协议也有一些先天性缺陷,于是就产生了有线传输协议。
Git 目前主要支持的网络协议有三种,分别是 http(s)://
,ssh://
,git://
无论哪种协议,拉取实质上都是 git-fetch-pack/git-upload-pack
的数据交换,推送都是 git-send-pack/git-receive-pack
的数据交换,在 2018 年以前,均是采用智能传输协议,我们可以使用 Wireshark 这样的工具抓包分析其传输流程,也可以使用 GIT_CURL_VERBOSE=2
GIT_TRACE_PACKET=2
这样设置环境变量后运行相关命令调试 Git,在 Windows 中可以使用我编写的包管理器 baulk 中的命令运行器 baulk-exec
运行相关命令,如:
baulk-exec GIT_CURL_VERBOSE=1 GIT_TRACE_PACKET=2 git ls-remote https://github.com/baulk/baulk.git
分析协议的方法已经有了,我们就可以轻易的知道智能协议的流程,以 http(s)://
为例,我们把传输的第一个步骤叫做引用发现,客户端根据存储库的 URL 使用 GET
请求到 /repo.git/info/refs?service=git-upload-pack
这样的地址,服务端则以 --advertise-refs
--stateless-rpc
这样的参数启动 git-upload-pack
,该命令启动后将存储库目前的 HEAD
commitID,存储库支持的 capabilities
,以及 HEAD
对应的 symref
以及所有的引用名及其 commitID 返回给客户端,客户端根据这些信息,以及本地的存储库已经存在的对象清点出需要的 want
和存在的 have
commitID,然后通过 POST /repo,git/git-upload-pack
发送给服务端,服务端通过执行 git-upload-pack --stateless-rpc /path/to/repo.git
将打包好的对象返回给客户端,待客户端清点好对象,传输就结束了,对于 git pull
请求还需要将更新的文件检出到工作目录。
这里需要注意,实施 Git Over HTTP 服务器时,Git 客户端需要在 POST 请求响应最开始添加 001e# service=git-upload-pack\n0000
,另外我们还需要正确的设置 Content-Type
,服务端处理POST
请求时,请求体可能使用 gzip
编码,需要解压缩处理。
推送的传输协议流程类似,但服务变为 git-receive-pack
,相关的流程如下:
在推送时,Git 协议本身的权限验证机制极其有限,一些分支权限控制等安全功能基本上只能通过钩子实现,而钩子的标准错误实际上也会被 Git 命令行捕获作为响应返回给客户端,如果客户端的 Git 恰好运行在 Windows Terminal 、Mintty 、iTerm 等等终端中,那么我们就可以将一些信息以彩色的形式输出给用户,这些信息使用 ANSI 转义的。
ssh://
协议和 git://
协议同样支持智能传输协议,实现起来只需要把为客户端连接和 git-upload-pack/git-receive-pack
的标准输入和输出建立数据交换的通道即可。在实施 Git Over SSH SSH 服务器时,像 GitLab 会直接使用 OpenSSH,但 OpenSSH 可定制性有限,在分布式 Git 平台上需要实现模拟的 git-upload-pack/git-receive-pack 这样的命令,效率较低。像 GitHub 早期使用了 libssh 实现了 Git Over SSH 服务,BitBucket 使用了 Apache Mina SSHD,还有一些平台使用了 Golang crypto/ssh,无论采用什么样的技术,都应该经过慎重考虑,是否契合平台的架构,维护成本是否合适等等。在实施 Git Over TCP (git://
) 服务器时,只需要解析第一个 pktline
数据包即可,git://
协议简单,表达能力有限,没有足够的权限验证,公有云除了 GitHub 其他平台使用的较少,但我在设计读写分离和高可用时,会优先考虑使用 git://
协议作为内部传输协议以降低内部负载。
ssh://
协议和 git://
协议可以支持数据的多次往返,而 http(s)://
协议只能是 Request-->Response
这样的一个来回,不同的来回实际上状态已经丢失,所以需要指定为 State Less
也就是无状态。
智能协议虽然非常简单,但我们在 Git Over HTTP 上支持 shallow clone
时却不得不注意一些细节,在协商 commit deepin
时,客户端和服务端都在等待对方的响应,这时我们只能通过提前关闭服务端的标准输入中断一方的等待,这就是智能传输协议的大问题,HTTP 传输实现复杂,不支持扩展。另外随着 VFS for Git 这样技术的诞生,使得一个问题浮现在公众面前:“巨型存储库如何优化克隆”。VFS for Git 重新设计了传输协议更显得智能传输协议在这上面尤为不足。
Google 开发者的思路是,通过一个特殊的环境变量开关控制协议的切换。从外表看,传输协议仍然是几组命令的输入输出交换,但从内在看,新的传输协议更像是利用低级别的命令实现功能的扩展。我们依然可以使用上面的调试方法分析 Git 有线协议的传输流程,在新的协议中,服务端先返回了版本信息,支持的命令,过滤器,对象格式等等,客户端再次发送请求需要使用 ls-refs
发现引用,然后是 fetch
命令(以下截图中没有这一操作)获得数据。
实施 Git 有线传输协议非常简单,只需要升级 Git 命令,检测客户端请求是否为 GIT_PROTOCOL=2
,然后以环境变量 GIT_PROTOCOL=2
启动上述命令即可,在我们的博客《 Git Wire 协议杂谈》 中也有介绍。
Git Wire 协议是 Git 的一次大的改变,在协议中添加了命令、filter 等机制,有效解决了传输协议中最低效的部分,增强了可扩展性,比如我们使用部分克隆时,需要添加 blob filter,即我不需要我就可以不下载文件;支持 SHA256 时,告诉服务端,我需要 object-format=sha256
,这为 Git 增加了无限可能。目前 Git 的部分克隆,SHA256 存储库都依赖有线传输协议。
实际上集中式版本控制系统 SVN 早就利用子命令扩展了协议能力,SVN 协议使用 ABNF 描述协议,要比 Git 的有线协议解析起来复杂一些。
了解了 Git 的存储结构和传输协议后,再建立宏观上的 Git 数据交换映像就容易得多,对 Git 的操作实际上是发生在三个区域,工作区是我们实质上修改,添加,删除文件的地方,通过 git add/commit/checkout 等命令,我们就将工作区的文件纳入版本管理了,通过 git push/fetch 等命令,就将本地存储库和远程建立了关联。这里需要注意,git pull 实际 上是 git fetch+ git checkout (没有 merge 的情况下),大致如下图:
随着平台规模的增长,代码托管从业人员也会遇到一些问题难以解决,在我职业生涯中同样如此,解决问题的过程是艰辛的,去年年底,我曾经写过一篇文章:《性能,可扩展性和高可用 - 大型 Git 代码托管平台的关键问题》,文章的内容与本节内容相似,这里带领读者重新回顾一下。
目前国内 IT 行业版本控制系统都在往 Git 迁移,一些大型企业,软件源码历史悠久,存储的文件各种各样,在迁移到 Git 时,体积巨大的存储库给代码托管平台带来了压力,首当其冲的问题就是从其他版本控制系统迁移到 Git 耗时太长。
Git 在安装了 SVN 的前提下,支持 git svn
命令访问 SVN 仓库,从 SVN 仓库迁移到 Git 的逻辑很简单,就是从 Rev0 开始,递归的创建 Git 提交,如果这个存储库历史悠久,提交特别多,文件特别多,那么转换耗时将非常长。网络上也有一种优化方案,直接在 SVN 中央存储库,通过解析存储库元数据,直接在上面创建 Git 提交,这种方案的耗时可能是原本的数十分之一。KDE 团队维护的 svn-all-fast-export aka svn2git 就是其中一款。
转移到 Git 后,如果存储库包含很多的二进制文件,存储库体积巨大,那么用户拉取的时间还是会很长,一种解决方案是将不同的数据分离,也就是将体积大的二进制文件,通过 Git 扩展 git lfs
追踪,从源码中排除,通过这种措施存储库的体积减小,平台的压力降低,而这些大文件可以存储到其他的设备上,比如对象存储,利用 CDN 优化,就能提升用户的体验.实现 Git LFS 服务器可以参考我之前的博客《 Git LFS 服务器实现杂谈》。
如果存储库小文件特别多,这个时候 Git LFS 的作用反而没有那么大了,Git LFS 并不存在打包机制,也没有压缩,如果大量文件使用 Git LFS 跟踪,那么 HTTP 请求数会变得非常多,传输时间也会特别长。微软在将 Windows 源码迁移到 Git 做技术选型便遇到了问题,Windows 源码数百 GB,引用数量数十万,这些传统方案和 Git LFS 完全不能解决。于是微软的开发者推出了 VFS for Git 用来解决这个问题,简单来说,VFS for Git 的手段是只获得浅表 commit 以及相应的 tree 对象,然后在文件系统建立虚拟文件,也就是用户空间文件系统 Filesystem in Userspace (FUSE) 创建占位符文件,但向这种文件发起 IO 操作时,驱动会触发 VFS for Git 客户端取请求远程服务器,获得这些文件,在 Windows 上 FUSE 使用了 NTFS 重解析点,其 TAG 为 IO_REPARSE_TAG_PROJFS
,微软前员工 Saeed Noursalehi(现已加入 Facebook )曾写过一些 VFS for Git 的文章,比如 《 Git at Scale 》以及《 Git Virtual File System Design History 》,大家有兴趣可以看一下。VFS for Git 惊艳的架构也吸引了 GitHub 的注意,当时 GitHub 还未被 Microsoft 收购,GitHub 创建了 Linux projected filesystem library 项目试图在 Linux 上创建类似 Windows 平台的 projfs,以支持 VFS for Git 在 Linux 上运行,但该项目一直没有被完成。
VFS for Git 的设计是独树一帜的,也很难推广开来,目前除了 Microsoft 的 Azure,其他平台几乎都没有支持,核心就是 Git 客户端支持难度高。后来 Git 的一些开发者提议在 Git 中实现部分克隆,经过几年的努力,终于支持部分克隆,该方案和 VFS for Git 类似,使用有线传输协议的 filter 机制,实现一个 blob filter
过滤掉 blob
,与 VFS for Git 存在差异的是,没有 FUSE 加成,最终使用有限,是否能够有其他手段提升部分克隆的实用性,还得 Git 贡献者们进一步的努力了。
最近,Git 贡献者还增加了 Packfile URIs 设计,该方案旨在将对象通过 CDN 存储,然后客户端根据返回的地址请求到合适的 CDN 下载存储库对象,该方案仍处于早期,还有许多细节要处理,最终能做到什么程度有待观察。
大型代码托管平台面临的另一个问题则是系统的伸缩性,在架构上具备良好的伸缩性则意味着平台能做到多大的规模,比如 Gitea/Gogs 这种倾向于单节点的开源代码托管平台要做到大型分布式代码托管平台就麻烦得多,而 GitLab 则更容易搭建分布式可扩展的代码托管平台。
在讨论伸缩性之前,我们要解释一下分布式文件系统为什么不适合大型代码托管平台。
当了解到分布式文件系统不合适之后,我们也就只能采用笨办法,分片,将存储库分布在不同的存储节点,Git 命令也在这个节点上运行,这样无论是计算还是 I/O 都能够通过存储节点的扩展实现扩容,这就是 Git 目前最主要的分布式解决方案。
通过这样的方案实现平台的伸缩性时,还需要解决一些分布式环境常见的问题,比如存储库的分布,存储库队列等等,当然这些都有可用的方案,在本文就不展开细说。
无论是公共代码托管平台还是私有化部署的代码托管服务,当代平台发展到一定程度,高可用这个问题就会被反复提及,分布式系统的架构设计难度较高,与传统的单机服务有很大的差别,而 Git 代码托管平台分布式系统与普通的分布式系统有更大的差异,高可用的设计不仅要吸纳主流的分布式系统的架构经验,还需要迎合 Git 的特性,另外还需要考虑到架构的经济性。
首先我们看一下分布式大型代码托管平台的简易架构(下图的架构是精简版本,与实际架构存在差距),从下图我们可以看到,用户的 Git 请求实际上并不是直接请求到存储节点上的 Git 服务,而是通过代理服务转发过去,这些代理服务通过路由模块获得存储库位于那个存储节点,从架构上讲,这些代理服务都可以做到无状态,通过部署多个服务副本再在前端入口添加负载均衡健康检查,可以很好地做到这些代理服务的高可用,但这个架构也意味着存储节点上的存储库并不能支持高可用。
存储库要支持高可用,应该在不同的存储节点上都存在副本,在一个副本所在的节点无法正常提供服务时,需要其他副本所在的节点能够顶上来提供服务,这些副本要始终保持一致,如果不一致,在切换的时候就会出现数据紊乱,这显然是不符合用户期望的。高可用可分为主从同步高可用,以及读写分离高可用,还有同时多写高可用(多写高可用),设计一个简单的主从同步高可用系统,我们首先需要保证存储库的一致性,这里可以通过 git hooks 触发存储库实时同步,存储库副本分布在不同的节点,在用户推送代码后,被更新的存储库副本及时将数据通过内部传输协议同步到其他副本。早期 GitHub 使用 DRDB 实现同步,目前大多使用 Git 传输协议实现同步,我个人更偏好于实现自定义的 git://
提供存储库同步功能。
存储库实现了实时同步,还需要有一种机制保证存储库数据一致,GitHub 的方案是循环哈希校验和,而我的方案是使用 BLAKE3 计算引用哈希,原理很简单,就是将存储库的引用按字典排序计算哈希值,哈希值一致意味着两个存储库的引用一致,引用一致存储库克隆获得的数据也就是一致的,两个存储库肯定一致。
这里主从同步高可用如果支持将读取请求转发到其他副本而不仅是主副本,那么这种情况就叫读写分离高可用(简称读写分离),读写分离的好处就是对于特别活跃的存储库能够提供更高的并发。当然无论是看似简单的主从同步,还是复杂的读写分离,内里考虑的细节并不少,环环相扣,需要对整个代码托管架构有一个清晰的认识。
实施类似 Github Spokes (DGit is now Spokes) 一样的多写高可用要复杂一些,主要难点是要支持同时写入到多个副本,要做到这一点需要实现一些约束性条件:
要设计好高可用,应该实现一套良好的故障检测机制,合理的方案有多种,可以用专门的服务检测磁盘是否可用,服务是否联通,出现故障时标记不可用,恢复后直接标记为正常即可;还可以通过学习,将前端服务与存储节点通信的错误采集分离,进行健康评估,在节点故障时将其下线。两者都需要不断的汲取经验,故障的错误标记往往是灾难性的,GitHub 就出现过这样的事故,给其声誉带来了一定的影响。
无论是主从同步还是读写分离以及实时多写架构,都需要给存储库创建多个副本,这就意味着存储空间的消耗加倍,每个存储库有一个副本,存储空间的消耗就要增加一倍,两个副本就增加两倍,所以在设计高可用系统的时候还需要考虑到经济因素对架构的影响,这也是国内代码托管行业高可用架构发展并不顺利的原因之一。
多写系统如果能修改 Git 源码实现一些细节的优化,这在架构上有更好的设计余地,比如我们可以修改 Git 源码支持主动非侵入数据流的原子更新,我们也可以在 receive-pack
中修改执行钩子的逻辑,使其更符合读写系统的设计。而现实并不令人满意,没有足够的人手能够参与 Git 的研究,这阻碍了国内代码托管行业的创新,很容易陷入只能苦苦追随前人的困境。
代码托管早期有 SourceForge,我刚刚工作时,构建的 Clang On Windows
便是发布在 SourceForge 上分发的,现在已经好几年没登录 SourceForge 了,Git 的发展不快不慢,但终归是流行起来了,GitHub 把其他平台彻底碾压,有点所向披靡的样子。不过国内得益于政策环境,GitHub 想进来并不容易,国内也就有了另一番天地。但是做到 GitHub 那样的规模并不容易,做到 GitHub 那样的技术更不容易,罗马不是一天建成的,这仍需要同行们的持续努力。