使用Linux tracepoints、perf 和 eBPF 跟踪包的旅程

原文: Tracing a packet journey using Linux tracepoints, perf and eBPF

很久以来我一直在寻找一个底层的Linux网络调试工具。
我一直在寻找一个低级的Linux网络调试工具已经有一段时间了。Linux 允许使用虚拟接口(virtual interface)和网络命名空间(network namespace)的组合来构建直接在主机上运行的复杂网络。当出现问题时,故障排除相当乏味。如果这是 L3 路由问题,mtr则很有可能会有所帮助。但是,如果这是一个较低层的问题,我通常会手动检查每个接口/网桥/网络命名空间/iptables并启动几个tcpdump,以尝试了解正在发生的事情。如果您事先不了解网络设置,这可能感觉像走迷宫。

我需要的是一个工具,可以告诉我 “嘿,哥们,我已经看到了你的数据包:它已经这样消失了,在这个接口上,在这个网络命名空间中”。

基本上,我需要的是在L2上的mtr。

没有这样的工具?让我们白手起家建一个!

在这篇文章的最后,我们将有一个简单易用的底层数据包跟踪器。如果您 ping 本地 Docker 容器,它将显示如下内容:

1
2
3
4
5
6
7
# ping -4 172.17.0.2
[ 4026531957] docker0 request #17146.001 172.17.0.1 -> 172.17.0.2
[ 4026531957] vetha373ab6 request #17146.001 172.17.0.1 -> 172.17.0.2
[ 4026532258] eth0 request #17146.001 172.17.0.1 -> 172.17.0.2
[ 4026532258] eth0 reply #17146.001 172.17.0.2 -> 172.17.0.1
[ 4026531957] vetha373ab6 reply #17146.001 172.17.0.2 -> 172.17.0.1
[ 4026531957] docker0 reply #17146.001 172.17.0.2 -> 172.17.0.1

追踪救援 (Tracing to the rescue)

走出迷宫的一种方法是探索。这就是你走出迷宫时所做的。另一种走出迷宫的方法是改变你的观点,从上帝的视角,观察那些知道这条路的人所走的路。

在 Linux 术语中,这意味着转向内核视角,其中网络命名空间只是标签,而不是“容器”1。在内核中,数据包、接口等是普通的可观察对象。

在这篇文章中,我将重点介绍 2 个跟踪工具: perf 和 eBPF。

介绍 perf 和 eBPF

perf是 Linux 上每个性能相关分析的基准工具。它是在与 Linux 内核相同的源代码树中开发的,必须针对您将用于跟踪的内核进行专门编译。它可以跟踪内核以及用户程序。它还可以通过采样或使用跟踪点来工作。可以将其视为比strace的巨大超集,且开销要低得多的。本文我们只以非常简单的方式使用它。如果您想了解更多有关 perf 的知识 ,我强烈建议您访问 Brendan Gregg 的博客

eBPF 是 Linux 内核相对最近才添加的功能。顾名思义,这是BPF字节码(即“伯克利数据包过滤器”,用于在BSD家族系统上过滤数据包)的扩展版本。在 Linux上,如果满足一些安全标准,它也可以用来在运行时内核中安全地运行与平台无关的代码。例如,在程序运行之前会验证内存访问,并且必须能证明该程序会在受限的时间内结束。即使程序本身是安全的并且总是会终止,如果内核无法证明这一点,该程序也会被拒绝。

这样的程序可以用作QOS的网络分类器,非常底层的网络和过滤可以使用eXpress数据平面(XDP),这些程序作为跟踪代理以及许多其他场景。跟踪探针可以附加到/proc/kallsyms中的任意函数或任何跟踪点。在这篇文章中,我将重点介绍附加到跟踪点(tracepoint)的跟踪代理。

有关附加到内核函数的跟踪探针的示例或作为更详细的介绍,请阅读我之前关于eBPF的文章

