Git 原生钩子的深度优化

on under git
10 minute read

前言

Git 是最流行的版本控制工具,和大多数版本控制工具一样,Git 也拥有钩子特性,用户可以利用钩子实现一些附加功能,在 《Pro Git v2》 中,对钩子类型,消息格式等有详细介绍: 8.3 Customizing Git - Git Hooks

代码托管平台也会使用钩子,一般是使用 Server-Side Hooks。包括 pre-receive update post-receive

为什么要使用钩子?我们得先思考目前的 git 代码托管平台架构大多数是无状态的,也就是说 Web 是 Web, git 是 git。究其原因,目前服务器上的 git 传输实现基本上还是使用 git 命令做 smart 传输,这种传输本质上是一对命令做输入输出交换,类似 inetd。这种协议的缺陷在于:在子进程中传输的数据是隔离的,不透明的,不可控的。启动 Git 子命令后,权限控制,大文件检测等操作已不是 SSH 或者 HTTP 服务器能控制的了。当然,劫持网络数据进行深度分析是可以的,但那相当于重新实现一套 git。并且,这种性能上的损失也是平台不可接受的。 代码托管平台绝不能裹足不前,对于不适合的数据推送当然要拒绝他!幸运的是,我们还可以使用 hook 来阻挡不合适的数据推送了。

Gitlab 的 Update 钩子

码云最初利用 Gitlab 搭建起来,而钩子的使用策略是 Gitlab 早先的策略,即使用 Update 钩子。Sidekiq, 以及后来推出的分支保护功能以及大文件检测,都是利用 Update 钩子实现的。这块代码是在 Gitlab-Shell 中。保护分支实际上是在运行 Update 钩子时,请求 Gitlab 判断引用是否被允许修改。而大文件检测则是使用 Commit-Between 进行一个回溯 diff,深度最大为 20。Sidekiq 则是插入 redis 队列实现的。

我们知道推送代码时需要在远程服务器中运行 git-receive-pack 命令,recieve-pack 会在整个生命周期运行三种钩子,也就是前面所说的 Server-Side Hooks(这里当然有个前提,钩子不存在就不会被调用),Update 是第二个被调用的钩子。receive-pack 将使用如下命令执行钩子:

$GIT_DIR/hooks/update refname oldrev newrev

每更新一个引用执行一次,当钩子返回值不为零时,当前引用不会被更新。

新建分支时,oldrev 值为 0000000000000000000000000000000000000000
删除分支时,newrev 值为 0000000000000000000000000000000000000000

既然每一个引用都会执行一次,那么我们试想一下,一次性推送多个分支,并且分支都是新建分支,那么可以预见,无论是 Commit-Between Diff 还是保护分支还是任务队列的消耗时间都是成倍增加的。事实上也是如此,我们在测试服务器上推送大存储库,多分支,多 commit 时就发现了这个问题。

由于 Commit-Between Diff 深度的限制,一个精心构造的大文件是能够被推送到服务器而不被拒绝。

完全检测的 Git 原生钩子

既然 Update 钩子并不好,我们就得使用替代方案。pre-receive 是第一个被 receive-pack 调用的钩子,没有额外的命令行参数,无论更新多少引用都只会调用一次,引用列表会被 receive-pack 写入到 pre-receive 钩子进程的标准输入。格式原语如下:

refname SP oldrev SP newrev LF
refnameN SP oldrev SP newrev LF

这个时候,我们可以将保护分支功能移入到此钩子,使用此钩子实现保护分支与 update 不一致的是同时推送多个引用,一旦有一个分支被拒绝,所有的分支都会被拒绝,而 update 钩子并不是如此。不过带来的好处是显而易见的,在推送镜像存储库,多分支项目时,可以避免多次发起对 Gitlab 的网络请求。

