前言

可能你听过 eBPF 这个听上去非常高大上的技术,但实际并没有使用过。今天让我们来用 rust 快速体验一下 eBPF 的能力。

之所以使用 Rust 是因为 aya 框架确实很容易上手,当然如果使用 golang 也有 https://github.com/cilium/ebpf 的帮助也非常容易的。

因为只是体验,你可以几乎没有语言本身的使用经验,就能直接运行并感受到 eBPF 的能力。当然整体几乎也不需要写代码。

环境准备

  • 开发环境,如果直接在 Linux 环境上开发自然最好,博主使用的是 M 系列的 MacOS 环境,然后交叉编译到 Linux 上运行
  • 运行环境,当然你需要一个 Linux 环境能运行,并且内核版本要足够,前提就是需要支持 eBPF 和 XDP 才可以

前置知识点

由于只是体验,我也不过多深入其中的原理和内容,就简单快速让你了解什么是 eBPF 和 XDP。详细原理可参考各种文档

eBPF

在不更改内核源代码的情况下,向操作系统添加额外的功能

简单的来说就是通过一些钩子在运行一些调用方法的前后添加一些处理逻辑,以便实现一些功能。有点像 Java 的 AOP,又或者是像一种插件机制,总之我称为是一种动态扩展的能力。强大的地方在于,他是建立在内核上的,也就是说,它获取的都是最原始的信息,拿到了几乎无所不能(因为在系统底层,通常越底层能做的就越多也越复杂)。而还有一个强大的点是无侵入、或者说透明,因为对上层的系统来说是无感知的。

ebpf-principle

上图就很清楚的描述了他的作用。

XDP

我们知道了 eBPF 其实 XDP 就很容易理解,XDP 就是其中一种钩子,专门用来在网络数据包到达网卡驱动层时对其进行处理。XDP 全称是 eXpress Data Path,XDP 能够在数据包到达内核网络栈之前拦截并处理它们。关键就是快,网络包实在是太多了,所以性能非常的重要。

安装开发环境依赖

话不多说,我们赶紧实际上手感受一下吧。首先是我们今天最困难的一个步骤,安装环境。由于环境各种各异,所以遇到的问题也各种各样,对于第一次上手的同学来说这或许就是最困难的一步了。

文档参考:https://aya-rs.dev/book/start/development/

安装 rust 环境

1
2
$ rustup install stable
$ rustup toolchain install nightly --component rust-src

安装 bpf-linker

如果是 x86 的 linux 可以直接装

1
$ cargo install bpf-linker

由于我是 macOS 环境,使用下面的命令先安装 llvm 再进行安装

1
2
$ brew install llvm
$ LLVM_SYS_180_PREFIX=$(brew --prefix llvm) cargo install --no-default-features bpf-linker

注意此处可能会出现各种问题,由于之前的环境会有影响,总是就是如果有提示报错,不要由于一定是少了什么,直接安装提示安装对应没有的命令和依赖就可以了。

安装 cargo-generate

1
$ cargo install cargo-generate

至此基本环境到位,后面还需安装一些交叉编译所需的工具。

构建第一个 eBPF 程序

我们使用官方推荐的例子程序直接使用模板进行构建,构建一个 xdp 的 eBPF 程序,用于监听网络包,这也是我们非常用的一个使用场景了

1
$ cargo generate --name myapp -d program_type=xdp https://github.com/aya-rs/aya-template

执行这个命令就会按照模板创建以嗯 myapp 的 eBPF 应用程序,然后安装一些交叉编译所需的工具

1
$ brew install filosottile/musl-cross/musl-cross

然后以目标设备 x86 举例

1
2
$ export ARCH=x86_64
$ rustup target add ${ARCH}-unknown-linux-musl

这一部分在生成的 README 中有,如果不清楚可以直接查看。

编译并运行调试

最后二话不说,直接编译

1
2
3
4
$ export ARCH=x86_64
$ CC=${ARCH}-linux-musl-gcc cargo build --package myapp --release \
--target=${ARCH}-unknown-linux-musl \
--config=target.${ARCH}-unknown-linux-musl.linker=\"${ARCH}-linux-musl-gcc\"

如果没问题,就会在 myapp/target/x86_64-unknown-linux-musl/release 文件夹下生成一个 myapp 的文件就成功了,然后 scp 到目标 Linux 设备上进行调试。

1
$ RUST_LOG=info ./myapp --iface lo

这里一定注意需要先指定 LOG 等级,以便能看到日志输出,然后 --iface lo 是指监听本地的回环网卡默认不写是 eth0,这里我们测试所以写 lo 其实也就是我常说的 127.0.0.1 啦,新开一个窗口 ping 测试

1
$ ping -c 1 127.0.0.1

然后你就能看到输出的日志 received a packet

1
2
3
4
RUST_LOG=info ./myapp --iface lo
Waiting for Ctrl-C...
[INFO myapp] received a packet
[INFO myapp] received a packet

输出这个代表了什么意思呢?其实就相当于已经拿到了我们网络包了。

尝试修改

大致浏览代码结构,囫囵吞枣就可以,反正第一次看也看不明白(手动狗头)。首先我们直接搜代码 received a packet

