Hi,欢迎来到 卡瓦邦噶!我是 laixintao,现在生活在新加坡。我的工作是 SRE,喜欢在终端完成大部分工作,对各种技术都感兴趣。我从 2013 年开始写这个博客,写的内容很广泛,运维的方法论,编程的思考,工作的感悟,除了技术内容之外,还会分享一些读书感想,旅行游记,电影和音乐等。欢迎留下你的评论。

声明:本博客内容仅代表本人观点,和我的雇主无关。本博客承诺不含有 AI 生成的内容,所有内容未加说明均为博主原创,一经发布自动进入公有领域,本人放弃所有权利。转载无需本人同意。但是依然建议在转载的时候留下本博客的链接,因为这里的很多内容在发布之后会还会不断地继续更新和追加内容。 Not By AI

一个由 BGP Route Aggregation 引发的问题

上周遇到的一个问题很有意思,后来搜索相关的资料,找到的也比较少,感觉有必要记录一下。

问题的场景很简单:我们有两个路由设备同时发布了 10.81.0.0/16 的网段做 ECMP1,网络一切正常。拓扑如下图。现在,有一个新的 IP,只存在于 Router A 上,所以 Router A 宣告网段 10.81.100.100/32,而 Router B 不宣告。这样,由于在路由表中,/32 的 prefix 比 /16 要长,所以 Router X 在从路由表选路的时候,10.81.100.100 会优先选择去 Router A,而对于其他的 10.81.0.0/16 的网段,会负载均衡到 A 和 B 两台路由器上。

简化的拓扑图

理论上,一切看似合理并且正常。但是 /32 的网段一经宣告,10.81.0.0/16 的网络都挂了。

事后我们得知,在 Router X 上有一条路由聚合配置。但是这条合理的路由聚合,怎么会让整个网段挂掉呢?

BGP 路由聚合

为什么需要路由聚合呢?

Router A 每次宣告一个网段给 Router X,Router X 的 BGP 路由就会多一个。Router B 每次宣告一个网段,X 上也会多一个。可想而知,Router X 上的路由是它的下游的总和。同理,Router X 上游的路由器的路由将会更多。路由的条目越多,对路由器的性能要求就越高。所以,核心路由器要想处理所有的路由条目,就需要性能非常高。性能是有上限的,假设性能再搞也无法处理这么多路由,怎么办呢?我们可以优化另一个变量——路由条目2

如何减少路由条目呢? 考虑下面 3 个网段:

  • 10.81.2.0/24
  • 10.81.3.0/24
  • 10.83.4.4/26

其实都可以汇聚成一个网段:10.81.0.0/16。把这个网段宣告出去,收到的流量可以在 X 这里根据自己的路由表进行转发。

这里产生了一个问题:就是我们宣告了自己没有路由的网段出去,比如我们的路由中并不存在 10.81.5.0/24 这个段,但是被我们的 10.81.0.0/16 宣告了出去。

由此,会产生两个问题。第一个问题,假设其他路由器有到 10.81.5.0/24 的路由,那么会不会走到我们的 10.81.0.0/16 这里来呢?答案是不会的。因为路由表的匹配规则是最长前缀匹配/24 比我们的 /16 优先级更高。

第二个问题更加严重一些,路由的聚合可能导致环路3

考虑下面这个拓扑图,两个路由器都存在路由聚合的配置。

路由聚合导致环路产生的例子

这里的问题是,10.81.4.1 这个 IP 不存在于 A 也不存在于 B,但是由于路由聚合的配置,A 认为在 B 上,B 认为在 A 上,导致在转发的时候会出现环路。虽然 IP 层有 TTL 机制,会让这个包最终被丢弃,但是也会让两个路由器在某些网段的转发上浪费一些计算资源。

如何避免在转发「不存在的网段」的时候出现的环路呢?一个思路是我们精确的控制聚合的配置,不配置出来可能产生环路的聚合,但是这几乎是不可能的。(就像用静态路由配置替代动态路由一样不可能)。

