导语:我寻找一个低级Linux网络调试工具已经有一段时间了, Linux允许使用虚拟接口和网络命名空间的组合在主机上直接运行复杂网络。

我寻找一个低级Linux网络调试工具已经有一段时间了, Linux允许使用虚拟接口和网络命名空间的组合在主机上直接运行复杂网络。当出现问题时,排除故障相当耗时。如果这是L3路由器的问题,可以使用mtr命令进行路由分析。但是,如果这是一个较低级别的问题,我通常会手动检查每个接口、桥接、网络命名空间以及防火墙,并启动几个tcpdump,以便了解发生了什么。这个过程是如此复杂,以至于我想要找到一个可以直接发现数据包的工具。

基本上,我需要的是L2的mtr。在本文最后,我将有一个简单易 用的低级数据包跟踪,如果你ping一个本地的Docker容器,它会显示如下:

1.jpg

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

Perf 是用来进行软件性能分析的工具,通过它,应用程序可以利用 PMU,tracepoint 和内核中的特殊计数器来进行性能统计。它不但可以分析指定应用程序的性能问题 (per thread),也可以用来分析内核的性能问题,当然也可以同时分析应用代码和内核,从而全面理解应用程序中的性能瓶颈。最初的时候,它叫做 Performance counter。

eBPF是Linux内核最近的一个补充,顾名思义,这是一个名为“Berkeley Packet Filter”的BPF字节码的扩展版本,用于过滤BSD的数据包。eBPF一共有3大类指令:跳转、运算、加载与存储。除此之外,它还可以用于在活动内核中安全运行独立于平台的代码,例如,存储器访问在程序可以运行之前被验证,并且必须能够证明该程序将在有限的时间内结束。如果内核不能证明它,即使它是安全的并也将被拒绝。eBPF不但是程序,还可以访问外部的数据,重要的是这个外部的数据可以在用户空间管理。

这样的程序可以用作QOS的网络分类器,作为eXpress数据平面(XDP)的一部分的非常低级别的网络和过滤,用于跟踪代理。跟踪探测器可以附加到/ proc / kallsyms或任何跟踪点中导出其符号的任何函数。不过,在这篇文章中,我将只分析对跟踪点的跟踪代理。

实验所需的配置

在本文所进行的试验中,我需要使用一些工具来与perf和eBPF共同协调合作,比如bcc。bcc是一个强大而灵活的工具,是一个开源的 Linux 动态跟踪工具,允许你将内核探测器作为受限制的C语言编写,并使用Python将其设置在用户界面中。

我将在本文重现Ubuntu 17.04(Zesty)的安装说明,注意,将eBPF附加到跟踪点至少需要Linux内核的版本要高于 4.7。

安装perf的示意图:

2.jpg

如果你看到一条错误消息,可能意味着你的内核最近已更新,但你尚未重新启动。

安装bcc的示意图:

1501747766411183.jpg

找到好的跟踪点,也称为用perf手动跟踪数据包,目标是追踪数据包所采用的路径。根据交叉界面的不同,交叉的跟踪点也可能不同。

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

1.本地主机IP为127.0.0.1
2.一个Docker容器,IP 172.17.0.2
3.我的手机通过USB网络共享IP 192.168.42.129
4.我的手机通过WiFi与IP 192.168.43.1

perf trace是perf的子命令,它默认产生类似于strace的输出。我可以很容易地调整它来隐藏系统调用,例如,将ping到具有IP 172.17.0.2的Docker容器,如下所示:

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,这看起来更可读。

5.jpg

我清楚地看到,通过docker0桥接的数据包,然后是veth的主机端,veth79215ff在本文的情况下,最后是veth的容器端,并伪装成eth0。不过,我还没有看到网络命名空间。

最后,在eth0上看到数据包之后,我会以相反的顺序命中跟踪点。

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

net_dev_queue
netif_receive_skb_entry
netif_rx
napi_gro_receive_entry

我们可以轻松地仔细检查以下选项:

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)

如果你想进一步浏览可用的网络跟踪点列表,你可以使用用户列表:

sudo perf list 'net:*'

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

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

如果你正在阅读这篇文章,那么读到这里,你就知道了如何在Linux虚拟机上追踪数据包。

从Linux Kernel 4.7开始,eBPF程序可以附加到内核跟踪点。在此之前,构建跟踪器的唯一替代方法是将探测器附加到导出的内核符号。虽然这可以起作用,但它有一些缺点:

1.内核内部API不稳定。

2.出于性能原因,大多数网络内部函数是内联或静态的,这两个都不能被探测。

3.找到这个函数的所有潜在的呼叫站点是非常耗时的,而且并不是所有需要的数据都可用。

让我从一个简单的hello world开始,每当我之前选择的4个跟踪点中的一个(net_dev_queue,netif_receive_skb_entry,netif_rx和napi_gro_receive_entry)被触发,我都将构建一个事件。为了保持运行的简单,我将发送comm程序,即基本上是程序名的16个字符串。

#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来自哪里,那么当你用TRACEPOINT_PROBE定义跟踪点时,通过bcc生成args结构。由于它是在运行中生成的,所以没有简单的方法可以看到它的定义,但是有一个更好的方法,就是直接从内核查看数据源。幸运的是,每个跟踪点都有一个/ sys / kernel / debug / tracing / events条目。例如,对于net:netif_rx,可以只输出“cat”/ sys / kernel / debug / tracing / events / net / netif_rx / format。

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

你可能会注意到记录末尾的print fmt行,这正是perf trace用来生成其输出的。

使用低级别的通道并且很好理解,我可以将其包装在Python脚本中,以便为探针的eBPF发送的每个事件显示一行:

