码云分布式之 Brzo 服务器

on under git
14 minute read

前言

码云是国内最大的代码托管平台之一。码云基于 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 客户端访问码云服务器,其中 HTTP(HTTPS) 比重最大。 在码云团队实现 git 的 SSH, GIT 协议的分布式,和存储机器上的分布式基础服务 (git-srv) 后, Git HTTP 分布式的改造也提上日程。

Git 的 HTTP 协议可以分为哑协议和智能协议,哑协议就是通过 GET 获取到存储库中的引用和包文件。这个不需要在服务器上安装 git 就可以访问, 可以简单的理解这个时候就是静态页面一样,类似于下载站。目前,包括码云在内的代码托管平台基本上都不支持哑协议。 另一类协议是智能协议,使用 HTTP 请求,方式描述如下:

Git clone 或者 fetch 操作:

  1. GET /pathto/repo.git/info/refs?service=git-upload-pack
  2. POST /pathto/repo.git/git-upload-pack

Git push 操作:

  1. GET /pathto/repo.git/info/refs/service=git-receive-pack
  2. POST /pathto/repo.git/git-receive-pack

GET 拿到的是服务器的引用列表和能力列表, POST 在 clone 时 推送需要的引用,返回远程库打包的对象文件,POST 在 push 时, 推送本地仓库与服务器引用差异的打包,返回服务器解包的结果,这些都是 git 对应命令动态输出到标准输出的。 了解 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 类似, 其主 HTTPListner 的要定位是 Restful Server,而不是 git 服务器这样的服务, Brzo 需要动态代理到存储服务器,并且需要针对 Git 的特殊场景进行优化。 Brzo 需要支持 Git 的 智能协议,并且与 git-srv 通讯,cpprestsdk 在实现这些功能时显得麻烦并且低效, 后来我还使用过 boost.http 库,发现也不是很适合。鉴于 HTTP 1.1协议比较简单,我在使用 CURL(WinHTTP) 实现 HttpRequest 时, 已经做过一些简单解析,实现 HTTP 协议也就没有什么难度了。于是我干脆直接基于 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 协议全文,可以访问: RFC7230RFC7231RFC7232 RFC7233RFC7234RFC7235RFC7236RFC7237

除此之外,还可以阅读 《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,继续等待请求即可。如图:

Server Flow

如果是客户端如下图:

TCP Flow

KeepAlive Client Flow

图片来自于 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;
    break;
  case ChunkedLengthOK:
    offset_ = 0;
    chklen_ = 0;
    break;
  }
  state_ = RequireInput;
  for (size_t i = 0; i < datalen; i++) {
    uint8_t c = static_cast<uint8_t>(data[i]);
    switch (c) {
    case '\r':
      break;
    case '\n': {
      offset_ = offset_ + i + 1;
      state_ = ChunkedLengthOK;
      return 1;
    }
    default:
      int8_t c2 = unhex[c];
      if (c2 == -1) {
        return -1;
      }
      chklen_ *= 16;
      chklen_ += c2;
      break;
    }
  }
  return 0;
  }
  std::size_t Chklen() const { return chklen_; }
  std::size_t Offset() const { return offset_; }

private:
  State state_{StatusClear};
  std::size_t offset_{0};
  std::size_t chklen_{0}; //// FM
};

ChunkedsizeEx 负责解析,根据返回值的不同,Session 将决定写 chunk 到后端还是继续读取。这样虽然使编码变得复杂,当减少内存分配, 降低拷贝。这对于长期运行的服务器来说,是非常重要的。

生成 chunked 编码时,使用 async_read_some 读取一定大小的数据,准备的缓冲区预留前8个字节,后2字节,共计10字节,读取到数据后, 将数据长度按照 chunk 规定格式写入前8个字节,8字节与缓冲区大小有关,尾部写入 “\r\n”。与存储服务器连接断开时(或者进程关闭输出), 写入 chunk-end 即 “0\r\n\r\n”

