SOCKMAP - 未来的TCP拼接
摘要
Cloudflare 探索利用Linux内核的SOCKMAP基础设施进行TCP套接字拼接,从而减少用户空间开销,并实现更高效的反向代理数据转发。
<p><a href="https://lobste.rs/s/zng304/sockmap_tcp_splicing_future">评论</a></p>
查看缓存全文
缓存时间: 2026/06/21 04:31
# SOCKMAP - 未来 TCP 拼接技术
来源:https://blog.cloudflare.com/sockmap-tcp-splicing-of-the-future/
2019-02-18
7 分钟阅读
最近我们偶然发现了反向代理的圣杯——一个 TCP 套接字拼接 API。这引起了我们的注意,因为正如你可能知道的,我们运营着一个全球性的反向代理服务网络。正确的 TCP 套接字拼接可以减少用户空间进程的负载,并实现更高效的数据转发。我们意识到 Linux 内核的 SOCKMAP 基础设施可以重用于此目的。SOCKMAP 是一个非常具有前景的 API,很可能引起像软件代理这类数据密集型应用架构的剧烈变革。
31958194737_e06ecd6fcc_oImage (https://www.flickr.com/photos/mustadmarine/31958194737/) 由 Mustad Marine (https://www.flickr.com/photos/mustadmarine/) 提供,公共领域
但让我们先回顾一下。
### L7 代理的诞生之痛
从用户空间传输大量数据是低效的。Linux 提供了一些专门的系统调用来解决这个问题。例如,`sendfile(2)` 系统调用(Linus 不太喜欢它 (https://yarchive.net/comp/linux/splice.html))可用于加速从磁盘到套接字的大文件传输。还有 `splice(2)`,传统代理用它来在两个 TCP 套接字之间转发数据。最后,`vmsplice` 可用于将内存缓冲区粘贴到管道而不进行复制,但正确使用非常困难。
遗憾的是,`sendfile`、`splice` 和 `vmsplice` 非常专用、同步,并且只解决了问题的一部分——它们避免了将数据复制到用户空间。它们没有解决其他效率问题。
**避免用户空间内存**
**零拷贝**
sendfile
磁盘文件 --> 套接字
是
否
splice
管道 <--> 套接字
是
是?
vmsplice
内存区域 --> 管道
否
是
转发大量数据的进程面临三个问题:
1. 系统调用开销:为每个转发的数据包进行多次系统调用成本高昂。
2. 唤醒延迟:用户空间进程必须经常被唤醒以转发数据。根据调度器的不同,这可能导致较差的尾延迟。
3. 复制开销:将数据从内核复制到用户空间,然后立即复制回内核并非免费,并且累积起来会产生可测量的成本。
### 许多尝试
在 TCP 套接字之间转发数据是一种常见做法。它用于:
- 透明的正向 HTTP 代理,如 Squid。
- 反向缓存 HTTP 代理,如 Varnish 或 NGINX。
- 负载均衡器,如 HAProxy、Pen 或 Relayd。
多年来,有 (https://www.haproxy.org/download/1.3/doc/tcp-splicing.txt) 许多 (http://wwwconference.org/proceedings/www2002/refereed/627/index.html) 尝试 (https://lwn.net/Articles/200902/) 来降低在 Linux 上的 TCP 套接字之间进行简单数据转发的成本。这个问题通常被称为“TCP 拼接”、“L7 拼接”或“套接字拼接”。
让我们比较一下 TCP 拼接的常见方式。为了简化问题,我们不编写一个功能丰富的第 7 层 TCP 代理,而是编写一个简单的 TCP 回显服务器。
这不是玩笑。回显服务器可以很好地说明 TCP 套接字拼接。你知道——"echo" 基本上是将套接字与自身拼接!
### 朴素方法:读取-写入循环
朴素的 TCP 回显服务器如下所示:
``
while data:
data = read(sd, 4096)
writeall(sd, data)
``
没有比这更简单的了。在阻塞套接字上,这是一个完全有效的程序,并且运行良好。为了完整性,我准备了完整代码 here (https://github.com/cloudflare/cloudflare-blog/blob/master/2019-02-tcp-splice/echo-naive.c#L57-L78)。
### Splice:专用系统调用
Linux 有一个了不起的 splice(2) 系统调用 (http://man7.org/linux/man-pages/man2/splice.2.html)。它可以告诉内核在套接字上的 TCP 缓冲区和管道上的缓冲区之间移动数据。数据保留在缓冲区中,位于内核侧。这解决了不必要地在用户空间和内核空间之间复制数据的问题。使用 `SPLICE_F_MOVE` 标志,内核可能完全避免复制数据!
我们使用 `splice()` 的程序如下:
``
pipe_rd, pipe_wr = pipe()
fcntl(pipe_rd, F_SETPIPE_SZ, 4096);
while n:
n = splice(sd, pipe_wr, 4096)
splice(pipe_rd, sd, n)
``
我们仍然需要唤醒用户空间程序并进行两次系统调用来转发任何数据,但至少我们避免了所有复制。完整源代码 (https://github.com/cloudflare/cloudflare-blog/blob/master/2019-02-tcp-splice/echo-splice.c#L76-L98)。
### io_submit:使用 Linux AIO API
在之前关于 io_submit() (https://blog.cloudflare.com/io_submit-the-epoll-alternative-youve-never-heard-about/) 的博客文章中,我们提议将 AIO 接口与网络套接字一起使用。有关详细信息,请阅读博客文章,但这里是准备好的程序 (https://github.com/cloudflare/cloudflare-blog/blob/master/2019-02-tcp-splice/echo-iosubmit.c#L81-L107),它仅使用单个系统调用实现了回显服务器循环。
452423494_31aa5caca5_z-1Image (https://www.flickr.com/photos/jrsnchzhrs/452423494) 由 jrsnchzhrs (https://www.flickr.com/photos/jrsnchzhrs/) 提供,署名-非商业性使用 2.0
### SOCKMAP:终极武器
近年来,Linux 内核引入了 eBPF 虚拟机 (https://lwn.net/Articles/740157/)。有了它,用户空间程序可以在内核上下文中运行专门的、非图灵完备的字节码。如今,可以为数十种用例选择 eBPF 程序 (https://blog.cloudflare.com/tag/ebpf/),范围从数据包过滤到策略执行。
从内核 4.14 开始,Linux 获得了可用于套接字拼接的新 eBPF 机制——SOCKMAP。它由 John Fastabend 在 Cilium.io (https://cilium.io/blog/2018/04/24/cilium-security-for-age-of-microservices/) 创建,向 eBPF 程序公开了 Strparser (https://www.kernel.org/doc/Documentation/networking/strparser.txt) 接口。Cilium 使用 SOCKMAP 进行第 7 层策略执行,其所有逻辑都嵌入在 eBPF 程序中。该 API 文档不完善,需要 root 权限,并且根据我们的经验,有些小 (https://lore.kernel.org/netdev/[email protected]/) 问题 (https://lore.kernel.org/netdev/[email protected]/)。但它非常有前景。了解更多:
- LPC2018 - 结合 kTLS 和 BPF 进行内省和策略执行 论文 (http://vger.kernel.org/lpc_net2018_talks/ktls_bpf_paper.pdf) 视频 (https://www.youtube.com/watch?v=NnibidVRtWY) 幻灯片 (http://vger.kernel.org/lpc_net2018_talks/ktls_bpf.pdf)
- 原始 SOCKMAP 提交 (https://lwn.net/Articles/731133/)
这是如何使用 SOCKMAP:SOCKMAP 或特指 "BPF_MAP_TYPE_SOCKMAP",是一种 eBPF 映射类型。这个映射是一个“数组”——索引是整数。这一切都很标准。神奇之处在于映射的值——它们必须是 TCP 套接字描述符。
这个映射非常特殊——它附加了两个 eBPF 程序。你没看错:eBPF 程序存在于“附加到映射上”,而不是像通常那样附加到套接字、cgroup 或网络接口。以下是在用户程序中设置 SOCKMAP 的方法 (https://github.com/cloudflare/cloudflare-blog/blob/master/2019-02-tcp-splice/echo-sockmap.c#L36-L80):
``
sock_map = bpf_create_map(BPF_MAP_TYPE_SOCKMAP, sizeof(int), sizeof(int), 2, 0)
prog_parser = bpf_load_program(BPF_PROG_TYPE_SK_SKB, ...)
prog_verdict = bpf_load_program(BPF_PROG_TYPE_SK_SKB, ...)
bpf_prog_attach(prog_parser, sock_map, BPF_SK_SKB_STREAM_PARSER)
bpf_prog_attach(prog_verdict, sock_map, BPF_SK_SKB_STREAM_VERDICT)
``
哒哒!此时我们建立了一个 `sock_map` eBPF 映射,并附加了两个 eBPF 程序:解析器和裁决器。下一步是向这个映射添加一个 TCP 套接字描述符。没有比这更简单的了 (https://github.com/cloudflare/cloudflare-blog/blob/master/2019-02-tcp-splice/echo-sockmap.c#L130-L142):
``
int idx = 0;
int val = sd;
bpf_map_update_elem(sock_map, &idx, &val, BPF_ANY);
``
此时,“神奇的事情发生了”。从现在开始,每次我们的套接字 `sd` 收到一个数据包时,都会调用 prog_parser 和 prog_verdict。它们的语义在 strparser.txt (https://www.kernel.org/doc/Documentation/networking/strparser.txt) 和介绍性的 SOCKMAP 提交 (https://lwn.net/Articles/731133/) 中有描述。为简单起见,我们简单的回显服务器只需要最小的存根。这是 eBPF 代码 (https://github.com/cloudflare/cloudflare-blog/blob/master/2019-02-tcp-splice/echo-sockmap-kern.c#L32-L43):
``
SEC("prog_parser")
int _prog_parser(struct __sk_buff *skb)
{
return skb->len;
}
SEC("prog_verdict")
int _prog_verdict(struct __sk_buff *skb)
{
uint32_t idx = 0;
return bpf_sk_redirect_map(skb, &sock_map, idx, 0);
}
``
旁注:为了这个测试程序的目的,我写了一个最小的 eBPF 加载器。它没有依赖(既没有 bcc、libelf,也没有 libbpf),并且可以执行基本的重定位(比如解析上面提到的 `sock_map` 符号)。参见代码 (https://github.com/cloudflare/cloudflare-blog/blob/master/2019-02-tcp-splice/tbpf.c)。
对 `bpf_sk_redirect_map` 的调用完成了所有工作。它告诉内核:对于收到的数据包,请将其从某个套接字的接收队列“重定向”到位于 sock_map 中索引 0 下的套接字的发送队列。在我们的例子中,这些是同一个套接字!在这里,我们实现了回显服务器应该做的事情,但纯粹在 eBPF 中完成。
这项技术有多个好处。首先,数据永远不会被复制到用户空间。其次,我们永远不需要唤醒用户空间程序。所有操作都在内核中完成。很酷,不是吗?
我们还需要一段代码,让用户空间程序挂起直到套接字关闭。最好使用老式的 `poll(2)`:
``
/* 等待套接字关闭。让 SOCKMAP 施展魔法。 */
struct pollfd fds[1] = {
{.fd = sd, .events = POLLRDHUP},
};
poll(fds, 1, -1);
``
完整代码。 (https://github.com/cloudflare/cloudflare-blog/blob/master/2019-02-tcp-splice/echo-sockmap.c#L144-L148)
### 基准测试
到目前为止,我们展示了四个简单的 TCP 回显服务器:
- 朴素读写循环
- splice
- io_submit
- SOCKMAP
回顾一下,我们正在衡量三件事的成本:
1. 系统调用开销
2. 唤醒延迟,主要表现为尾延迟
3. 复制数据的成本
理论上,SOCKMAP 应该击败所有其他方法:
**系统调用开销**
**唤醒用户空间**
**复制成本**
读写循环
2 次系统调用
是
2 次复制
splice
2 次系统调用
是
0 次复制 (?)
io_submit
1 次系统调用
是
2 次复制
SOCKMAP
无
否
0 次复制
### 给我看数据
这是文章中展示惊人数字的部分,清楚地显示了不同方法的效果。遗憾的是,基准测试很难,而且嗯…… SOCKMAP 竟然是最慢的。发布负面结果 (https://cyber.wtf/2017/07/28/negative-result-reading-kernel-memory-from-user-mode/) 很重要,所以这里就是。
我们的测试环境如下:
- 两台裸机 Xeon 服务器,通过 25Gbps 网络连接。
- 两者都禁用了 Turbo Boost,测试程序已绑定 CPU。
- 为了更好的局部性,我们将 RX 和 TX 队列各自分配到一个 IRQ/CPU。
- 测试服务器运行一个脚本,发送 10k 批次的固定大小数据块。脚本测量回显服务器返回流量所需的时间。
- 我们对每个测量的回显服务器程序运行 10 次独立的测试。
- TCP: "cubic" 且 NONAGLE=1。
- 两个服务器都运行 4.14 内核。
我们对实验数据的分析识别出了一些异常值。我们认为一些最差的时间,表现为较长的回显响应,是由不相关因素引起的,比如网络丢包。在展示的图表中,我们(可能具有争议地)跳过了最差的 1% 的异常值,以便聚焦于我们认为重要的数据。
此外,我们发现 SOCKMAP 中存在一个错误。有些运行的延迟被延迟了多达 64ms。以下是其中一个测试:
``
Values min:236.00 avg:669.28 med=390.00 max:78039.00 dev:3267.75 count:2000000
Values:
value |-------------------------------------------------- count
1 | 0
2 | 0
4 | 0
8 | 0
16 | 0
32 | 0
64 | 0
128 | 0
256 | 3531
512 |************************************************** 1756052
1024 | ***** 208226
2048 | 18589
4096 | 2006
8192 | 9
16384 | 1
32768 | 0
65536 | 11585
131072 | 1
``
绝大多数回显运行(在此情况下为 128KiB)在 512us 范围内完成,而一小部分则停滞了 65ms。这相当糟糕,使得将 SOCKMAP 与其他实现进行比较毫无意义。这是我们从所有运行中跳过最差的 1% 结果的第二个原因——它使得 SOCKMAP 的数字更可用。抱歉。
### 2MiB 块 - 吞吐量
我们程序中最快的通过单个流达到了约 15Gbps,这似乎是硬件限制。这在第一次迭代中非常明显,它显示了我们的回显程序的吞吐量。
这个测试显示:通过我们测试的回显服务器发送和接收 2MiB 数据块的时间。我们重复此操作 10k 次,并运行测试 10 次。在剥离最差的 1% 的数字后,我们得到以下延迟分布:
numbers-2mib-2这张图表显示,朴素读写和 io_submit 程序都能够为 2MiB 块的 TCP 回显服务器实现 1500us 的平均往返时间。
这里我们清楚地看到 splice 和 SOCKMAP 比其他方法慢。它们受 CPU 限制,无法达到线路速率。我们过去曾提出过不寻常的 splice 性能问题 (https://www.spinics.net/lists/netdev/msg539609.html),但也许我们应该再次调试它。
对于每个服务器,我们运行两次测试:不设置和设置 SO_BUSYPOLL。此设置应消除“唤醒延迟”并大幅减少抖动。结果显示朴素和 io_submit 测试几乎相同。完美!BUSYPOLL 确实减少了偏差和延迟,但代价是增加了 CPU 使用率。请注意,splice 和 SOCKMAP 都不受此设置影响。
### 16KiB 块 - 唤醒时间
我们的第二轮测试使用了更小的数据大小,一次发送 16KiB 小块。这个测试应说明测试程序的“唤醒时间”。
numbers-16kib-1在此测试中,所有程序(SOCKMAP 除外)的非 BUSYPOLL 运行看起来相当相似(最小值和最大值),但 SOCKMAP 是例外。这很好——我们可以推测唤醒时间是可比的。令人惊讶的是,splice 的中位数时间略好于其他方法。也许这可以用 CPU 工件来解释,比如由于更少的数据复制而具有更好的 CPU 缓存局部性。SOCKMAP 再次最慢,具有最差的最大值和中位数时间。嘘。
请记住我们截断了最差的 1% 的数据——我们人为地缩短了“最大值”。
### TL;DR
在这篇博客文章中,我们讨论了 SOCKMAP 的理论优势。遗憾的是,我们发现它还没有准备好投入实际使用。我们将其与 splice 进行了比较,注意到 splice 并没有从 BUSYPOLL 中受益,并且性能令人失望。我们注意到朴素读写循环和 iosubmit 方法具有完全相同的每
相似文章
实验性 OpenBSD MAP-E CE 支持(反馈)
该项目提供补丁和脚本,用于向 OpenBSD 7.8 路由器添加实验性 MAP-E CE(RFC7597)支持,包括内核补丁和一个名为 maped 的配套应用程序。
masterking32/MasterDnsVPN
MasterDnsVPN 是一个开源的科学/研究项目,通过 DNS 查询和响应来隧道传输 TCP 流量,与 DNSTT 和 SlipStream 等同类工具相比,提供了多路径路由、ARQ 可靠性传输以及低协议开销等高级特性。
FediMeteo、HAProxy 与不浪费 snac 线程的艺术
作者介绍了在 FediMeteo 服务中使用 HAProxy 缓存来减少 snac 线程上的不必要负载,此前已用 nginx 做过类似优化。该方法旨在通过让反向代理吸收重复的公共请求,保持轻量级 ActivityPub 服务器的高效。
近期内核漏洞、攻击面减少,以IPSEC为例
Hanno Böck 讨论了影响 ESP (IPSEC) 模块的近期内核漏洞,并建议禁用与 IPSEC 相关的内核配置选项以减少攻击面,突出显示了默认情况下加载了许多未使用的内核模块。
Silk: 开源协作式纤程调度器
Silk 是一个面向 Linux 的开源协作式纤程调度器,具有每 CPU 调度线程、io_uring 集成和拓扑感知的工作窃取功能,专为低开销下的高并发而设计。