post-receive 是最后被调用的钩子,格式与 pre-receive 完全一致,我们不能使用 pre-receive 更新 Sidekiq ,这是由于只有再在调用 update 钩子后,引用才会被更新,若 Sidekiq 在 pre-receive 钩子执行期间就响应可能会导致错误,因此在 post-receive 中更新 Sidekiq(redis)才是最安全的,在 post-receive 中执行 redis 命令还可以利用 KeepAlive 减少对 redis 的请求次数,从而优化服务器内部的网络。

update 钩子最后的功能只剩大文件检测了。如果将此功能移除,就完全不再需要 update 钩子。

在前面的博客:Git 存储格式与运用,我正是直接解析 pack/idx 文件格式来实现大文件检测。

一开始,我还使用 zip 解压松散文件读取文件大小,然后在 pack 文件中使用 libgit2 解析原始文件大小。实际上这种事情意义并不大,远程服务器上的存储库是一个 bare 存储库,所有的文件都是被压缩的,我们在统计存储库大小的时候也只是统计裸仓库的 objects 目录占用空间大小,因此,我们不需要检测原始文件大小,这样一来,钩子能够避免检测原始大小带来的性能损失。(实际上检测原始大小有个策略,只有超过一定值的对象才会检测原始大小用来判断文件是否超大。)

原生钩子使用 C++ 开发,经测试,效率远比 Update 钩子效率高,实际上 Update 钩子在解析 commit 的过程中就避免不了性能损失了。

这个时候的原生钩子还有一些不足,比如一些大的 pack 文件需要频繁检测,因此,我还实现了一个缓存机制,将 pack 检测到的数据写入到缓存文件中,避免频繁检测对应的 pack。这一点,我们需要知道,pack 文件一旦内容改变,名字也会改变,名字格式为 pack-$sha1.pack

原生钩子使用环境隔离特性

在 Git 2.11.0 时,git 改进了其推送的工作流程,增加了 Quarantine Environment 机制,此时,receive-pack 将会把所有推送的对象放置在隔离的临时目录中,一旦推送被接受才会将对象移动到常规的主对象目录,环境隔离的机制在整个 pre-receive 钩子的生命周期中是有效的。启动 update 钩子之前就会失效。

因此,我将原生钩子使用环境隔离机制进行改造。好处显而易见,只需要检测隔离目录中的 pack。pack 缓存也不再需要了。对于大文件检测的效果更明显,比如超出警告的大文件只会在第一次推送时发出警告,提高了用户体验。

当推送被拒绝时,临时目录会被删除,这样能够避免重复的失败推送回带来大量悬空对象,造成存储库的无效膨胀,存储库的无效膨胀会占用用户的配额,而清理无效膨胀需要使用 git gc 命令 (git gc –prune=now)。频繁的运行 GC 也会给服务器带来过重的压力。

由于在 update 钩子执行时,环境隔离已经失效,这种特性也就决定了 update 钩子无法胜任这些工作。

而 Gitlab 也支持了此特性 :Accept environment variables from the pre-receive script

环境隔离需要理解几个环境变量:

$GIT_QUARANTINE_PATH
$GIT_OBJECT_DIRECTORY
$GIT_ALTERNATE_OBJECT_DIRECTORIES

我只用到了 GIT_QUARANTINE_PATH

在 git 2.15.0 之前的版本中,如果在隔离目录中运行 git update-ref 会损坏存储库,之后的版本已经拒绝了 git update-ref 在隔离目录中运行。

延时读的原生钩子

我在解析 pack 文件时,设计了一个 ObjectIndex 结构,读取 index 文件中关于 pack 中的文件数目后,使用一个 vector 存储对象。将所有的偏移依次读取,然后通过 std::sort 将偏移按大到小排序,依次相减,就得到对应的对象压缩后的体积。然后判断是否超限。

struct ObjectIndex{
    uint8_t sha1[20];
    uint32_t offset;
}

在使用 std::vector 之前,使用 std::list 存储对象,效率不高,在分析 FreeBSD 的 1G 多大的存储库时,在我的破笔记本上跑出了 9s 耗时,对象 300 多万。太慢了,而改成 vector 后,耗时为 3s。

