tcpprobe, observe the TCP flow with kprobes

这篇文章介绍最近用到的一个监听TCP信息的内核模块:tcpprobe.
主要说说这个内核模块:

  1. tcpprobe是什么?
  2. tcpprobe的基本用法?
  3. tcpprobe是怎么实现的?
  4. 实践中如何用好tcpprobe?

1. tcp probe 是什么?

下面摘录LINUX FOUNDATION对于tcpprobe上对于tcpprobe的一段解释:

tcpprobe is a module that records the state of a TCP connection in response to incoming packets.  
It works by inserting a hook into the tcp_recv processing path using kprobe, so that the 
congestion window and sequence number can be captured.  

首先tcpprobe是一个内核模块,可以按需加载与卸载。在下文的实践中如何用好tcpprobe可以看出模块化的好处。
同时tcpprobe是用于监听某些特定TCP的,而不是整个TCP/IP协议栈的内容。在使用tcpprobe模块时需要指定特定的端口号用于监听。
这个限制主要是出于效率上的考虑,但是基本上也能满足大部分的需求了,后面我们也将介绍如何根据需要实现我们自己的限制条件。
最后tcpprobe是基于kprobe机制实现的,它能捕捉cwnd和seq等信息。这些内容将在后续的内容中具体介绍。


2. tcpprobe的基本用法

Linux Foundation上有一个简单地例子,这里我结合我的实际使用给出一个更加具体的例子。

2.1 编译tcpprobe模块

// 方法1:tcpprobe是一个内核模块,在Linux 3.10中它的实现代码是net/ipv4/tcp_probe.c  
//        可以设置在编译内核时,将tcpprobe也编译了。方法就是在.config中添加
CONFIG_NET_TCPPROBE=m  

// 方法2:可以像一个普通内核模块一样,只编译tcpprobe模块,然后加载  
# vim Makefile    // 为tcpprobe模块建立一个新Makefile文件,最好把整个tcp_probe.c和Makefile放到单独的一个文件夹中
obj-m += tcp_probe.o
all:
    make -C /lib/modules/`uname -r`/build M=${PWD} modules  
clean:
    make -C /lib/modules/`uname -r`/build M=${PWD} clean

2.2 tcpprobe模块的加载与卸载

// 如果是通过修改.config来编译tcpprobe的,可以使用modprobe加载    
# modprobe tcp_probe port=5001          // 监听所有本地端口是5001的TCP流,仅在cwnd变化时捕捉信息  
# modprobe tcp_probe full=1 port=5001   // full选项表示每次收到数据包都捕捉信息

// 如果是普通内核模块形式编译的,则使用insmod加载  
# insmod tcp_probe.ko full=1 port=5001  

// 卸掉tcpprobe模块的命令也很简单,只要当前没有任务在使用tcpprobe模块即可卸载  
# rmmod tcp_probe  

2.3 tcpprobe模块的使用
加载tcpprobe模块后,会新增一个/proc/net/tcpprobe的接口,可以通过这个接口获取tcpprobe捕捉的信息。

# cat /proc/net/tcpprobe > data.out &    // tcpprobe捕捉的信息是持续性的,因此读这个接口可以放到后台读  
# pid=$!   // 保存上一个读命令的pid,用于结束读tcpprobe接口  
# iperf -c otherhost    // 使用iperf建立一个TCP流  
# kill $pid  

记录在data.out中的数据大致是这个样子:

1
2
3
11.172120204 193.168.0.2:8089 193.168.0.25:54320 32 0x842ccad 0x84299c5 10 168 14608 143
11.172141113 193.168.0.2:8089 193.168.0.25:54320 32 0x842ccad 0x842b60d 11 168 29088 137
11.172283353 193.168.0.2:8089 193.168.0.25:54320 32 0x842f9ed 0x842bbb5 11 168 31984 133

每行的各列分别为:

timestamp  
saddr:port  // 源IP及端口,我的数据是在发送端捕捉的,所以port是固定的8089    
daddr:port  // 目的IP及端口  
skb->len    // 收到的数据包skb大小,收到的都是ACK包,所以len都比较小。  
snd_nxt     // 下一个待发送数据的序列号  
snd_una     // 待确认数据的序列号  
snd_cwnd    // 拥塞窗口大小  
ssthresh    // 慢启动阈值  
snd_wnd     // 接收窗口大小  
srtt        // smoothed RTT  

可以使用简单地文本处理及gnuplot将得到的数据进行可视化,不过这里就不展开了。


3. tcpprobe是怎么实现的?

tcpprobe是一个机遇kprobe机制实现的模块,代码量其实不大,这节算是我的一个学习笔记吧。

首先tcpprobe定义了两个由于记录捕捉信息的数据结构:struct tcp_log和static struct tcp_probe。
tcp_log的元素基本就是上面介绍的一行中各列的数据,而tcp_probe结构体算是一个管理结构体,比如spinlock和用于判断cwnd是否改变的lastcwnd。

作为一个内核模块,必不可少的就是初始化和退出函数。下面先总体上说下这两个函数完成的任务。