这种 chunk 策略,能够减少内存拷贝,降低服务器资源占用。Brzo 的 clone 速度和 GIT 协议服务器 Mixture (git-daemon) 接近。

GZip 编码

Git 客户端在 fetch 的 POST 请求时,发送给客户端的需要的引用列表一般使用 gzip 编码,此时 Content-Encoding 对应的编码是 gzip。

gzip 使用的是 Deflate 算法,要解析 gzip,通常的办法是使用 zlib 来实现。在 我开发 Exile 时, 就使用过 zlib 解析 gzip GZipStream.cpp 当然, GZipStream.cpp 解析的是已知 gzip 编码大小,一次性解析完毕就行,Brzo 解析则是读取多少解析多少, 所以这里使用了类似与 ChunkedsizeImpl 的策略。

class GZipExchange {
public:
  GZipExchange();
  ~GZipExchange();
  bool Initialize(char *refbuf, size_t bufsize); ////
  bool IsEmpty() const { return stream_.avail_out != 0; }
  bool AvailableJoin(const char *buf, size_t len);
  ssize_t Decompress();

private:
  z_stream stream_;
  uint8_t *out_;
  std::size_t chunksize_;
  bool initialized_{false};
};

其中 Initialize 将缓冲区绑定到 GitZipExchange, 然后 AvailableJoin 就是待解析的 gzip 缓存区, Decompress 就是不断解析,直至 IsEmpty 为真。 在 Session 中, GitZipExchange 变量使用 shared_ptr 包装, KeepAlive 重置时, reset 即可。

Unix domain socket 支持

Brzo 核心版运行在 Linux 上,用户在使用 NGINX, Apache 之类的 HTTP 服务器作为负载均衡服务器时, 可以通过 Unix domain socket 与 Brzo 通信, Brzo 基于 Boost.Asio 开发,在实现对 Unix domain socket 的支持时也就考虑到使用 Boost.Asio 的方案。

Boost.Asio 支持 Unix domain socket 可以使用: boost::asio::local::stream_protocol::socket

而普通的 TCP 使用 boost::asio::ip::tcp::socket, 两个类都拥有相同的读写函数,可以直接使用模板包装一下,就可以支持两种 socket 了。 更多的细节可以查看开源版本源码。

超时

与普通的 HTTP 服务器不同的是,GIT HTTP 服务器的超时需要特殊设置。比如,当使用 –depth 参数克隆远程仓库时, 虽然传输包大小会更小一些,但是程序在接受数据前将等待更长的时间,还有执行钩子也将花费更多的时间, 所以在 Brzo 的源码里,读取后端服务器时会将超时时间额外的延长。

Brzo 开源版本

Brzo 开源版本移除了验证和分布式功能,而 ProcessAsync 取代了后端的 socket。

进程的包装

ProcessAsync 就是进程的包装,将输入输出与对于平台的 绑定到一起。然后实现 async_read_some async_write_some 这样的函数。

#ifndef PROCESS_HPP
#define PROCESS_HPP
#include <boost/asio.hpp>
#ifdef _WIN32
#include <boost/asio/windows/stream_handle.hpp>
typedef boost::asio::windows::stream_handle stdiostream;
typedef DWORD ProcessId;
#else
#include <boost/asio/posix/stream_descriptor.hpp>
typedef boost::asio::posix::stream_descriptor stdiostream;
typedef pid_t ProcessId;
#endif

