Skip to content
Blogster on GitHub Dinesh on Twitter

CS Infra工作一年碎碎念

从离开校园,步入社会到现在已经工作一年多了,之前都很少写东西,现在是个契机把这一年来工作的方方面面总结一下。

入职两周半转组

我2022年7月进入一家上海互联网公司工作,主要部门是基础架构云计算的网络部门,当时刚入职对网络了解十分有限,仅限于TCP、UDP之类的东西,但也说得上是热情十足,觉得可以在公司里面好好干一番大事业。当时还是疫情期间,我允许在家先remote一段时间,公司给我分配的mentor与我并不是同一个base,而是与我同一个base的组长来指导我,能感觉到我的组长的活非常多,经常只有晚上十点多才有空和我们(我和其他的应届生)答疑一下工作的问题。当时组长给了我一个landing的文档,主要的内容就是工作环境的配置,以及组内做的项目的一些文档,在前两周要求我输出一个组内产品的试用报告。在入职没几天,我不知道发了什么疯,去找我的mentor问,能不能给我讲一下某个项目的代码,mentor让我先看下项目的设计架构,但是我看了好久也没看出个所以然来,对公司的网络云产品大图更是抓瞎,别提有多痛苦了,感觉自己不适合做网络。随后在第二周,我的leader突然找我,问我愿不愿意转到隔壁组去做IaaS,我有点懵逼,不过为什么不呢,总好过一直那么痛苦,就这样我转到了IaaS,和新leader聊了聊,才知道本来我入职的组就应该是在IaaS,只不过网络部门缺人,所以我被要了过去,不过兜兜转转,又回到了IaaS。

初识Linux和BPF

随着疫情的影响减小,我也来到了上海,正式换到了新的组,遇到了新的面孔。刚入职主要就是捣鼓下虚拟机,mentor给了我个任务,了解libvirt、qemu和kvm,并创建一台虚拟机,于是我就在我的开发机(也是一台linux虚拟机)上用virt-manager创建了一台Centos-8的虚拟机,没错,就是嵌套虚拟化,主要就是了解并学习了下虚拟化相关的知识,只是学习到了皮毛,门都没有入。

随后,我也没能继续把玩虚拟机,mentor给我分配了一些benchmark竞赛的活,主要是调优自家的云服务器,看看为啥redis跑在自家某些特定规格的云服务器上为何与友商的差距这么大。在这期间学习了NUMA架构,Linux网卡多队列、网卡中断对网络收发包性能的影响(irqbalance),同时也学习了一些通用的Linux工具,perf、sar、iperf、top、numactl、ethtool、bcc(主要是 syscount 以及 funclatency)。这个期间感觉自己是在快速的增长见识,从对Linux一无所知到略知皮毛,Linux是如此庞大的一个系统,而之前对我来说完全就是一个黑盒,现在已经知道在部分场景下如何观测它,面对性能问题更有信心了一些。

在入职第二个月的一天中午食堂排队打饭的时候,我和我mentor闲聊到,怎么入职以来一直都没有写代码的活,谁知mentor会心一笑,说别急,会有的。谁知道我这嘴贱的一问,会对我之后的工作产生这么大的影响。在第二天,我就被leader和mentor拉到了一个群聊里面,群里还有一些容器网络的同学,他们的leader也在,我leader提到了国外某家做云基础设施监控的厂商在网络性能分析链路这块做的非常好,使用到了eBPF技术,我们也需要做一个类似的东西出来。好家伙,又是一个我从来没有听过的词汇,容器网络那边的同学有过一些相关方面的经验,提到了不要用bcc,libbcc的可移植性太垃圾,库大还需要在客户的机器上的编译,推荐我们使用libbpf,就这样踏上了一条不归路。

