VXLAN 把以太网帧装进 UDP 数据包,使覆盖网络能够跨越底层 IP 网络。隧道端点负责封装与解封装,二十四位 VXLAN 网络标识符 VNI 用来标识虚拟网段。外层 IP 地址负责端点之间的选路,内层以太网与 IP 地址服务于覆盖网络。这是具有不同配置和可达性条件的两个层次,不能用其中一层的成功替另一层作结论。

坏掉的样子

能够 ping 通隧道端点,无法充分证明特定覆盖网段正常。两端地址可能均可达,却使用不同 VNI、UDP 目的端口或远端节点配置。底层防火墙也可能允许 ICMP,却丢弃封装后的 UDP。排查应针对真正的传输协议和虚拟网段,不能因为底层一次 ping 成功,就认为其上所有租户流量都已验证通过。

封装占用底层包容量。无内层 VLAN 标签、采用普通外层 IPv4 时,覆盖 IP MTU 一千四百五十可放入底层 IP MTU 一千五百:内层以太网十四、VXLAN 八、UDP 八、IPv4 二十字节。内层 VLAN 标签、外层 IPv6 或额外封装会改变这个 IP 预算;外层以太网 VLAN 标签影响的是链路帧大小。RFC 7348 禁止 VTEP 主动分片,并允许目的端丢弃分片。小请求成功不能验证路径 MTU 或大包。

VNI 也不是加密密钥或经过认证的租户身份。能够观察封装流量的一方,可能直接读取其中内容;隧道端点是否可信,依赖周边网络控制。VXLAN 的分段职责不能自动提供机密性。应保护底层网络,在威胁模型要求时采用适当的认证加密,避免把“处于不同虚拟网段”理解成报文内容必然不可读。

分别显示 VTEP 底层与覆盖地址、嵌套封装和有条件的 1500 到 1450 MTU 预算,再展示预期 VNI 对照、保密性与证据边界。
VXLAN:分层封装,分别验证:概念机制与预期检查,不是新增运行证据。

快速验证

将 check-vxlan.py 保存在已获授权的一次性 Linux 实验环境,使用核实为本地的 Docker daemon,并保留 Python 断言。配方注明 Python 3.11.16、iproute2 6.1.0、tcpdump 4.99.3,仍需另查架构、包与内核支持。NET_ADMIN、SYS_ADMIN 无法覆盖 Docker 默认 AppArmor 等强制策略的挂载限制;应使用兼容且获批的环境,不削弱保护。运行时隔离不代表构建离线,清理返回码未全部检查。本次未恢复或新生成这份 fence 的精确运行抓包,下面应读作预期观察。

