[eBPF] 基于 eBPF XDP 的高性能 UDP 转发程序设计 (experimental) (WIP) 踩坑经验(大概)

(本文尚未完成, 后面持续更新)

前言

援引 ebpf.io 对 eBPF 技术的描述:

eBPF 是一项革命性的技术, 起源于 Linux 内核, 它可以在特权上下文中 (如操作系统内核) 运行沙盒程序. 它用于安全有效地扩展内核的功能, 而无需通过更改内核源代码或加载内核模块的方式来实现.

由于内核具有监督和控制整个系统的特权, 操作系统一直是实现可观测性、安全性和网络功能的理想场所. 但由于操作系统内核对稳定性和安全性的高要求, 操作系统内核很难快速迭代发展. 为此, eBPF 顺势而出: 通过允许在操作系统中运行沙盒程序的方式, 应用程序开发人员可以运行 eBPF 程序, 以便在运行时向操作系统添加额外的功能, 在 JIT 编译器和验证引擎的帮助下, 操作系统确保它像本地编译的程序一样具备安全性和执行效率.

粗略地说, eBPF 是事件驱动的, 当内核或应用程序通过某个钩子 (Hook) 点时运行. 常见的 Hook 点如图所示:

我们今天主要聚焦于 eBPF XDP (eXpress Data Path), 其主要运行于图中 Network DeviceNetwork (hardware) 处的 Hook 点 (在网卡驱动程序 poll 时运行, 这种属于 Native 模式, 后文详述). 由于该 Hook 点远早于操作系统内核开始建立缓冲区处理网络数据, 性能极高 (高达 2400 Mpps).

目前被大企业用以生产环境的基于 eBPF XDP 技术的项目包括 Facebook 的 Katran (L4 LB), Cloudflare 的 L4Drop (DDoS migration) 等等, 都充分利用了 eBPF XDP 运行于网络栈最底层带来的极致性能.

为此, 我试图利用 eBPF XDP 构建流量转发程序, 并于本文分享自己这几天及接下来的折腾经验.

一些文档

很遗憾, eBPF XDP 由于其过于接近底层, 资料鲜少, 更不用说中文资料了, 大部分都是企业内部. 在此列出我搜寻到的一些高质量资料源供参考:

入门了解 eBPF XDP

进阶

一些参考过的项目

其他的一些文章

实际问题

在修改包头时需要注意 checksum, 为此参考了如下文章:

艰难摸索

环境搭建

建议使用最新内核, 但理论上 5.X 的 LTS 也能用.

Linux HANTONG-DEV 6.13.5-x64v3-xanmod1 #0~20250227.gdc8f2a7 SMP PREEMPT_DYNAMIC Thu Feb 27 16:46:42 UTC x86_64 GNU/Linux

笔者使用 Rust 在 WSL 下开发. 参考 Development Environment - Aya

配置 Rust 开发环境

# 需要 nightly Rust
rustup install nightly
rustup default nightly
# 需要从源码编译核心库
rustup compoment add rust-src
# 添加 target: bpfel-unknown-none
rustup target add bpfel-unknown-none
# x86 下安装 bpf-linker 很简单, 如果不是, 需要自行配置 LLVM, 参考原文档
cargo install bpf-linker
# 创建模板项目用
cargo install cargo-generate
# 添加

配置完成后自行找个文件夹, 进入后使用 cargo generate https://github.com/aya-rs/aya-template 在当前目录下创建项目.

配置 Cargo 及 VSCode

默认情况下 rust-analyzer 的编译目标是当前平台, 我们需要在 {你的项目名称}-ebpf 目录下新建 .vscode 文件夹, 配置工作区配置:

{
    "rust-analyzer.cargo.target": "bpfel-unknown-none",
    "rust-analyzer.check.allTargets": false,
    "rust-analyzer.checkOnSave.allTargets": false
}

类似地, 需要指定 cargo 编译目标:

[build]
target = "bpfel-unknown-none"

[unstable]
build-std = ["core"]

目录结构参考:

代码初见

打开 main.rs, 我们能看见:

#![no_std]
#![no_main]

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    #[allow(unsafe_code, reason = "eBPF")]
    unsafe {
        core::hint::unreachable_unchecked()
    }
}

意思是无 std 环境, 没有传统意义上的 main 入口点. eBPF 程序不会也不应该 panic, 而 #[panic_handler] 只是为了 “取悦” rustc 的罢了()

使用 Rust 编写 eBPF 代码是比较痛苦的, 不过总比写 C 好. 由于极其接近底层, 代码里充斥着 unsafe 块, 我们需要时刻提醒自己为什么 unsafe, 怎么让 unsafe 更 safe.

