单连接多路径,合并多服务器公网带宽

对单一连接进行拆分发送合并接收,有拓展传输层 TCP 的 MPTCP 标准,也有使用普通 TCP 自定义应用层协议来实现的纯上层工具。理论上来说,前者的上限更高,后者兼容度更广。

这几天分别尝试了一下 Linux 内核支持的 MPTCP 和基于一般 TCP 的应用层工具 aggligator,来合并两台内网互联的服务器的带宽,公网带宽一台 5 Mbps,一台 4 Mbps。测试结果振奋人心,带宽真的合并了;同时出乎意料,内核级的 MPTCP 慢了一些。可能测试次数不够,可能内核优化还不完善,也可能部分配置项需要精调。

两只龟龟服务器的带宽叠加前后

合成大乌龟

标准 MPTCP

兼容性考量

标准多路径 TCP 应当可以概括为对 TCP 协议的一种拓展,是在 TCP 协议头中定义的一堆专用的 TCP Options 和相关逻辑。

在握手阶段,任一端就可以通过收到的相关 Option 知道另一端是否支持 MPTCP,对端支持则后续接着使用 MPTCP 专用字段沟通需要的额外信息;对端不支持则无缝切换一般 TCP 行为逻辑。

MPTCP socket 兼容一般 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。

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

  l_info("MPTCP suggest path manager initialized.");

  return 0;
}

static void suggest_exit(struct mptcpd_pm *pm)
{
  l_info("MPTCP suggest path manager exited.");
}

// 注册自身为一个 MPTCP 路径管理插件。
MPTCPD_PLUGIN_DEFINE(PLUGIN_NAME,
                    "Suggest path management plugin",
                    MPTCPD_PLUGIN_PRIORITY_DEFAULT,
                    suggest_init,
                    suggest_exit);

2/3,configure.ac:

AC_PREREQ([2.71])
AC_INIT([suggest_plugin],[0.1])

AM_INIT_AUTOMAKE([foreign])
LT_INIT([disable-static])

AC_CONFIG_SRCDIR([suggest.c])
AC_CONFIG_MACRO_DIRS([m4])

# ---------------------------------------------------------------
# Checks for programs.
# ---------------------------------------------------------------
AC_PROG_CC
AM_PROG_CC_C_O

# ---------------------------------------------------------------
# Checks for libraries.
# ---------------------------------------------------------------
# Find mptcpd.
PKG_CHECK_MODULES([MPTCPD], [mptcpd])

# Determine default mptcpd plugin directory.
PKG_CHECK_VAR([MPTCPD_PLUGINDIR], [mptcpd], [plugindir])

# ---------------------------------------------------------------
# Generate our build files.
# ---------------------------------------------------------------
AC_CONFIG_FILES([Makefile])
AC_OUTPUT

3/3,Makefile.am:

## "plugindir" specifies where plugins should be installed.
## Override as needed if the default mptcpd plugin directory
## is not used.
plugindir = @MPTCPD_PLUGINDIR@
plugin_LTLIBRARIES = suggest.la

suggest_la_SOURCES = suggest.c
suggest_la_CFLAGS  = $(MPTCPD_CFLAGS)
suggest_la_LDFLAGS = -no-undefined -module -avoid-version
suggest_la_LIBADD  = $(MPTCPD_LIBS)

编译,安装。

autoreconf
./configure
make
sudo make install

编辑 mptcpd 配置文件 /usr/local/etc/mptcpd/mptcpd.conf,将路径管理插件,即 path-manager 字段,由 addr_adv 设为刚编译安装好的 suggest,保存。

修改内核参数,使 MPTCP 使用用户模式的路径管理器(默认为内核模式)。只有用户模式的路径管理器才能对不同连接进行不同配置。

sudo -i sysctl -w net.mptcp.pm_type=1

你可能需要采用某种方法使这处变更在重启后依然生效。

重启 mptcp 服务。