#!/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()

注意,在这个阶段没有过滤。

$> 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驱动程序也会收到一些数据包。于是,我开始添加一些有用的数据或过滤器。这将更好地展示eBPF的功能和局限性。

添加网络接口信息

首先,你可以安全地删除“comm”字段,加载和sched.h标头。

其次,你可以包括net / inet_sock.h,以便有必要的声明,并事件结构中添加char ifname [IFNAMSIZ]。

现在,我将从设备结构加载设备名称。这是有趣的,因为这是一个在实践中非常有用的信息,它能展示加载任何数据的技术:

// Get device pointer, we'll need it to get the name and network namespace
struct net_device *dev;
bpf_probe_read(&dev, sizeof(skb->dev), ((char*)skb) + offsetof(typeof(*skb), dev));
// Load interface name
bpf_probe_read(&evt.ifname, IFNAMSIZ, dev->name);

在知道它的工作原理后,我就要加载接口名称了,这就需要知道接口设备结构。我将从最后一个语句开始,因为它是最容易理解的。它使用bpf_probe_read从dev-> name读取长度为IFNAMSIZ的数据,并将其复制到evt.ifname。

不过要注意的是,eBPF的目标是允许安全地编写内核。这意味着禁止随机存储器访问,必须验证所有内存访问。除非你在堆栈中访问的内存,否则需要使用bpf_probe_read读取访问器。这使代码读写变得相当麻烦,但也非常的安全。 bpf_probe_read是某种安全的memcpy版本,它在内核中的bpf_trace.c中定义。有趣的是如果发生错误,bpf_probe_read将返回一个初始化为0的缓冲区并返回一个错误,它不会崩溃或停止程序。

接下来,我将使用以下宏来帮助保持可读性:

13.jpg

这允许我写入:

member_read(&dev, skb, dev);

添加网络命名空间ID

命名空间标识符可以从2个位置加载:

1.套接字'sk'结构

2.设备'dev'结构

我最初使用的是socket结构,因为这是我在编写solisten.py时使用的。不幸的是,我不知道为什么一旦数据包跨越命名空间边界,命名空间标识符就不再可读了。读出的字段全为0,这是无效内存访问的清晰标识。

幸运的是,方法仍然有效。通过调整不同的接口,来查找它所属的命名空间的接口。

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);

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

#define member_address(source_struct, source_member) 
({                                                   
  void* __ret;                                       
  __ret = (void*) (((char*)source_struct) + offsetof(typeof(*source_struct), source_member)); 
  __ret;                                             
})

作为附带作用,它允许简化member_read宏:

$> sudo python ./tracepkt.py
[  4026531957]          docker0
[  4026531957]      vetha373ab6
[  4026532258]             eth0
[  4026532258]             eth0
[  4026531957]      vetha373ab6
[  4026531957]          docker0

上图就是你发送ping到Docker容器时应该看到的,数据包通过本地docker0网桥,然后移动到veth pair,跨越网络命名空间边界。

跟踪请求回复和返回的数据包

为了继续从数据包中加载IP,我必须读取IP报头。我会在坚持用IPv4,但是同样的逻辑也适用于IPv6。这个过程很复杂,由于我正在网络路径中处理内核,所以某些数据包尚未打开。这意味着某些报头偏移量仍未初始化,我必须把它们都计算所出来,从MAC头到IP头,最后到ICMP头。

可以通过加载MAC头地址轻轻地启动,并推断IP头地址。我们不会加载MAC头本身,而是假定它是14个字节长。

// 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头的前4位,即第一个字节的前半部分中解码IP版本,并确保它是IPv4:

// Load IP protocol version
u8 ip_version;
bpf_probe_read(&ip_version, sizeof(u8), ip_header_address);
ip_version = ip_version >> 4 & 0xf;
// Filter IPv4 packets
if (ip_version != 4) {
    return 0;
}

现在加载完整的IP头,得到IP,使Python信息更加有用,确保下一个头是ICMP并导出ICMP头部偏移量。

// 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头本身,确保这是一个回应请求,并从中加载id和seq:

// 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是ping的PID,至少使用Linux的ping。

使用一些简单的Python来处理事件,就可以在几种情况下进行测试。以root身份启动程序,在另一终端启动一些“ping”并观察:

# 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回应请求报文(ICMPEcho-Request)由进程20212(Linux ping上的ICMP id)发送到Loopback接口上,传递给同一个回环接口,并生成返回应答,环回接口既是发射接收接口也是接收接口。

那WiFi网关呢?

# 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

在这种情况下,返回请求和返回应答会通过WiFi接口。请注意,当我只打印拥有数据包的进程的“comm”时,返回请求将属于ping进程,而返回应答将属于WiFi驱动程序,因为就Linux而言这是生成它的方式。

这是我最喜欢的方式,因为它能最好地展现eBPF的全部功能,并允许构建一个像ping的工具的“x-ray”,。

# 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
Host netns           | Container netns
+---------------------------+-----------------+
| docker0 ---> veth0e65931 ---> eth0          |
+---------------------------+-----------------+

总结

eBPF/bcc使我能够编写一系列新的工具来深入排除故障,跟踪和跟踪以前无法访问的问题,而无需修补内核。跟踪点也非常方便,因为它们在有趣的地方提供了一个好的提示,消除了对繁琐的内核代码的仔细阅读,并且可以被放置在代码中,否则这些代码将不能从kprobe访问,比如内联或静态函数。要进一步利用,可以添加IPv6。

你可以在Github上看到完整的代码(支持IPv6):https://github.com/yadutaf/tracepkt

源链接

Hacking more

...