用 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) 一模一样。

#include <stdio.h>
#include <netdb.h>
#include <string.h>
#include <sys/socket.h>

int getnameinfo(const struct sockaddr *__restrict addr, socklen_t addrlen,
                char *__restrict host, socklen_t hostlen,
                char *__restrict serv, socklen_t servlen, int flags) {

    strncpy(host, "kawabangga.com", hostlen);

    return 0;
}

不过,这个函数无论对于什么 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/ ↩︎
 

Keepalived 脑裂问题排查

用户报告问题,说在虚拟机里面启动的 Keepalived 有脑裂问题,一启动就开始脑裂了。

Keepalived1 是一个基于 LVS 的负载均衡和高可用框架。负载均衡主要是通过 VRRPv2 协议来实现的。VRRP 协议2在这个博客中介绍过,主要场景是两个路由器可以通过 VRRP 协议来协商出来一个 master,对外提供服务,当 master 挂的时候,slave 会成为 master 继续提供服务。

VRRP 的全称是 Virtual Router Redundancy Protocol,Keepalived 可不是 router,为什么用这个协议呢3?简单来说,Keepalived 要提供的是一个高可用的 VIP 服务,而 Virtual Router 本质上也是一个 VIP 来给其他的 Host 当作网关,这样一想,就很合理了。

Keepalived 实例之间无法达成一致,肯定是 VRRP 协商失败,而 VRRP 又是机器简单的协议,只有一种包的类型。出现 2 个 master 节点,那就肯定是 slave 的节点收不到 master 的 VRRP 协议包,认为 master 挂了,所以站出来当 master。

首先,用 tcpdump 检查了一下两个 keepalived,确实都在发送 VRRP 的包。而且VRRP 包的内容,如 auth,组 id,权重等,都正确。并且都只看得到自己发送出去的包,无法收到对方的包。

那接下来就来排查为什么这个包无法发送给 VRRP 的 slave。

虚拟机环境的网络简化如下。

虚拟机环境的网络架构

这个图看起来复杂,其实逻辑很简单。物理机 Host 使用 bonding 连接交换机,在 bond0 接口上配置 VLAN 封装,和交换机做 trunking,这一层对物理机内的网络使用几乎是透明的。然后虚拟机内网络使用 TAP4,所有 VM 内 eth0 发送的包会出现在 macvtap1 上,然后从 Host 的 bond0.1000 出去。 macvtap 其实就是 TAP 设备加上一个 bridge5,macvtap 基于 macvlan driver,使用一个 module 解决了原来 TAP + Bridge 的工作。

下一步就是定位 VRRP 包丢在了哪里,虽然涉及的网络 interface 很多,但是一个一个来排查就行了。首先给这个网络做了个简单的体检套餐,发现 ICMP 还是 TCP 都是一点问题没有,只有 VRRP 协议有问题。VRRP 协议是基于组播 Multicast 的,直觉上觉得可能是什么 ACL 把组播网络给 DROP 了。

从 Keepalived 发包的实例开始抓包,下一步直接抓发包的物理机接口 eth0 (二分查找定位么),确认 VRRP 正确发出去了,然后去收包的物理机上抓 eth0,也收到了,直接排除了交换机的问题。再抓 bond0.1000 接口,也抓得到,排除了 bonding 和 VLAN 问题。

下一步是 [email protected],也抓得到,然后去收包的 VM2 抓包 eth0,抓不到。那丢包就发生在 macvtap1 -> eth0 中。感觉已经接近真相了!

实际上并没有……

定位到这里之后,后面花了几个小时来研究为什么 macvtap1 的包没有转发到 eth0 中。

期间研究了 macvtap 设备的原理,libvirt 相关的文档,中间还有 chatGPT 这个半吊子瞎出主意,甚至把相关的 sysctl 参数都看了一遍,rp_filterxx_forwarding 之类的,仍然没有解决问题。这些没有用的排查就不记录了。