需要指出 eBPF 程序的限制:

  • 函数调用有限制, 所以能内联的内联 (#[inline(always)]).
  • 不能有 loop, 使用 const_for 代替, 类似于 #pragma unroll 的功能.
  • 所有指针操作都要慎重, 包括但不限于:
    • 一般不能把 Rust 里面的引用再转回裸指针, 很容易验证失败被拒绝执行.

    • 任何对裸指针的解引用都是 unsafe 的, 可能为 null, 也可能指向的数据不合法.

      文末提供了一些我验证过的 helper 方法.

    • 不要图省事裸指针强转类型后解引用后立即使用, 可能因为 LLVM 优化出问题而验证失败.

      如:

      // 此处实际上直接按 u32 读 ptr 也行
      let flow_data = unsafe {
          *(const_ptr_at::<{ EthHdr::LEN }, Ipv6Hdr>(&ctx)? as *const u32)
      };
      
      flow_data & 0x0FFFFFFFu32.to_be()
      

      不能图省事写成:

      unsafe {
          *(const_ptr_at::<{ EthHdr::LEN }, Ipv6Hdr>(&ctx)? as *const u32)
      } & 0x0FFFFFFFu32.to_be()
      

核心代码一览

基本原理

  • 原地修改包头, 然后使用 XDP_TX 就地发送, 或者 XDP_REDIRECT 通过别的网卡发送.

    疑问: XDP_REDIRECT 能在同一个网卡发送吗?

  • 通过 bpf_fib_lookup 方法获得新的 DST 的 MAC 地址.

    在 eBPF 程序内实现 ARP 是相当麻烦的.

    疑问: BPF_FIB_LKUP_RET_NO_NEIGH 怎么处理
    答曰: 用户空间做健康检测 (定期发送 ICMP 包).

  • 怎么维护连接关系比较好?

(TODO)

附录

附录 1 一些好用的辅助方法

#[inline(always)]
#[allow(unsafe_code, reason = "eBPF XDP context is unlikely to be null")]
/// Get a pointer to a type at the given offset.
const unsafe fn ptr_at<T>(ctx: &XdpContext, offset: usize) -> Result<*const T, ()> {
    let ctx = match unsafe { ctx.ctx.as_ref() } {
        Some(ctx) => ctx,
        None => {
            core::hint::cold_path();

            return Err(());
        }
    };

    let start = ctx.data as usize;
    let end = ctx.data_end as usize;

    let len = mem::size_of::<T>();

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

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

#[inline(always)]
#[allow(unsafe_code, reason = "eBPF XDP context is unlikely to be null")]
/// Get a pointer to a type at the given offset.
const unsafe fn const_ptr_at<const OFFSET: usize, T>(ctx: &XdpContext) -> Result<*const T, ()> {
    let ctx = match unsafe { ctx.ctx.as_ref() } {
        Some(ctx) => ctx,
        None => {
            core::hint::cold_path();

            return Err(());
        }
    };

    let start = ctx.data as usize;
    let end = ctx.data_end as usize;

    let len = mem::size_of::<T>();

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

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

#[inline(always)]
#[allow(unsafe_code, reason = "eBPF XDP ptr is unlikely to be null")]
/// Get a reference to a type at the given offset.
const fn ref_at<T>(ctx: &XdpContext, offset: usize) -> Result<&T, ()> {
    unsafe {
        match ptr_at::<T>(ctx, offset) {
            Ok(ptr) => match ptr.as_ref() {
                Some(r) => Ok(r),
                None => {
                    core::hint::cold_path();
                    Err(())
                }
            },
            Err(e) => Err(e),
        }
    }
}

#[inline(always)]
#[allow(unsafe_code, reason = "eBPF XDP ptr is unlikely to be null")]
/// Get a reference to a type at the given offset.
const fn const_ref_at<const OFFSET: usize, T>(ctx: &XdpContext) -> Result<&T, ()> {
    unsafe {
        match const_ptr_at::<OFFSET, T>(ctx) {
            Ok(ptr) => match ptr.as_ref() {
                Some(r) => Ok(r),
                None => {
                    core::hint::cold_path();
                    Err(())
                }
            },
            Err(e) => Err(e),
        }
    }
}

#[inline(always)]
#[allow(unsafe_code, reason = "eBPF XDP ptr is unlikely to be null")]
/// Get a reference to a type at the given offset.
const fn mut_ref_at<T>(ctx: &XdpContext, offset: usize) -> Result<&mut T, ()> {
    unsafe {
        match ptr_at::<T>(ctx, offset) {
            Ok(ptr) => match ptr.cast_mut().as_mut() {
                Some(r) => Ok(r),
                None => {
                    core::hint::cold_path();
                    Err(())
                }
            },
            Err(e) => Err(e),
        }
    }
}

#[inline(always)]
#[allow(unsafe_code, reason = "eBPF XDP ptr is unlikely to be null")]
/// Get a reference to a type at the given offset.
const fn const_mut_ref_at<const OFFSET: usize, T>(ctx: &XdpContext) -> Result<&mut T, ()> {
    unsafe {
        match const_ptr_at::<OFFSET, T>(ctx) {
            Ok(ptr) => match ptr.cast_mut().as_mut() {
                Some(r) => Ok(r),
                None => {
                    core::hint::cold_path();
                    Err(())
                }
            },
            Err(e) => Err(e),
        }
    }
}

其中 core::hint::cold_path() 需要 nightly feature #![feature(cold_path)], 为了最大化优化性能. 返回 ZST 也是性能着想, 默认错误就 XDP_ABORT 就行.

13 个赞

太强了,大佬!

也是在摸索哈哈, 等弄成了直接买一个月 IEPL 然后公测()

看不懂盲区了tieba_087