eBPF 实际上提供了贯穿整个网络协议栈的过滤、捕获以及重定向等丰富的网络功能:
一方面,网络协议栈也是内核的一部分,因而网络相关的内核函数、跟踪点以及用户程序的函数等,比如 kprobe、uprobe、USDT 等跟踪类 eBPF 程序进行跟踪(如上图中紫色部分所示)。
另一方面,eBPF 提供了大量专用于网络的 eBPF 程序类型,包括 XDP 程序、TC 程序、套接字程序以及 cgroup 程序等。这些类型的程序涵盖了从网卡(如卸载到硬件网卡中的 XDP 程序)到网卡队列(如 TC 程序)、封装路由(如轻量级隧道程序)、TCP 拥塞控制、套接字(如 sockops 程序)等内核协议栈,再到同属于一个 cgroup 的一组进程的网络过滤和控制,而这些都是内核协议栈的核心组成部分(如上图中绿色部分所示)。
如何把内核函数跟相关的网络问题关联起来呢?跟踪调用栈,根据调用栈回溯路径,找出导致某个网络事件发生的整个流程,进而就可以再根据这些流程中的内核函数进一步跟踪。
既然是调用栈的回溯,只有我们知道了最接近整个执行逻辑结尾的函数,才有可能开始这个回溯过程。对 Linux 网络丢包问题来说,内核协议栈执行的结尾,当然就是释放最核心的 SKB(Socket Buffer)数据结构。查询内核 SKB 文档,你可以发现,内核中释放 SKB 相关的函数有两个:
- 第一个,kfree_skb ,它经常在网络异常丢包时调用;
- 第二个,consume_skb ,它在正常网络连接完成时调用。
这两个函数除了使用场景的不同,其功能和实现流程都是一样的,即都是检查 SKB 的引用计数,当引用计数为 0 时释放其内核内存。所以,要跟踪网络丢包的执行过程,也就可以跟踪 kfree_skb 的内核调用栈。
为了方便调用栈的跟踪,bpftrace 提供了 kstack 和 ustack 这两个内置变量,分别用于获取内核和进程的调用栈。打开一个终端,执行下面的命令就可以跟踪 kfree_skb 的内核调用栈了:
sudo bpftrace -e 'kprobe:kfree_skb /comm=="curl"/ {printf("kstack: %s\n", kstack);}'
这个命令中的具体内容含义如下:
- kprobe:kfree_skb 指定跟踪的内核函数为 kfree_skb;
- 紧随其后的 /comm=="curl"/ ,表示只跟踪 curl 进程,这是为了过滤掉其他不相关的进程操作;
- 最后的 printf() 函数就是把内核协议栈打印到终端中。
打开一个新终端,并在终端中执行 curl time.xxx.org 命令,然后回到第一个终端,就可以看到如下的输出:
kstack:
kfree_skb+1
udpv6_destroy_sock+66
sk_common_release+34
udp_lib_close+9
inet_release+75
inet6_release+49
__sock_release+66
sock_close+21
__fput+159
____fput+14
task_work_run+103
exit_to_user_mode_loop+411
exit_to_user_mode_prepare+187
syscall_exit_to_user_mode+23
do_syscall_64+110
entry_SYSCALL_64_after_hwframe+68
kstack:
kfree_skb+1
udpv6_destroy_sock+66
sk_common_release+34
udp_lib_close+9
inet_release+75
inet6_release+49
__sock_release+66
sock_close+21
__fput+159
____fput+14
task_work_run+103
exit_to_user_mode_loop+411
exit_to_user_mode_prepare+187
syscall_exit_to_user_mode+23
do_syscall_64+110
entry_SYSCALL_64_after_hwframe+68
kstack:
kfree_skb+1
unix_release+29
__sock_release+66
sock_close+21
__fput+159
____fput+14
task_work_run+103
exit_to_user_mode_loop+411
exit_to_user_mode_prepare+187
syscall_exit_to_user_mode+23
do_syscall_64+110
entry_SYSCALL_64_after_hwframe+68
kstack:
kfree_skb+1
__sys_connect_file+95
__sys_connect+162
__x64_sys_connect+24
do_syscall_64+97
entry_SYSCALL_64_after_hwframe+68
kstack:
kfree_skb+1
__sys_connect_file+95
__sys_connect+162
__x64_sys_connect+24
do_syscall_64+97
entry_SYSCALL_64_after_hwframe+68
这个输出包含了多个调用栈,每个调用栈从下往上就是 kfree_skb 被调用过程中的各个函数(函数名后的数字表示调用点相对函数地址的偏移),它们都是从系统调用(entry_SYSCALL_64)开始,通过一系列的内核函数之后,最终调用到了跟踪函数。
输出中包含多个调用栈,是因为同一个内核函数是有可能在多个地方调用的。因此,我们需要对它进一步改进,加上网络信息的过滤,并把源 IP 和目的 IP 等基本信息也打印出来。比如,我们访问一个网址,只需要关心 TCP 协议,而其他协议相关的内核栈就可以忽略掉。
最常见的丢包是由系统防火墙阻止了相应的 IP 或端口导致的,你可以执行下面的 nslookup命令,查询到XXX的 IP 地址,然后再执行iptables 命令,禁止访问XXX的 80 端口:
# 首先查询极客时间的IP
$ nslookup time.xxx.org
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
Name: time.xxx.org
Address: 39.106.233.176
# 然后增加防火墙规则阻止80端口
$ sudo iptables -I OUTPUT -d 39.106.233.176/32 -p tcp -m tcp --dport 80 -j DROP
防火墙规则加好之后,在终端一中启动跟踪脚本:
sudo bpftrace dropwatch.bt
然后,新建一个终端,访问XXX,你应该会看到超时的错误:
$ curl --connect-timeout 1 39.106.233.176
curl: (28) Connection timed out after 1000 milliseconds
返回第一个终端,你就可以看到 eBPF 程序已经成功跟踪到了内核丢包的调用栈信息,如下所示:
SKB dropped: 192.168.1.129->39.106.233.176, kstack:
kfree_skb+1
__ip_local_out+219
ip_local_out+29
__ip_queue_xmit+367
ip_queue_xmit+21
__tcp_transmit_skb+2237
tcp_connect+1009
tcp_v4_connect+951
__inet_stream_connect+206
inet_stream_connect+59
__sys_connect_file+95
__sys_connect+162
__x64_sys_connect+24
do_syscall_64+97
entry_SYSCALL_64_after_hwframe+68
从这个输出中,我们可以看到,第一行输出中我们成功拿到了源 IP 和目的 IP,而接下来的每一行中都包含了指令地址、函数名以及函数地址偏移。
从下往上看这个调用栈,最后调用 kfree_skb 函数的是 __ip_local_out,那么 __ip_local_out 这个函数又是干什么的呢?根据函数名,你可以大致猜测出,它是用于向外发送网络包的,但具体的步骤我们就不太确定了。所以,这时候就需要去参考一下内核源代码。
这里推荐你使用 https://elixir.bootlin.com/ 这个网站来查看内核源码,因为它不仅列出了所有内核版本的源代码,还提供了交叉引用的功能。在源码文件中点击任意函数或类型,它就可以自动跳转到其定义和引用的位置。
知道了发生丢包的问题来源,接下来再去定位 iptables 就比较容易了。在终端中执行下面的 iptables 命令,就可以查询 OUTPUT 链的过滤规则:
sudo iptables -nvL OUTPUT
命令执行后,你应该可以看到类似下面的输出。可以看到,正是我们之前加入的 iptables 规则导致了丢包:
Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
1 180 DROP tcp -- * * 0.0.0.0/0 39.106.233.176 tcp dpt:80
到这里,通过简单的几行 bpftrace 脚本,成功使用 eBPF 精确定位了一个常见的网络丢包问题。
清楚了问题的根源,要解决它当然就很简单了。只要执行下面的命令,把导致丢包的 iptables 规则删除即可:
sudo iptables -D OUTPUT -d 39.106.233.176/32 -p tcp -m tcp --dport 80 -j DROP