Git 原生钩子的深度优化
Git 原生钩子的深度优化
Force武装卫队 发表于1个月前
Git 原生钩子的深度优化
  • 发表于 1个月前
  • 阅读 5
  • 收藏 0
  • 点赞 0
  • 评论 0
摘要: Git 原生钩子是基于 C++ 开发的实现分支检测,大文件扫描,大存储库检测的工具。

前言

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

最后

优化是无止尽的。如果大家有更好的方案可以与我讨论。
共有 人打赏支持
Force武装卫队
粉丝 170
博文 35
码字总数 69446
作品 3
×
Force武装卫队
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
* 金额(元)
¥1 ¥5 ¥10 ¥20 其他金额
打赏人
留言
* 支付类型
微信扫码支付
打赏金额:
已支付成功
打赏金额: