在真正 accept 之前暂存待握手连接的内核队列

TCP backlog 收纳还在握手中的连接

一个监听中的 TCP 套接字有两个队列,不是一个;而本文标题里那个 sysctl 管的,恰恰是几乎从不丢东西的那一个。传给 listen(2) 的 backlog 是 accept 队列,装的是已经完成握手、正等应用来取走的连接;SYN 队列装的才是半开请求。把两者混为一谈,就会开错药方。 这件事值得掰开,因为那句著名的建议——把 tcp_max_syn_backlog 调大——基本已经过时。tcp_syncookies 默认就是 1,而按 listen(2) 的说法,一旦启用 syncookies,SYN 队列就没有逻辑上的长度上限,这个设置会被忽略。真正会卡住你的是 accept 队列,以及应用调用 accept() 的速度。 backlog 参数管的是 accept 队列 listen(2) 明确写着,这个行为在 Linux 2.2 就变了:backlog 现在指的是「已完成建立、正等待被 accept 的套接字队列长度」,而不是半开请求的数量。半开队列由 /proc/sys/net/ipv4/tcp_max_syn_backlog 单独限制。而如果 backlog 参数超过 /proc/sys/net/core/somaxconn,它会被静默截断到那个值——Linux 5.4 之后是 4096,之前是 128。所以在默认配置的机器上写 listen(8192),实际拿到的是 4096,而且不会有任何日志告诉你。 ...

三月 24, 2028 · 2 分钟 · XB Field Notes
进程仍持有引用时,删除名称不会释放整个网络命名空间。

网络命名空间隔离网络资源,名称并不拥有其中所有进程