物理机的 macvtap 的 TAP 设备是由 qemu libvirt 接管的,负责转发到虚拟机中去。在研究 libvirt 和 multicast 流量的时候,发现了这个问题和回答6,虽然没有直接解决我的问题,但是感觉脑中一道闪电划过,有一个重要的本质问题被我忽略了——VRRP 是组播网络

单播是点对点发,广播是点对所有点群发,组播的特点是点对多点群发,「多点」指的是哪些点呢?怎么知道多点包括了哪些点呢?本质的原理是「订阅」,订阅了特定流量则会收到,如果不订阅,则收不到。

在网络上,多个设备之间订阅组播是通过 IGMP 协议7实现的。在同一个设备上,就是设备自己的实现了。

有了这个想法之后,我马上在 VM 中和 Host 中运行了 ip maddr show dev 命令进行验证。结果如下。

在 VM 中的运行结果
在 Host 中的运行结果

这条命令的含义是,列出来当前这个接口订阅的组播流量。通过结果可以看到,在 VM 中订阅了 VRRP 协议规定的组播地址,但是在物理机的 macvtap 接口上,就没有订阅这个地址。所以物理机的接口在收到发给 224.0.0.1 的 VRRP 流量之后,会认为当前这个接口没有订阅过这个组播,所以不需要这些流量,直接忽略。这是组播协议预期的工作方式,所以当我在排查接口的丢包参数的时候,都没有发现什么异常,因为这不算做是丢包吧,而算作是正常的处理方式。

那么为什么会出现这种情况呢?

按照组播协议的工作方式,当需要组播流量的时候,需要向操作系统通过 syscall 来发出「订阅」8,因为协议是由操作系统来处理的。但是我们这里存在 2 个操作系统,VM 是一个操作系统,Host 是一个操作系统。如上图所示,虽然 VM 知道订阅了这个组播地址,但是 Host 操作系统并不知情,两个系统是隔离的。所以当 Host 收到组播流量的时候,直接忽略了。

解决办法是,对接口设置 ip link set dev macvtap8 allmulticast on,意思是告诉接口,把所有的 multicast 都给收了,这样 VM 内的接口决定处理还是忽略,就正常了。(libvirt 也有 trustGuestRxFilters9 的配置选项)

VRRP 脑裂问题需要避免,在物理网络中,网关之间的 vrrp keepalive 会使用专用的 keepalive 线路,并且多条物理线路做 LACP 高可用。

  1. https://keepalived.readthedocs.io/en/latest/introduction.html ↩︎
  2. 数据中心网络高可用技术之从服务器到网关:首跳冗余协议 VRRP ↩︎
  3. Keepalived 的系统设计:https://keepalived.readthedocs.io/en/latest/software_design.html ↩︎
  4. TAP 设备在 VPN 和虚拟机网络中比较常见:https://en.wikipedia.org/wiki/TUN/TAP ↩︎
  5. https://virt.kernelnewbies.org/MacVTap ↩︎
  6. https://superuser.com/questions/944678/how-to-configure-macvtap-to-let-it-pass-multicast-packet-correctly ↩︎
  7. https://en.wikipedia.org/wiki/Internet_Group_Management_Protocol ↩︎
  8. 如何使用组播的教程 https://tldp.org/HOWTO/Multicast-HOWTO-6.html ↩︎
  9. https://libvirt.org/formatnetwork.html ↩︎
 

Linux interface Vlan 和 Bond 配置错误问题排查

昨天同事报告了一个 Linux 机器网络问题,现象是:一台服务器无法 ping 192.168.1.253,但是可以 ping 192.168.1.252 和 192.168.1.254. 这三个 IP 都是交换机的 IP,并且和和服务器的 IP 在同一个子网下。

服务器使用了 bond1 分别连接两台交换机2,两台交换机通过 VRRP 协议提供一个高可用的网关 IP3。其中,网段的最高位一般是 VRRP 的 VIP,即 192.168.1.254,而最高位 -1 和 -2 分别是两个交换机的物理 IP,即 192.168.1.253 和 192.168.1.252 分别是两台交换机。

