bigywater.tech
网络协议 · 105

VPN 隧道的封装:IP-in-UDP 与 MTU 的连锁反应

VPN 隧道把你的包装进另一个包里。多出来的那几十个字节,会把 MTU、分片、丢包率和 TCP 的 MSS 一连串地牵动。这一篇在 Docker 的 Linux 虚拟机里搭了真的 GRE、VXLAN、WireGuard 和 IPsec ESP 隧道,在外层网卡上抓包,把每个包多多少字节、MTU 配错会怎样、丢包怎么被放大、TCP 效率差多少,逐项量出来。

+24 / +50 / +60~72
GRE / VXLAN / WireGuard 每个包额外的字节(实测;ESP 是 +55~56)
1450 vs 1500
VXLAN 指定底层网卡 dev 时接口 MTU 自动是 1450;不指定则是 1500,要自己调
4.6% → 9.2%
10 MB TCP 传输的线上额外开销:不用隧道 4.6%,GRE 6.3%,VXLAN 8.3%,WireGuard 9.2%

封装开销

实测 · 本机执行输出
  内核:7.0.14-linuxkit;ta 的 eth0 MTU 1500
    隧道                              内层   84   内层   85   内层  100   内层  528   内层 1028   内层 1229   
    GRE                               →  108(+24) →  109(+24) →  124(+24) →  552(+24) → 1052(+24) → 1253(+24) 
    VXLAN                             →  134(+50) →  135(+50) →  150(+50) →  578(+50) → 1078(+50) → 1279(+50) 
    WireGuard                         →  156(+72) →  156(+71) →  172(+72) →  588(+60) → 1100(+72) → 1292(+63) 
    IPsec ESP(隧道模式,AES-GCM-128)       →  140(+56) →  140(+55) →  156(+56) →  584(+56) → 1084(+56) → 1284(+55) 
  GRE 每个包恒定 +24(外层 IP 20 + GRE 头 4);VXLAN 恒定 +50(外层 IP 20 + UDP 8 + VXLAN 8 + 内层以太网头 14);WireGuard 额外字节随内层大小变化,范围 +60~+72(内层先补零到 16 的倍数);ESP 同样不是常数,范围 +55~+56(填充到 4 字节边界,加 ESP 尾 2 字节)
  内层大小为 16 的倍数时,WireGuard 刚好 +60(外层 IP 20 + UDP 8 + 头 16 + 认证标签 16);不是 16 的倍数的内层包,补到下一个 16 的倍数。IPv6 作外层则每个包多 20 字节(外层 IPv6 头 40,不是 20)——这一点没有测,是计算

GRE 恒定 +24,VXLAN 恒定 +50(含内层以太网头),WireGuard +60~+72、IPsec ESP +55~+56——后两者要把内层包补齐到 16 或 4 字节的倍数,所以随内层大小变化。内层 MTU = 链路 MTU − 额外字节。

接口 MTU

实测 · 本机执行输出
  g0  默认 MTU 1476
  v0  默认 MTU 1500
  w0  默认 MTU 1420
  v1(VXLAN,指定了底层网卡 dev eth0)MTU 1450;v0(没指定 dev)MTU 1500——后者内核不知道底层是哪块网卡,没有自动减 50 字节,要自己调
  IPsec ESP 在 Linux 里不是一个接口,没有接口 MTU;路径 MTU 由路由和 xfrm 状态推算

内核给 GRE 自动设 1476、WireGuard 1420;VXLAN 只有指定了底层网卡才会自动减 50 字节。隧道接口的 MTU 要和外层链路配套,否则大包会被分片或被丢。

超过 MTU 之后