随后的一个月,主要学习了BPF相关的知识以及bcc和libbpf的一些基本使用,同时leader联络了美研那边的有过eBPF经验的同学参与到这个项目来,我们的目标是能观测到系统上进程的网络流量,美研的同学提到procfs里面 /proc/<pid>/net/dev里面可能会进程网络的统计数据,我调研过后发现其实不是这样子的,/proc/<pid>/net/dev里面的内容其实和 /proc/net/dev的内容一致,原因是网卡统计信息来说对于进程并不可见。在此期间,我发现bcc工具里面个别工具(tcplife tcpretrans tcprtt)就能满足我们的需求,并且不需要重新造轮子,只需要集成到我们的监控组件里面,我尝试说服我的mentor先用现有的工具,看看效果怎么样,但因为不可能在客户的机器上装bcc工具被mentor回绝。作罢,于是就这样,几个人吭哧吭哧地进行调研,最后决定了先基本照抄国外云基础设施监控厂商的代码,先有个demo。

一个完整的eBPF程序包含内核程序(也叫BPF程序,为了混淆,后文统一称内核程序)和用户程序,由于美研那边的同学之前有过开发BPF程序的经验,自然而然地开发内核程序地重担交到了他们身上,而我被mentor叫去写用户程序。由于我们的监控组件是用go写的,我也调研了社区上开发eBPF程序的go第三方库,之前已经决定了使用libbpf,所以选择了社区上一个用cgo对libbpf封装的第三方库libbpfgo。在期间,踩了一些cgo的一些坑,比如cgo静态链接glibc会导致程序panic,与程序内其他代码调用dlopen加载动态链接库导致动态库符号冲突,由于坑太多,于是换成了动态链接方式,以为到此就万事大吉了,但没想到的是,噩梦才刚刚开始。

我在测试不同发行版OS的兼容性时发现,在比较低内核的OS上发现我们监控组件链接glibc的版本与部署机器上的glibc版本不一致发生了符号冲突,监控组件因为目标机器上的glibc版本太低而缺少我们程序所需的一些函数符号发生了crash。我请教了组内的同事以及我们组的架构师,他表示还是推荐用静态链接的方式,因为glibc在不同OS版本上会遇到特别多的问题,但静态链接已经被封死了道路,并且由于我们监控组件是部署在所有的云服务器上的,当时已经临近封板,我已经心生退意,但又有点不甘心自己做的东西暴毙在半路上,再垃圾也要把它做完。于是我们基于glibc的版本做了不同版本的分发,对于glibc版本过低的发行版OS,不引入BPF,而满足glibc版本要求的发行版OS,安装引入BPF版本的监控组件,解决了燃眉之急。

考虑到维护两套代码的人力成本太高,后续肯定是要把两套代码统一的,于是我开始着手解决之前glibc版本冲突的问题,我请教了语言组的同学,语言组的同学表示表示可以利用glibc前向兼容的能力解决,于是我开始着手寻找自家云服务器上glibc版本最低的镜像,发现只有CentOS 7满足这个需求,glibc版本为2.17,但是由于BPF程序至少需要clang10才能编译,而CentOS 7的软件源太旧,根本没有clang 10,索性我就下载了llvm的源码自行编译,这期间又踩了一些坑,都是泪不说了。就这样,我重新构建了我们监控组件的编译镜像,我们终于有了一个可以兼容任何云服务器glibc版本的libbpf动态链接库,以为到这里就万事大吉了?没想到的是我们的监控组件还需要跨平台编译,这意味着我们需要在编译镜像里面安装ARM的编译工具链,但CentOS 7的软件源太旧了,根本没有backport ARM的编译工具链,一口老血,于是我想了个办法,分两个编译镜像,一个用于编译依赖2.17 glibc版本的libbpf动态链接库,一个用于编译监控组件,至此,链接问题终于解决了,监控组件不会发生crash了,期间还学习了一些链接的知识(LD_LIBRARY_PATHLD_PRELOAD)。

