前言
就目前而言,tcpdump是捕获生产中流量最常见的解决方案之一。但是缺点是它不允许应用程序级别的过滤(L7过滤),因此每当捕获相关的HTTP会话时,最终需要存储数百兆字节的流量通过。另一种解决方案是在源代码中添加一个算法来查找相关的HTTP会话,但这需要在生产中进行代码检测,并且无法达到非侵入式可观测性。
eBPF应运而生。eBPF是Linux应用程序在Linux内核空间执行代码的一种机制。使用eBPF实现流量捕获远远超出一些标准的解决方案(WireShark、Fiddler和tcpdump)
eBPF允许添加多个过滤层并直接从内核捕获流量,从而显著减少相关数据的输出量,并确保可以高吞吐量的处理应用程序流量。
实验环境
- Ubuntu20.04
- BCC v0.21.0
- GO v1.19
请求流程分析
首先通过GO的Gin框架搭建一个简单的HTTP服务器。源码如下
server.go
1 | package main |
执行代码
1 | root@ubuntu:/home/caoyifan/eBPF-Sniffer# go run server.go |
如上因为没有安装gin框架,因此我们先安装gin框架。修改代理后使用go get
命令
1 | export GOPROXY="https://goproxy.cn" |
下载完成后运行server服务
1 | root@ubuntu:/home/caoyifan/eBPF-Sniffer# go run server.go |
本地发送POST请求,服务器接收到POST请求后会响应随机生成的Payload。
1 | root@ubuntu:/home/caoyifan# curl -X POST http://localhost:8080/customResponse -d '{"size": 100}' |
如果我们需要通过eBPF去捕获完整的HTTP请求,第一步需要了解到本次请求使用了哪些系统调用,因此我们可以使用strace
工具进行查看。
通过如下命令运行server服务
1 | sudo strace -f -o syscalls_dump.txt go run server.go |
-f
:从服务器的线程中捕获系统调用-o
:将结果写入到文件中
接着再次运行上述POST请求
1 | root@ubuntu:/home/caoyifan# curl -X POST http://localhost:8080/customResponse -d '{"size": 100}' |
查看syscalls_dump.txt
文件
1 | 2507558 accept4(3, <unfinished ...> |
通过上述文件可以看到。服务器首先使用accept4
系统调用来接受一个新的连接。整个请求服务器的调用流程如下:
accept4
:使用系统调用接受新连接read
:使用套接字文件描述符(fd)上的系统调用从套接字读取内容write
:使用套接字文件描述符(fd)上的系统调用将响应写入套接字close
:使用系统调用关闭文件描述符
内核代理eBPF实现
我们需要通过eBPF hook 8个钩子,分别是accept4、read、write、close
的进入和退出钩子。程序通过C语言编写,我们会通过所有的钩子组合来执行完整的捕获过程。
在大多数情况下,eBPF程序由执行hook的内核代理和处理从内核发送的事件的用户代理组成。在一些其他用例中,可能只有内核代理(如阻止恶意流量的防火墙)
首先我们需要挂载accept4
系统调用。在eBPF程序中,我们可以在每个系统调用的进入和退出放置钩子。这对于获取系统调用的输入参数很有用。
在如下代码片段中,我们声明结构体以将系统调用的输入参数保存在系统调用的入口中,并在accept4
系统调用的出口处使用。
1 | // 保存系统调用的addr参数结构 |
由于在系统调用进入期间我们无法知道系统调用是否会成功,并且在系统调用退出期间我们无法访问输入参数,因此我们需要存储参数。这里我们使用的逻辑是process_syscall_accept
,该函数会检查系统调用是否成功完成,然后我们会将链接信息保存在全局map中,以便后续的系统调用(read、write、close)使用。
在下面的代码中,我们创建了accept4
hook使用的函数。并在我们自己的map映射中注册到服务器上的任何新的连接。
1 | // 一个结构体,表示由 pid、fd和结构体timestamp组成的唯一ID。 |
到此,我们能够在内核侧识别新的连接并将信息发送至用户代理。接下来,我们将hook read系统调用
在下面的代码中,我们将hook read系统调用
1 | // 一个辅助结构,用于缓存入口钩子和出口钩子之间读/写系统调用的输入参数。 |
在下面的代码中,我们创建了辅助函数来处理read
和write
系统调用。
1 | // 数据缓冲区消息大小,BPF最多可以将这个数量的数据提交到perf缓冲区。 |
由此可以看到,我们的辅助函数会检查read
或者write
系统调用是否成功完成。通过检查 读取(写入)字节,检查正在读取(或写入)的数据是否为HTTP。如果是,则发送它到用户模式代理并作为一个事件。
然后会快速转到write系统调用。
最后代码处理close事件。在下面的代码中,我们创建了辅助函数来处理close系统调用。
1 | //发送到用户模式代理的关闭事件的结构 |
用户代理Go实现
用户模式代理使用gobpf库编写。第一步是编译代码。
1 | bpfModule := bcc.NewModule(string(bpfSourceCodeContent), nil) |
然后创建一个连接工厂,负责保存所有连接实例并打印就绪连接并删除不活动或格式错误的连接。
1 | // 创建连接工厂并将 1分钟设置为不活动阈值,这意味着在最后一分钟内未收到任何事件的连接将被关闭。 |
加载 perf 缓冲区处理程序
1 | if err := bpfwrapper.LaunchPerfBufferConsumers(bpfModule, connectionFactory); err != nil { |
以下是关于单用户模式perf缓冲区处理程序的说明。
每个处理程序通过管道(inputChan)获取事件,并且每个事件的类型为字节数组([]byte),对于每个事件,我们需要将其转换为go的数据结构表示。
1 | // ConnID is a conversion of the following C-Struct into GO. |
同时我们修复了事件的时间戳,因为内核模式返回单调时钟而不是实时时钟,最后,我们使用新事件更新连接对象字段。
1 | func socketCloseEventCallback(inputChan chan []byte, connectionFactory *connections.Factory) { |
最后一步是attach到钩子上。
1 | if err := bpfwrapper.AttachKprobes(bpfModule); err != nil { |
实验
非docker环境
bcc工具安装
Ubuntu - Source
To build the toolchain from source, one needs:
- LLVM 3.7.1 or newer, compiled with BPF support (default=on)
- Clang, built from the same tree as LLVM
- cmake (>=3.1), gcc (>=4.7), flex, bison
- LuaJIT, if you want Lua support
1 | # For Focal (20.04.1 LTS) |
1 | git clone https://github.com/iovisor/bcc.git |
执行报错
1 | root@ubuntu:/home/caoyifan/ebpf-training/workshop1/capture-traffic# go run main.go ./sourcecode.c |
最后发现是bcc版本的问题
1 | git clone -c http.proxy="http://192.168.19.16:17890" -b v0.21.0 https://github.com/iovisor/bcc.git |
启动ebpf sniffer
1 | root@ubuntu:/home/caoyifan/ebpf-training/workshop1/capture-traffic# go run main.go ./sourcecode.c |
启动服务端程序
1 | root@ubuntu:/home/caoyifan/ebpf-training/workshop1/demo-server# go run main.go |
本地发送post请求
1 | caoyifan@MacBookPro [16:19:17] [~] |
sniffer查看获取到的信息
1 | ========================> |
docker环境
docker镜像打包,Dockfile
如下
1 | FROM golang:1.16-bullseye as builder |
1 | docker build -t sniffer:v1 |
docker启动,setup_docker.sh
如下
1 | ! /bin/bash |