另一个思路是,在 10.81.4.1 这种本地没有路由的包出现的时候,直接「黑洞」掉。方法很简单,就是在每次聚合的时候,创建一条路由,终点是 Null0,即直接丢弃。

具体来说,在上图的 Router A 中,聚合本地的三条路由到 /16,我们应该这么做:

  • 向外宣告路由 10.81.0.0/16,以达到减少路由条目的目的4
  • 在本地插入一条 Null0 的路由,使得本地的路由最终如下。

注意,路由表的顺序没有意义,因为用的是最长前缀匹配。转发包的时候,对于 10.81.1.0/24 这种本地存在的段,因为它们的前缀比 /16 长,所以正常转发;对于不存在的段,比如 10.81.4.1,会命中 10.81.0.0/16 -> Null0 的路由,直接在本地丢弃。这样,就可以阻止环路的产生。由聚合而自动产生的 /16 是一个防环的兜底路由,正常情况下,不应该使用这条路由,如果命中这条路由,说明无法转发的包到达了路由器,直接丢弃即可。

回到本文开头的问题上,为什么宣告一条 /32 会导致整个网段挂掉呢?Null0 不是说只是兜底而已吗?回答这个问题,还要补充一点知识。

BGP 和路由表

路由设备按照路由表(叫做 RIB, Routing Information Base)进行转发(实际上还有一层加速用的 FIB,但是 FIB 的 source of the truth 是 RIB,所以这里先忽略)。RIB 转发的逻辑是最长前缀匹配。

RIB 是怎么生成的呢?一种是静态配置,即静态路由。另一种是动态路由协议。路由协议之间交换路由信息,然后负责动态修改 RIB。在有多条可达路由的时候,怎么决定把哪一条路由写入到 RIB 呢?这就是不同的路由协议来决定的了。比如,BGP 有 13 条选路原则5;OSPF 和 IS-IS 这种协议也有自己的路径选择算法。

路由协议和 RIB 的关系6

这张图比较好,不同的路由协议可以同时运行,不同的路由协议可以根据自己的算法来操作路由表,决定转发路径。

路由的聚合也是路由协议的一部分。像 OSPF, EIGRP, BGP 这些协议,都有关于路由聚合的定义和支持。重申一下:路由聚合是路由协议的 feature,而不是路由表 RIB 的。

这也就是说,路由聚合中产生的 Null0 黑洞条目首先出现在 BGP 中,然后 BGP 根据自己的选路原则,放到路由表中。

回到本文最先开始讨论的问题,现在就可以用上面的知识来解释这个问题了。

首先,Router X 会收到 3 条路由。

到达 Router X,经过聚合之后,在 BGP 里面,会有 4 条路,多出来的一条是聚合产生的 Null0 黑洞路由。

到达 10.81.0.0/16 的路由有 3 个

BGP 会按照自己的选路原则,在 10.81.0.0/16 的 3 条路径中选择一条放到 RIB 中。这 3 条路径中,Null0 这条可是本地路由,Weight 是最高的。所以,Null0 由于其他两条真实存在的路由,进入了 RIB。

show ip route

可以看到路由表中,只有 10.81.100.100 明细路由和 10.81.0.0/16 到 Null0 的黑洞路由,其他两条路由被刷下去了。

到这里,真相就大白了。10.81.100.100 在没有发布的时候,10.81.0.0/16 工作正常。但是一旦发布,10.81.0.0/16 的正常路由就被路由聚合产生的 Null0 给刷下去了。

  1. 数据中心网络高可用技术:ECMP ↩︎
  2. Understand Route Aggregation in BGP – Cisco ↩︎
  3. 网络中的环路和防环技术 ↩︎
  4. 确认了下没有写错,这里的意思是 Tiao Mu De Mu Di,博大精深的中文! ↩︎
  5. Select BGP Best Path Algorithm – Cisco ↩︎
  6. 来源:FIB表与RIB表的区别与联系 – &Yhao – 博客园 ↩︎
 