实验室设置

对于这篇文章,我们需要perf和一些用于eBPF的工具。由于我不是手写汇编代码的忠实拥趸,所以这里我将使用bcc。这是一个强大且灵活的工具,允许你用受限的C语言编写内核探针,并在用户空间用Python进行检测。对于生产环境来说可能过重,但对于开发非常完美!

在这里我重述在Ubuntu 17.04(Zesty)上的安装说明,这是我笔记本电脑所使用的操作系统。从其他发行版到“perf”的说明不应该有太大的差异,而特定的bcc安装说明可以在github上找到。

注意:将eBPF附加到跟踪点至少需要Linux内核版本高于4.7。

安装 perf:

1
2
3
4
5
// 安装
sudo apt install linux-tools-generic
# 测试
perf

如果看到错误信息,很可能是你的内核最近更新了但是操作系统还没有重启。

安装 bcc:

1
2
3
4
5
6
7
8
9
10
11
12
# 安装原来
sudo apt install bison build-essential cmake flex git libedit-dev python zlib1g-dev libelf-dev libllvm4.0 llvm-dev libclang-dev luajit luajit-5.1-dev
# 获取bcc源代码
git clone https://github.com/iovisor/bcc.git
# 编译并安装
mkdir bcc/build
cd bcc/build
cmake .. -DCMAKE_INSTALL_PREFIX=/usr
make
sudo make install

寻找好的跟踪点,也就是“用 perf 手动跟踪数据包的旅程”

有多种方法可以找到好的跟踪点。在这篇文章的早期版本中,我从 veth 驱动的代码开始,并从那里追踪函数调用来找到要跟踪的函数。虽然它确实导致了可以接受的结果,但我无法捕获所有的包。的确,所有数据包共同经过的路径都在未导出的(内联或静态)方法中。这就是我意识到 Linux 有跟踪点并决定重写这篇文章及相关代码使用跟踪点而不是函数。这很令人沮丧,但对我来说也更有趣。

我废话太多了,言归正传。

我们的目标是跟踪数据包所经历的路径。根据它们所经过的接口, 它们经过的跟踪点可能会有所不同(剧透:它们确实不同)。

为了找到合适的跟踪点,我在使用 perf trace时 ping了2个内部目的IP和2个外部的目的IP:

  • localhost,IP 为 127.0.0.1
  • 一个“无辜的” Docker 容器,IP 为 172.17.0.2
  • 通过 USB 共享网络的我的手机,IP 为 192.168.42.129
  • 通过 WiFi 的我的手机,IP 为 192.168.43.1