sudo systemctl restart mptcp

确认 mptcp 服务正确加载了 suggest 插件。

sudo systemctl status mptcp

上面的输出中,应包含 MPTCP suggest path manager initialized.

主服服务启动

启动前,确认每个连接所允许新增的子流数量不为 0。下面命令的输出中, subflow 字段后面的数字即为该数量。

ip mptcp limits show

可以使用如下的命令进行调整。

sudo ip mptcp limits set subflow 2

你可能需要采用某种方法使这处变更在重启后依然生效。

主服服务程序有多种方法可以接入,选一种即可。

  1. 使用 mptcpize。如果在上一节选用了 mptcpd 管理路径,mptcpize 应当已经随 mptcpd 同时完成了编译安装。如果没有装过,可以运行:

    sudo apt install mptcpize

    使用时,只需在服务程序运行命令前加 mptcpize run,例如:

    mptcpize run java -Xmx1024M -Xms1024M -jar server.jar --port 2024 --nogui

    mptcpize 同样支持 systemd 服务,可以查阅该命令 help 信息。

    mptcpize 劫持目标程序对 Linux socket 函数的调用,将 IPv4/IPv6 的 TCP 流,直接替换成 MPTCP 协议的对应流。简单有效。

  2. 或者,安装使用 socat 工具,将 2024 端口的 MPTCP 连接转换为服务程序所在端口的 TCP 连接。例如:

    socat TCP4-LISTEN:2024,fork,protocol=0x106,nodelay TCP:127.0.0.1:52024
    java -Xmx1024M -Xms1024M -jar server.jar --port 52024 --nogui

    注意:这种方法会让服务程序认为所有的客户端都来自本机,可能会影响一些功能。

  3. 或者,使用 TUN 等虚拟网卡技术,透明代理 2024 端口的入站 MPTCP 连接为服务程序所在端口的 TCP 连接。有些前沿代理工具已经可以做到这一点。

  4. 如果合适,尝试联系开发者添加支持。

  5. 使用原生 socket 函数的开发者,可以参考本文#开发者接入一节,完成 MPTCP 适配。

至此,服务端的配置全部完成。对支持 MPTCP 的客户端,会使用 MPTCP 协议,并在建连后向客户端建议中转地址创建子流。对不支持的客户端,会无缝回落使用一般 TCP 协议。

客户端接入

Linux

首先,升级内核至 5.15+,并确认 MPTCP 已经启用。旧内核可能不支持某些操作。

sudo -i sysctl net.mptcp.enabled

在启动客户端前,确认每个连接允许接受的建议地址数量、每个连接允许新增的子流数量均不为 0。下面命令的输出中, add_addr_acceptedsubflow 字段后面的数字即分别为上述数量。

ip mptcp limits show

可以使用如下的命令进行调整。

sudo ip mptcp limits set add_addr_accepted 2
sudo ip mptcp limits set subflow 2

你可能需要采用某种方法使这些变更在重启后依然生效。

Linux 客户端程序有多种方法可以接入,选一种即可。

  1. 安装使用 socat 工具,将 TCP 流转换为连接主服的 MPTCP 流,例如:

    socat TCP4-LISTEN:2024,fork TCP:1.1.1.1:2024,protocol=0x106,nodelay

    然后,运行客户端程序,让其以本机 2024 端口为服务端。

  2. 或者,使用 mptcpize。安装:

    sudo apt install mptcpize

    配置:由于安装 mptcpize 同时也会安装 mptcpd,而 mptcpd 默认又总会将“每个连接允许接受的地址建议数量”设为 0,我们需要修改其配置,在 /etc/mptcpd/mptcpd.conf 文件中取消掉 addr-flags 字段的注释,保存。mptcpd 在安装期间可能已经重设了一次接受数量限制,重启它,再次确认接受数量限制、新增子流数量限制不为 0:

    sudo sed -i 's/# addr-flags=subflow/addr-flags=subflow/' /etc/mptcpd/mptcpd.conf # 编辑配置
    sudo systemctl restart mptcp # 重启
    ip mptcp limits show # 检查

    运行:在客户端运行命令前加 mptcpize run 即可,例如

    mptcpize run wget https://example.com/

    mptcpize 同样支持处理 systemd 服务,可以查阅该命令 help 信息。

  3. 或者,使用 TUN 等虚拟网卡技术,透明代理所有/特定出站 TCP 为 MPTCP 连接。有些前沿代理工具已经可以做到这一点。

  4. 如果合适,尝试联系开发者添加支持。

  5. 使用原生 socket 函数的开发者,可以参考本文#开发者接入一节,完成 MPTCP 适配。