2024 年的总结

今年依旧是平静而充实的一年。

特别喜欢 Armin1 的这一段话:

Whether it’s working on a project, solving a difficult problem, or even refining soft skills like communication, the act of showing up and putting in the hours is essential. Practice makes perfect, but more so it’s all about progress rather than perfection. Each hour you spend iterating, refining, failing and retrying brings you closer to excellence. It doesn’t always feel that way in the moment but when you look back at what you did before, you will see your progress. And that act of looking back, and seeing how you improved, is immensely rewarding and in turn makes you enjoy your work.

「注重进步,而非完美」。当知道自己每天在朝着自己的目标进步的时候,每天都有一种充实的感觉。

生活上

年初体验了邮轮2,今年旅行去了东京3。然后去了马六甲和吉隆坡,马六甲和吉隆坡的旅行比较随意,说去就去了,路途上也没有特别惊艳的地方,就连博客都没有写。今年去了很多次新山,新马通道支持自助过关了,方便了很多。

工作

今年有意控制自己喜欢创造新的项目的冲动,所以新的项目开启的不多,自己写了一个有意思的玩具,只不过从来没有公开过,属于自娱自乐,除此之外就没有什么新的项目了,Github 空空,只有偶尔维护一些已有的项目。

大部分的精力花在了工作上,处理了非常多的问题,有意思的一些已经纪录在了博客上,所以今年博客产量稍微多一些。

一如即往,今年在公司里也有一些新项目的尝试。比较自豪的是写了一个 IP 信息查询系统。可以查询一个 IP 对应的设备,机器,容器,虚拟机,VIP,等等,集成了 20 多个系统,并且用了并发加载,后端只用了 500 行左右,虽然小,但是用户体验很不错,个人很满意。

也有一些想法,比如经常在讨论某某技术的时候,一些大公司的人会评价说「这种开源的方案不适用我们 XX 公司的规模,规模一大就不行了。」然后发明一种新的技术宣称能解决超大规模的问题。我发现很多时候规模大是有用户滥用的本质问题的。比如监控系统的 metrics,遇到过好几次,低质量的 metrics 占用了 50% 的存储资源,只要用户优化一下4,就可以节省很多资源;还有镜像存储服务,我们的镜像存储服务的压力越来愈大,但是用户的 docker 镜像的优化空间还很大5,甚至很多镜像中存储了很多不需要的内容。去解决技术问题无法从根本解决滥用问题,规模扩大到多大总有一天还是会遇到规模问题,不如从根本解决用户计费问题来的根本。现在认为,即使是内部产品,也要像公有云那样的思路去做,不能提供免费的服务。另一个思路也能解决问题,就是只招聘专家,每一个人都知道自己在做什么,不做出来浪费的事情。

另一个想法是关于运维系统的透明,即我们做操作所使用平台。我越来越喜欢 Jenkins 了,因为它足够透明和简单,当运维操作失败的时候,我只看 Job 的日志就可以了,从来不需要去排查 Jenkins 自己内部的问题。但是最近公司总是冒出来一个又一个的团队,想要用代码开发出来一个运维平台来给 SRE 使用。然后遇到问题总是得去排查平台的代码问题,最终变得很复杂,得明白运维平台的代码是怎么运行的,才能用的好。怀念 Jenkins 和 Ansible 的操作。

业余的爱好

读书方面,印象比较深的是读了莫言的《丰乳肥臀》,非常震撼。读了很多和网络相关的书。读了一本 The Manager’s Path: A Guide for Tech Leaders Navigating Growth and Change,写的很好,也许后面有空单独写一篇博客。

今年也看了很多影视作品,年末上映的《好东西》非常好看;偶然发现 Disenchantment 已经更新完了,之前只看过第一季,所以今年一口气全部看完了,连着看居然有一些审美疲劳;印象比较深的还有《奥本海默》,《沙丘2》(我今年最喜欢的电影了),Shameless 年初跟了一段时间,感觉烂尾了,已经弃剧。