perf trace 是 perf 命令的一个子命令,默认情况下会产生类似于strace的输出(开销更小)。我们可以轻松地调整它,隐藏系统调用本身,只打印“net”类别的事件。例如,跟踪ping一个IP为172.17.0.2的Docker容器看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sudo perf trace --no-syscalls --event 'net:*' ping 172.17.0.2 -c1 > /dev/null
0.000 net:net_dev_queue:dev=docker0 skbaddr=0xffff96d481988700 len=98)
0.008 net:net_dev_start_xmit:dev=docker0 queue_mapping=0 skbaddr=0xffff96d481988700 vlan_tagged=0 vlan_proto=0x0000 vlan_tci=0x0000 protocol=0x0800 ip_summed=0 len=98 data_len=0 network_offset=14 transport_offset_valid=1 transport_offset=34 tx_flags=0 gso_size=0 gso_segs=0 gso_type=0)
0.014 net:net_dev_queue:dev=veth79215ff skbaddr=0xffff96d481988700 len=98)
0.016 net:net_dev_start_xmit:dev=veth79215ff queue_mapping=0 skbaddr=0xffff96d481988700 vlan_tagged=0 vlan_proto=0x0000 vlan_tci=0x0000 protocol=0x0800 ip_summed=0 len=98 data_len=0 network_offset=14 transport_offset_valid=1 transport_offset=34 tx_flags=0 gso_size=0 gso_segs=0 gso_type=0)
0.020 net:netif_rx:dev=eth0 skbaddr=0xffff96d481988700 len=84)
0.022 net:net_dev_xmit:dev=veth79215ff skbaddr=0xffff96d481988700 len=98 rc=0)
0.024 net:net_dev_xmit:dev=docker0 skbaddr=0xffff96d481988700 len=98 rc=0)
0.027 net:netif_receive_skb:dev=eth0 skbaddr=0xffff96d481988700 len=84)
0.044 net:net_dev_queue:dev=eth0 skbaddr=0xffff96d481988b00 len=98)
0.046 net:net_dev_start_xmit:dev=eth0 queue_mapping=0 skbaddr=0xffff96d481988b00 vlan_tagged=0 vlan_proto=0x0000 vlan_tci=0x0000 protocol=0x0800 ip_summed=0 len=98 data_len=0 network_offset=14 transport_offset_valid=1 transport_offset=34 tx_flags=0 gso_size=0 gso_segs=0 gso_type=0)
0.048 net:netif_rx:dev=veth79215ff skbaddr=0xffff96d481988b00 len=84)
0.050 net:net_dev_xmit:dev=eth0 skbaddr=0xffff96d481988b00 len=98 rc=0)
0.053 net:netif_receive_skb:dev=veth79215ff skbaddr=0xffff96d481988b00 len=84)
0.060 net:netif_receive_skb_entry:dev=docker0 napi_id=0x3 queue_mapping=0 skbaddr=0xffff96d481988b00 vlan_tagged=0 vlan_proto=0x0000 vlan_tci=0x0000 protocol=0x0800 ip_summed=2 hash=0x00000000 l4_hash=0 len=84 data_len=0 truesize=768 mac_header_valid=1 mac_header=-14 nr_frags=0 gso_size=0 gso_type=0)
0.061 net:netif_receive_skb:dev=docker0 skbaddr=0xffff96d481988b00 len=84)

仅保留事件名称和 skbaddr,这看起来更具可读性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
net_dev_queue dev=docker0 skbaddr=0xffff96d481988700
net_dev_start_xmit dev=docker0 skbaddr=0xffff96d481988700
net_dev_queue dev=veth79215ff skbaddr=0xffff96d481988700
net_dev_start_xmit dev=veth79215ff skbaddr=0xffff96d481988700
netif_rx dev=eth0 skbaddr=0xffff96d481988700
net_dev_xmit dev=veth79215ff skbaddr=0xffff96d481988700
net_dev_xmit dev=docker0 skbaddr=0xffff96d481988700
netif_receive_skb dev=eth0 skbaddr=0xffff96d481988700
net_dev_queue dev=eth0 skbaddr=0xffff96d481988b00
net_dev_start_xmit dev=eth0 skbaddr=0xffff96d481988b00
netif_rx dev=veth79215ff skbaddr=0xffff96d481988b00
net_dev_xmit dev=eth0 skbaddr=0xffff96d481988b00
netif_receive_skb dev=veth79215ff skbaddr=0xffff96d481988b00
netif_receive_skb_entry dev=docker0 skbaddr=0xffff96d481988b00
netif_receive_skb dev=docker0 skbaddr=0xffff96d481988b00

这里有几点需要说明。最明显的是skbaddr在中间发生了变化,但其他时候保持不变, 这是发生中echo reply数据包作为对该echo request(ping)的回复生成时。在其他时间,同一网络数据包在接口之间移动,希望没有复制。复制是昂贵的...

另一个有趣的点是,我们明确看到数据包经过docker0网桥,然后是veth的主机端,在我的例子中是veth79215ff,最后是veth的容器端,假装是eth0。我们还没有看到网络命名空间,但它已经给出了很好的概览。