Linux 网络命名空间分离接口、路由、邻居表、套接字端口和包过滤规则等网络资源。它与其他命名空间共享内核,不是独立操作系统。进程通常继承父进程的网络命名空间,工具也可以在另一个范围中执行进程。这种隔离针对网络资源,不会自动涵盖全部进程身份和文件系统访问,因此需要按实际机制说明边界。 坏掉的样子 运行 ip netns exec 不会自动获得完整容器隔离。PID、用户身份和主机名隔离属于其他机制。应用可能看到不同路由,却仍与父环境共享普通文件和主机名。如果把一个网络命名空间当成完整沙箱,就会赋予它从未配置过的保证。应区分实际改变了哪类资源,以及哪些属性仍然来自原有运行环境。 新命名空间也不会自动得到有用的外部连接。接口需要有意放置和配置,本地回环可能需要启用。移动接口会改变管理它的命名空间,原范围不再把该设备列为自己的接口。因此,从错误范围执行配置命令,可能报告接口不存在,尽管预期设备仍位于另一个命名空间。诊断前应先确认命令的执行上下文。 名称还有独立生命周期。ip netns 按约定在 /run/netns 暴露引用,通常也能通过 /var/run/netns 访问。删除名称会移除这项引用,但进程或其他引用仍可维持命名空间存活。如果清理流程把名称列表中消失,直接等同于所有服务终止,就可能留下网络资源和运行中的进程。名称只是管理入口,不是进程所有权的完整表达。 快速验证 将脚本保存为 check-network-namespace.py,准备 Docker 后运行 python3 check-network-namespace.py。固定环境使用 Python 3.11.16 和 iproute2 6.1.0,通过 NET_ADMIN 与 SYS_ADMIN 在一次性容器内进行嵌套命名空间操作。临时 proc 挂载支持其中的网络参数设置,不移动宿主接口,也不发布宿主端口;实验资源随容器清理: 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) with lab(['blue','green']) as (root,stack,setting): probe='import json,os,socket;print(json.dumps({"net":os.readlink("/proc/self/ns/net"),"pid":os.readlink("/proc/self/ns/pid"),"hostname":socket.gethostname()}))' parent=json.loads(run('python3','-c',probe).stdout) views={name:json.loads(ns(name,'python3','-c',probe).stdout) for name in ['blue','green']} assert len({parent['net'],views['blue']['net'],views['green']['net']})==3 assert all(v['pid']==parent['pid'] and v['hostname']==parent['hostname'] for v in views.values()) for name in ['blue','green']: ip(name,'link','add','dummy0','type','dummy');ip(name,'addr','add','192.0.2.10/24','dev','dummy0');ip(name,'link','set','dummy0','up') ip('blue','route','add','203.0.113.0/24','dev','dummy0') assert json.loads(ip('blue','-j','route','show','203.0.113.0/24').stdout) assert not json.loads(ip('green','-j','route','show','203.0.113.0/24').stdout) setting('blue','net.ipv4.ip_forward',0);setting('green','net.ipv4.ip_forward',1) forwarding={name:ns(name,'sysctl','-n','net.ipv4.ip_forward').stdout.strip() for name in ['blue','green']};assert forwarding=={'blue':'0','green':'1'} marker=root/'shared.txt';marker.write_text('shared filesystem') assert ns('blue','cat',str(marker)).stdout==ns('green','cat',str(marker)).stdout=='shared filesystem' daemon=server(stack,'blue','192.0.2.10',label='blue') named=request('blue','192.0.2.10');assert named['ok'] and named['body']['server']=='blue' run('ip','netns','delete','blue');assert not Path('/run/netns/blue').exists() assert daemon.poll() is None and os.readlink(f'/proc/{daemon.pid}/ns/net')==views['blue']['net'] by_pid=run('nsenter','-t',str(daemon.pid),'-n','python3','-c', 'import http.client,json; c=http.client.HTTPConnection("192.0.2.10",18080,timeout=1);c.request("GET","/");r=c.getresponse();print(json.dumps({"status":r.status,"body":json.loads(r.read())}));c.close()') surviving=json.loads(by_pid.stdout);assert surviving['status']==200 and surviving['body']['server']=='blue' print(json.dumps({'parent':parent,'namespace_views':views,'blue_only_route':True,'forwarding':forwarding, 'same_file_visible':True,'before_name_deleted':named,'name_deleted_process_alive':True,'after_name_deleted':surviving})) """ 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) 父环境、blue 和 green 报告三个不同网络命名空间标识,但 PID 命名空间标识与主机名保持相同。父进程创建的标记文件,也能被两个子范围读取。这些正向观察明确了本例边界:网络范围改变了,而所检查的这些进程和文件系统属性仍然共享。不能由网络标识不同,推导其他所有隔离也已经建立。 ...

二月 19, 2028 · 2 分钟 · XB Field Notes
AAAA 回答给出 IPv6 目的地址,另一次真实连接才能验证服务是否工作。

AAAA 发布 IPv6 地址,不证明该地址可达

一条 AAAA 资源记录保存一个一百二十八位 IPv6 地址,A 记录则保存 IPv4 地址。同一个名称可以只有其中一类、同时拥有两类,或者在每类中包含多个地址。A 与 AAAA 属于不同记录集,因此可以使用不同 TTL。发布 AAAA 让地址能够通过 DNS 被发现,却不会替你创建接口、路由、监听套接字或可用应用;这些条件需要在实际服务路径上成立。 坏掉的样子 AAAA 查询成功之后,连接仍可能失败。发布的地址可能已经停用,服务可能只监听 IPv4,网络也可能缺少可用 IPv6 路由。两种地址族的防火墙与 TLS 配置也可能不同。只通过 IPv4 做可用性检查,会漏掉这些问题,而双栈用户可能正沿着公开的 IPv6 路径遭遇延迟或错误。记录存在与服务可用是两个需要分别观察的事实。 Happy Eyeballs 通过协调地址发现和连接尝试,减少部分双栈连接延迟,但它属于客户端算法,并不是 DNS 对所有客户端的保证。具体回退行为、截止时间和应用协议仍会影响结果。某个浏览器在一次测试中迅速改用 A 记录成功,不能推出错误 AAAA 对所有调用方无害。尤其对于没有相同行为的库、设备或后台任务,应验证其真实连接策略。 缺少 AAAA 也应准确描述。权威服务器可以对只有 A 的名称返回成功 DNS 状态,却没有 AAAA 数据,这通常称为 NODATA;它不同于表示名称不存在的 NXDOMAIN。仅有 A 的服务也不是在所有 IPv6-only 网络中都绝对不可达:具备相应基础设施时,DNS64 可以合成 AAAA,再通过 NAT64 访问 IPv4 服务器。不能忽略翻译网络这一明确反例。 ...

十二月 30, 2027 · 3 分钟 · XB Field Notes
持续发送数据时窗口增长,空闲或应用数据不足时不一定出现翻倍。

TCP 慢启动随数据确认增长,不会仅因时间流逝而翻倍

慢启动在 TCP 发送端探索路径可承受的未确认数据量时,逐步扩大拥塞窗口。数据充足且确认模式合适时,经典行为是在多个往返间近似指数增长。“每个 RTT 翻倍”是有用的简化模式,不是无条件计时器规则。窗口变化依赖传输反馈和实现的增长条件,不能只根据连接已经打开多久,就推断它获得了多少发送预算。 坏掉的样子 connect 之后等待,不会自动把拥塞窗口预热。应用没有发送数据时,就没有支持这种增长的数据确认。很小的交换,也可能在用满已有发送预算之前便结束。期待每个短请求都出现大幅翻倍序列,是把受应用数据量限制的传输误认成持续积压的发送端。缺少明显增长,并不自动说明慢启动没有工作或连接存在故障。 现代系统初始窗口也不统一为一到两个段。RFC 6928 描述了允许更大初始窗口的实验性扩展,本次 Linux 发送端起步为十个段。单位同样重要:Linux TCP_INFO 的 snd_cwnd 以段为单位,许多协议说明则用字节描述拥塞窗口。推算未确认数据量之前,应同时查看发送 MSS,避免将段数直接当成字节数或当前实际在途段数。 慢启动并非总要持续到第一次丢失,之后统一彻底缩回起点。慢启动阈值帮助决定启动增长与拥塞避免,具体实现还可能采用额外启动检测;重传超时恢复也不同于快速恢复。把同一条示意曲线套到所有算法、丢失信号和工作负载,即使读到了真实指标,也可能作出错误诊断。首先需要确认当前连接处于哪个阶段。 快速验证 将脚本保存为 check-slow-start.py,准备 Docker 后运行 python3 check-slow-start.py。一次性环境使用 Python 3.11.16 和 iproute2 6.1.0,在 Linux 6.12.76 上验证。NET_ADMIN 与 SYS_ADMIN 支持嵌套命名空间。客户端和目标通过新建 veth 相连,netem 在两个方向的出口各添加十毫秒延迟,全部变更仅作用于虚拟端点: 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',process.stderr.read()) 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 info_code=r''' import struct def tcp_info(stream): raw=stream.getsockopt(socket.IPPROTO_TCP,socket.TCP_INFO,104);assert len(raw)==104 offsets={'snd_mss':16,'unacked':24,'rtt_us':68,'snd_ssthresh':76,'snd_cwnd':80,'total_retrans':100} return {'ca_state':raw[1],**{key:struct.unpack_from('=I',raw,offset)[0] for key,offset in offsets.items()}} ''' with lab(['client','target']) as (root,stack,setting): wire('client','c0','target','t0',['192.0.2.1/30'],['192.0.2.2/30']) for name,interface in [('client','c0'),('target','t0')]: ns(name,'tc','qdisc','add','dev',interface,'root','netem','delay','10ms','limit','10000') server_code=r''' import socket,json,hashlib,sys size=int(sys.argv[1]);listener=socket.socket();listener.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) listener.bind(('192.0.2.2',18080));listener.listen();print('listening',flush=True) with listener.accept()[0] as connection: connection.settimeout(6);remaining=size;digest=hashlib.sha256() while remaining: chunk=connection.recv(min(65536,remaining));assert chunk;remaining-=len(chunk);digest.update(chunk) assert digest.hexdigest()==hashlib.sha256(b'x'*size).hexdigest() connection.sendall((digest.hexdigest()+'\n').encode()) ''' client_code=info_code+r''' import socket,json,time,threading,sys,hashlib size=int(sys.argv[1]);stream=socket.socket();stream.settimeout(6) stream.setsockopt(socket.IPPROTO_TCP,socket.TCP_CONGESTION,b'reno');stream.connect(('192.0.2.2',18080)) initial=tcp_info(stream);time.sleep(.06);idle=tcp_info(stream);assert idle['snd_cwnd']==initial['snd_cwnd'] done=threading.Event();errors=[];samples=[initial,idle];reply=[] def transfer(): try: stream.sendall(b'x'*size) with stream.makefile('rb') as incoming:reply.append(incoming.readline().decode().strip()) except BaseException as error:errors.append(repr(error)) finally:done.set() worker=threading.Thread(target=transfer);worker.start();deadline=time.monotonic()+7 while not done.wait(.005): assert time.monotonic()<deadline,'transfer deadline exceeded' snapshot=tcp_info(stream) if snapshot!=samples[-1]:samples.append(snapshot) worker.join(timeout=1);assert not worker.is_alive() and not errors,errors samples.append(tcp_info(stream));assert reply==[hashlib.sha256(b'x'*size).hexdigest()] assert all(row['total_retrans']==0 for row in samples),samples if size>1:assert max(row['snd_cwnd'] for row in samples)>initial['snd_cwnd']*2,samples else:assert max(row['snd_cwnd'] for row in samples)==initial['snd_cwnd'],samples print(json.dumps({'bytes':size,'algorithm':stream.getsockopt(socket.IPPROTO_TCP,socket.TCP_CONGESTION,16).split(b'\0')[0].decode(), 'before_data':initial,'after_idle_without_data':idle,'samples':samples,'sha256':reply[0]}));stream.close() ''' results={} for label,size in [('short',1),('bulk',1048576)]: service=spawn(stack,['ip','netns','exec','target','python3','-u','-c',server_code,str(size)]);assert line(service).strip()=='listening' results[label]=json.loads(ns('client','python3','-c',client_code,str(size),timeout=10).stdout) service.wait(timeout=2);assert service.returncode==0 print(json.dumps({'egress_delay_each_direction_ms':10,'transfers':results})) """ name='xbcatm-tcpflow-'+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) 客户端明确选择 Reno,避免实验悄悄受不同默认拥塞控制器影响。发送之前记录 TCP_INFO,在没有数据的情况下等待六十毫秒,然后再次记录。两次观察窗口均为十个段。这直接检验“新连接只要保持打开,拥塞预算就会按时间增长”的错误预期。增加等待时间与提供可确认的数据,属于不同输入,不能互相替代。 ...