今年玩的令人印象深刻的游戏是《黑神话·悟空》,已经期待了四年,今年终于玩上了。非常好玩,一点也没有让人失望。下半年腾讯宣布 Switch 游戏机服务器停止维护,作为国服勇士,我可以领四个游戏,最喜欢的是超级马里奥兄弟,和欣一块玩,不亦乐乎,已经通关了,现在再玩第二遍,寻找所有的隐藏金币。

欣对星星比较着迷,跟着普及了一下天文学的知识,能看到星星的夜晚都不会浪费掉,会去公园的躺椅上辨认一颗颗星星。

新的一年

  • 打算继续锻炼身体,2024 年锻炼的还是不够多,今年要多花点时间锻炼才行;
  • 打算带爸妈去新加坡和泰国旅行,已经订好酒店了,期待;
  • 继续在网络方面的研究,还有很多想要学习的内容在排队;

其他的年终总结列表:

  1. 2013年
  2. 2014年
  3. 2015年
  4. 2016年
  5. 2017年
  6. 2018年
  7. 2019年
  8. 2020年
  9. 2021年
  10. 2022年
  11. 2023年
  12. 2024年
  1. Armin 写的人生反思:https://lucumr.pocoo.org/2024/12/26/reflecting-on-life/ ↩︎
  2. 去远航 ↩︎
  3. 去伊豆去东京 ↩︎
  4. 程序的 Metrics 优化——Prometheus 文档缺失的一章 ↩︎
  5. Docker 镜像构建的一些技巧 ↩︎
 

用 LD_PRELOAD 写魔法程序

我和我的同事们排查网路问题非常喜欢 MTR,它是 traceroute 和 ping 的结合,可以快速告诉我们一个网络包的路径。是哪一跳丢包,或者延迟太高。

这些路径使用 IP 地址的形式表示的。没有人能记住这么多 IP 地址,所以我们需要有意义的名字。我在公司里写了一个平台,集成了其他的二十多个系统,给一个 IP,能查询出来这个 IP 对应的网络设备,容器,物理机,虚拟机等等。

复制 IP 到这个系统中查看结果,还是有些不方便,于是就想能不能让 MTR 直接展示设备的名字。

最终效果如图,可以展示 mtr 路径中所有的网络设备的名字。敏感信息已经隐藏。

MTR 支持 DNS PTR 反查,如果查到记录,会优先展示名字。这些名字在公网上通常没有什么意义。我们的内网 DNS 没有支持 PTR,所以这个 PTR 记录在默认的情况下也没有什么用。如果通过 DNS 系统来支持 PTR 记录的话,成本就有些大了,得对 DNS 做一些改造,DNS 又是一个比较重要的系统。那能不能有一个影响比较小的旁路系统来做到这个 feature 呢?

看了下 MTR 的代码,MTR 对 DNS PTR 的支持是通过 libc 的函数 getnameinfo(3) 来实现的。那么我就可以用 LD_PRELOAD 这个 hack,自己写一个 getnameinfo(3) 编译成 so,告诉 MTR 在寻找 getnameinfo(3) 的时候,先寻找我的 so 文件。这样,我就可以自己定义 getnameinfo(3) 的行为了。(就像魔法一样)

其实,proxychains1 程序也是用这种方式工作的,你只要在运行的命令前面添加 proxychains,proxychains 就会对后面运行的命令注入 LD_PRELOAD 环境变量,从而让程序调用的 socket API 是 proxychains 定义的,然后 proxychains 就会对 socket 做一些代理转发。

POC

可以写一个最简单的程序验证这样是否可行。

我们写一个最简单的函数,声明和 getnameinfo(3) 一模一样。

不过,这个函数无论对于什么 ip,都会返回 kawabangga.com. 然后编译,运行 traceroute 程序。

编译命令:gcc -shared -fPIC -o libmylib.so mylib.c -ldl

可以看到,traceroute 显示每一跳的名字都是 kawabangga.com 了。

用 Go 语言 POC