from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,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 line(process,timeout=3):
    assert select.select([process.stdout],[],[],timeout)[0],'child response deadline exceeded'
    value=process.stdout.readline();assert value,'child exited without a response'
    return value
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
@contextmanager
def lab(names):
    with TemporaryDirectory(prefix='net-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 names:
            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 names
            path=proc/'sys'/key.replace('.','/')
            return ns(name,'python3','-c',f'from pathlib import Path; p=Path({str(path)!r}); p.write_text({str(value)!r}); print(p.read_text().strip())').stdout.strip()
        yield root,stack,setting
def wire(left,left_if,right,right_if,left_addresses,right_addresses):
    run('ip','link','add',left_if,'type','veth','peer','name',right_if)
    for name,interface,addresses in [(left,left_if,left_addresses),(right,right_if,right_addresses)]:
        run('ip','link','set',interface,'netns',name)
        for address in addresses:ip(name,'address','add',address,'dev',interface,*(['nodad'] if ':' in address else []))
        ip(name,'link','set',interface,'up')
def nft(name,script):return ns(name,'nft','-f','-',input=script)
def counters(name):
    data=json.loads(ns(name,'nft','-j','list','ruleset').stdout);found={}
    for entry in data['nftables']:
        rule=entry.get('rule',{})
        if 'comment' in rule:
            for expression in rule['expr']:
                if 'counter' in expression:found[rule['comment']]=expression['counter']['packets']
    return found

def server(stack,name,address,port=18080,label='server'):
    code=r'''
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
import json,socket,sys
address,port,label=sys.argv[1],int(sys.argv[2]),sys.argv[3]
class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        body=json.dumps({'server':label,'peer':self.client_address,'path':self.path}).encode()
        self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body)
    def log_message(self,*args):pass
class Server(ThreadingHTTPServer):
    address_family=socket.AF_INET6 if ':' in address else socket.AF_INET
    def server_bind(self):
        if self.address_family==socket.AF_INET6:self.socket.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,1)
        super().server_bind()
service=Server((address,port),Handler);print('ready',flush=True);service.serve_forever()
'''
    process=spawn(stack,['ip','netns','exec',name,'python3','-u','-c',code,address,str(port),label])
    assert line(process).strip()=='ready';return process
def request(name,address,port=18080):
    code=r'''
import http.client,json,sys
client=http.client.HTTPConnection(sys.argv[1],int(sys.argv[2]),timeout=.7)
try:
    client.request('GET','/lab-visible-message');response=client.getresponse();body=json.loads(response.read())
    assert response.status==200;print(json.dumps({'ok':True,'status':response.status,'body':body}))
except OSError as error:print(json.dumps({'ok':False,'type':type(error).__name__,'errno':error.errno}))
finally:client.close()
'''
    return json.loads(ns(name,'python3','-c',code,address,str(port)).stdout)

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,'tcpdump exited before listening'
        if select.select([process.stderr],[],[],.1)[0] and 'listening on' in process.stderr.readline():return process,path
    raise AssertionError('tcpdump did not become ready')
def captured(process,path,ascii=False):
    process.send_signal(signal.SIGINT);_,diagnostic=process.communicate(timeout=3)
    assert process.returncode==0,diagnostic
    result=run('tcpdump','-nn','-vv',*(['-A'] if ascii else []),'-r',str(path))
    return result.stdout

with lab(['left','right']) as (root,stack,setting):
    wire('left','ul','right','ur',['192.0.2.1/30'],['192.0.2.2/30'])
    for name,interface in [('left','ul'),('right','ur')]:ip(name,'link','set',interface,'mtu','1500')
    def overlay(name,vni):
        local,remote,dev,address,mac=('192.0.2.1','192.0.2.2','ul','10.42.0.1/24','02:00:00:00:42:01') if name=='left' else ('192.0.2.2','192.0.2.1','ur','10.42.0.2/24','02:00:00:00:42:02')
        ip(name,'link','add','vx0','type','vxlan','id',str(vni),'local',local,'remote',remote,'dev',dev,'dstport','4789','nolearning')
        ip(name,'link','set','vx0','address',mac,'mtu','1450')
        ip(name,'addr','add',address,'dev','vx0');ip(name,'link','set','vx0','up')
    overlay('left',42);overlay('right',42);server(stack,'right','10.42.0.2',label='overlay')
    tap,path=capture(stack,root,'left','ul','udp port 4789','vxlan')
    successful=request('left','10.42.0.2');assert successful['ok'] and successful['body']['server']=='overlay'
    packets=captured(tap,path,ascii=True)
    assert 'vni 42' in packets.lower() and 'lab-visible-message' in packets and '.4789:' in packets,packets
    details=json.loads(ip('left','-d','-j','link','show','vx0').stdout)[0]
    assert details['linkinfo']['info_data']['id']==42 and details['mtu']==1450
    ip('right','link','delete','vx0');overlay('right',43)
    underlay=ns('left','ping','-n','-c','1','-W','1','192.0.2.2');assert underlay.returncode==0
    mismatch=request('left','10.42.0.2');assert not mismatch['ok'] and mismatch['type']=='TimeoutError'
    ip('right','link','delete','vx0');overlay('right',42)
    recovered=request('left','10.42.0.2');assert recovered['ok']
    print(json.dumps({'healthy_overlay':successful,'underlay_packet_capture':packets,'vni':details['linkinfo']['info_data']['id'],
        'underlay_mtu':1500,'overlay_mtu':details['mtu'],'mismatched_vni_underlay_ping_exit':underlay.returncode,
        'mismatched_vni_overlay':mismatch,'matching_vni_restored':recovered}))
"""
name='xbcatm-netpath-'+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)

底层地址分别为 192.0.2.1 与 192.0.2.2。每端明确指定远端节点,并把 UDP 目的端口设置为 4789,避免依赖实现历史上的不同默认值。两侧覆盖设备起初均使用 VNI 42、固定 MAC 地址,以及 10.42.0.1 和 10.42.0.2。这只是一个小型单播隧道,并非完整多点网络或 EVPN 部署,没有隐含提供其他控制面能力。

脚本预期取得覆盖网服务器身份和同时进行的底层捕获。抓包断言检查 VNI 42、UDP 端口 4789 与字面请求路径 /lab-visible-message,没有完整核对 ARP、TCP 解码。若实际观察到该明文路径,才可证明这个配置传输相应应用字节时未加密。本次没有恢复到能证实该观察的捕获;VXLAN 本身不提供机密性的结论另有规范依据,不能把规范事实写成这里已经取得了报文证据。

veth 观察点可能显示 UDP 或 TCP 校验和似乎错误,而 HTTP 仍成功。内核部分校验和与封装卸载处理可能解释这种现象,但单份捕获既不能定位精确处理阶段,也不能证明线路损坏。脚本不关闭卸载,更没有完成全面校验和诊断;先前捕获描述在本次没有得到新的核实。不要把可能机制写成唯一原因,也不要把静态代码中的 tcpdump 命令当成已经取得报文。

随后重建右侧设备为 VNI 43,并保持地址、MAC 相同。预期底层 ping 成功、覆盖 HTTP 超时,重建为 VNI 42 后 HTTP 恢复。VNI 是有意改变的配置参数,但重建设备同时重置内部状态。一次超时不是 VNI 不匹配的通用签名,也不能证明所有其他状态完全不变;应结合配置、预期服务器响应和恢复对照作有限归因。

让证据对应正在修改的网络层

检查端点底层路由、选中接口、UDP 端口、VNI、远端映射和覆盖地址。条件允许时,在隧道设备及其传输接口分别抓包,两者展示同一次通信的不同表示。保存捕获位置,尤其在解释包大小与校验和提示时,因为这些现象可能受内核卸载处理影响。只保留一张没有接口上下文的抓包截图,容易把观察点差异误判为网络差异。

本例有意通过一千五百与一千四百五十的 MTU 留出封装空间,但只测试短小应用交互。用于较大负载前,需要另外验证实际路径的包大小要求。生产网络还应审阅远端 MAC 目的地如何学习或分发,以及未知目的流量怎样处理。这些控制面选择不能从两个端点通信成功中推导出来,应由对应配置、流量与故障恢复证据单独支持。

延伸阅读

来源