实测 · 本机执行输出
  GRE        接口 MTU 1476,内层 1476 字节 DF=是:收到 2/2                           外层包 [(1500, 0, 'DF', '×2')]
  GRE        接口 MTU 1476,内层 1477 字节 DF=是:收到 0/2(message too long, mtu=1476) 外层包 无
  GRE        接口 MTU 1476,内层 1477 字节 DF=否:收到 2/2                           外层包 [(1500, 0, 'DF'), (45, 0, 'DF'), (1500, 0, 'DF'), (45, 0, 'DF')]
  WireGuard  接口 MTU 1420,内层 1420 字节 DF=是:收到 2/2                           外层包 [(1480, 0, 'none', '×2')]
  WireGuard  接口 MTU 1420,内层 1421 字节 DF=是:收到 0/2(message too long, mtu=1420) 外层包 无
  VXLAN      接口 MTU 1500,内层 1500 字节 DF=是:收到 1/2(message too long, mtu=1450) 外层包 [(1500, 0, '+'), (70, 1480, 'none')]
    10.2.0.2 dev v0 src 10.2.0.1 uid 0 cache expires 597sec mtu 1450  ← 发过一个超大包后,内核给这个目的地记下了更小的路径 MTU
  VXLAN      接口 MTU 1450,内层 1450 字节 DF=是:收到 2/2                           外层包 [(1500, 0, 'none', '×2')]
  VXLAN      接口 MTU 1450,内层 1451 字节 DF=是:收到 0/2(message too long, mtu=1450) 外层包 无
  VXLAN      接口 MTU 1450,内层 1451 字节 DF=否:收到 2/2                           外层包 [(1494, 0, 'none'), (77, 0, 'none'), (1494, 0, 'none'), (77, 0, 'none')]
  GRE        接口 MTU 1500,内层 1500 字节 DF=是:收到 0/2(Frag needed and DF set (mtu = 1476)) 外层包 无
  GRE        接口 MTU 1500,内层 1500 字节 DF=否:收到 1/2                           外层包 [(1500, 0, 'DF'), (68, 0, 'DF')]

内层包带 DF 就在本地被拒绝(message too long);不带 DF 就先在内层分片、各片再封装。外层分片只在隧道接口 MTU 配得比链路允许的大时出现,而内核会在发出第一个超大包后学到更小的路径 MTU。

en0 上的分片

实测 · 本机执行输出
    内层 IP 包大小   外层 UDP 载荷   外层 IP 包大小   在 en0 上发出的 IP 包(分片)           有无分片
          1280           1312            1340       [1340]                                                    无
          1400           1432            1460       [1460]                                                    无
          1420           1452            1480       [1480]                                                    无
          1440           1472            1500       [1500]                                                    无
          1441           1473            1501       [1500, 21]  → 分成 2 片(偏移 [0, 1480])                        有分片
          1480           1512            1540       [1500, 60]  → 分成 2 片(偏移 [0, 1480])                        有分片
          1500           1532            1560       [1500, 80]  → 分成 2 片(偏移 [0, 1480])                        有分片
  外层 IP 包不能超过 en0 的 MTU 1500:内层 IP 包 ≤ 1500 − 20 − 8 − 32 = 1440 字节时刚好一个包;再大一个字节,外层就被分成两片——分片后任何一片丢失,整个数据报都丢

自己写的 IP-in-UDP 封装:内层 ≤ 1440 字节时外层刚好 1500 字节一个包,1441 字节起外层被分成两片。

丢包放大

实测 · 本机执行输出
    基线:内层 84 字节                            发 15000 个,丢 149 个 = 0.99%;按公式 1 − 0.99^1 = 1.00%
    内层 1450 字节(1 个外层包)                     发 15000 个,丢 144 个 = 0.96%;按公式 1 − 0.99^1 = 1.00%
    内层 1500 字节(一个请求变成 2 个外层包)              发 15000 个,丢 260 个 = 1.73%;按公式 1 − 0.99^2 = 1.99%

一个内层包变成两个外层包,任何一个丢了都算丢。1% 丢包下实测丢失率约翻倍(0.99% → 1.73%,几轮在 1.7%~2.2% 之间)。

TCP 效率