于是,看到这个现象,自然而然地想到是其中一台交换机有问题,192.168.1.253 已经挂了,192.168.1.252 还存活,并且担任了 192.168.1.254 的 VIP 的责任。

先去这台服务器 ping 了一下,果然是 ping 不通的,ping 显示的错误信息是 Destination Host Unreachable。然后在服务器抓包,确认下 ICMP reply 确实没有发送回来。tcpdump -i bond0 icmp. 抓包确实没有看到 ICMP reply 包,但是奇怪的是,居然连 ICMP echo 也没有抓到

之后又去检查了交换机的配置,包括 channel-group,VLAN 配置,ACL 等等,也确认了下两台交换机之间的横连状态是正常的。这时候看起来不像是交换机的问题了。使用另一台服务器 ping 了一下这三个 IP,.252, .253, .254 都是通的。那应该是服务器的问题而不是交换机的问题。

其实这部分有些走弯路,因为 ping 明确显示 Destination Host Unreachable,说明这个包并没有发出去;而且 tcpdump 也没有抓到包,也可以印证。

接下来继续在服务器上定位问题。

ICMP 发包有问题,就先检查一下发包链路。之前遇到过类似错误,是 iptables 的 OUTPUT chain 把包 drop 了,于是先检查了 iptables,确认没有相关的 DROP。

ICMP 是基于 IP 层的协议,IP 层的协议依赖 ARP 协议来找到 MAC 地址,然后封装成二层 Frame,才能发出去,接下来去检查 ARP。(其实上一步直接检查 iptables 是不合理的,ARP 是第一步,有了 ARP 才可能构造出来完整的 Frame 开始发送,应该先从 ARP 开始排查)。

检查 arp -a | grep .253,发现 ARP 的 cache 结果是 <incomplete>. 然后用 arping 192.168.1.253 验证 ARP request 是否能得到正常的 reply,发现结果都是 Timeout。

到这里已经知道为什么 ping 会失败了,因为服务器得不到这个 IP 对应的 ARP 请求,所以 ping 无法将 ICMP request 的包发送出去,直接报错了。

接下来就定位为什么 ARP 会失败。

正常来说,ARP 应该从 bond0 接口发送出去一个 request,然后收到一个 reply,刷新服务器的 ARP cache entry。

服务器的 interface 配置如下,服务器所在的 VLAN 是 1000,和交换机做了 Trunking4,发送包的路由是走 bond0.1000@bond0 这个 interface,bond0.1000@bond0 是一个虚拟 interface,主要的功能是,发包的时候对包进行 802.1Q VLAN 封装,然后通过底层的 interface——在这里是 bond0——发送出去,收包的时候对 VLAN 进行解封装。

接口的逻辑图

我首先在 bond0 抓包,确认 ARP 的发送和接收在协议上是正常的。

结果在这一步就发现问题了,bond0 抓包发现,只有发出去的包,没有收到的包。

为啥交换机不响应 ARP 了呢?

这时候又怀疑是交换机的问题,去检查了交换机的两个端口配置。没有发现问题。而且在其他机器上,ping 和 arping 都是没有问题的,交换机设备的问题可能性比较小。

也不会是服务器安全策略的问题,如果是的话,tcpdump 也会先抓到包的,在后面才会被 iptables 之类的 DROP 掉。

于是仔细想一想交换机和服务器之间经过了哪些组件,网卡收包,中断,网卡 driver,bond driver,协议栈处理。抓包都没抓到,说明问题出在协议栈之前,于是怀疑到 bond driver 头上去。

下一步,在物理 interface 上抓包,确认物理 interface 到底收到了 ARP reply 了没有。结果是,发现 eth0 这个 interface 收到了 ARP reply!

