单连接多路径,合并多服务器公网带宽---MPTCP---aggligator
https://sliphua.work/try-mptcp/#%E5%BC%80%E5%8F%91%E8%80%85%E6%8E%A5%E5%85%A5
对单一连接进行拆分发送合并接收,有拓展传输层 TCP 的 MPTCP 标准,也有使用普通 TCP 自定义应用层协议来实现的纯上层工具。理论上来说,前者的上限更高,后者兼容度更广。
这几天分别尝试了一下 Linux 内核支持的 MPTCP 和基于一般 TCP 的应用层工具 aggligator,来合并两台内网互联的服务器的带宽,公网带宽一台 5 Mbps,一台 4 Mbps。测试结果振奋人心,带宽真的合并了;同时出乎意料,内核级的 MPTCP 慢了一些。可能测试次数不够,可能内核优化还不完善,也可能部分配置项需要精调。
合成大乌龟
标准 MPTCP
兼容性考量
标准多路径 TCP 应当可以概括为对 TCP 协议的一种拓展,是在 TCP 协议头中定义的一堆专用的 TCP Options 和相关逻辑。
在握手阶段,任一端就可以通过收到的相关 Option 知道另一端是否支持 MPTCP,对端支持则后续接着使用 MPTCP 专用字段沟通需要的额外信息;对端不支持则无缝切换一般 TCP 行为逻辑。
Apple 部分内置应用 2013 年就开始使用 MPTCP v0(RFC 6824),Linux 内核自 5.6 开始支持 MPTCP v1(RFC 8684)。但终端码农角度的文章不论中外目前还是很少,好多问题需要自己啃规范原文找到答案。幸运的是,相关内核开发者为了进一步降低接入门槛,写了很多文档和代码工具,大大降低了理解和接入成本。
“多路径”理解
MPTCP 在握手期间双方生成一个 key 并交换,用于判断后续对话的是哪个连接。
握手后,不论包从哪里哪个 IP 来,从哪个端口来,只要拿着对应的 key,就可能与内核建立一条新的子流 Subflow,内核帮你把数据合并入一个 socket。流与流之间另用一个 Address ID 字段区分。
发包的时候,内核看看已有的子流,选一个发出去。如果对面有建议过还可走哪条道、自己有多张网卡等,就可能尝试新建一条子流。也可以发建议给对面,建议对面主动与建议地址建立子流。
子流的建立、维护、关闭等行为与一般 TCP 连接类似,但是“连接”是一个整体的概念,子流是连接的一部分。对内核来说,子流是 MPTCP 连接的一条路径;对应用层来说,子流是完全透明的,只需要像使用一般 TCP socket 一样使用 MPTCP socket。
完整步骤还要加上哈希认证、排序重传、路径管理模式、调度策略等,精准的行为逻辑建议阅读规范原文,RFC 8684: TCP Extensions for Multipath Operation with Multiple Addresses。
服务端接入
Linux MPTCP 开发者提供了一些实用工具帮助接入 MPTCP,包括:
mptcpd
: 提供用户空间的路径管理接口。mptcpize
: 让目标程序或服务创建的 TCP socket 都变为 MPTCP socket。
假设,要在某台 Debian 服务器 2024 端口搭建一个 Minecraft Java 服务器,称为主服;另一台服务器称为中转服。
中转配置
规范允许服务端以中转 IP 主动与客户端建立子流,也允许将中转地址作为建议发给客户端,让客户端主动建立。
考虑到公网,NAT 普及,太多时候只能客户端主动与服务器建联。所以最实用简单的,我们将中转服某一端口 NAT 内网转发到主服,即完成中转配置。这样也使不经 NAT 的少数客户端的体验一致了,都是客户端主动建立子流,避免偶现奇奇怪怪的问题。
具体而言,配置中转服转发到主服,又有两种模式可选:
- 单转发单服务模式:一个 NAT 端口转发,对应一个主服 MPTCP 服务端口。这种模式目前配置起来最简单,就是需要为每一个服务端口都配置一个 NAT 中转地址。
- 单转发多服务模式:一个 NAT 端口转发,可以对应多个主服 MPTCP 服务端口。这种模式要求主服分配一个专用的端口给内核,配置中转服转发到主服这个专用端口上。内核目前尚未完全支持这种模式,Linux 内核开发者有提出暂时的变通方案,但是笔者没有试验成功。
这两种转发模式,在中转服上的配置不同。不过正好,多服务模式本人没有尝试成功,所以后文只介绍单转发单服务模式的配置。
假设中转服同样选用 2024 端口,NAT 内网转发到主服的 2024 端。
值得注意的是,由于 MPTCP 的相关参数含于 TCP 数据包头,L7 层的端口转发,即通过与目的端新建一条连接,然后不断中转两条连接的数据体实现的转发,不能用于服务端 MPTCP 中转。
主服准备
首先,升级内核至 6.1+,并确保 MPTCP 已经启用。旧内核可能不支持后续操作。
sudo -i sysctl net.mptcp.enabled
可选地,打开 MPTCP 的校验和功能。规范建议在不可控环境中开启。客户端服务端只要有一端开启,这个功能就是双端生效的。几次测试下来没看到速度差别。看自己需求了。
sudo -i sysctl -w net.mptcp.mptcp_checksum=1
你可能需要采用某种方法使这处变更在重启后依然生效。
主服路径管理
接下来,我们配置主服向 MPTCP 客户端发送中转 IP 和相关端口,告诉客户端还有中转路径可走。
这一步可以通过单纯调整内核配置来实现,也可以通过使用 mptcpd
用户空间应用来实现。
纯内核配置
单纯调整内核配置的优点是简单快捷,不需要编写编译额外代码,缺点是
- 要求中转端口号与主服端口号一致;
- 会针对所有的 MPTCP 连接生效,即不区分本地端口是否为 2024。
假设中转服 IP 为 2.2.2.2,执行下面的命令,即可。
sudo ip mptcp endpoint add 2.2.2.2 signal
用户空间接口
或者,我们使用 mptcpd
,在用户空间自定义灵活的路径管理策略。mptcpd
- 不要求中转端口号与主服端口号一致;
- 支持特定连接特定处理,可以仅针对 2024 端口的连接,向对端发送地址建议。
不过,目前 mptcpd
尚不支持建议会经 NAT 中转的 IP,需要像下面这样调整。
安装编译依赖。
sudo apt install build-essential autoconf automake libtool autoconf-archive libell-dev
克隆 mptcpd
。
git clone https://github.com/multipath-tcp/mptcpd.git
cd mptcpd
找到 upstream_announce
函数(截至发文,src/netlink_pm_upstream.c
第 219 行),该函数用于向对端发送地址建议。注意开头的侦听调用 (232-235 行),由于无法侦听外部 IP 会失败,由此阻止了我们对外发送中转建议。调用上方 @todo
也提到这处侦听不是强制的。为了尽早尝鲜,我们直接将这块调用注释掉,保存。
编译,安装。
./bootstrap
./configure
make
sudo make install
接着,我们就可以通过创建 mptcpd
路径管理插件,自定义路径管理策略,使得每建立一个 2024 端口的 MPTCP 连接,我们都向对端发送中转地址建议。
各连接地址之间是平级的,内核允许客户端先走中转地址创建连接,再走主服地址新增子流。要做到这一点,可以直接向对端发送所有可用地址建议,对端会自行筛选;也可以自行编写逻辑判断当前地址,再向对端发送剩余地址建议。本文不作展开。
阅读 mptcpd 插件开发 wiki,跟随示例,编写我们需要的插件。下面给出一个简单的例子,新建一个文件夹,把下面的三个文件放进去。
1/3,suggest.c
:
#include <arpa/inet.h>
#include <ell/ell.h>
#include <mptcpd/id_manager.h>
#include <mptcpd/path_manager.h>
#include <mptcpd/plugin.h>
#define PLUGIN_NAME suggest // 插件名,以 suggest 为例
#define MATCH_PORT 2024 // 主服端口
#define SUGGEST_IP "2.2.2.2" // 中转机 IP
#define SUGGEST_PORT 2024 // 中转机端口
// 在 MPTCP 连接建立时,
static void on_connection_established(
mptcpd_token_t token,
struct sockaddr const *laddr,
struct sockaddr const *raddr,
bool server_side,
struct mptcpd_pm *pm)
{
// 获取本地端口,
struct sockaddr_in *sin = (struct sockaddr_in *)laddr;
uint16_t port = ntohs(sin->sin_port);
// 不是服务端口,不处理;
if (port != MATCH_PORT) {
return;
}
// 是服务端口,则生成中转地址的结构体,
struct sockaddr_in alt_addr = {};
alt_addr.sin_family = AF_INET;
alt_addr.sin_addr.s_addr = inet_addr(SUGGEST_IP);
alt_addr.sin_port = htons(SUGGEST_PORT);
// 为其注册一个 Address ID 用于给内核分辨,
struct mptcpd_idm *const idm = mptcpd_pm_get_idm(pm);
mptcpd_aid_t const id = mptcpd_idm_get_id(idm, (struct sockaddr *)&alt_addr);
if (id == 0) {
l_error("Unable to map suggesting address to ID.");
return;
}
// 然后向对端发送。
if (mptcpd_pm_add_addr(pm, (struct sockaddr *)&alt_addr, id, token) != 0) {
l_error("Unable to suggest address to connection with token '%d'.", token);
return;
}
l_info("Suggested subflow address to connection with token '%d'.", token);
}
// 把上面的函数存到一个路径管理器的操作集中,
static struct mptcpd_plugin_ops const pm_ops = {
.connection_established = on_connection_established,
};
// 在插件初始化时,
static int suggest_init(struct mptcpd_pm *pm)
{
static char const name[] = L_STRINGIFY(PLUGIN_NAME);
// 注册上述操作集。
if (!mptcpd_plugin_register_ops(name, &pm_ops)) {
l_error("Failed to initialize suggest path manager.");
return -1