就这样大半年过去了,虽然解决了监控组件的crash问题,但还是遇到了新的问题,比较老的发行版OS并没有打开BTF内核选项 CONFIG_INFO_DEBUG_BTF,这是libbpf的CORE(Compiler once, Run everywhere))得以实现的一个重要配置。这意味着,没有BTF内核选项的机器就无法通过libbpf运行BPF程序,为了能让所有没打开BTF内核选项的云服务器镜像运行我们的BPF程序,在社区上发现了btf-archive,里面存在着不同内核提取出来的BTF文件供程序自定义加载,我支持了少数内核版本的镜像,但一个BTF文件太大了,大概几MB,为了支持所有没打开BTF内核选项的云服务器的镜像,打包了一些BTF文件,整个监控组件的安装包已经膨胀地非常厉害了,从原来地35MB大概135MB左右。好在,没有膨胀得更大,我也乐得维持现状,不幸的是,云服务器的镜像是会更新的,这意味着内核版本会发生改变,而btf-archive中一个具体的内核版本会对应一个BTF文件,而镜像的更新是不会通知到我们的,这意味着镜像内核一旦更新,这边的BTF文件就会失效,这里有两种方法,一种是继续添加对应内核版本的BTF文件,坏处就是程序会膨胀得非常大,另一个方法就是在程序中指定不同内核版本的镜像使用同一个BTF文件,这里不同内核版本指的是在(major, minor, patch, minor-patch)表示方法中,major、minor和patch相同,而minor-patch不同的内核,这里其实具有一些侥幸心理,那就是我们的BPF程序不会踩到不同BTF文件的有变化的地方。更好的做法是什么呢?社区上有btf-gen用来生成精简的BTF文件,他针对不同内核 以及所编写的BPF程序 生成精简的、可移植的BTF文件,相对于原来根据内核提取出来的的BTF文件体积缩小到了几KB,这样即使我们要支持所有内核的OS,BTF文件也不会占用过大的体积。

在这期间还遇到老旧内核无法通过BPF verifier的情况,以及不同内核版本的函数符号不一致出现的一些不兼容问题,这些都通通略过了。讲述一个期间遇到的一个十分有趣的问题,我在同一份代码(同一个分支)不同机器编译出来的libbpf.so会有不同的结果,不同的结果的意思是其中低版本内核的OS编译出来的libbpf.so没法正常加载BPF程序,而高版本内核编译出来的则不会出问题。通过gdb单步调试定位到是bpf_opon syscall出了问题。用bpftrace hook到同一个kprobe上发现没问题,于是通过strace把BPF syscall的参数都打印出来做对比,发现是kernel version code不一致的问题,于是探究了一下,发现是编译的时候的一个计算内核版本码的宏在不同内核上的定义不一样导致的,为此,我还向linux社区发了一个patch修复这个问题,不过内核维护者认为这是发行版的问题,不是内核的问题,我也同意他的说法,不过仍然是感到十分遗憾。最后我在编译镜像上手动更正了这个宏定义,至此问题解决。

监控怎么这么难

整个demo上线后并没能如预期一致,很快TSDB组的同学找过来,说我们云服务器的监控打点格式不规范(labels里面value存在空格字符之类)导致他们存储节点的CPU发生尖刺(spike),我想了想不对劲,一般只有打点数量过大才会导致CPU负载变高,后来他们排查后确实是如此,我们自查后发现了BPF追踪网络收发包kprobe会存在一些奇怪的现象,当然这里是我们的程序写的有问题,导致BPF Map中缓存了大量的entry,用户程序打点就会膨胀的特别厉害。另外一个是高基数问题导致的时间线膨胀,我们的BPF追踪的网络连接是以五元组(saddr, daddr, sport, dport, pid)来标识的,这里的五元组和平时我们说的五元组有点区别,我们平时说的五元组是包含family的,但这里有点不一样,因为我们最初的目标是为了监控进程的网络流量情况,但我们也需要网络连接的监控,从这里可以发现,我们几乎每个label都是高基数信息,它们的取值范围几乎都不是固定的,除了sport和dport,但取值范围也非常大,可以说每个label都踩在了黑名单上,这意味着这个label组合会造成很严重的时间线膨胀。当然这里有一些工作可以做,比如降基,按服务粒度来表示网络流量,(saddr:sport) -> src_service, (daddr:dport) -> dst_service,当然如果是在云原生、微服务场景下,这样实现会特别方便。另外一种方式是避免指标的滥用,高基的信息不适合放在指标里面,更适合用日志进行承载。

