TCP 最大段大小选项 MSS,按照其定义的开销规则,通告接收端可接受的 TCP 数据大小。两个方向分别通告,数值不必相同。路由器转发 SYN 消息时可以减小这些值,使后续数据段适应受限路径。这改变的是相关连接的 TCP 行为,不会修改底层链路 MTU。把选项调整与网络本身修复区分开,才能准确说明它解决了什么。

坏掉的样子

中间瓶颈可能比两个端点接口都小。如果经典路径 MTU 反馈被过滤,连接可以成功建立,却在较大数据上停滞。降低 MSS 能帮助该 TCP 流量避开超大报文,但缺失的反馈仍然缺失。若把这个变通措施当作完整网络修复,其他协议与流量模式仍可能暴露原有问题,健康检查也可能继续遗漏受影响场景。

MSS 不是完整 IP 包大小。IPv4 路径 MTU 为 1280、普通 IPv4 与 TCP 基础头各占 20 字节时,基本计算得到通告 MSS 1240。形成真实数据段时,还要为实际 IP 或 TCP 选项另外留空间。把通告值直接等同于最终线路报文长度,可能使看似保守的设置仍然超限。应明确每个数字包含哪些头部,而不是只套一个固定减法。

方向和时机也很重要。只修改一个 SYN 方向,另一端通告仍保持原样;更新规则,也不会改写已有连接早先交换过的选项。真正的限制应降低过大通告,而不是抬高对端原本更小的上限。策略应针对真实瓶颈和相关流量,不能把一个硬编码值视为所有网络都合适,也不能假定旧连接会自动采用新配置。

快速验证

将脚本保存为 check-mss-clamp.py,准备 Docker 后运行 python3 check-mss-clamp.py。一次性环境使用 Python 3.11.16、iproute2 6.1.0、nftables 1.0.6 和 tcpdump 4.99.3,在 Linux 6.12.76 上验证。NET_ADMIN 与 SYS_ADMIN 支持四个嵌套命名空间。端点链路 MTU 为 1500,两台路由器之间的链路为 1280:

from pathlib import Path
from tempfile import TemporaryDirectory
import subprocess,json,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends iproute2=6.1.0-3 iputils-ping=3:20221126-1+deb12u1 nftables=1.0.6-2+deb12u2 tcpdump=4.99.3-1 traceroute=1:2.1.2-1 procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/*\nCMD ["python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,os,select,signal,subprocess,time
def run(*args,check=True,input=None,timeout=8):
    result=subprocess.run(args,input=input,capture_output=True,text=True,timeout=timeout)
    if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
    return result
def ns(name,*args,**kwargs):return run('ip','netns','exec',name,*args,**kwargs)
def ip(name,*args,**kwargs):return ns(name,'ip',*args,**kwargs)
def stop(process):
    if process.poll() is None:
        process.terminate()
        try:process.wait(timeout=2)
        except subprocess.TimeoutExpired:process.kill();process.wait(timeout=2)
def spawn(stack,command):
    process=subprocess.Popen(command,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
    stack.callback(stop,process);return process
def line(process,timeout=3):
    assert select.select([process.stdout],[],[],timeout)[0],'child response deadline exceeded'
    value=process.stdout.readline();assert value,process.stderr.read();return value
@contextmanager
def lab():
    with TemporaryDirectory(prefix='mtu-path-') as directory,ExitStack() as stack:
        root=Path(directory);proc=root/'proc';proc.mkdir();run('mount','-t','proc','proc',str(proc));stack.callback(run,'umount',str(proc))
        for name in ['client','r1','r2','target']:
            run('ip','netns','add',name);stack.callback(run,'ip','netns','delete',name,check=False);ip(name,'link','set','lo','up')
        def setting(name,key,value):
            assert name in ['client','r1','r2','target']
            path=proc/'sys'/key.replace('.','/')
            ns(name,'python3','-c',f'from pathlib import Path; p=Path({str(path)!r});p.write_text({str(value)!r});assert p.read_text().strip()=={str(value)!r}')
        yield root,stack,setting
def topology(setting,ipv6=False):
    links=[('client','c0','r1','a0',1,2,1,1500),('r1','a1','r2','b0',2,1,2,1280),('r2','b1','target','t0',3,1,2,1500)]
    for left,left_if,right,right_if,network,left_host,right_host,mtu in links:
        run('ip','link','add',left_if,'type','veth','peer','name',right_if)
        for name,interface,host in [(left,left_if,left_host),(right,right_if,right_host)]:
            run('ip','link','set',interface,'netns',name);ip(name,'link','set',interface,'mtu',str(mtu))
            address=f'2001:db8:{network}::{host}/64' if ipv6 else f'10.210.{network}.{host}/30'
            ip(name,'addr','add',address,'dev',interface,*(['nodad'] if ipv6 else []));ip(name,'link','set',interface,'up')
    family='-6' if ipv6 else '-4'
    for name in ['r1','r2']:setting(name,'net.ipv6.conf.all.forwarding' if ipv6 else 'net.ipv4.ip_forward',1)
    routes=[('client','default','2001:db8:1::1' if ipv6 else '10.210.1.1'),('target','default','2001:db8:3::1' if ipv6 else '10.210.3.1'),
        ('r1','2001:db8:3::/64' if ipv6 else '10.210.3.0/30','2001:db8:2::2' if ipv6 else '10.210.2.2'),
        ('r2','2001:db8:1::/64' if ipv6 else '10.210.1.0/30','2001:db8:2::1' if ipv6 else '10.210.2.1')]
    for name,destination,gateway in routes:ip(name,family,'route','add',destination,'via',gateway)
    if ipv6:
        deadline=time.monotonic()+4
        while any('tentative' in ip(name,'-6','address','show').stdout for name in ['client','r1','r2','target']):
            assert time.monotonic()<deadline,'IPv6 link-local DAD did not finish';time.sleep(.02)
def nft(name,script):return ns(name,'nft','-f','-',input=script)
def counters(name):
    result={}
    for item in json.loads(ns(name,'nft','-j','list','ruleset').stdout)['nftables']:
        rule=item.get('rule',{})
        if 'comment' in rule:
            for expression in rule['expr']:
                if 'counter' in expression:result[rule['comment']]=expression['counter']['packets']
    return result
def capture(stack,root,name,interface,expression,label):
    path=root/(label+'.pcap')
    process=subprocess.Popen(['ip','netns','exec',name,'tcpdump','--immediate-mode','-Z','root','-U','-n','-i',interface,'-w',str(path),expression],stdout=subprocess.DEVNULL,stderr=subprocess.PIPE,text=True)
    stack.callback(stop,process);deadline=time.monotonic()+3
    while time.monotonic()<deadline:
        assert process.poll() is None,'capture exited'
        if select.select([process.stderr],[],[],.1)[0] and 'listening on' in process.stderr.readline():return process,path
    raise AssertionError('capture did not become ready')
def captured(process,path):
    process.send_signal(signal.SIGINT);_,diagnostic=process.communicate(timeout=3);assert process.returncode==0,diagnostic
    return run('tcpdump','-nn','-vv','-r',str(path)).stdout


def server(stack):
    code=r'''
import socketserver,hashlib
class Handler(socketserver.StreamRequestHandler):
    def handle(self):
        self.request.settimeout(3)
        header=self.rfile.readline()
        if not header:return
        length=int(header);data=self.rfile.read(length)
        if len(data)!=length:return
        self.wfile.write(hashlib.sha256(data).hexdigest().encode()+b'\n');self.wfile.flush()
class Server(socketserver.ThreadingTCPServer):allow_reuse_address=True;daemon_threads=True
service=Server(('10.210.3.2',18080),Handler);print('ready',flush=True);service.serve_forever()
'''
    process=spawn(stack,['ip','netns','exec','target','python3','-u','-c',code]);assert line(process).strip()=='ready'
def transfer(size):
    code=r'''
import socket,json,hashlib,sys,struct
size=int(sys.argv[1]);stream=socket.socket();stream.settimeout(1.5);connected=False
try:
    stream.connect(('10.210.3.2',18080));connected=True
    stream.sendall(str(size).encode()+b'\n'+b'x'*size)
    with stream.makefile('rb') as incoming:reply=incoming.readline().decode().strip()
    assert reply==hashlib.sha256(b'x'*size).hexdigest()
    print(json.dumps({'ok':True,'connected':True,'bytes':size,'sha256':reply,'snd_mss':stream.getsockopt(socket.IPPROTO_TCP,socket.TCP_MAXSEG)}))
except OSError as error:
    stream.setsockopt(socket.SOL_SOCKET,socket.SO_LINGER,struct.pack('ii',1,0))
    print(json.dumps({'ok':False,'connected':connected,'type':type(error).__name__,'errno':error.errno}))
finally:stream.close()
'''
    return json.loads(ns('client','python3','-c',code,str(size)).stdout)
def hide_fragmentation_feedback():
    nft('r1','''table ip hidden_mtu {
 chain output { type filter hook output priority 0; policy accept;
  icmp type destination-unreachable icmp code frag-needed counter drop comment "fragmentation_feedback_dropped"
 }
}
''')
with lab() as (root,stack,setting):
    topology(setting);setting('client','net.ipv4.tcp_mtu_probing',0);server(stack);hide_fragmentation_feedback()
    failed=transfer(65536);assert failed['connected'] and not failed['ok'] and failed['type']=='TimeoutError'
    initial_counter=counters('r1')['fragmentation_feedback_dropped'];assert initial_counter>0
    nft('r1','''table ip clamp {
 chain forward { type filter hook forward priority 0; policy accept;
  tcp flags & syn == syn tcp option maxseg size > 1240 tcp option maxseg size set 1240 counter comment "mss_rewritten"
 }
}
''')
    tap,path=capture(stack,root,'client','c0','tcp port 18080','mss')
    recovered=transfer(65536);assert recovered['ok'] and recovered['snd_mss']<=1240,recovered
    trace=captured(tap,path);assert 'mss 1240' in trace,trace
    after_transfer=counters('r1');assert after_transfer['mss_rewritten']>=2 and after_transfer['fragmentation_feedback_dropped']==initial_counter,after_transfer
    large_ping=ns('client','ping','-n','-M','do','-s','1400','-c','1','-W','1','10.210.3.2',check=False)
    assert large_ping.returncode!=0 and counters('r1')['fragmentation_feedback_dropped']>initial_counter
    print(json.dumps({'before_clamp':failed,'after_clamp':recovered,'counters_after_tcp':after_transfer,
        'icmp_echo_still_fails':{'returncode':large_ping.returncode,'stdout':large_ping.stdout,'stderr':large_ping.stderr},'capture':trace}))
"""
name='xbcatm-mtupath-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
 with TemporaryDirectory(prefix=name+'-') as directory:
  root=Path(directory);(root/'Dockerfile').write_text(recipe)
  build=docker('build','-t',name,str(root));assert build.returncode==0,build.stderr
  result=docker('run','--rm','-i','--name',name,'--network','none','--cap-add','NET_ADMIN','--cap-add','SYS_ADMIN','--memory','256m','--pids-limit','128',name,'python3','-',input=program)
  assert result.returncode==0,result.stderr
  print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
 docker('rm','-f',name);docker('image','rm',name)

第一台路由器抑制向外发送的 ICMP 需要分片消息,客户端关闭独立 TCP MTU 探测,从而构成受控的经典发现失败。65536 字节传输完成 TCP 建立后达到应用截止时间,路由器计数确认反馈被抑制。失败套接字随后中止,防止其残留重传被误认成后面比较中的报文。这里首先建立明确失败基线,而不是只展示添加规则后的成功。

脚本添加转发规则,匹配带 SYN 且通告 MSS 大于 1240 的 TCP 报文,将该选项设为 1240 并计数。SYN-ACK 同样带有 SYN,所以相应返回通告也被覆盖。比较使用全新连接,因为已建立字节流仍保留自身握手期间收到的选项。条件限制保证不会主动抬高原本较小的值,但不能因此省略生产环境的方向与范围审阅。

新的 65536 字节传输成功,并验证返回的 SHA-256 摘要。捕获显示 MSS 已减小,规则计数记录两个握手方向的变化。本次实际发送 MSS 为 1228,因为生效的 TCP 选项还占用额外空间。成功传输期间,需要分片反馈的丢弃计数没有增长,说明完成它不再依赖此前受损的反馈路径,而不是反馈过滤已经被悄悄移除。

之后,载荷 1400 字节并设置禁止分片的 IPv4 echo 探测仍然失败,被抑制反馈的计数再次增加。这个较大的 ICMP 包不属于 TCP MSS 机制:修改 TCP 握手选项,既没有把它缩小,也没有修复路由器反馈策略。两种流量的不同结果,通过实际通信明确了变通措施范围,不能把 TCP 成功推广成所有协议都已恢复。

让变通措施对应实际包大小约束

本地捕获包含可能大于最终 TCP 分段的卸载表示,也可能显示看似不完整的校验和。不能仅凭这些记录,就断言 MSS 被忽略,或巨大报文已经穿过 1280 字节链路。选项、套接字诊断、计数器和经过验证的应用响应,分别提供不同处理阶段的证据。保持观察点一致,才能避免把内核表示与最终线路帧混淆。

合适上限应依据真实封装和 IP 版本推导。IPv6 外层头或额外隧道开销会改变包预算,路径变化也可能使曾经足够的固定值失效。本隔离拓扑使用已知瓶颈,规则不是自动发现算法,不能保证学习之后的每次路由变化。部署到其他网络前,应重新核对相应路径,而不是因为示例数值通过就直接复用。

生产策略生效后,应测试新连接和相关非 TCP 流量,保留对端本来更小的通告,并在上传、下载行为不同时检查两个方向。条件允许时,优先修复必要反馈与路径配置。MSS 调整的价值来自职责明确、范围匹配,并且目标 TCP 交互经过真实验证。只看到规则存在或握手完成,仍不足以宣布大传输问题已经解决。

来源