【译文】游戏程序员的 XDP

不过说真的。10 年内,每个人都将拥有 10GBps 的互联网。这将如何改变游戏的制作方式?我们该如何使用这些带宽?5v5 的射击游戏已经不能满足需要了。下一步会是什么?

作为本博客的第一篇文章,如果你发现自己像我一样,需要为应用程序提供绝对最大的带宽,那么你就需要使用内核旁路(bypass)技术。为什么呢?因为否则,在内核中处理每个数据包并将其下传到用户空间,然后再返回内核并输出到网卡的开销会限制您所能达到的吞吐量。这里说的是 10gbps 及以上。

好消息是,在过去的 5 年里,被称为 XDP/eBPF 的 Linux 内核旁路技术已经足够成熟,它已经从内核开发的领域,发展到现在的 2024 年初,可以被像你我这样的普通人普遍使用。

在本文中,我将简要介绍 XDP/eBPF 的工作原理,向你展示 XDP/eBPF 的实际功能,并给出一些简单 XDP 程序的示例代码 (https://github.com/mas-bandwidth/xdp),这样你就可以在自己的应用程序中开始使用这项技术了。

您可以用 XDP 做一些真正令人惊叹的事情,请继续阅读!

什么是 XDP,它是如何工作的?

XDP 是 “express data path(快速数据路径)”的缩写,基本上是一种编写函数的方法,当数据包从网卡发出时,在 Linux 内核对数据包进行任何分配或处理之前,该函数就会被调用。

这太不可思议了。功能强大。这就是程序员的破解之道。你可以编写一个在 Linux 内核中运行的函数,几乎可以做任何你想做的事情。你可以

  • 丢弃数据包
  • 修改数据包内容,或完全替换它
  • 扩大或缩小数据包的头部或尾部
  • 发送响应数据包,或将数据包转发到另一个地址

  • 将数据包下传给内核进行常规处理

最后一条是关键。使用其他内核旁路技术(如 DPDK),您需要安装第二个网卡来运行程序,或者基本上要实施(或授权)整个 TCP/IP 网络协议栈,以确保引内部的一切工作正常(网卡的作用远不止为您的游戏处理 UDP 数据包……)。

现在,您只需将 XDP 程序聚焦到例如只适用于发送到 40000 端口的 IPv4 UDP 数据包,而将其他所有数据包交给 Linux 内核进行常规处理即可。轻松搞定。

更正:显然,现在你可以使用 DPDK 的 “分叉驱动程序”,将某些数据包传回操作系统。我上次使用 DPDK 时还没有这种功能,那是很久以前的事了。不过相比 DPDK,我还是更喜欢 XDP。

什么是 eBPF?

eBPF 是 “扩展伯克利数据包过滤器”(extended Berkeley Packet Filter)的缩写,是一种能让你在 Linux 内核中编译、链接和运行 XDP 程序的技术。

简而言之,eBPF 是一种字节码和轻量级虚拟机,可在 Linux 内核中运行功能。eBPF 功能可以插入许多不同的地方,XDP 只是其中之一。

由于 eBPF 函数在 Linux 内核中运行,因此它们不能崩溃,也绝对不能停止。为了确保这一点,BPF 函数在加载到内核之前必须通过验证。

在实践中,这意味着 XDP 函数的功能非常有限。它们不是图灵完备的(halting problem),你必须做很多手脚,才能向验证者证明你没有越界编写。但在实践中,只要你保持简单,并愿意创造性地与验证器斗智斗勇,通常就能让它相信你的程序是安全的。

在 Ubuntu 22.04 LTS 上设置 eBPF/XDP

在开始编写 XDP 程序之前,您需要对机器进行设置,使其能够编译、链接和运行 eBPF 程序,并将其加载到内核中。

从 Ubuntu 22.04 LTS 发行版开始。

首先,你需要确保你拥有 6.5 Linux 内核:

uname -r

如果输出结果不是 6.5 版,请用以下命令更新内核:

sudo apt install linux-generic-hwe-22.04 -y

在命令行中运行以下命令:

# install necessary packages

sudo NEEDRESTART_SUSPEND=1 apt autoremove -y
sudo NEEDRESTART_SUSPEND=1 apt update -y
sudo NEEDRESTART_SUSPEND=1 apt upgrade -y
sudo NEEDRESTART_SUSPEND=1 apt dist-upgrade -y
sudo NEEDRESTART_SUSPEND=1 apt full-upgrade -y
sudo NEEDRESTART_SUSPEND=1 apt install libcurl3-gnutls-dev build-essential vim wget libsodium-dev flex bison clang unzip libc6-dev-i386 gcc-12 dwarves libelf-dev pkg-config m4 libpcap-dev net-tools -y
sudo NEEDRESTART_SUSPEND=1 apt install linux-headers-`uname -r` linux-tools-`uname -r` -y
sudo NEEDRESTART_SUSPEND=1 apt autoremove -y

# install libxdp and libbpf from source

cd ~
wget https://github.com/xdp-project/xdp-tools/releases/download/v1.4.2/xdp-tools-1.4.2.tar.gz
tar -zxf xdp-tools-1.4.2.tar.gz
cd xdp-tools-1.4.2
./configure
make -j && sudo make install

cd lib/libbpf/src
make -j && sudo make install
sudo ldconfig

# setup vmlinux btf

sudo NEEDRESTART_SUSPEND=1 apt install linux-headers-`uname -r` linux-tools-`uname -r` -y
sudo cp /sys/kernel/btf/vmlinux /usr/lib/modules/`uname -r`/build/

总之,关键步骤是从源代码构建 libxdp,然后构建并安装 libxdp 中包含的 libbpf 的准确版本。

我只能猜测为什么需要这样做,但如果不这样做,我就找不到其他方法让 XDP 在 Ubuntu 22.04 上完全正常工作,包括 BTF、kfuncs 和内核模块等所有功能。稍后再详述。

XDP Reflect 反射

现在,我们将构建并运行一个简单的 XDP 程序。在这个程序中,我们只需将发送到 40000 端口的 UDP 数据包反射回发送方。所有其他数据包都将交由内核进行常规处理。

首先,从 GitHub 克隆我的 XDP example repo :

git clone https://github.com/mas-bandwidth/xdp

切换到 reflect 目录并制作程序:

cd xdp/reflect && make

运行 UDP reflect 程序,输入要连接该程序的网络接口名称。你可以使用 ifconfig 列出 Linux 机器上的网络接口。

sudo ./reflect enp4s0

打开另一个终端窗口,查看 XDP 程序的日志:

sudo cat /sys/kernel/debug/tracing/trace_pipe

然后在另一台机器上再次克隆 XDP 软件仓库,并运行相应的 reflect 程序客户端,将 192.168.1.40 替换为运行 XDP 程序的 Linux 机器的 IP 地址:

git clone https://github.com/mas-bandwidth/xdp
cd xdp/reflect && go run client.go 192.168.1.40

如果一切正常,您应该能看到这样的日志:

gaffer@batman reflect % go run client.go
sent 256 byte packet to 192.168.1.40:40000
sent 256 byte packet to 192.168.1.40:40000
sent 256 byte packet to 192.168.1.40:40000
sent 256 byte packet to 192.168.1.40:40000
sent 256 byte packet to 192.168.1.40:40000
sent 256 byte packet to 192.168.1.40:40000
sent 256 byte packet to 192.168.1.40:40000
sent 256 byte packet to 192.168.1.40:40000
sent 256 byte packet to 192.168.1.40:40000
sent 256 byte packet to 192.168.1.40:40000
received 256 byte packet from 192.168.1.40:40000
received 256 byte packet from 192.168.1.40:40000
received 256 byte packet from 192.168.1.40:40000
received 256 byte packet from 192.168.1.40:40000
received 256 byte packet from 192.168.1.40:40000
received 256 byte packet from 192.168.1.40:40000
received 256 byte packet from 192.168.1.40:40000
received 256 byte packet from 192.168.1.40:40000
received 256 byte packet from 192.168.1.40:40000
received 256 byte packet from 192.168.1.40:40000

恭喜你,你已经创建并运行了第一个 XDP 程序,而且它还不是玩具。只要注释掉 reflect_xdp.c 中的 #define DEBUG 1 行,它就能在 10G 网卡上以线路速率反射数据包。

XDP 丢弃

接下来,我们将运行一个程序,监听 UDP 端口并丢弃与模式不匹配的数据包。这种类型的 XDP 程序可用于加固游戏服务器以抵御 DDoS,但它肯定不是万能的。

总体思路是对关键数据包数据进行散列处理,例如:数据包长度、源地址、目的地址和端口,如果你想花哨一点,还可以使用一些每分钟都会变化的滚动魔法数字。虽然这并不完美,也无法防范数据包重放攻击,但至少随机生成的 UDP 数据包不会通过模式检查。

诀窍在于以可逆方式在数据包开头的 15 个字节中对这 8 个字节的哈希值进行粉碎(shmear ),这样做实际上与压缩相反。我们将以非常低效的方式存储这些数据,这样,数据头中每个字节的有效值范围就会非常小,而大部分都是无效的。现在,我们有了一种熵值极低的模式,我们甚至不需要计算哈希值,就能对其进行检查。

下面是一个使用哈希值并填充 16 字节报头的示例,其中 15 字节为哈希值的低熵编码,第一个字节保留为数据包类型:

func GeneratePacketHeader(packet []byte, sourceAddress *net.UDPAddr, destAddress *net.UDPAddr) {

    var packetLengthData [2]byte
    binary.LittleEndian.PutUint16(packetLengthData[:], uint16(len(packet)))

    hash := fnv.New64a()
    hash.Write(packet[0:1])
    hash.Write(packet[16:])
    hash.Write(sourceAddress.IP.To4())
    hash.Write(destAddress.IP.To4())
    hash.Write(packetLengthData[:])
    hashValue := hash.Sum64()

    var data [8]byte
    binary.LittleEndian.PutUint64(data[:], uint64(hashValue))

    packet[1] = ((data[6] & 0xC0) >> 6) + 42
    packet[2] = (data[3] & 0x1F) + 200
    packet[3] = ((data[2] & 0xFC) >> 2) + 5
    packet[4] = data[0]
    packet[5] = (data[2] & 0x03) + 78
    packet[6] = (data[4] & 0x7F) + 96
    packet[7] = ((data[1] & 0xFC) >> 2) + 100

    if (data[7] & 1) == 0 {
        packet[8] = 79
    } else {
        packet[8] = 7
    }
    if (data[4] & 0x80) == 0 {
        packet[9] = 37
    } else {
        packet[9] = 83
    }

    packet[10] = (data[5] & 0x07) + 124
    packet[11] = ((data[1] & 0xE0) >> 5) + 175
    packet[12] = (data[6] & 0x3F) + 33

    value := (data[1] & 0x03)
    if value == 0 {
        packet[13] = 97
    } else if value == 1 {
        packet[13] = 5
    } else if value == 2 {
        packet[13] = 43
    } else {
        packet[13] = 13
    }

    packet[14] = ((data[5] & 0xF8) >> 3) + 210
    packet[15] = ((data[7] & 0xFE) >> 1) + 17
}

要运行 xdp drop 程序,只需进入 “drop “目录,然后在网络接口上运行即可:

cd xdp/drop && sudo ./drop enp4s0

然后在另一台电脑上运行 drop 客户端,分别用客户端和 drop XDP 程序地址替换地址:

cd xdp/drop && go run client.go 192.168.1.20 192.168.1.40

在 XDP 机器上,你会在日志中看到数据包过滤器通过了:

sudo cat /sys/kernel/debug/tracing/trace_pipe

尝试修改 client.go,发送随机生成的不带报头的数据包。你会在日志中看到数据包过滤器丢弃了数据包。散列的编码熵很低,随机生成的数据包几乎不可能通过数据包过滤器。

如果您最终在生产中使用了这种技术,请确保将低熵编码改成您游戏中独有的编码,因为脚本小孩也会看这些文章。此外,请确保您的编码是可逆的,这样您就可以在接收端重建哈希值,并在哈希值与预期值不匹配时在 XDP 中丢弃数据包。现在,人们无法欺骗他们的源地址或端口了!

XDP 白名单

还有更简单的方法吗?为什么不直接维护一个允许与游戏服务器通信的 IP 地址列表,然后丢弃任何非白名单地址的数据包呢?

当然,你需要在后台做一些工作,以便在连接之前 “打开 “服务器上的客户端地址,并且在客户端断开连接时 “关闭 “地址……但这是可行的,现在,在 Linux 内核做任何处理之前,XDP 就会丢弃来自随机地址的数据包。

为此,我们需要一种将白名单传递给 XDP 程序的方法。在这里,我们可以使用 BPF 的一项新功能:Maps.

Maps 是 BPF 中极其丰富的数据结构集。数组、哈希值、每 CPU 数组、每 CPU 哈希值等等。所有这些数据结构都是无锁的,你既可以从 BPF 程序内部读写它们,也可以从用户空间程序读写它们。

如果你明白我的意思,你现在就有办法从 BPF 程序返回到用户空间,反之亦然。现在几乎太简单了:只需调用用户空间程序中的函数,就能从白名单哈希映射中添加和删除条目。

运行白名单 XDP 程序,用你自己的接口名称代替:

cd xdp/whitelist && sudo ./drop enp4s0

然后在另一台电脑上运行白名单客户端,分别用客户端地址和 XDP 程序地址替换这些地址:

cd xdp/whitelist && go run client.go 192.168.1.20 192.168.1.40

如果你查看 XDP 程序的日志:

sudo cat /sys/kernel/debug/tracing/trace_pipe

你会看到它打印出丢弃数据包的原因是这些数据包不在白名单中。编辑 whitelist/whitelist.c,添加运行 client.go 的机器地址,然后重新加载 XDP 程序。再次运行 client.go,数据包应该会通过。此时,如果在 XDP 机器上绑定一个 UDP 插口到 40000 端口,它将只接收通过白名单检查的数据包。

如果在生产中使用这种方法,就需要编写自己的系统来添加和删除白名单条目。也许你的服务器会定期访问后台以获取开放地址列表?也许它订阅了某个队列?此外,本例中的白名单哈希值是空的,但你可以在其中输入数据。如果为每个客户端设置一个秘钥,使数据包过滤哈希值更加安全,会怎么样?你可以将白名单与数据包过滤器和哈希值检查结合起来,阻止攻击者伪造 IPv4 源地址通过白名单。

XDP 中继

即使 XDP 已通过白名单、数据包过滤检查和哈希检查丢弃了数据包,但您的游戏服务器仍然受到 DDoS 攻击,怎么办?

DDoS 攻击的规模越来越大。大得多恭喜你,你的游戏超级成功。为什么不在游戏服务器前放置一个中继器,只转发有效数据包,完全隐藏游戏服务器的 IP 地址呢?您可以在每个数据中心安装中继器,保护您的游戏服务器,这些中继器可以配备 10、40 或 100gbps 网卡。

我把这个问题留给读者练习。将上述白名单方法与白名单哈希值条目中的足够信息结合起来,让中继器将数据包从客户端转发到服务器,反之亦然。

现在,尽快丢弃任何来自不在白名单中的地址的数据包。加分点:跟踪每个客户端连接的序列号以避免重放攻击,并将客户端连接的速率限制在每个客户端的最大带宽包络范围内。这已经开始成为一个相当可靠的系统。

至此,你基本上就拥有了自己的 Steam Data Relay (SDR) 版本,而且不是免费的。它甚至可能比 SDR 更好。做得好!如果你像 Valve 一样拥有无限的资源,并在地下室拥有自己的印钞机,那么你也有能力大规模运行这个系统。

利用 XDP 进行网络加速

你知道吗,在任何时候都会有大约 5-10% 的玩家遇到网络性能不佳的问题,如延迟比平时高很多、抖动大或丢包率高?这很难让人相信,但却是事实。我有 5000 多万名玩家的数据可以证明这一点。

更有趣的是,这种糟糕的网络性能每个月都会发生变化,影响到大约 90% 的玩家。而不是每天都是这 5-10% 的玩家。

这不仅仅是少数玩家网络连接不良的问题,这是一个系统性问题。每场比赛之间的网络性能不一致会影响到大多数玩家。

没错……我们甚至可以通过高级转接将谷歌云连接到他们自己的数据中心,通过亚马逊全球加速器将亚马逊连接到他们自己的数据中心,而且我们的性能远远超过 Steam Data Relay (SDR)。

Network Next 中继是在 XDP 中实现的。

XDP 中的加密

在实施 Network Next 中继时,我遇到的一个问题是,如何在 XDP 程序中访问加密?当然,我可以快速转发或丢弃数据包,但我决定是转发还是丢弃数据包的依据不仅包括白名单、数据包过滤器和哈希检查,还包括 sha256 和 chachapoly 等加密测试。

当然,我也可以对抗验证器(verifier),直接在 BPF 中编写自己的加密基元,但这似乎会适得其反。我将花费大量时间与验证器对抗,到头来甚至可能无法在验证器的限制范围内实现特定的加密基元。它有一种不可思议的能力,就是不知道为什么你的代码是绝对安全的。说真的,写完一个 XDP 程序后,你真想给它一拳。

本文将讨论 BPF 的最后两个功能。BTF 和 kfuncs。

简而言之,你可以编写自己的内核模块,然后从该模块导出名为内核函数(kernel funcs)或(kfuncs)的函数,并在 XDP 内部调用它们。你甚至可以注释这些函数,以便 BPF 校验器知道:ok,这个函数参数是一个 void 指针数据,这里是数据的长度 int data__sz。这些注释是通过 BTF 完成的,BTF 是一种轻量级类型系统,它从 Linux 内核(包括内核模块)中导出类型数据,因此可以从 BPF 中访问它们。

利用这一点,我们可以在自定义的 crypto_module 内核模块中实现这个函数,使用现有的 Linux 内核加密原语执行 sha256。

要查看实际操作,请首先构建并加载内核模块:

cd xdp/crypto
make module

接下来,构建并运行 XDP 程序,将网络接口名称替换为自己的名称:

make && sudo ./crypto enp4s0

现在在另一台计算机上,切换到 crypto 目录并运行客户端,将地址替换为运行 XDP 程序的计算机的 IP 地址:

cd xdp/crypto && go run client.go 192.168.1.40

如果一切顺利,您将看到 XDP 程序为您发送的每个数据包回复一个 32 字节的数据包,其中包含数据包前 256 字节的 sha256。为什么只有前 256 个字节?要理解这一点,你需要了解 BPF 校验器的局限性…

BPF 校验器的局限性

有了 kfuncs,现在似乎就可以将整个 void * 数据包和来自 XDP 的 int packet__sz 传递给 kfunc,然后完全在内核模块中进行处理了。

但没那么快。BPF 校验器有一些限制,这些限制了你能做的事情(至少在 2024 年)。希望 Linux BPF 开发人员能在未来解决这些问题。

我对 BPF 对于 XDP 程序的局限性的最佳描述是,它非常 “固定 “地从左到右处理数据包。通常情况下,你从数据包的开头开始,检查数据包中是否有足够的字节来读取以太网头,然后将指针向右移动一定量,读取 IP 头,再向右移动一定量,读取 UDP 头,以此类推。

但是,如果你尝试编写读取数据包中最后 2 个字节的代码,即使代码是完全安全的,而且不会读取超出边界的内存,我也找不到任何方法让代码通过验证。

下一个限制是(在 2024 年),你似乎只能将数据包数据的恒定大小部分传递给 kfuncs。例如,我可以检查 UDP 数据包有效载荷是否至少有 256 字节,然后调用一个指向数据包数据指针和 256 字节常量大小的 kfunc,这样就能通过验证。但如果输入从 XDP 上下文导出的数据包实际大小,似乎就没有办法让验证器相信这是安全的。

这是一个巨大的遗憾,因为如果我们能简单地将 XDP 数据包传入内核模块,然后在那里做一些事情,那我们就真的可以大干一场了。希望 BPF 的未来版本能再次修复这个问题。

结论

在这篇文章中,我们探讨了内核 6.5 的 Ubuntu 22.04 LTS 中的 XDP 和 eBPF(一种内核旁路技术)。这项技术以前并不稳定,只有脖子上长着胡须的内核黑客才会使用,但现在已经足够稳定和成熟,游戏开发者可以普遍使用。

一旦掌握了窍门,它将成为一个功能强大且易于使用的系统。你可以编写一个 XDP 函数,用于反射数据包、丢弃数据包、转发数据包、运行数据包过滤器、执行白名单检查甚至加密。您只需做很少的工作,就能以 10gbps 及以上的线路速率完成所有这些工作。请查看本文的示例源代码 (https://github.com/mas-bandwidth/xdp) 并亲身体验。

我知道这听起来很疯狂,但在未来,我实际上正在探索用 UDP 请求/响应(request/response)实现整个后端系统和可扩展的游戏服务器,这几乎完全是在 XDP 内部实现的。有了地图、内核模块和 kfuncs,你几乎可以做任何想做的事,如果做不到,大不了把数据包传到用户空间处理。例如,如果你要在 2024 年创建一款新的网络游戏或超玩家人数游戏,我想不出有什么比 XDP/eBPF 更好的基础技术了。

我希望这篇文章能帮助你开始编写 XDP 程序,它们非常强大,编写起来也很有趣,即使验证器会让你抓狂。我期待着看到你用它创造出什么!

本文文字及图片出自 XDP for Game Programmers

余下全文(1/3)
分享这篇文章:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注