十二月 28, 2027 · 2 分钟 · XB Field Notes
只有一端处于管理启用状态,对端仍关闭时链路并不具备载波。

veth 对在两个端点之间搬运帧,路由决定这些帧承载的路径

veth 对由两个互联的虚拟以太网设备组成。从一端发送的帧到达另一端,再由接收网络栈或网桥处理。两端可以放在不同 Linux 网络命名空间,因此常用于容器网络。创建这一对设备只提供链路,地址、路由、桥接关系、转发和访问策略都需要另外配置。不能把接口已经出现,等同于完整网络已经准备好。 坏掉的样子 接口显示管理状态 UP,仍可能因为 veth 对端关闭而没有载波。只读取 UP 标志,会让尚未完成的配置看起来已经就绪。应检查两个端点,并区分管理状态与 LOWER_UP。路由也可能已经指向这个接口,但链路仍无法承载有效流量,所以路由列表本身不是连通性检查,更不能代替一次真实应用交换。 veth 对不会把 IP 载荷限制为对端接口自身地址。路由选择报文经过哪个链路,随后以太网转发或接收网络栈决定下一步处理。发往容器直接邻居之外的流量,也可以通过这一对设备到达路由器或网桥。声称“不发给对端的包都会留在本地”,混淆了链路交付与 IP 目的地选择,容易误导容器出口排障。 两端生命周期也相互关联。删除一个 veth 端点,会移除另一个端点,即使对端由另一个命名空间管理。因此宿主侧清理动作可能删除容器接口。影响活跃工作负载时,应有意识地安排停止流量、调整路由和删除设备的顺序。命名空间分离了管理视图,却没有让成对设备变成两项互不影响的资源。 快速验证 将脚本保存为 check-veth.py,准备 Docker 后执行 python3 check-veth.py。固定 Linux 容器使用 Python 3.11.16、iproute2 6.1.0,以及用于嵌套命名空间的 NET_ADMIN 和 SYS_ADMIN。它只创建一次性虚拟设备。左右命名空间各获得一个端点,分别配置 192.0.2.1/30 和 192.0.2.2/30,没有移动现有宿主网卡: 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) with lab(['left','right']) as (root,stack,setting): run('ip','link','add','left0','type','veth','peer','name','right0') run('ip','link','set','left0','netns','left');run('ip','link','set','right0','netns','right') ip('left','addr','add','192.0.2.1/30','dev','left0');ip('right','addr','add','192.0.2.2/30','dev','right0') ip('left','link','set','left0','up') before=json.loads(ip('left','-j','link','show','left0').stdout)[0] assert 'LOWER_UP' not in before['flags'] failed=ns('left','ping','-n','-c','1','-W','1','192.0.2.2',check=False);assert failed.returncode!=0 ip('right','link','set','right0','up') after={name:json.loads(ip(name,'-j','link','show',interface).stdout)[0] for name,interface in [('left','left0'),('right','right0')]} assert all('LOWER_UP' in v['flags'] for v in after.values()) ip('left','neigh','flush','dev','left0');ip('right','neigh','flush','dev','right0') successful=ns('left','ping','-n','-c','1','-W','1','192.0.2.2');assert successful.returncode==0 daemon=server(stack,'right','192.0.2.2',label='right');response=request('left','192.0.2.2');assert response['ok'] ip('right','addr','add','203.0.113.9/32','dev','lo');ip('left','route','add','203.0.113.9/32','via','192.0.2.2') server(stack,'right','203.0.113.9',label='beyond-link-address');routed_response=request('left','203.0.113.9');assert routed_response['ok'] ip('left','link','delete','left0');gone=ip('right','link','show','right0',check=False);assert gone.returncode!=0 and 'does not exist' in gone.stderr print(json.dumps({'one_end_up_flags':before['flags'],'one_end_up_ping_exit':failed.returncode, 'both_up_flags':{n:v['flags'] for n,v in after.items()},'both_up_ping_exit':successful.returncode,'http':response,'other_ip_destination':routed_response, 'deleting_one_removes_peer':True,'peer_absent_diagnostic':gone.stderr.strip()})) """ 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) 起初只启用 left0,它的标志包含 UP 和 NO-CARRIER,没有 LOWER_UP,因为 right0 仍关闭。单次 ping 失败。这里同时提供链路状态和实际失败交换,没有用“接口创建成功”来替代对端已经配置正确的证据。管理状态说明操作者希望设备工作,载波状态则反映配对链路当前能否成立。 ...

十一月 21, 2027 · 2 分钟 · XB Field Notes
VNI 不匹配会破坏覆盖网络,即使两个底层地址仍能正常通信。

VXLAN 既需要底层可达,也需要两端隧道参数一致

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 发现。 ...

十月 28, 2027 · 3 分钟 · XB Field Notes
accept 会移出监听队列,backlog 不是应用全部打开连接的数量上限。

listen backlog 保存等待 accept 的连接

对于 Linux TCP 套接字,listen() 的 backlog 关注已经建立、等待应用接受的连接;未完成握手的队列是另一种概念。调用 accept() 会移出一条待处理连接,并返回供应用使用的已连接套接字。即使这个套接字之后长时间保持打开或忙碌,它也不再占用监听器的 accept 队列。因此,等待接受与已经接受后的活动状态需要分别计数。 坏掉的样子 如果连接到达速度超过应用接受速度,待处理队列会填满。根据内核行为和设置,新客户端可能遇到重传延迟或超时,而已经接受的请求仍可能正常。进程活着,或者部分请求成功,都不能证明监听器正足够快地接受新连接。问题发生在接受之前时,仅查看业务处理成功率可能看不到完整失败路径。 请求的 backlog 还受到 net.core.somaxconn 限制;只把应用值调得更大,不能超过这个上限。但可观察的精确队列边界与实现有关,因此不能把整数直接当成跨平台承诺,认为必定恰好有那么多同时 connect 成功。应测量实际部署内核,而不是把一个很小的实验结果推广成通用的加一或减一规则。 队列计数也需要上下文。ListenOverflows 和 ListenDrops 是网络命名空间计数,不会自动解释到某个具体服务。其他监听器也能贡献计数,客户端超时也可能来自 accept 队列之外。将生产失败归因于某个 backlog 设置,或者宣称扩大队列能修复问题之前,应结合队列占用、接受进度和计数共同判断。 快速验证 把脚本保存为 check-listen-backlog.py,准备宿主 Python、Docker 和固定 Python 镜像后运行。真实套接字操作发生在可丢弃 Linux 容器内,网络只保留回环。记录内核为 6.12.76-linuxkit。脚本读取相关设置但不修改它们,并要求 tcp_abort_on_overflow=0,使本次超时观察对应一个明确、可以检查的前提: import json,subprocess image='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' program=r''' from contextlib import ExitStack from pathlib import Path import json,platform,socket def counters(): lines=Path('/proc/net/netstat').read_text().splitlines() for index,line in enumerate(lines): if line.startswith('TcpExt:'): return dict(zip(line.split()[1:],map(int,lines[index+1].split()[1:]))) def queued(port): for line in Path('/proc/net/tcp').read_text().splitlines()[1:]: fields=line.split() if fields[3]=='0A' and int(fields[1].split(':')[1],16)==port: return int(fields[4].split(':')[1],16) raise AssertionError('listener missing') with ExitStack() as stack: def fresh(): s=stack.enter_context(socket.socket());s.settimeout(.25);return s def listener(backlog): s=fresh();s.bind(('127.0.0.1',0));s.listen(backlog);return s somaxconn=int(Path('/proc/sys/net/core/somaxconn').read_text()) abort=int(Path('/proc/sys/net/ipv4/tcp_abort_on_overflow').read_text());assert abort==0 small=listener(1);address=small.getsockname();before=counters();clients=[];timed_out=False for _ in range(8): client=fresh() try:client.connect(address) except TimeoutError:timed_out=True;client.close();break clients.append(client) depth=queued(address[1]);after=counters() assert timed_out and depth==len(clients) and 1<=depth<8,(depth,len(clients)) changes={name:after[name]-before[name] for name in ['ListenOverflows','ListenDrops']} assert changes['ListenOverflows']>0 and changes['ListenDrops']>0,changes accepted=[] for client in clients: server,_=small.accept();stack.enter_context(server);accepted.append(server);server.sendall(b'ready') reader=stack.enter_context(client.makefile('rb'));assert reader.read(5)==b'ready' assert queued(address[1])==0 for _ in range(4): client=fresh();client.connect(address);server,_=small.accept();stack.enter_context(server) server.sendall(b'ready');reader=stack.enter_context(client.makefile('rb'));assert reader.read(5)==b'ready' accepted.append(server);clients.append(client) assert len(accepted)>1 and all(s.fileno()>=0 for s in accepted) larger=listener(16) for _ in range(6):client=fresh();client.connect(larger.getsockname()) assert queued(larger.getsockname()[1])==6 print(json.dumps({'kernel':platform.release(),'somaxconn':somaxconn,'tcp_abort_on_overflow':abort, 'requested_small_backlog':1,'queued_before_accept':depth,'next_connect_timed_out':timed_out, 'counter_changes':changes,'accepted_connections_still_open':len(accepted),'larger_backlog_queued':6},sort_keys=True)) ''' result=subprocess.run(['docker','run','--rm','-i','--network','none',image,'python','-'], input=program,capture_output=True,text=True,timeout=30) assert result.returncode==0,result.stderr record=json.loads(result.stdout);print(json.dumps(record,sort_keys=True)) 第一个监听器请求 backlog 为一,并故意不接受连接。在记录环境里,两次客户端 connect 完成,/proc/net/tcp 也报告两条排队连接;下一次 connect 超过客户端设定的二百五十毫秒超时。脚本记录实际深度,而不是断言每个 Linux 版本在这个参数下都必须接受恰好两条。配置参数、实际队列和客户端结果分别被观察。 ...

十月 25, 2027 · 2 分钟 · XB Field Notes
IPv6 分片由原始发送端产生,中间路由器对超大未分片报文返回 Packet Too Big。

IPv6 路由器报告超大报文,由源端决定怎样适应路径

IPv6 路由器在向目的地转发报文时,不会替它进行分片。如果报文大于下一条链路所能承载的大小,路由器通常丢弃它,并发送 ICMPv6 Packet Too Big 反馈。源端随后可以调整报文大小,或在适当情况下通过 IPv6 分片扩展头发送分片;这些分片在目的端重组。明确各阶段由谁负责,是判断超大报文故障的基础。 坏掉的样子 假定路由器会自动拆开过大的 IPv6 包,会掩盖与大小相关的故障。相同地址与路由上,小 echo 可以成功,大 echo 却失败。端点本地接口可能支持原始大小,中间链路却不支持。应检查整条路径约束,不能仅因接口配置了 MTU 1500,就推断该大小能够端到端通过,也不能用一次小探测成功替代完整大小检查。 Packet Too Big 反馈缺失,会使经典发现更难学到这个限制。分片被过滤,或者其中一片丢失,也可能阻止正确重组。能够生成分片扩展头,不证明沿途策略都允许相应报文。不能把源端分片当作正确路径大小与必要控制流量的万能替代,它有自己的交付条件,需要通过对应流量验证,而不是只检查发送选项已经开启。 反方向也有自己的路径估计。请求发送端学到 MTU,不会自动教会响应端怎样发送较大回复。因此双向 echo 测试需要分别了解两个方向。TCP 分段还不同于 IP 分片:TCP 发送端学到限制后,可以构造更小的段,而不必把每个 IP 包再分片。应先确定观察的是哪一层的切分,避免看到多个包就统称为分片。 快速验证 将脚本保存为 check-ipv6-fragmentation.py,准备 Docker 后运行 python3 check-ipv6-fragmentation.py。一次性环境使用 Python 3.11.16、iproute2 6.1.0、iputils ping 和 tcpdump 4.99.3,在 Linux 6.12.76 上验证。NET_ADMIN 与 SYS_ADMIN 支持四个嵌套命名空间。两台路由器和两个端点使用新建 veth,不修改宿主接口或路由: ...

十月 6, 2027 · 3 分钟 · XB Field Notes