class ProcessAsync {
public:
  ProcessAsync(boost::asio::io_service &ios) : input_(ios), output_(ios) {}
  ~ProcessAsync() { ProcessClean(); }
  int Execute(int Argc, char **Argv);
  void ProcessClean();
  boost::asio::io_service &get_io_service() { return input_get_io_service(); }
  /// Write
  template <typename ConstBufferSequence, typename WriteHandler>
  void async_write_some(const ConstBufferSequence &buffers,
                        WriteHandler &&handler) {
    input_.async_write_some(buffers, handler);
  }
  //// Read
  template <typename MutableBufferSequence, typename ReadHandler>
  void async_read_some(const MutableBufferSequence &buffers,
                       ReadHandler &&handler) {
    output_.async_read_some(buffers, handler);
  }
  ////
  template <typename ConstBufferSequence, typename WriteHandler>
  void async_write(const ConstBufferSequence &buffers, WriteHandler &&handler) {
    boost::asio::async_write(input_, buffers, handler);
  }
  /////
  template <typename MutableBufferSequence, typename ReadHandler>
  void async_read(const MutableBufferSequence &buffers, ReadHandler &&handler) {
    boost::asio::async_read(output_, buffers, handler);
  }
  void cancel() {
    input_.cancel();
    output_.cancel();
  }
  void cancel(boost::system::error_code &ec) {
    input_.cancel(ec);
    output_.cancel(ec);
  }

private:
  stdiostream input_;
  stdiostream output_;
  ProcessId id_{0};
};

#endif

对于不同平台,Execute 函数是重中之重,启动进程,修改输入输出等等。

Windows 命名管道

在 Windows 中,匿名管道不支持端口完成,而对于 boost stream_handle 而言,不支持端口完成意味着不能异步读写, 所以我们需要改造新的管道,实际上匿名管道是命名管道的一种特殊实现,同样的,我们也可以使用命名管道实现支持端口完成的等价匿名管道。

BOOL WINAPI MzCreatePipeEx(OUT LPHANDLE lpReadPipe, OUT LPHANDLE lpWritePipe,
                           IN LPSECURITY_ATTRIBUTES lpPipeAttributes) {
  HANDLE ReadPipeHandle, WritePipeHandle;
  DWORD dwError;
  WCHAR PipeNameBuffer[MAX_PATH];

  //
  // Only one valid OpenMode flag - FILE_FLAG_OVERLAPPED
  //

  auto PipeId = InterlockedIncrement(&ProcessPipeId_);
  StringCchPrintfW(PipeNameBuffer, MAX_PATH, L"\\\\.\\Pipe\\Brzo.%08x.%08x",
                   GetCurrentProcessId(), PipeId);

  ReadPipeHandle = CreateNamedPipeW(
      PipeNameBuffer, PIPE_ACCESS_INBOUND | FILE_FLAG_OVERLAPPED,
      PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT,
      1,     // Number of pipes
      65536, // Out buffer size
      65536, // In buffer size
      0,     //
      lpPipeAttributes);

  if (!ReadPipeHandle) {
    return FALSE;
  }

  WritePipeHandle = CreateFileW(
      PipeNameBuffer, GENERIC_WRITE,
      0, // No sharing
      lpPipeAttributes, OPEN_EXISTING,
      FILE_ATTRIBUTE_NORMAL |
          FILE_FLAG_OVERLAPPED, /// child process input output is wait
      NULL                      // Template file
      );

  if (INVALID_HANDLE_VALUE == WritePipeHandle) {
    dwError = GetLastError();
    CloseHandle(ReadPipeHandle);
    SetLastError(dwError);
    return FALSE;
  }

  *lpReadPipe = ReadPipeHandle;
  *lpWritePipe = WritePipeHandle;
  return TRUE;
}

最后

Brzo 与之前码云使用的 grack 相比,具备了和 git-srv 服务集成的能力,由于完全异步的设计,不会阻塞的等待而导致服务失去响应。 Grack 在操作大仓库时,可能会导致服务器内存占用居高不下,而在 brzo 中,这个问题并不存在,并发很高时,内存和 CPU 也处于较低水平。

值得一提的是,gitlab 也使用 go 实现了 gitlab-workhorse 用以取代 grack。

在实现 Brzo 的过程中,遇到诸多难题,在解决这些难题的过程中也收获很多经验。关于 Brzo 的开源版,我们也将适时发布。

备注

  1. 此处 NDK 是 ngx_devel_kit, nginx development kit
  2. RIO - Registered Input/Output (RIO) API Extensions