最后,在看到eth0上的数据包之后,我们按相反顺序看到了跟踪点。这不是响应,而是传输的最终目的路径。

通过在4种目标场景中重复类似的过程,我们可以选择最合适的跟踪点来跟踪数据包的旅程。我选择了其中的4个:

  • net_dev_queue
  • netif_receive_skb_entry
  • netif_rx
  • napi_gro_receive_entry

采用这4个跟踪点将按顺序为我提供跟踪事件,没有重复,节省了一些去重工作。仍然是一个非常好的选择。

我们可以轻松地对这个选择进行双重检查,像这样:

1
2
3
4
5
6
7
8
9
10
11
12
sudo perf trace --no-syscalls \
--event 'net:net_dev_queue' \
--event 'net:netif_receive_skb_entry' \
--event 'net:netif_rx' \
--event 'net:napi_gro_receive_entry' \
ping 172.17.0.2 -c1 > /dev/null
0.000 net:net_dev_queue:dev=docker0 skbaddr=0xffff8e847720a900 len=98)
0.010 net:net_dev_queue:dev=veth7781d5c skbaddr=0xffff8e847720a900 len=98)
0.014 net:netif_rx:dev=eth0 skbaddr=0xffff8e847720a900 len=84)
0.034 net:net_dev_queue:dev=eth0 skbaddr=0xffff8e849cb8cd00 len=98)
0.036 net:netif_rx:dev=veth7781d5c skbaddr=0xffff8e849cb8cd00 len=84)
0.045 net:netif_receive_skb_entry:dev=docker0 napi_id=0x1 queue_mapping=0 skbaddr=0xffff8e849cb8cd00 vlan_tagged=0 vlan_proto=0x0000 vlan_tci=0x0000 protocol=0x0800 ip_summed=2 hash=0x00000000 l4_hash=0 len=84 data_len=0 truesize=768 mac_header_valid=1 mac_header=-14 nr_frags=0 gso_size=0 gso_type=0)

任务完成!

如果你想更进一步,探索可用网络跟踪点的列表,你可以使用 perf list:

1
sudo perf list 'net:*'

这应该会返回一个像net:netif_rx这样的跟踪点名称列表。冒号前的部分('net')是事件类别;冒号后的是该类别中的事件名称。

使用eBPF / bcc编写自定义跟踪器

对大多数情况来说,这已经远远超出需求了。如果你读这篇文章是为了学习如何在Linux系统上跟踪数据包的旅程,你已经获取了所有需要的信息。但是,如果你想更深入地研究,运行自定义过滤器,跟踪更多的数据,如数据包经过的网络命名空间或源和目的IP,请继续跟我走。

从Linux内核4.7开始,可以将eBPF程序附加到内核跟踪点上。在此之前,构建此跟踪器的唯一替代方法是将探针附加到导出的内核符号上。尽管这种方法可行,但它有一些缺点:

  • 内核内部API不稳定。跟踪点是稳定的(尽管数据结构不一定是...)。
  • 出于性能考虑,大多数网络内部函数是内联的或静态的。它们都不能被探测。
  • 找到所有这些函数的潜在调用点很麻烦,有时在这一阶段不可用所需的所有数据。

本文的早期版本尝试使用kprobes,它们更易于使用,但结果却是不完整的。

现在,让我们坦诚一点,通过跟踪点访问数据要比使用它们的 kprobe 对应物要繁琐得多。虽然我尽量保持这篇文章尽可能易懂,但您可能希望从我先前的一篇文章开始。