我比较倾向于用 Go 语言来实现逻辑,而不是用 C 语言。

Go 语言也是支持编译到 shared lib 的2。hello world 代码如下:

编译命令是:go build -o libmylib.so -buildmode=c-shared mylib.go

程序运行的命令一样,也可以看到 getnameinfo(3) 被成功 hook 了。

剩下的只需要在程序里面写逻辑代码就可以了,应该很简单(实际发现不简单)。

遇到问题 1: log 不打印

因为 traceroute 和 mtr 这种程序都是往 stdout 打印的,我开发代码又需要用 print 来调试,所以为了不干扰正常输出,就把日志打印到一个文件,通过 ENV 来控制日志是否需要打印,以及打印的日志路径。

结果就遇到了问题:日志打印在 traceroute 中是正常的,但是在 mtr 中看不到日志。

因为程序是「寄生」在 mtr 的代码中的,而且在 traceroute 中没有问题,所以应该和 mtr 的代码有关。

去看了一下代码,发现 mtr 和 traceroute 不一样的地方是:mtr 是用了异步的方式来执行 getnameinfo 函数,因为这个函数可能使用 DNS PTR 记录,涉及到网络请求,耗时可能很长。所以在调用的时候,mtr 会 fork 一个进程,专门执行这个函数。fork 出来的进程使用 PIPE 和主进程通信,并且 fork 之后就把除了 stdin, stdout, stderr 和 PIPE 之外的 fd 都关闭了。跟着关闭的,也包括我们的日志文件 fd。

解决办法就是修改了一下程序,不写日志到文件了,而是写到 stderr。在 debug 的时候,就 mtr 2>/tmp/stderr.log 这样,就可以了。

问题2: Golang 程序卡住

之前的 POC 代码运行正常,我把它改成通过 HTTP 请求 IP 信息服务的时候,居然就出问题了。mtr 显示的是 IP 而不是名字。从现象看,是函数执行失败了。

但是失败在哪里呢?

经过一段时间的排查,发现了这么几个现象:

  1. 每次程序运行之后,都有一些 mtr 进程残留在系统中没有结束;
  2. traceroute 还是正常的,但是 mtr 每次都会出问题;
  3. 不断加 print 来 debug,发现程序的问题出现在发送 HTTP 请求的地方,但是把这个地方的代码改成直接返回固定字符串,程序就正常了;

使用 gdb 去 debug,backtrace 如下,也看不出什么信息。

Golang 程序的 backtrace

看起来这个线程好像没有什么事情可以做。

花了一天排查无果,问了朋友,最后发现这个问题:Goroutines cause deadlocks after fork() when run in shared library #155383,而且开发人员的回复是:This is to be expected. It’s almost impossible for multithreaded Go runtime to handle arbitrary forks.

而 mtr 正好执行了 fork,所以这算是一个 Golang 的 runtime 问题——如果以 shared-lib 的方式运行,那么主程序是不能 fork 的,如果 fork,Go runtime 中的 goroutine 管理与多线程模型,fork 后线程状态的不一致可能会导致无法正常恢复,从而触发死锁。

最后,我把程序的逻辑用 C 语言实现了一下,就没有问题了。把它打包成 deb 包发布到了内网中。打包推荐用 nfpm4,非常方便,传统的用 apt 工具链打包太复杂了。

  1. Proxychains 项目:https://github.com/rofl0r/proxychains-ng,我的博客:编译安装proxychains4 ↩︎
  2. Fun building shared libraries in Go, https://medium.com/@walkert/fun-building-shared-libraries-in-go-639500a6a669 ↩︎
  3. https://github.com/golang/go/issues/15538 ↩︎
  4. https://github.com/goreleaser/nfpm ↩︎
 

Little Endian vs Big Endian

看了一个视频,Endianness Explained1, 对 Endianness 介绍的非常好。尤其是对常见的两个误解的解释。

