eBPF 实际上提供了贯穿整个网络协议栈的过滤、捕获以及重定向等丰富的网络功能:

如何使用eBPF排查网络问题_eBPF

如何使用eBPF排查网络问题_eBPF_02

一方面,网络协议栈也是内核的一部分,因而网络相关的内核函数、跟踪点以及用户程序的函数等,比如 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