撇开这个声明,让我们从一个简单的 Hello World 程序开始介绍。在这个 Hello World 示例中,每当我们选择的 4 个跟踪点之一被触发时(net_dev_queue、netif_receive_skb_entry、netif_rx 和 napi_gro_receive_entry),我们将建立一个事件。为了在这个阶段保持简单,我们将发送程序的 comm,即一个 16 个字符的字符串,基本上是程序的名称。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <bcc/proto.h>
#include <linux/sched.h>
// Event structure
struct route_evt_t {
char comm[TASK_COMM_LEN];
};
BPF_PERF_OUTPUT(route_evt);
static inline int do_trace(void* ctx, struct sk_buff* skb)
{
// Built event for userland
struct route_evt_t evt = {};
bpf_get_current_comm(evt.comm, TASK_COMM_LEN);
// Send event to userland
route_evt.perf_submit(ctx, &evt, sizeof(evt));
return 0;
}
/**
* Attach to Kernel Tracepoints
*/
TRACEPOINT_PROBE(net, netif_rx) {
return do_trace(args, (struct sk_buff*)args->skbaddr);
}
TRACEPOINT_PROBE(net, net_dev_queue) {
return do_trace(args, (struct sk_buff*)args->skbaddr);
}
TRACEPOINT_PROBE(net, napi_gro_receive_entry) {
return do_trace(args, (struct sk_buff*)args->skbaddr);
}
TRACEPOINT_PROBE(net, netif_receive_skb_entry) {
return do_trace(args, (struct sk_buff*)args->skbaddr);
}

这个代码片段会连接到“net”类别的 4 个跟踪点,加载skbaddr字段,并将其传递给通用部分,目前通用部分仅加载程序名称。如果您想知道这个 args->skbaddr 是从哪里来的(我很高兴您这样想),args 结构是由 bcc 为您生成的,每当您使用 TRACEPOINT_PROBE 定义一个跟踪点时,它都会为您生成。由于它是即时生成的,没有简单的方法来查看它的定义,但是有更好的方法。我们可以直接查看来自内核的数据源。幸运的是,每个跟踪点都有一个 /sys/kernel/debug/tracing/events 条目。例如,对于 net:netif_rx,您可以只运行命令 cat /sys/kernel/debug/tracing/events/net/netif_rx/format,这应该会输出类似于以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
name: netif_rx
ID: 1183
format:
field:unsigned short common_type; offset:0; size:2; signed:0;
field:unsigned char common_flags; offset:2; size:1; signed:0;
field:unsigned char common_preempt_count; offset:3; size:1; signed:0;
field:int common_pid; offset:4; size:4; signed:1;
field:void * skbaddr; offset:8; size:8; signed:0;
field:unsigned int len; offset:16; size:4; signed:0;
field:__data_loc char[] name; offset:20; size:4; signed:1;
print fmt: "dev=%s skbaddr=%p len=%u", __get_str(name), REC->skbaddr, REC->len

您可能会注意到记录末尾的打印 fmt 行。这正是 perf trace 用来生成其输出的内容。

有了底层基础代码,并且你已了解了它,我们可以将其包装在一个 Python 脚本中,以显示 eBPF 探针发送的每个事件的一行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/usr/bin/env python
# coding: utf-8
from socket import inet_ntop
from bcc import BPF
import ctypes as ct
bpf_text = '''<SEE CODE SNIPPET ABOVE>'''
TASK_COMM_LEN = 16 # linux/sched.h
class RouteEvt(ct.Structure):
_fields_ = [
("comm", ct.c_char * TASK_COMM_LEN),
]
def event_printer(cpu, data, size):
# Decode event
event = ct.cast(data, ct.POINTER(RouteEvt)).contents
# Print event
print "Just got a packet from %s" % (event.comm)
if __name__ == "__main__":
b = BPF(text=bpf_text)
b["route_evt"].open_perf_buffer(event_printer)
while True:
b.kprobe_poll()

您现在可以测试它了。您需要以 root 权限运行。

请注意:在这个阶段我们没有对包进行筛选。即使是网络使用很低情况也可能会让你的终端刷屏

1
2
3
4
5
6
7
$> sudo python ./tracepkt.py
...
Just got a packet from ping6
Just got a packet from ping6
Just got a packet from ping
Just got a packet from irq/46-iwlwifi
...