实测 · 本机执行输出
    不用隧道,直连                              MSS 1448→1448:出站  6916 个帧,共 10456464 字节(比 10,000,000 多 4.6%),最大帧 1514 字节,其中 IP 分片 0 个
    GRE(接口 MTU 1476)                     MSS 1424→1424:出站  7039 个帧,共 10633518 字节(比 10,000,000 多 6.3%),最大帧 1514 字节,其中 IP 分片 0 个
    WireGuard(接口 MTU 1420)               MSS 1368→1368:出站  7315 个帧,共 10921806 字节(比 10,000,000 多 9.2%),最大帧 1494 字节,其中 IP 分片 0 个
    VXLAN(接口 MTU 1450)                   MSS 1398→1398:出站  7158 个帧,共 10830336 字节(比 10,000,000 多 8.3%),最大帧 1514 字节,其中 IP 分片 0 个
    VXLAN(MTU 错配:接口 MTU 仍是 1500)         MSS 1448→1398:出站  7176 个帧,共 10832014 字节(比 10,000,000 多 8.3%),最大帧 1514 字节,其中 IP 分片 10 个
  (MSS 栏是 “连接建立时 → 传完 10 MB 后” 的值;抓的是外层:隧道里的 TCP 段加上隧道头;“线上总字节”不含以太网帧间隙与 CRC;只算 ta→tb 的方向,不含 tb 回的 ACK)

隧道让 MSS 变小、每段多出头部:10 MB 传输的额外开销从 4.6%(不用隧道)升到 GRE 6.3%、VXLAN 8.3%、WireGuard 9.2%。MTU 错配时 TCP 带着 DF,开头几个包付代价后 MSS 降下来,不会一直分片。

本机的隧道接口

实测 · 本机执行输出
  gif0     MTU 1280
  stf0     MTU 1280
  utun0    MTU 1500
  utun1    MTU 1380
  utun2    MTU 2000
  utun3    MTU 1000
  共 6 个隧道类接口,MTU 各不相同(['1000', '1280', '1380', '1500', '2000']):实际路径 MTU 取决于数据走的是哪个接口

6 个隧道类接口 MTU 各不相同,路径 MTU 取决于路由选中的接口。

分步演示

隧道封装与 MTU
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64(Apple 芯片)/ Python 3.14.8 / tcpdump(libpcap)/ scapy 2.8 / dpkt / dnspython / h2 / aioquic / OpenSSL / curl 8.7.1;抓包在 lo0(环回)上做,少数实验用真实网卡 en0(这台机器上 en0 是 Wi-Fi,tcpdump 看到的是系统转成以太网格式的帧,所以文中的“以太网头”都指这个 14 字节的头)或访问公网(会在文章里注明);需要丢包 / 延迟 / 限速时,用一个只作用于 lo0 端口 9400-9499 的 dummynet 辅助脚本(netlab);所有服务端都是本机自己起的进程;时间戳取自 tcpdump 的 -tt,数字每次运行会有小幅抖动。一到三、五、六节在 Docker Desktop 的 Linux 虚拟机(内核 7.0.14-linuxkit)里的两个容器 ta、tb 之间做,容器放在一个 MTU 1500 的 Docker 桥上,各自 NET_ADMIN 权限,用 iproute2 / wireguard-tools 建隧道,tcpdump 在 ta 的 eth0 上抓 ta→tb 方向的外层包;WireGuard 密钥、ESP 密钥都是实验用的一次性值;关掉了容器里的 IPv6 以减少背景包;ESP 隧道的状态和策略用 ip xfrm 手工建立;第五节用 tc netem 给 ta 的 eth0 出站加 1% 随机丢包(只影响请求方向),每轮 5000 个、共 3 轮;第六节用 Python 套接字单连接发 10,000,000 字节,先用 ethtool 关掉 tso / gso / gro,统计的是 ta→tb 方向的以太网帧长度;第四节是这台 Mac 的 en0 上自己写的 IP-in-UDP,目的地址是文档专用的 198.51.100.1,不会到达真实主机;第七节只读接口名与 MTU
    • 没有验证:IPIP 隧道——在这个 Docker 虚拟机里创建 ipip 接口会让 Docker 后端崩溃,所以没有测;GRE 之外的 IPv6 外层(外层 IPv6 的额外字节是计算值);真实网络里 PMTUD 依赖的 ICMP 3/4 报文被中间设备丢弃的情况(见 w16);NAT 穿越下 UDP 隧道的保活;真实广域网上 WireGuard 的握手与吞吐;MSS 钳制设备的真实效果(见 w65);隧道内再套隧道的叠加开销;分片放大丢包的数字每轮不同(几次完整运行在 1.7%~2.2% 之间),不是精确的 1.99%
    京ICP备2026067502号-1京公网安备11010502063487号