除了时间线膨胀外,这里面还存在性能问题,QA在测试Intel SPR机器上redis的benchmark时,发现结果出奇的差,最后内核组的同学定位到是我们hook的一些kprobe导致的,后来自查发现是因为我们hook到的kprobe的触发频率太快导致的性能下降,网络链路上的hot path基本踩了一遍,有没有什么办法解决呢,当然有,就是hook到一些触发频率没那么高的kprobe上,这里面brendan gregg大神的博客也给出相应的介绍,后悔没能早点看到,收获颇丰。

我从中学到了什么?回想之前踩过的坑,我认为都是在设计之初就能避免的问题,主要原因在于调研不够充分,对要做的东西没有一个全面的认识,由于团队内并不是专门做监控观测的组,很多东西都没有了解得特别透彻,例如是高基数的问题,如果仔细去学习Prometheus的官方文档,最佳实践,是可以提前避免这种情况发生的。BPF网络监控导致的应用程序性能降低也是同理,如果能充分些调研,早些看到bcc/libbpf tools中针对网络监控hook function的考量,就可以少走很多弯路。

内存被谁吃掉了

在线上阶段也遇到一些OOM的情况,这里挑一例分享一下,我们得监控组件(通过systemd管理)在某个GPU机器上很快(2、3分钟左右)就OOM了,内存上涨得十分的快,内存上限设置得是50M,我去查看进程的RSS内存,发现其实是稳定的在30多M这样子,RSS和我看到的内存占用不太一样,那到底是哪里再涨呢,回想了一下,我看到的内存使用(systemctl status xxx.service))其实是cgroup的内存占用,于是翻看了cgroup内存子系统的文档,发现了kmem这一个项,虽然文档没有直接指明cgroup总的内存使用是包含kmem的(也许是我漏了某个地方?),但我通过计算确定,cgroup内存占用一定是包含kmem这一项的占用的。为了确定这一点,我把cgroup的内存上限从50MB扩大到了200MB,再次观察,RSS占用到了90MB这样,但是kmem占用一至在持续增长,甚至到与RSS相当的地步。我使用了BCC的memleak工具进行内存泄露排查,确实发现了代码中存在内存没有释放的情况,发现是引用的一个第三方库导致的,于是升级了第三方库的版本,解决了内存泄露的问题,但仍然发现kmem上涨得特别快,基本上17分钟左右cgruop内存就超过200MB,服务就被OOM kill了,为了确定kmem没有泄露,我求助了内核组的同学,他们建议我扩大内存上限到更大,持续跑一天,看下kmem是否能稳定在一个peak值,我把cgroup内存上限扩大到了20G,跑了一天后,发现kmem稳定在了150MB左右,并没有发生泄露。于是,为了探究内存究竟被kmem哪部分吃掉了,把cgroup内存上限调回到了200MB,等到OOM kill时,通过crash工具排查,发现大部分kmem都存在slub的per cpu partial cache中!这时候问题突然变得可解释了,我们的代码每分钟会去调用nvidia驱动的接口,设备驱动申请内存时会通过slub内存分配器,而大部分都落在了per cpu partial上,通过cpu offline了一半后,我们发现kmem占用也少了一半,实锤了这个问题, 那如何解决这个问题呢?

  • offline 部分cpu或者绑定cpu亲和性,减少监控组件申请slub内存时到per cpu partial cache上。当然这种方案在生产环境不太可用。
  • 继续扩大内存限制,因为我们知道不会发生内存泄露,在内存充足并且客户允许的情况下

还有一个有意思的是,在这个机器的内核版本上,内核会为每一个cgroup分配一个kmem_cache,这也会导致大量的内存占用,内核在5.7版本中将这种方式取消了,所有的cgruop共享同一个kmem_cache,减少了大量的内存占用。

除此之外,还遇到几例特别有意思的内存问题,例如memcg的异步回收以及内存碎片,受限于篇幅,这里就不展开了。

迷茫

上述都是工作一年来遇到的一些比较有意思的事,但之外更多的是感到迷茫,自己特别想找一个方向深耕下去,例如eBPF或者性能调优,但是都没有特别多的机会,工作上的活都比较分散,我觉得是特别遗憾的一件事,比如说一直要学Rust已经喊了很久了,但是都没有一个特别合适的时机,或许在工作中没有应用的场景,导致动力不足,希望未来一切会好起来吧。