myapp/myapp-ebpf/src/main.rs 文件中你可以看到如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
#[xdp]
pub fn myapp(ctx: XdpContext) -> u32 {
match try_myapp(ctx) {
Ok(ret) => ret,
Err(_) => xdp_action::XDP_ABORTED,
}
}

fn try_myapp(ctx: XdpContext) -> Result<u32, u32> {
info!(&ctx, "received a packet");
Ok(xdp_action::XDP_PASS)
}

很显然,myapp 直接去调用了 try_myapptry_myapp 输出了日志,也就是拿到了网络包,可想而知在这个方法里面就能拿到网络包的相关数据了。也就是入参 XdpContext。那我们稍作修改,简单解析一下试试看。

首先解释一下 myapp 其实就是看 try_myapp 返回的是什么,如果是返回了错误,那么直接就 ABORTED 了这个包,也就是直接将这个包给扔掉了。其他情况就是返回什么就是什么。

解析 XdpContext

考验你计算机网络基础知识是否扎实的挑战来了,还好我之前写过 回首网络知识之 TCP 协议,里面有报文格式的图片可以供你回忆

其实 ip 报文最关键的信息就前面,所以我们就尝试修改拿到一下源 ip + 端口这个信息

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
use network_types::{
eth::{EthHdr, EtherType},
ip::{IpProto, Ipv4Hdr},
tcp::TcpHdr,
udp::UdpHdr,
};

#[xdp]
pub fn myapp(ctx: XdpContext) -> u32 {
match try_myapp(ctx) {
Ok(ret) => ret,
Err(_) => xdp_action::XDP_ABORTED,
}
}

fn try_myapp(ctx: XdpContext) -> Result<u32, ()> {
info!(&ctx, "received a packet");

// 获取以太网报文头部
let ethhdr: *const EthHdr = ptr_at(&ctx, 0)?;
// 如果不是IPv4报文,直接放行
match unsafe { (*ethhdr).ether_type } {
EtherType::Ipv4 => {}
_ => return Ok(xdp_action::XDP_PASS),
}

// 获取IPv4报文头部
let ipv4hdr: *const Ipv4Hdr = ptr_at(&ctx, EthHdr::LEN)?;

// 获取源IP地址
let source_addr = u32::from_be(unsafe { (*ipv4hdr).src_addr });
// 获取源端口
let source_port = match unsafe { (*ipv4hdr).proto } {
IpProto::Tcp => {
let tcphdr: *const TcpHdr = ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)?;
u16::from_be(unsafe { (*tcphdr).source })
}
IpProto::Udp => {
let udphdr: *const UdpHdr = ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)?;
u16::from_be(unsafe { (*udphdr).source })
}
// ICMP报文打印日志并直接丢弃
IpProto::Icmp => {
info!(&ctx, "ICMP packet");
return Err(());
}
_ => return Err(()),
};

info!(&ctx, "SRC IP: {:i}, SRC PORT: {}", source_addr, source_port);
Ok(xdp_action::XDP_PASS)
}

#[inline(always)]
fn ptr_at<T>(ctx: &XdpContext, offset: usize) -> Result<*const T, ()> {
let start = ctx.data();
let end = ctx.data_end();
let len = mem::size_of::<T>();

if start + offset + len > end {
return Err(());
}

Ok((start + offset) as *const T)
}

这里我们做了两件重要的事情:

  1. 解析报文并拿到源 IP 和端口
  2. 拒绝掉 ICMP 报文也就 ping 请求

这其实是我们最常用的两个基本场景,一个就是判断来源,一个就是拦截报文。你可以想到,如果你想要封禁一些 ip 的访问,你就可以直接在这里做判断,并拦截直接丢弃,太强了。

注意:其中由于用到了 network_types 需要 add 一下,在 myapp-ebpf ,目录下执行下 cargo add network-types,然后重新编译并运行试试看。

调试看看

1
2
3
4
5
6
7
8
$ RUST_LOG=info ./myapp --iface lo
Waiting for Ctrl-C...
[INFO myapp] received a packet
[INFO myapp] ICMP packet
[INFO myapp] received a packet
[INFO myapp] SRC IP: 127.0.0.1, SRC PORT: 41102
[INFO myapp] received a packet
[INFO myapp] SRC IP: 127.0.0.1, SRC PORT: 9090
1
2
3
4
5
6
7
$ ping -c 1 127.0.0.1
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.

--- 127.0.0.1 ping statistics ---
1 packets transmitted, 0 received, 100% packet loss, time 0m

$ curl http://127.0.0.1:9090

可以看到使用 ping 的时候直接就丢包了,而使用 http 情况的时候拿到了 源 ip+端口,非常棒棒。

总结

整体体验下来,你一定会认为我说的没错,除了一开始安装环境很麻烦,本身其实非常容易上手,由于整体框架已经很容易做开发了,简单的改改就能实现你的需求。通过这次你一定能快速体验 eBPF 究竟是什么样一个东西 XDP 到底厉害在哪里。有了这样的体验之后,我相信你再去深入学习会事半功倍。

有需要其他的 aya 使用案例可以直接参考官方的其他例子,也非常容易上手。https://github.com/aya-rs/book/tree/main/examples