用Rust和eBPF路由转发流量

直接开始:

在转发使用的通常情况下,首个数据报文可以包含确定端点的元数据或可以确定是否终止连接的认证数据。首先我们可以通过构建反向代理但使用自定义逻辑来解决此问题。但我现在想用rust处理他们,且需要考虑的限制是我们在 Linux 上的容器中运行。

一般来说,这可以通过 6 个步骤完成:

  1. 创建一个 TCP 套接字
  2. 等待新的连接
  3. 缓冲并读取第一个数据包
  4. 确定目的地
  5. 打开到目标的 TCP 连接
  6. 转发流量

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

为什么这不好?

因为我们需要处理大量的流量,所以这种方法简洁但是负载能力实在太差。让我们更深入地看看当我们收到需要转发的数据时会发生什么。应用程序将循环执行读取系统调用到 linux 内核以读取在套接字上接收到的数据。这会将数据从内核空间的读取缓冲区复制到用户空间的缓冲区(多次复制)。然后,应用程序将再次执行写入系统调用,将数据从先前填充的用户空间缓冲区复制到另一个套接字的内核空间写入缓冲区。而且,这些都不受使用阻塞或非阻塞 IO 的影响。
将数据从内核空间复制到用户空间并返回是对内存带宽的浪费,但是执行两个系统调用的成本是一个值得关注的重要原因。由于用户模式和内核模式之间的切换,系统调用会增加延迟,这会在内存中的堆栈中保存和恢复 CPU 寄存器值。
TCP 套接字之间的数据复制并不是一个新问题,代理和负载均衡器等应用程序长期以来就有这种需求。他们中的一些人使用了诸如使用 splice 系统调用在套接字之间传递数据而不复制到用户空间缓冲区的技术。然而,这只解决了复制内存的问题,因为这仍然需要两个系统调用来转发一个数据包。

其他的方法

好消息是,这个问题有一个解决方案,可以避免上述两种成本。解决方案是使用 eBPF 程序来处理套接字之间的数据包转发。 eBPF 是用户空间应用程序在内核空间内运行沙盒程序而无需修改内核或加载内核模块的框架。这些程序可以附加到各种内核资源,等待由套接字接收数据等事件触发。

eBPF Stream Parser(流解析器) and Sockmap

在我们的用例中利用 eBPF 的一种方法是创建一个 BPF Sockmap 并在其上附加一个程序,该程序会将数据包重定向到正确的目标套接字。 Sockmap 用作套接字列表,源到目标套接字的映射需要定义为单独的 BPF 映射。当地图中的任何套接字接收到数据时,将执行附加的程序。

我们的应用程序只需要创建一个 BPF Sockmap 和一个 BPF 通用映射,我们将其称为目标映射。然后我们将一个程序附加到 Sockmap 以根据目标映射路由数据包。一旦我们的应用程序确定了传入连接的目的地,它会将套接字文件描述符与目的地映射中的条目一起添加到 Sockmap。此时所有传入的数据包都将由 eBPF 程序处理和转发,并且我们在用户空间的应用程序在此连接的生命周期内不参与处理任何数据。唯一剩下的就是在套接字断开连接后删除映射条目。

它真的有效吗?

在这一点上,我们想证明我们可以用 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"
    ))
}
不幸的是,RedBPF crates 要求我们在不安全的块中编写 eBPF 代码的几个部分。我们很高兴地看到,自从我们最初对这个回显服务器(2021 年 2 月)进行原型设计以来,RedBPF 增加了对 Sockmap 和 Stream Parser 程序类型的更好支持,这使我们能够从示例回显服务器中删除许多不安全代码块的实例。但是,由于缺乏 RedBPF 提供的安全抽象,我们被迫使用 unsafe,因此仍有改进的空间。
Full example source code is available at github.com/nacardin/ebpf-proxy

现在好了?

需要注意的一个重要事实是,用户空间应用程序必须以 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