ARP reply 在 eth0 上收到了,但是 bond0 上没收到。这下感觉快要得到答案了。bond 有两个 slave,我把 eth0 shutdown 了,只留下 eth1,然后网路正常了。那要么是 bond driver 真的有问题,要么是我们的配置有问题。从经验上看,Linux driver 存在 bug 的概率要远远小于我们的配置错误。于是我去检查 bond 相关的配置。

检查 bond 状态 (/proc/net/bonding/bond0 文件), bond 配置,都没发现问题。可能是 eht0 这个接口有问题?

在重新看 interface 的时候(即上面的 ip link 命令和输出),我发现了可疑的一条 interface:

这里多出来一个 VLAN interface。

所以,实际上的 interface 配置应该是如下这样。由于 eth0.1000 的存在,我怀疑 eth0 收到的 ARP reply 实际上是送给了 eth0.1000@eth0 而不是 bond0,然后在 ARP 协议处理的时候,Linux 认为我们没有从 eth0.1000 发送出去 ARP request,但是却收到了 ARP 响应,属于 Gratuitous ARP5. 而发送 ARP request 的 bond0,从来没有收到 ARP reply。ARP cache 是 per interface 的,所以 bond0 无法发送 ICMP 出去。

eth0.1000 的配置

证明这个猜测很简单,只要在 eth0.1000@eth0 抓包,看是否有 ARP reply 就好了。抓包发现果然有。

并且把这个接口的 arp_accept 打开,让其接受 Gratuitous ARP,发现 ARP cache 出现了如下记录:

说明这个结论是正确的。到这里就发现,其实问题不仅仅是 ARP 的问题,因为 bond 的两个 slave 有一个不对,收包的时候可能是从 eth0 收,也可能是从 eth1 收,取决于交换机的 hash 策略6。如果从 eth0 进来,那么协议栈的 skb 的 device 就会是 eth0.1000@eth0,所有有连接的协议处理都匹配不上。

于是我 shutdown eth0.1000@eth0 这个接口,理论上机器的配置应该都是对的了。

结果不是,问题依然存在,有点让人怀疑人生。由于接口 down 了就无法抓包了,不太好确认包是不是还在往 eth0.1000@eth0 送了。此处又花了一些时间排查,因为怀疑自己的推论是错误的,是不是有别的地方导致这个问题?一通误打误撞,决定删除这个多余的接口,然后网路就完全恢复了。从结果看,只 shutdown 这个接口不能阻止包往这个 vlan 接口送,得删除才行。

事后我们得知,这台服务器在 infra 团队交付的时候存在问题,应该配置 bonding,但是没有配置,只是在一条线(eth0)上配置了 VLAN。我们的同事拿到机器之后修复了 bonding 问题,但是并没有删除 eth0.1000@eth0 这个 VLAN 虚拟接口,导致产生了非预期的行为。

后来看了下源代码,发现 VLAN 的处理确实优先级比较高,在 __netif_receive_skb_core7 这里就会执行 vlan_do_recieve8,然后会把 device 的 id 设置在 skb 上。这个逻辑比 bond driver 的逻辑靠前,导致后续协议栈的处理,会认为这个包是从 eth0.1000@eth0 收到的,而不是从 bond0 收到的。

  1. 数据中心网络高可用技术之从服务器到交换机:802.3 ad ↩︎
  2. 数据中心网络高可用技术之从交换机到交换机:MLAG, 堆叠技术 ↩︎
  3. 数据中心网络高可用技术之从服务器到网关:首跳冗余协议 VRRP ↩︎
  4. VLAN Trunking Protocol ↩︎
  5. 特殊的 ARP 用法:Gratuitous ARP, ARP Probe 和 ARP Announce ↩︎
  6. 数据中心网络高可用技术之从服务器到交换机:链路聚合 (balance-xor, balance-rr, broadcast) ↩︎
  7. https://elixir.bootlin.com/linux/v6.12.6/source/net/core/dev.c#L5457 ↩︎
  8. https://elixir.bootlin.com/linux/v6.12.6/source/net/8021q/vlan_core.c#L10 ↩︎