摘要: 码云是国内最大的代码托管平台,为了支持更大的用户规模,开发团队也在对一些组件进行大规模的重构.
前言
码云是国内最大的代码托管平台。码云基于 Gitlab 5.5 开发,经过几年的开发已经和官方的 Gitlab 有了很大的不同。 为了支撑更大的用户规模,码云也在不断的改进,而本文也主要分享码云分布式 Brzo GIT HTTP 服务器的开发经验。
码云分布式概述
自码云研发分布式以来,其分布式方案也发生了几次演。在 2014 年,码云(当时的 GIT@OSC ) 出现了高速的增长, 用户和项目越来越多,在旧的方案中,多个机器通过 NFS 挂载在前端服务器上,用户对仓库的读写和网页浏览最终都是在前端服务器上被处理, 这样的机制容易带来严重的性能问题,第一是 IO 与计算 过于集中,第二是 NFS 带来了巨大的内网流量。
团队决定使用 Ceph FS,当服务最终迁移到 Ceph 上时,迁移成功一天之后就出现了严重的宕机事故,经过研究发现, Git 存储库具有海量的小文件,海量小文件一直是分布式文件系统的难题,并且当时 Ceph 也并未完善,我们不得不回退到旧的 NFS 方案。 码云的分布式由此被提出到议程。
码云分布式研发之初,最先被提出的方案是也就是直接使用分布式 RPC, 然而开发团队并未对 Git 版本控制软件的基础特性有深入的研究, 并且团队也缺乏基础服务开发人员,基于 RPC 的分布式方案也就只限于我的 demo 之中。
后来有团队成员提出了使用 NGINX 动态代理,通过解析请求的 URL,将与存储相关的请求代理到存储服务器上,然后, 存储服务器上的 Gitlab 对请求进行解析。这样一来, 浏览器访问,以及 Git 的 HTTP 协议 clone 都能够分发到各个存储服务器上。 这个时候剩下的就是如何实现 NGINX 动态代理了,由于我实现 git 的 svn 接入时有过 NGINX 模块开发经验,所以就被安排到 NGINX 动态代理模块的开发, 以及路由模块的开发。路由策略一开始直接使用Gitlab 的 Magic Path 策略,即取用户的前两个字符 (A~Z|a~z|0~9|-_) 等, 不同的 Magic Path 对应不同的内网 IP。后来改为在 MySQL 中存储用户的仓库所在的机器内网 IP,并将 IP 缓存到 Redis 中,独立存储。 路由模块自主的向 Redis~MySQL 读取路由,当从 MySQL 中也找不到存储机器时,才返回错误。对于存储库无关的 URL 请求, 随机分发到不同的存储服务器上。先后有 zouqilin 和 lowkey2046 参与开发。
NGINX 的动态代理方案中,其模块开发也经历了基于 NDK 旧版路由,NDK 新版路由,以及 Upstream 新版路由的演进。 目前已经稳定运行,其中不乏企业用户的私有化部署。
对于 SSH 协议方案的分布式支持,最初采用的是 zouqilin 的意见: 使用端口转发。 gitlab-shell 只需要少量修改就能支持了 SSH 端口转发。 这个时候,唯一没有分布式支持的就是 svn 协议,作为 svn 兼容实现的开发者,我在接受 svn 分布式任务后,使用 Boost.Asio 开发实现了 svnsrv 动态代理服务器,经过一些波折,svnsrv 服务器也逐渐稳定下来。
在今年初,我研究 Git 协议后,开发了 git-srv 服务器,这个服务器接受一些参数,然后启动 git 传输命令 (这些命令有 git-upload-pack git-receive-pack git-upload-archive) ,将接收的网络数据写到命令的标准输入,将命令的标准输出, 标准错误通过网络发送给客户端。基于 git-srv ,实现了 hook 的 git-upload-pack,git-receive-pack,git-upload-archive。 在这些命令启动时, 会加载 CratosMini 路由库,自动连接到对应的存储服务器上的 git-srv, Git 的 Git 协议和 HTTP 协议以及 SSH 协议 操作都可以通过这些命令支持分布式。 然而这个方案需要频繁的启动进程,并不是非常高效。后来便开始开发 Miracle(SSHD),Mixture,Hover 这些项目。当然 SSH 方案也有新的 SSHD 取代。
Sshd (ssh://) 基于 libssh 开发,减少了 ssh 连接过程的进程创建次数,直接与 git-srv 通信。 而 Github 实际上也是使用 libssh 开发的服务器。 Mixture (git-daemon git://) 基于 Boost.Asio 开发,是 git 协议分布式动态服务器,直接与 git-srv 通信。 Hover (Brzo http://) 基于 Boost.Asio 开发,是 HTTP 协议服务器,直接与 git-srv 通信。 Aton 基于 Crow 开发,是监听服务器,将机器上的服务信息以及机器信息输出成 JSON 格式,返回给管理员。
这些服务的实现,使得码云整个架构变得清晰起来。也能够支撑更大的用户规模,更好的横向扩展。
Brzo 架构与实现
Brzo 是码云分布式架构的重要组件,它实现了 Git HTTP 协议的分布式,在 Git 的网络协议中,HTTPS 流量占据了很大一部分。 在码云团队实现了 SSH, GIT 协议的分布式,以及存储机器上的分布式基础服务 (git-srv) 后, Git HTTP 分布式的改造也提上日程。
Git 的 HTTP 协议可以分为哑协议和智能协议,哑协议就是通过 GET 获取到存储库中的引用和包文件。这个不需要在服务器上安装 git 就可以访问, 目前,包括码云在内的代码托管平台基本上都不支持哑协议。 另一类协议是智能协议,使用 HTTP 请求,方式描述如下:
Git clone 或者 fetch 操作:
- GET /pathto/repo.git/info/refs?service=git-upload-pack
- POST /pathto/repo.git/git-upload-pack
Git push 操作:
- GET /pathto/repo.git/info/refs/service=git-receive-pack
- POST /pathto/repo.git/git-receive-pack
GET 拿到的是服务器的引用列表和支持的操作, POST 在 clone 时 推送需要的引用,返回远程库打包的 pack 文件,POST 在 push 时, 推送本地仓库与服务器引用差异的打包,返回服务器解包的结果,这些都是动态生成的。 了解到 GIT 的 HTTP 协议原理, 才能更好的实现 GIT 的 HTTP 协议分布式服务器。
在项目初期,我曾经使用 .Net Core 实现过 Brzo 同等功能的服务器,在 Linux 上正常运行,由于团队没有 C# 使用经验, 项目可能无法维护,于是 C# 版也就没有继续开发了,仅仅是个实验性项目。
码云的基础服务主要是使用 C++开发,在使用 C++ 的过程中,虽然 C++ 标准没有添加网络库,但是有许多第三网络库可以被开发者使用, 操作系统提供的 API 也能直接被 C++ 项目使用( 比如在 Windows 系统,如果开发 HTTP 服务器,可以直接使用HTTP.sys 提供的 API, 这个是经过内核优化,再使用 RIO 优化 ,效率一骑绝尘)。
在选择第三方库时,却苦于这些第三方 HTTP 库并不一定适合服务场景,比如 Microsoft 开源的 cpprestsdk,基于 HTTP.sys ( Linux 是 Boost.Asio ), 支持 Linux,还专门实现了 Parallel Patterns Library,使用体验和 C# await 类似, 而 Brzo 需要动态代理到存储服务器, 并且需要针对 Git 的特殊场景进行优化。 Brzo 需要支持 Git 的 智能协议,并且与 git-srv 通讯,cpprestsdk 在实现这些功能时显得麻烦并且低效, 后来我还使用过 Boost。HTTP 库,发现并不是很合适,鉴于 HTTP 1.1协议比较简单,我在使用 CURL(WinHTTP) 实现 HttpRequest 时, 曾经做过一些简单解析,于是我干脆直接基于 Boost.Asio 实现 HTTP 协议服务器 Brzo。 Brzo 被设计为一个针对 GIT HTTP 协议优化的服务器, 需要支持 HTTP 1.1, 支持 Chunked Encoding,支持 GZip 解析(可以不支持 GZip 响应); 由于 Brzo 可能与 NGINX 一同运行在同一前端机器, 支持 Unix domain socket 能够优化反向代理效率,故而 Brzo 添加了 Unix domain socket 支持。
HTTP 协议解析
要获取 HTTP 协议全文,可以访问: RFC7230, RFC7231 ,RFC7232 RFC7233, RFC7234, RFC7235, RFC7236,RFC7237。
除此之外,还可以阅读 《HTTP 权威指南》。
Git 的 HTTP 协议是 HTTP 协议的真子集,当 GIT 使用哑协议访问远程仓库时,就是纯粹的 GET 请求,请求的资源都是静态的存在在远程服务器上, 面对这种请求, NGINX 开启 sendfile 就能很好的支持。 当 GIT 使用智能协议访问远程仓库时,情况变得稍微复杂,请求分为 GET 和 POST, 然后头部的一些字段的属性需要符合 GIT 的规范,比如 Content-Type。并且请求体也可能是动态生成的,这个时候就是 chunked 编码了。
了解了 GIT 的 HTTP 协议,如果要针对 GIT 实现 HTTP 服务器,首先要解析头部,然后请求体解析需要支持解析 gzip,以及 chunked 编码, 由于 git 不会同时使用chunked+gzip 编码,所以这一点可以忽略。然后就是生成 chunked 编码。 HTTP 1.1协议要支持 KeepAlive, 所以 Brzo 还要支持 KeepAlive。
HTTP KeepAlive 策略
KeepAlive 的实现简单来说就是打开 socket 后,处理完流程后,服务端 socket 并不主动断开,而是设置超时,超时时间内,有新的连接就继续处理, 重设定时器。如果超时时间过后仍然没有新的连接,就关闭 socket。
如果使用 Session 来描述整个 HTTP 处理流程, 处理完成后重置 Session,继续等待请求即可。如图:
如果是客户端如下图:
图片来自于 HTTP Keepalive Connections and Web Performance
Chunked 编码
在网络的世界里,有些资源是静态的,大小可期的,使用 HTTP 请求获取文件时,Content-Length 就能够拿到大小,从而按照大小将数据全部读取, 然而,还有很多资源是动态生成的,而 GIT 的 HTTP 协议,Push 的 POST 操作的请求体是 send-pack 的标准输出, 这个大小只能边读取边计算。 所以这个时候的请求体就是 chunked 编码。在服务器上,无论是 fetch 还是 push 操作,都是 git-upload-pack (git-receive-pack) 的标准输出, 这个时候响应体也是 chunked 编码。
Chunked 编码的 BNF 格式描述如下:
Chunked-Body = *chunk
last-chunk
trailer
CRLF
chunk = chunk-size [ chunk-extension ] CRLF
chunk-data CRLF
chunk-size = 1*HEX
last-chunk = 1*("0") [ chunk-extension ] CRLF
chunk-extension= *( ";" chunk-ext-name [ "=" chunk-ext-val ] )
chunk-ext-name = token
chunk-ext-val = token | quoted-string
chunk-data = chunk-size(OCTET)
trailer = *(entity-header CRLF)
在使用 Boost.Asio 实现 HTTP 协议时,遇到 Chunked 编码的第一选择是使用 boost::asio::streambuf 配合 boost::asio::async_read_until 先读取 chunk-size 然后读取 chunk-data,cpprestsdk 正是使用 streambuf 解析 chunked-encoding, async_read_until 先读取一定长度的数据, 如果存在 CRLF 就返回,不存在就继续度, async_read_until 内部使用 boost::regex 实现。 出于内存分配和读取效率上的考量, Brzo 使用固定长度缓冲区,并且封装了一个 chunked 解析状态机:
static const int8_t unhex[256] = {
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8,
9, -1, -1, -1, -1, -1, -1, -1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1};
class ChunkedsizeImpl {
public:
enum State {
StatusClear, ////
RequireInput,
ChunkedLengthOK
};
void Reset() {
state_ = StatusClear;
offset_ = 0;
chklen_ = 0;
}
int ChunkedsizeEx(const char *data, size_t datalen){
switch (state_) {
case StatusClear:
break;
case RequireInput:
offset_ = 0;