最近,笔者决定优化一下,第一步是将比较函数的内联。最初的比较函数如下:

bool objectidxcompare(const ObjectIndex &first, const ObjectIndex &second) {
  return (first.offset > second.offset);
}

我们知道,函数调用是需要耗费时间的,随着对象数目增多,这种影响愈加明显。于是我将 ObjectIndex 改造成如下:

struct ObjectIndex {
  /// DON't Modify
  bool operator<(const ObjectIndex &o) { return offset > o.offset; }
  uint8_t sha1[20];
  uint32_t offset{0};
};

通过内联,运行时间减少了 13.8%。这还不够,std::sort 内部使用了 std::swap 交换对象,而 ObjectIndx::sha1 的交换需要拷贝,并且读取 sha1 值也是需要系统调用的。为什么不先不读取 sha1 值,而是保存 sha1 值的 index。ObjectIndex 格式改成如下:

struct ObjectIndex {
  /// DON't Modify
  bool operator<(const ObjectIndex &o) { return offset > o.offset; }
  uint32_t offset{0};
  uint32_t index{0};
};

这样,我们不再读取 sha1 值,需要 sha1 值的时候,再通过偏移计算 sha1 在 idx 文件中的位置。

#define GIT_SHA1_RAWSZ 20
// so buffer >41,
char *sha1_to_hex_r(char *buffer, const unsigned char *sha1) {
  static const char hex[] = "0123456789abcdef";
  char *buf = buffer;
  int i;

  for (i = 0; i < GIT_SHA1_RAWSZ; i++) {
    unsigned int val = *sha1++;
    *buf++ = hex[val >> 4];
    *buf++ = hex[val & 0xf];
  }
  *buf = '\0';

  return buffer;
}

inline const char *Sha1FromIndex(FILE *fp, char *buf, std::uint32_t i) {
  unsigned char sha1__[20];
  constexpr int offsetbegin = 4 + 4 + 4 + 255 * 4;
  fseek(fp, offsetbegin + i * 20, SEEK_SET);
  if (fread(sha1__, 1, 20, fp) != 20) {
    return "unkown";
  }
  ::sha1_to_hex_r(buf, sha1__);
  return buf;
}

这样真的减少了一半的时间。比如 Linux 内核源码 1.9GB 数据,562 W 对象,从 1442 毫秒减少到 700 多毫秒。内存占用也减少了 2/3。不要小看 16Byte 字节的节省,几百万个对象节省的空间就很客观了。

利用内存布局减少系统调用次数

就函数调用而言,要尽可能的减少频繁调用的函数的调用次数,特别是达到百万级别的,在读取偏移时就可以一次性读取,于是我将读取偏移改为一次性读写,利用 vector 预先分配的内存,核心代码如下:

 std::vector<ObjectIndex> objs(counts);
  auto objsraw = objs.data();
  auto bufc = reinterpret_cast<char *>(objsraw);
  /// 4*counts
  auto binteger =
      reinterpret_cast<int *>(bufc + sizeof(ObjectIndex) * counts / 2);
  if (fread(binteger, 4, counts, fp) != counts) {
    console::Printeln("fread error ");
    fclose(fp);
    return false;
  }
  for (uint32_t i = 0; i < counts; i++) {
    /// DON'T  change the order of operations
    objsraw[i].offset = ntohl(binteger[i]);
    objsraw[i].index = i;
  }

  std::sort(objs.begin(), objs.end());

这里一定要注意,index 的填充一定要后于偏移的计算。

这次优化比前面的 700 多毫秒减少了 100 多毫秒。

更大的大文件检测

前面主要是针对 pack 文件小于 2GB的优化, 对于推送大于 2GB 的 pack 文件需要额外的处理,比如 offset 需要改成 int64_t 而且 offset 需要读取 8bytes 的条目。git 的文档并不详细,因此还需要从 git 源码中发现一些真实的解决方案。