在这种情况下,您可以看到我正在使用 ping 和 ping6,WiFi 驱动程序刚刚接收了一些数据包。在这种情况下,这是echo reply。

现在让我们开始添加一些有用的数据/筛选条件。

在本文中,我不会重点关注性能。这将更好地展示 eBPF 的功能和限制。要使它(明显)更快,我们可以使用数据包大小作为筛选,假设没有设置“奇怪的” IP 选项。使用这个示例程序会减慢您的网络流量。

请注意:为了限制此帖子的长度,我将在此处专注于 C/eBPF 部分。我会在帖子末尾放置完整源代码的链接。

添加网络接口信息

首先,您可以安全地删除“comm”资源、加载和 sched.h 标头。在这里它没有真正用处,抱歉。

然后,您可以包含 net/inet_sock.h,以便我们有必要的声明,并向事件结构中添加 char ifname[IFNAMSIZ];

现在,我们将从设备结构中加载设备名称。这很有趣,因为这是一个实际有用的信息,并且在可管理的范围内演示了加载任何数据的技术:

1
2
3
4
5
6
// 得到设备指针
struct net_device *dev;
bpf_probe_read(&dev, sizeof(skb->dev), ((char*)skb) + offsetof(typeof(*skb), dev));
// 加载网络接口名称
bpf_probe_read(&evt.ifname, IFNAMSIZ, dev->name);

您可以测试它,它可以正常工作。但不要忘记在 Python 部分添加相关的代码 :)

好的,它是如何工作的呢?为了加载接口名称,我们需要接口设备结构。我将从最后一个语句开始解释,因为它最容易理解,前一个实际上只是更复杂的版本。它使用 bpf_probe_read 从 dev->name 读取长度为 IFNAMSIZ 的数据,并将其复制到 evt.ifname。第一行遵循完全相同的逻辑。它将 skb->dev 指针的值加载到 dev 中。不幸的是,我没有找到另一种在没有 offsetof / typeof 花招的情况下加载字段地址的方法。

作为提醒,eBPF 的目标是允许对内核进行安全脚本化。这意味着禁止随机内存访问。所有内存访问必须经过验证。除非您访问的内存位于堆栈上,否则需要使用 bpf_probe_read 读取访问器。这使得代码阅读/编写起来很繁琐,但也使其更安全。bpf_probe_read 在内核中的 bpf_trace.c 中定义。其中有一些有趣的部分:

  1. 它类似于 memcpy。请注意复制对性能的成本。
  2. 如果出现错误,它将返回一个初始化为 0 的缓冲区并返回一个错误。它不会崩溃或停止程
1
2
3
4
5
6
7
8
#define member_read(destination, source_struct, source_member) \
do{ \
bpf_probe_read( \
destination, \
sizeof(source_struct->source_member), \
((char*)source_struct) + offsetof(typeof(*source_struct), source_member) \
); \
} while(0)

这使得我们可以编写:

1
member_read(&dev, skb, dev);

好极了!

添加网络命名空间 ID

这可能是最有价值的信息。就其本身而言,它是所有这些努力的正当理由。不幸的是,这也是最难加载的。

命名空间标识符可以从以下两个地方加载:

  • socket'sk' 结构
  • 设备 'dev' 结构

最初,我使用套接字结构,因为这是我在编写 solisten.py 时使用的结构。不过,不知为何,一旦数据包跨越命名空间边界,命名空间标识符就不再可读。该字段全为0,这是一个明显的无效内存访问的指示器(请记住 bpf_probe_read 在出现错误时的工作原理),并且破坏了整个目的。

幸运的是,设备方法有效。可以将其看作是询问数据包在哪个接口上以及接口属于哪个命名空间的过程。