Windows

Windows 内核尚未支持 MPTCP。WSL 官方内核也不支持,但可以自己编译调整。尝试让 Windows 上的客户端程序,通过 WSL 以 MPTCP 协议转发连接。

首先在 WSL 内,安装编译依赖。

sudo apt install build-essential flex bison dwarves libssl-dev libelf-dev

将 WSL 内核源码克隆到本地。

git clone https://github.com/microsoft/WSL2-Linux-Kernel.git
cd WSL2-Linux-Kernel

调整配置文件开启 MPTCP 支持:编辑 .config,找到 #CONFIG_MPTCP is not set 这一行,将其改为 CONFIG_MPTCP=y,保存。

cp Microsoft/config-wsl .config
sed -i 's/#CONFIG_MPTCP is not set/CONFIG_MPTCP=y/' .config

编译内核。假设我们将编译后的内核文件放到 C:\wsl\bzImage

make -j$((`nproc`+1))
cp arch/x86/boot/bzImage /mnt/c/wsl/

Windows 端,创建或编辑 %USERPROFILE%\.wslconfig 文件,添加以下内容,保存。

[wsl2]
kernel=C:\\wsl\\bzImage

Windows 端,完全关闭 WSL 实例。

wsl --shutdown

重新进入 WSL 实例后,就可以走前文 Linux 接入流程了。可以选用 socat 接入方式,然后让 Windows 运行的客户端程序,连接本机 2024 端口。

确保 WSL 默认开启的 localhostForwarding 功能没有设为关闭。如果依然连不通,可能需要调整防火墙、解除 UWP 回环限制、和/或 更新 Windows/WSL 版本。

也可以在 WSL 起一个本地代理,Windows 端通过环境变量、系统代理或 TUN 等方式连接,将所有/特定 TCP 转换为 MPTCP 连接。文章头图即为使用 WSL 代理前后的 Windows 测试结果。

未经涂画加工的测试结果截图

可以看到,使用 MPTCP 前,平均下载速度为 467 KiB/s (3.83 Mbps),仅主服提供网络带宽;使用 MPTCP 后,平均下载速度升至 1.04 MiB/s (8.72 Mbps),两台服务器共同提供网络带宽,公网流量十分稳定。