#define MEMSIZE_LIMIT (scale::Gigabyte * 4)
/// Please don't modify type layout.
template <typename IntegerT> struct object_base {
  bool operator<(const object_base<IntegerT> &o) { return offset > o.offset; }
  IntegerT offset{0};
  std::uint32_t index{0};
};
typedef object_base<std::uint32_t> ObjectIndex;
typedef object_base<std::uint64_t> ObjectIndexLarge;
////
bool Gitidx::reviewlarge(std::size_t limitsize, std::size_t warnsize) {
  if (fseek(fp, 4 + 4 + nr * (20 + 4) + 256 * 4, SEEK_SET) != 0) {
    console::Printeln("fseek error");
    return false;
  }
  auto mmsize = nr * sizeof(ObjectIndexLarge) + sizeof(std::uint64_t) * lnr;
  if (mmsize > MEMSIZE_LIMIT) {
    console::Printe("Idx file Too large. Unsupport it");
    return false;
  }
  std::vector<ObjectIndexLarge> objs(nr);
  auto objsraw = objs.data();
  auto bufc = reinterpret_cast<char *>(objsraw);
  /// 4*counts
  auto binteger = reinterpret_cast<std::uint32_t *>(
      bufc + sizeof(ObjectIndexLarge) * nr - 4 * nr);
  if (fread(binteger, 1, 4 * nr, fp) != 4 * nr) {
    console::Printeln("fread error ");
    return false;
  }
  std::vector<std::uint64_t> lnrv(lnr); //
  if (fread(lnrv.data(), 1, 8 * lnr, fp) != 8 * lnr) {
    console::Printeln("fread 8byte error");
    return false;
  }
  /// https://github.com/git/git/blob/master/builtin/index-pack.c#L1511
  for (uint32_t i = 0; i < nr; i++) {
    /// DON'T  change the order of operations
    auto off = ntohl(binteger[i]);
    if (!(off & 0x80000000)) {
      objsraw[i].offset = off;
      objsraw[i].index = i;
      continue;
    }
    off = off & 0x7fffffff;
    if (off >= lnr) {
      console::Printeln("Bad Index data");
      return false;
    }
    objsraw[i].offset = default_bswap64(lnrv[off]);
    objsraw[i].index = i;
  }
  std::sort(objs.begin(), objs.end());
  uint64_t pre = pksize - 20;
  for (auto &i : objs) {
    auto size = pre - i.offset;
    pre = i.offset;
    if (size >= limitsize) {
      char buf[50];
      console::Printeln("File: %s size %4.2f MB, more than %4.2f MB %ld",
                        Sha1FromIndex(fp, buf, i.index),
                        (float)size / scale::Megabyte,
                        (float)limitsize / scale::Megabyte, i.offset);
      // bfilecount++;
      return false;
    }
    if (size >= warnsize) {
      if (wfs.size() < MaxNumberOfDetails) {
        FileInfo fileinfo;
        char buf[50];
        fileinfo.path = Sha1FromIndex(fp, buf, i.index);
        fileinfo.size = size;
        wfs.push_back(std::move(fileinfo));
      }
    }
  }
  return true;
}

Pack 文件格式限制

以上内容都是基于 Git Packfile 格式第二版。当 pack 文件内对象数目巨大时,以上优化需要大量内存,而在前面设置了最大内存限制,这意味着对象的数目是存在限制的,实际上需要的内存与 idx 文件的大小接近。在解析 pack 文件时常规的做法是使用 mmap ,但这不能很好的对偏移进行排序,从而达到快速检测的目的。本文也将更新一个 C++17 样例程序。

检测何时引入大文件

GitNativeHook 为了性能还是损失了一个功能,无法检测何时引入了大文件,大文件的文件名是什么,这个时候大家可以使用我开发的 git-analyze 工具去检测什么时候引入了大文件以及文件名:Git-Analyze

最后

优化是无止尽的。如果大家有更好的方案可以与我讨论。