Endian 的概念是 1980 年的4月1日 Danny Cohen 在 On Holy Wars and a Plea for Peace2 提出来的。名字的来源是《格列佛游记》小说中有两派人由于「打鸡蛋打哪头」意见不同而打架。鸡蛋的端,叫做 end,end-ian 就是这么来的。

格列佛游记

在计算机的世界中,最小可以寻址的单位通常是 byte,1 个 byte 是 8 bits,这个没有争议。

如果一个数据类型需要占用多个 bytes,比如 32 位的 int,占用 4 个 bytes,那我们在编程的时候会用一个内存地址表示这个 int,从这个地址开始,后面一共 4 个 bytes,来表示一个 int。比如一个 16 进制表示的 int 值:0x01020304,其中 01 是 most significant bit,是重要的数字,因为它决定了整个数字的数量级是多大,可以把 01 叫做大头,如果把大头放在前面,即,表示成 0x01020304,这就是 Big Endian. 如果把大头放在这 4 个 bytes 的最后,即,表示成 0x04030201,那就是 Little Endian.

一般来说,这对于程序员是透明的,因为处理 endian 的是 CPU,把数据写入内存的是 CPU,从内存取出来的还是 CPU。所以不管 CPU 是 Little Endian 还是 Big Endian,只要它始终保持一致,就可以了。

但是如果涉及到网络的序列化(其实不仅仅是网络,数据只要离开本机,就涉及 endianness),问题就来了。一个 CPU 序列化成 Little Endian,通过网络传输给另一个机器,另一个机器的 CPU 是 Big Endian,那不就乱套了吗?(现代的大部分 CPU 是 Little Endian, 网络使用 Network Byte order,是 Big Endian。)

所以这里就需要一个转换,libc 提供了两个转换函数:

  • htons: Host byte order 转换成 Network byte order
  • ntohs: Network byte order 转换成 Host byte order