1
2
3
4
5
6
7
struct net* net;
// Get netns id. Equivalent to: evt.netns = dev->nd_net.net->ns.inum
possible_net_t *skc_net = &dev->nd_net;
member_read(&net, skc_net, net);
struct ns_common* ns = member_address(net, ns);
member_read(&evt.netns, ns, inum);

使用以下附加宏以提高可读性:

1
2
3
4
5
6
#define member_address(source_struct, source_member) \
({ \
void* __ret; \
__ret = (void*) (((char*)source_struct) + offsetof(typeof(*source_struct), source_member)); \
__ret; \
})

将这些部分组合在一起,然后... 完成!

1
2
3
4
5
6
7
$> sudo python ./tracepkt.py
[ 4026531957] docker0
[ 4026531957] vetha373ab6
[ 4026532258] eth0
[ 4026532258] eth0
[ 4026531957] vetha373ab6
[ 4026531957] docker0

如果您向 Docker 容器发送 ping,您应该会看到这个。数据包通过本地的 docker0 桥接器传递,然后移动到 veth 对,跨越了网络命名空间边界,回复沿着完全相反的路径返回。

回复这确实是一个棘手的问题!

更进一步:只跟踪request reply 和 echo reply 数据包

作为奖励,我们还将加载数据包的 IP 地址。无论如何,我们都必须读取 IP 标头。我将在这里坚持使用 IPv4,但相同的逻辑适用于IPv6。

坏消息是,没有什么是真正简单的。请记住,我们正在处理内核,在网络路径中。某些数据包尚未打开。这意味着某些标头偏移仍未初始化。我们将不得不计算它的所有,从 MAC 标头到 IP 标头,最后到 ICMP 标头。

让我们先轻轻松松地加载 MAC 标头地址,并推导出 IP 标头地址。我们不会加载 MAC 标头本身,而是假设它的长度为 14 字节。

1
2
3
4
5
6
7
8
9
10
// Compute MAC header address
char* head;
u16 mac_header;
member_read(&head, skb, head);
member_read(&mac_header, skb, mac_header);
// Compute IP Header address
#define MAC_HEADER_SIZE 14;
char* ip_header_address = head + mac_header + MAC_HEADER_SIZE;

这基本上意味着 IP 标头从 skb->head + skb->mac_header + MAC_HEADER_SIZE; 开始。

现在,我们可以解码 IP 标头中的 IP 版本,即第一个字节的前 4 位,确保它是 IPv4:

1
2
3
4
5
6
7
8
9
// 加载ip的版本
u8 ip_version;
bpf_probe_read(&ip_version, sizeof(u8), ip_header_address);
ip_version = ip_version >> 4 & 0xf;
// 过滤 IPv4 packets
if (ip_version != 4) {
return 0;
}

现在,我们加载完整的 IP 标头,提取 IP 地址以使 Python 信息更有用,确保下一个标头是 ICMP,并推导出 ICMP 标头偏移量。下面是所有这些操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Load IP Header
struct iphdr iphdr;
bpf_probe_read(&iphdr, sizeof(iphdr), ip_header_address);
// Load protocol and address
u8 icmp_offset_from_ip_header = iphdr.ihl * 4;
evt.saddr[0] = iphdr.saddr;
evt.daddr[0] = iphdr.daddr;
// Filter ICMP packets
if (iphdr.protocol != IPPROTO_ICMP) {
return 0;
}

最后,我们可以加载 ICMP 标头本身,确保这是一个echo request of reply,并从中加载 id 和 seq:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Compute ICMP header address and load ICMP header
char* icmp_header_address = ip_header_address + icmp_offset_from_ip_header;
struct icmphdr icmphdr;
bpf_probe_read(&icmphdr, sizeof(icmphdr), icmp_header_address);
// Filter ICMP echo request and echo reply
if (icmphdr.type != ICMP_ECHO && icmphdr.type != ICMP_ECHOREPLY) {
return 0;
}
// Get ICMP info
evt.icmptype = icmphdr.type;
evt.icmpid = icmphdr.un.echo.id;
evt.icmpseq = icmphdr.un.echo.sequence;
// Fix endian
evt.icmpid = be16_to_cpu(evt.icmpid);
evt.icmpseq = be16_to_cpu(evt.icmpseq);