static __init int tcpprobe_init(void)  
    => spin_lock_init(&tcp_probe.lock)  // 初始化tcp_probe结构体中的内容  
    => tcp_probe.log = kcalloc(bufsize, sizeof(struct tcp_log), GFP_KERNEL) // 为捕捉数据分配内存空间  
    => proc_create(procname, S_URUSR, init_net.proc_net, &tcpprobe_fops)    // 为tcpprobe模块创建一个proc文件系统接口  
    => register_jprobe(&tcp_jprobe) // 将tcp_jprobe注册到内核中  

static __exit void tcpprobe_exit(void)
    => remove_proc_entry(procname, init_net.proc_net);  // 删除tcpprobe模块的proc接口  
    => unregister_jprobe(&tcp_jprobe);  // 注销tcp_jprobe
    => kfree(tcp_probe.log)      // 释放内存

tcp_probe中锁的初始化和内存分配就不说了,先介绍一下proc_create()函数创建的proc接口,再说说register_jprobe()函数。
proc_create()函数的原型如下,在include/linux/proc_fs.h中实现。

static inline struct proc_dir_entry *proc_create(
        const char *name, umode_t mode, struct proc_dir_entry *parent,
        const struct file_operations *proc_fops)

tcpprobe模块中的各个参数的含义如下:

  1. name = “tcpprobe”
  2. mode = S_IRUSR, 允许读文件
  3. parent = init_net.proc_net,新建的proc接口将出现在/proc/net/目录中
  4. proc_fops = &tcpprobe_fops

tcpprobe_fops结构体的声明如下

static const struct file_operations tcpprobe_fops = {
    .owner   = THIS_MODULE,
    .open    = tcpprobe_open,  
    .read    = tcpprobe_read,  
    .llseek  = noop_llseek,
};

proc接口也是被当做文件来处理的,且由于是只读的,所以主要需要实现open和read这两个函数。
tcpprobe_open()主要就是将tcp_probe结构体的各项内容进行reset,
tcpprobe_read()则是读取已经记录的数据,当没有数据可读时则睡眠,正常情况就将tcp_probe.log数组的最后一项读取出来。

至此还只是建好了一个只读的proc接口,但是数据的采集函数并没有注册到内核中,接下来就简单说说tcp_jprobe这个数据结构体。
register_jprobe()函数的原型如下,在kernel/kprobes.c中实现。

int __kprobes register_jprobe(struct jprobe *jp)  

tcp_jprobe结构体的定义如下:

static struct jprobe tcp_jprobe = {
    .kp = {
        .symbol_name  = "tcp_rcv_established",
    },
    .entry = jtcp_rcv_established,
};

可以看出来,tcpprobe其实是用的jprobe方式实现的,监听tcp_rcv_established函数(这也是为什么tcpprobe不会抓取三次握手时候的包信息的原因)。
对kprobe和jprobe感兴趣的话,可以看看最后给出的几个参考资料。

最后说说jtcp_rcv_established()函数,由于它是用于监听tcp_rcv_established()函数的,因此函数的参数需要完全一致,从而jtcp_rcv_established()就拿到了一个TCP流最关键的sk结构体。
具体实现就是如果这个包的Port是监听的那一个,则往tcp_probe.log中新写入一项,然后唤醒等待数据的读进程。
还有一点就是tcp_probe.log是以一个循环数组形式实现的,再具体点就是那种保留一项不用的实现。代码就补解释了。

4. 实践中如何用好tcpprobe?

在实践中往往会发现tcpprobe的功能还不够强大,但只要适当修改,利用tcpprobe的实现原理还是能做很多的事情的。
tcpprobe算是目前为止我了解到的用于分析Linux内核TCP流的最赞的工具之一了。

首先,由于tcpprobe能够拿到TCP流的sk结构体,其实它能用于打印输出的信息远远不止是cwnd。
sk结构体是内核中记录一个TCP流最关键的数据结构,里面包含一条TCP流几乎任何的信息。
比如内存相关的可以看发送缓冲区的大小和占用情况,接收缓存区的大小。
其实只要在内核中拿到了一条TCP流的sk结构体,几乎可以对这条TCP流做任何事情。
所以建议在使用tcpprobe的时候,根据自己的需求修改tcp_probe.log结构体的内容。

其次,有时候无法或不便知道端口号,tcpprobe的现有实现就无法满足要求了。但是其实只要根据实际情况适当的修改tcpprobe模块,或许就能达到目的。比如如果我们知道src IP和dst IP,那么只要修改jtcp_rcv_established()函数记录数据的判断条件即可。

最后如果对TCP的内核实现熟悉的话,甚至可以改掉tcpprobe的监听入口函数。比如我们想监听skb发送时的一些信息,可以把监听入口函数改为tcp_transmit_skb()。

以上。


参考资料

tcpprobe by LINUX FOUNDATION
使用Kprobes调试内核 by IBM
Gaining insight into the linux kernel with kprobes
Kernel Debugging Using Kprobe and Jprobe
An introduction to KProbes