两个误解:

  • 「Endian 是 bit 级别的。」这是错误的,Endian 是 byte 级别的。假如一个数据格式只有 8 个 bit,那么无论是 Little Endian 还是 Big Endian,这 8 个 bit 的表示方法都不会变。也没有 Endian 转换的必要。(有些情况下,同一个 byte 内,编译器也会考虑 endianness,见评论2
  • 另一个需要注意的点,是 Endian 只涉及多个 byte 组成的数据结构。char array 这种不算,假设一个 char array 是 “abcd”,那么它无论在哪里都是 “abcd”。Endian 的问题只发生在用一个地址表示多个字节,而 array 中,每一个元素都可以被寻址。在序列化的时候,是对 4 个元素分别序列化。

在 yiran 的博客3中看到这样一个问题:写代码来判断当前的 CPU 是 Big Endian 还是 Little Endian,可以这么来解决:通过把一个 4 bytes 的 int 转换成一个 char array,看下 most significant bit 是在高位还是低位即可。

如果是 4 1 就是小端,因为最高有效位(most significant bit)1 是在最低位 p[3]

  1. https://www.youtube.com/watch?v=LxvFb63OOs8 ↩︎
  2. ON HOLY WARS AND A PLEA FOR PEACE https://www.rfc-editor.org/ien/ien137.txt ↩︎
  3. https://zdyxry.github.io/2025/01/12/Weekly-Issue-%E5%86%AC%E6%B3%B3/#comment-6628440263 ↩︎
 

CPU 越多,延迟越高的问题排查

最近上线了一组规格比较高的 CPU 的机器。36 cores x 2 threads x 2 Sockets,在 htop 上可以看到 144 个 CPU。目的是用新机型来 POC。规格更高的硬件虽然更贵,但是总拥有成本 (TCO1) 会更低。机器运行成本只有一部分来自于购买硬件,还有 Rack 部署成本,电力等等,原来放 3 台机器,现在 1 台就够了。况且,在超售的情况下,10 个 CPU 可能可以当 12 个来用,100个 CPU 或许可以当成 150 个来用。超售的本质就是假设机器上的所有用户(容器)不会同时用满申请的 CPU capacity。那么更多的 CPU 就可以做更高的超售比例,因为资源池更大了。

htop

这是背景。然后有一天,用户报告这些机器上的网络延迟明显比较高。我使用 ping 确认了下,确实延迟已经高到可以让 ping 都感受到了。

现在已经排除用户程序的问题了,问题应该出在 kernel 的网络栈。

首先检查了 IRQ 队列相关的配置,都是正确的。就没有思路了。就去请教了另一位同事,原话引用如下:

什么情况下会引入延迟? 只有「多线程异步」操作能带来延迟,因为如果是单线程同步操作的话,那「延迟」几乎是恒定,因为就是纯代码执行而已了。

网络收包处理,在由网卡中断触发,Kernel 的中断处理分两部分:上半部分处理 hardirq,做的事情很少,只是处理中断信号然后 schedule softirq,即下半部分。softirq 会真正做协议栈的处理。

中断处理如果延迟了,就可能造成包的网络栈处理延迟。根据经验,问题发生在这里的概率较大。

同事推荐用 trace-irqoff2 这个工具来跟踪中断处理的延迟,这个工具能够统计中断被推迟处理的时间,以及导致中断处理推迟的栈。看了下代码,原理3应该是以一个 Kernel module 的方式运行,注册一个高精度计时器 hrtimer 定时执行硬中断,注册一个 timer 定时执行自己的代码,默认是 10ms 执行一次。每次执行的时候获取当前时间,和上次执行的时间对比。如果时间超过 10ms 太多,那就是因为某种原因导致 timer 没有定时执行了,同理,softirq 可能也被推迟了。timer 也是一种 interrupt,在 IRQ enable 的情况下会以抢占的方式运行,当我们的 timer 抢占进来的时候,可以打印出来抢占之前的 CPU stack,就可以知道在 timer 之前 CPU 在运行什么什么内容。

按照 trace-irqoff 的使用文档进行追踪,结果如下。

trace-irqoff 的结果

一个叫 estimation_timer 的函数夺人眼球。

查阅 Kernel 的源代码我们得知,这个是 IPVS 的统计函数。目的是遍历所有的 IPVS 相关的 rule4 (第一层循环),对于每一个 rule,读取每一个 CPU 的数据(第二层循环)。两层循环嵌套,就导致执行时间会比较长。这个函数也是以 timer 的形式注册,即每隔一段时间就会执行,执行期间 IRQ 是关闭的,即没有其他线程可以抢占 CPU,estimation_timer 函数会占据 CPU 直到执行结束。假设有 soft irq 调度到这个 CPU,那么延迟就增加了。

由于新机型 CPU 数量比较多,那么在相同的 rule 数量下,遍历所需要的时间也就更多。所以延迟也更高。之前的机型延迟也受此影响,不过不是很严重罢了。

通过和用户讨论,发现我们没有使用 IPVS 提供的这些统计信息,所以这个函数可以关闭。目前已经可以通过 sysctl 参数关闭了5,不需要打 patch:sysctl -w net.ipv4.vs.run_estimation=0

趣头条的相同问题的排查记录,也很有趣,值得一读:https://www.ebpf.top/post/ebpf_network_kpatch_ipvs/ (不过这个网站的证书貌似过期了)。

  1. Total cost of own: https://en.wikipedia.org/wiki/Total_cost_of_ownership ↩︎
  2. 字节跳动开发的中断追踪工具 https://github.com/bytedance/trace-irqoff ↩︎
  3. 对原理的理解不是特别自信,有错误请读者指出。 ↩︎
  4. estimation_timer 函数的源代码https://elixir.bootlin.com/linux/v5.8/source/net/netfilter/ipvs/ip_vs_est.c#L191ip_vs_read_cpu_stats 遍历 CPU 的源代码:https://elixir.bootlin.com/linux/v5.8/source/net/netfilter/ipvs/ip_vs_est.c#L56 ↩︎
  5. 相关的讨论:https://lore.kernel.org/netdev/[email protected]/T/ ↩︎