这就是全部内容!

如果您想从特定 ping 实例中过滤 ICMP,您可以假定 evt.icmpid 是 Linux 的 ping 的 PID。

是时候展示了!

启动程序,然后在另一个终端中运行一些 "ping" 命令,观察结果:

1
2
3
4
5
# ping -4 localhost
[ 4026531957] lo request #20212.001 127.0.0.1 -> 127.0.0.1
[ 4026531957] lo request #20212.001 127.0.0.1 -> 127.0.0.1
[ 4026531957] lo reply #20212.001 127.0.0.1 -> 127.0.0.1
[ 4026531957] lo reply #20212.001 127.0.0.1 -> 127.0.0.1

一个 ICMP echo request由进程 20212 发送(Linux 的 ping 中的 ICMP id)通过回环接口发送,传递到完全相同的回环接口,其中生成并发送回一个echo reply。回环接口既是发出接口也是接收接口。

那么 WiFi 网关呢?

1
2
3
# ping -4 192.168.43.1
[ 4026531957] wlp2s0 request #20710.001 192.168.43.191 -> 192.168.43.1
[ 4026531957] wlp2s0 reply #20710.001 192.168.43.1 -> 192.168.43.191

在这种情况下,echo request和echo reply通过 WiFi 接口进行。很容易。

稍微不相关的一点是,还记得当我们只打印拥有数据包的进程的“comm”时吗?在这种情况下,echo request将属于 ping 进程,而reply将属于 WiFi 驱动程序,因为在 Linux 视角下,WiFi 驱动程序是生成回复的进程。

最后一个,也是我个人最喜欢的,ping 一个 Docker 容器。这不是因为 Docker,而是因为它最好地展示了 eBPF 的强大之处。它允许构建了一个类似于 "x-ray" 的工具,用于分析 ping。

1
2
3
4
5
6
7
# ping -4 172.17.0.2
[ 4026531957] docker0 request #17146.001 172.17.0.1 -> 172.17.0.2
[ 4026531957] vetha373ab6 request #17146.001 172.17.0.1 -> 172.17.0.2
[ 4026532258] eth0 request #17146.001 172.17.0.1 -> 172.17.0.2
[ 4026532258] eth0 reply #17146.001 172.17.0.2 -> 172.17.0.1
[ 4026531957] vetha373ab6 reply #17146.001 172.17.0.2 -> 172.17.0.1
[ 4026531957] docker0 reply #17146.001 172.17.0.2 -> 172.17.0.1

经过一些处理,现在看起来如下所示:

1
2
3
4
Host netns | Container netns
+---------------------------+-----------------+
| docker0 ---> veth0e65931 ---> eth0 |
+---------------------------+-----------------+

最后的话

eBPF/bcc使我们能够编写一系列新的工具,用于深度故障排除、跟踪和追踪先前无法通过内核补丁到达的地方的问题。跟踪点也非常方便,因为它们为我们提供了有趣的位置的良好提示,消除了繁琐阅读内核代码的需要,并可以放置在从 kprobe 中无法访问的代码部分,例如内联或静态函数。

要进一步深入,我们可以添加 IPv6 支持。这很容易做到,我将把它作为读者的练习留下。理想情况下,我希望能够衡量性能的影响。但是这篇帖子已经非常长了。通过跟踪路由和 iptables 决策以及 ARP 数据包,改进这个工具可能会很有趣。所有这些将使这个工具成为像我这样的人的完美“X光”数据包跟踪器,有时需要应对复杂的Linux网络设置。

正如承诺的那样,您可以在Github上查看完整的代码(带有IPv6支持):https://github.com/yadutaf/tracepkt