VXLAN 把以太网帧装进 UDP 数据包,使覆盖网络能够跨越底层 IP 网络。隧道端点负责封装与解封装,二十四位 VXLAN 网络标识符 VNI 用来标识虚拟网段。外层 IP 地址负责端点之间的选路,内层以太网与 IP 地址服务于覆盖网络。这是具有不同配置和可达性条件的两个层次,不能用其中一层的成功替另一层作结论。
坏掉的样子
能够 ping 通隧道端点,无法充分证明特定覆盖网段正常。两端地址可能均可达,却使用不同 VNI、UDP 目的端口或远端节点配置。底层防火墙也可能允许 ICMP,却丢弃封装后的 UDP。排查应针对真正的传输协议和虚拟网段,不能因为底层一次 ping 成功,就认为其上所有租户流量都已验证通过。
封装还会占用底层数据包容量。内层以太网帧没有 VLAN 标签、外层采用普通 IPv4 和 UDP 时,覆盖 IP MTU 设为一千四百五十,可以装进一千五百的底层 IP MTU:内层以太网、VXLAN、UDP 与外层 IPv4 头共占五十字节。外层 IPv6、VLAN 标签或其他封装会改变计算。小请求成功,本身没有验证大包或路径 MTU 发现。
VNI 也不是加密密钥或经过认证的租户身份。能够观察封装流量的一方,可能直接读取其中内容;隧道端点是否可信,依赖周边网络控制。VXLAN 的分段职责不能自动提供机密性。应保护底层网络,在威胁模型要求时采用适当的认证加密,避免把“处于不同虚拟网段”理解成报文内容必然不可读。
快速验证
将脚本保存为 check-vxlan.py,准备 Docker 后运行 python3 check-vxlan.py。一次性 Linux 容器使用 Python 3.11.16、iproute2 6.1.0 和 tcpdump 4.99.3。NET_ADMIN 与 SYS_ADMIN 支持嵌套网络命名空间及临时 proc 挂载。两个端点仅通过新建 veth 相连,不修改宿主路由,不挂载宿主目录,也不发布服务端口:
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 部署,没有隐含提供其他控制面能力。
跨覆盖网络发送 HTTP 请求后,客户端取得预期服务器身份。同时,tcpdump 在底层捕获标有 VNI 42 的 UDP 报文,其中包含内层 ARP 与 TCP 流量。捕获文本直接出现请求路径 /lab-visible-message。这证明该配置传输应用字节时没有加密;VNI 限定虚拟网段的交付范围,却不会隐藏载荷内容。结论来自真实通信与对应捕获,而非协议名称推断。
本地 veth 抓包还显示 UDP 与 TCP 校验和似乎不正确,但 HTTP 交互成功。Linux 可以把校验和表示为稍后处理阶段才完成的部分工作,封装流量的卸载也可能参与其中。这份捕获不能证明线路报文损坏。脚本没有关闭卸载,也未逐个定位校验和处理阶段,因此不会声称已经测得硬件故障,或已经完成全部校验和原因诊断。
随后重建右侧覆盖设备,仅将 VNI 改为 43,保持地址与 MAC 相同。底层 ping 仍成功,覆盖 HTTP 请求却超时。将设备恢复为 VNI 42 后,HTTP 恢复成功。固定其他覆盖属性,使标识符不一致成为受控变化,避免把重建导致的 MAC 变化、旧邻居缓存等第二个因素误认为 VNI 的效果。恢复结果进一步支持了这个故障归因。
让证据对应正在修改的网络层
检查端点底层路由、选中接口、UDP 端口、VNI、远端映射和覆盖地址。条件允许时,在隧道设备及其传输接口分别抓包,两者展示同一次通信的不同表示。保存捕获位置,尤其在解释包大小与校验和提示时,因为这些现象可能受内核卸载处理影响。只保留一张没有接口上下文的抓包截图,容易把观察点差异误判为网络差异。
本例有意通过一千五百与一千四百五十的 MTU 留出封装空间,但只测试短小应用交互。用于较大负载前,需要另外验证实际路径的包大小要求。生产网络还应审阅远端 MAC 目的地如何学习或分发,以及未知目的流量怎样处理。这些控制面选择不能从两个端点通信成功中推导出来,应由对应配置、流量与故障恢复证据单独支持。
延伸阅读
来源
- RFC 7348 VXLAN,VNI、UDP 封装、MTU 与安全边界,已核验 2026-09-12。
- Linux VXLAN documentation,VXLAN 设备、目的端口与隧道卸载,已核验 2026-09-12。
- Linux checksum offloads,不同处理阶段的部分校验和与封装校验和,已核验 2026-09-12。