MPTCP 子流新增需要额外的时间,这里的数据不足以计算出 MPTCP 的传输效率。按照 Linux 内核开发者 @matttbe 的说法(golang/go#56539 评论),大量数据传输 MPTCP 相比 TCP 带来的额外开销应该在 1% 左右。

OpenWrt

可以安装使用 OpenMPTCProuter,将经过软路由的所有/特定 TCP 连接转换为 MPTCP 连接。

开发者接入

使用原生 socket 函数的开发者,可以修改创建 IPv4/IPv6 TCP socket 时的参数,创建 IPPROTO_MPTCP 协议的 socket,即完成 MPTCP 适配。如: socket(AF_INET, SOCK_STREAM, IPPROTO_MPTCP)。其中,IPPROTO_MPTCP == IPPROTO_TCP + 256 == 0x106

考虑不支持 MPTCP 的环境,可以参考 Go 语言的官方实现,src/net/mptcpsock_linux.go,整合了下面的错误处理和回落判断逻辑。

错误处理

Ref: golang/go#56539 评论

MPTCP socket 在创建时可能产生:

  • EINVAL 22 Invalid argument:内核完全不认识 MPTCP。(Linux 内核 5.5-)
  • EPROTONOSUPPORT 93 Protocol not supported:MPTCP 没有编译进内核。(Linux 内核 5.6+)
  • ENOPROTOOPT 92 Protocol not available:内核包含 MPTCP,但没有启用。(net.mptcp.enabled 为 0)
  • 其他错误:MPTCP 被 SELinux、BPF 或其他安全机制禁用。

如果第一次创建 MPTCP socket 时,就返回前两类错误(EINVALEPROTONOSUPPORT),即意味着当前运行内核不可能支持 MPTCP。可以将结果缓存起来,后续创建 socket 时直接使用一般 TCP socket。其他错误则可能是临时的,可以在每次遇到时才使用一般 TCP socket。

回落判断

Ref: multipath-tcp/mptcp_net-next#294

Linux 内核 5.16+ 提供了 SOL_MPTCP 常量,可以用来判断当前 socket 连接是否在使用 MPTCP。使用 getsockopt 函数来获取 socket 的 SOL_MPTCP 选项时,如果产生错误,即返回值 < 0,即表示当前 socket 不使用 MPTCP。这可能是因为相关 socket 创建时并未指定 MPTCP 协议,也可能是因为连接对端不支持 MPTCP,回落到了一般 TCP。

应用层模拟实现

标准 MPTCP 的落地程度还不高,需要内核支持,而且目前子流断线后不会重连,自定义行为需要写 C。幸运的是,我们可以转而自定义应用层协议,通过构建双端配合的 TCP 代理,来实现类似的功能,同时兼容更多的系统。aggligator 就是这样一个能拆分聚合 WiFi、Ethernet、USB 等网口连接的应用层工具。在笔者随手进行的几次测试中,aggligator 多数时候发挥了比当前版本的原生 MPTCP 更佳的性能。

客户端代理拆分发送,服务端代理合并接收

由于应用层实现没有统一的规范标准,这里给出 aggligator 的一种可行配置步骤,不作过多深入。

安装

服务端和客户端的安装流程基本一致,首先安装 Rust 工具链,然后使用 cargo 安装 aggligator-util,一个基于 aggligator 的命令行工具。国内网络环境可能需要换源加速。

cargo install aggligator-util

服务端配置

假设将服务端程序所运行的服务器称为主服,另一服务器称为中转服,服务程序已经运行在 7003 端口。

内网 IP 公网 IP
主服 10.0.1.1 30.1.1.1
中转服 10.0.1.2 30.1.1.2

主服运行:

agg-tunnel server --tcp 7001 --port 7003

中转服:任意方式 TCP 转发自身 7002 端口到 10.0.1.1:7001。

客户端配置

如果服务器公网和内网对应同一个网络接口,很可能会有一条子流被“优化”掉——这是因为 aggligator 类似 MPTCP,主要是为了更好地支援单客户端/单服务端的多网口而设计的,aggligator 的策略更为激进。可以指定 --tcp-link-filterinterface-ip,来避免这种情况。

执行:

agg-tunnel client --tcp-link-filter interface-ip --tcp 30.1.1.1:7001 --tcp 30.1.1.2:7002 --port 7003:2024

最后,运行客户端程序,以本机 2024 端口为服务端。

结语

MPTCP 是一个很有意思的协议,但目前落地程度还不高。内核开发、上层语言支持、相关应用层工具都处在比较初级的阶段。这既是机遇,也是挑战。

我是一名前端新人,抓住大四的小尾巴不务正业,对 TCP、MPTCP 等网络协议的理解很肤浅,本文只是记录了在尝鲜 MPTCP 过程中的一些经验,希望能帮助到有需要的人。如有错误之处,还望海涵,欢迎联系指正。