用Rust和eBPF路由转发流量
直接开始:
在转发使用的通常情况下,首个数据报文可以包含确定端点的元数据或可以确定是否终止连接的认证数据。首先我们可以通过构建反向代理但使用自定义逻辑来解决此问题。但我现在想用rust处理他们,且需要考虑的限制是我们在 Linux 上的容器中运行。
一般来说,这可以通过 6 个步骤完成:
- 创建一个 TCP 套接字
- 等待新的连接
- 缓冲并读取第一个数据包
- 确定目的地
- 打开到目标的 TCP 连接
- 转发流量
这6个步骤的简单通过将数据包有效载荷同时将数据包有效载荷同时复制到另一个侧的发送缓冲区,直到任一侧TCP流终止(echo例子)
let listener = TcpListener::bind("127.0.0.1:10000").unwrap(); let mut stream = listener.accept().await.unwrap(); let mut buf = [0u8; 16]; loop { let bytes_read = stream.read(&mut buf).await.unwrap(); if bytes_read == 0 { break; } else { stream.write(&buf).await.unwrap(); println!("Echoed from userspace: {}", String::from_utf8_lossy(&buf)); } }
为什么这不好?
其他的方法
好消息是,这个问题有一个解决方案,可以避免上述两种成本。解决方案是使用 eBPF 程序来处理套接字之间的数据包转发。 eBPF 是用户空间应用程序在内核空间内运行沙盒程序而无需修改内核或加载内核模块的框架。这些程序可以附加到各种内核资源,等待由套接字接收数据等事件触发。
eBPF Stream Parser(流解析器) and Sockmap
在我们的用例中利用 eBPF 的一种方法是创建一个 BPF Sockmap 并在其上附加一个程序,该程序会将数据包重定向到正确的目标套接字。 Sockmap 用作套接字列表,源到目标套接字的映射需要定义为单独的 BPF 映射。当地图中的任何套接字接收到数据时,将执行附加的程序。
它真的有效吗?
在这一点上,我们想证明我们可以用 Rust 编写一个使用上述 eBPF 功能的应用程序。为此,我们编写了一个简单的 TCP 回显服务器,其中用户空间应用程序仅处理初始连接,并使用上述技术将所有数据包回显到源。
RedBPF 项目下有一系列 crate,它们处理编译 eBPF 程序的繁重工作,并提供一个 API 供我们在应用程序中加载和附加 eBPF 程序。通常,eBPF 程序是用 C 语言编写的,并使用 BCC 工具链加载。使用 RedBPF,我们用 Rust 编写我们的 eBPF 代码,并使用 RedBPF 提供的安全 Rust API 来加载和附加 eBPF 程序。
一个rust eBPF内核例子:
我们将把在 eBPF 中运行的应用程序部分放在一个 crate 中,我们将其命名为 echo-probe
。 RedBPF 将所有 eBPF 程序都称为探针,所以我们现在将遵循这个约定。
#![no_std] #![no_main] use core::mem; use core::ptr; use redbpf_probes::sockmap::prelude::*; program!(0xFFFFFFFE, "GPL"); #[map(link_section = "maps/sockmap")] static mut SOCK_MAP: SockMap = SockMap::with_max_entries(1); #[stream_parser] fn parse_message_boundary(sk_buff_wrapper: SkBuff) -> StreamParserResult { let len: u32 = unsafe { (*sk_buff_wrapper.skb).len }; Ok(StreamParserAction::MessageLength(len)) } #[stream_verdict] fn verdict(sk_buff_wrapper: SkBuff) -> SkAction { let index = 0; match unsafe { SOCK_MAP.redirect(sk_buff_wrapper.skb as *mut _, index) } { Ok(_) => SkAction::Pass, Err(_) => SkAction::Drop, } }
在上面的示例中,我们使用 Stream Verdict 程序通过同一个套接字将数据包重定向回来。这很简单,因为 sockmap 中唯一的套接字 FD 是传入连接之一,因此我们使用索引 0
。
一个用户空间程序例子:
我们将把我们的应用程序放在一个单独的 crate 名称 echo 中。在这个 crate 中,我们需要在 build.rs 中包含自定义构建过程。这使用 cargo-bpf crate 将我们的 echo-probe crate 编译为 eBPF 代码。内部 cargo-bpf 使用 rustc 但带有特殊标志。输出被发送到这个 crate 的输出目录。
build.rs
use std::env; use std::path::{Path, PathBuf}; use cargo_bpf_lib as cargo_bpf; fn main() { let cargo = PathBuf::from(env::var("CARGO").unwrap()); let target = PathBuf::from(env::var("OUT_DIR").unwrap()); let probes = Path::new("../echo-probe"); cargo_bpf::build(&cargo, &probes, &target.join("target"), Vec::new()) .expect("couldn't compile probes"); cargo_bpf::probe_files(&probes) .expect("couldn't list probe files") .iter() .for_each(|file| { println!("cargo:rerun-if-changed={}", file); }); println!("cargo:rerun-if-changed=../echo-probe/Cargo.toml"); }
在主循环中,我们使用 include_bytes! 将编译后的 eBPF 字节码作为数据嵌入到我们的用户空间应用程序中。然后我们加载 eBPF 资源,附加 sockmap,我们准备使用传入连接填充 sockmap。
main.rs:
use std::os::unix::io::AsRawFd; use futures_lite::{AsyncReadExt, AsyncWriteExt}; use glommio::net::{TcpListener}; use glommio::prelude::*; use redbpf::load::Loader; use redbpf::SockMap; fn main() { let server_handle = LocalExecutorBuilder::new().spawn(|| async move { let loaded = Loader::load(probe_code()).expect("error loading BPF program"); let bpf_map = loaded.map("sockmap").unwrap(); // Reference to the sockmap, which we can add and remove sockets from let mut sockmap = SockMap::new(bpf_map).unwrap(); // Attach the sockmap to the stream parser program loaded .stream_parsers() .next() .unwrap() .attach_sockmap(&sockmap) .expect("error attaching sockmap to stream parser"); // Attach the sockmap to the stream verdict program loaded .stream_verdicts() .next() .unwrap() .attach_sockmap(&sockmap) .expect("error attaching sockmap to stream verdict"); let listener = TcpListener::bind("127.0.0.1:10000").unwrap(); println!( "Server Listening on {}", listener.local_addr().unwrap() ); let mut stream = listener.accept().await.unwrap(); // Add the socket of the new connection to the sockmap // This is where the magic happens sockmap.set(0, stream.as_raw_fd()).unwrap(); println!( "Sockmap set fd {}", stream.as_raw_fd() ); loop { let mut buf = [0u8; 16]; let b = stream.read(&mut buf).await.unwrap(); if b == 0 { break; } else { stream.write(&buf).await.unwrap(); println!("Echoed from userspace: {}", String::from_utf8_lossy(&buf)); } } }).unwrap(); server_handle.join().unwrap(); } fn probe_code() -> &'static [u8] { include_bytes!(concat!( std::env!("OUT_DIR"), "/target/bpf/programs/echo/echo.elf" )) }
现在好了?
需要注意的一个重要事实是,用户空间应用程序必须以 root
身份运行才能加载和附加 eBPF 程序。另一种方法是将 unprivileged_bpf_disabled
systl 选项设置为 0
,这是系统范围的更改,需要考虑安全性。
还有其他利用 eBPF 的方法,例如使用 XDP,它甚至可能具有更好的性能,因为它在分配内核套接字缓冲区之前被触发。但是,这需要 NIC 驱动程序支持此功能集。此外,我们将失去根据数据包内容采取行动的能力。这可能是我们可以接受的解决方案,需要进一步考虑。
所以看来我们还有一些研究要做,敬请期待……
更多关于 BPF Sockmap (lwn.net/Articles/731133)
有关 BPF Sockmap 性能的更多信息 (blog.cloudflare.com/sockmap-tcp-splicing-of-the-future)
原文链接:https://www.infinyon.com/blog/2021/05/ebpf-routing-rust/#now-what