精确匹配立即结束搜索,普通最长前缀仍可能被匹配的正则替代。

nginx 前缀在匹配结束前只是候选配置

nginx location 在已选中的 server 内为请求选择配置。对于这里验证的平面前缀与正则结构,精确匹配会结束搜索;否则 nginx 先记住最长匹配前缀,再可能选择第一个匹配的正则。如果没有适用正则胜出,就使用先前记住的前缀。这是一个完整选择过程,不是简单执行配置文件里最先遇到的块,也不是只比较路径看起来有多具体。 坏掉的样子 前缀是字符串前缀,不会自动要求完整路径段。location /api 也能匹配 /apix。如果真正需要的是端点 /api 和 /api/ 下的后代,应明确表达这两个边界。依赖未声明的路径段边界,可能让只是共享开头字母的路径,进入错误处理器、缓存策略或访问规则。配置应反映真实 URL 集合,而不是依靠读者自行补充含义。 最长普通前缀不一定是最终选择。匹配 .php 的正则,可以接管一个也匹配了更详细 API 前缀的请求。把前缀移到正则之前,不会改变基本优先规则。竞争正则之间的声明顺序有意义,普通前缀则主要比较匹配长度,而不是出现位置。因此,单纯整理文本顺序不一定修复实际路由问题。 ^~ 在该前缀成为相关层级的最长匹配前缀时,抑制正则搜索。它不是正则运算符,也不是对所有以某个较短标记前缀开头的路径提供无条件保护。嵌套 location 和内部跳转需要额外推理;下面故意使用平面 server 结构,让每次选择都能明确对应一个规则,不把复杂嵌套情况隐藏在简化结论里。 快速验证 先取得固定 nginx 镜像,再用宿主 Python 和 Docker 运行脚本。实测为 Linux arm64 上的 nginx 1.28.0、Docker 29.3.1。它构建临时配置镜像,用 nginx -t 验证,再通过只发布到宿主回环的端口发送真实 HTTP 请求,最后清理自己的容器、镜像标签和网络,不改动已有站点配置: ...

一月 17, 2028 · 2 分钟 · XB Field Notes
多台服务器宣告同一地址,由路由选择最近的来应答。

Anycast 路由到最近副本

Anycast 让一个地址拥有多个家。多个站点宣告同一个前缀,每个网络按照自己的 BGP 策略选出一条可用路径,客户端通常无需先经过全球负载均衡就能到达拓扑上较近的副本。某个站点撤回宣告后,控制面可以收敛到另一个家。这种设计把“就近”和“切换”交给网络完成,但也把策略错误和收敛窗口带到了应用面前。 一个地址,多点宣告 Anycast 把同一个单播地址或前缀配置给相互独立的实例,每个边缘路由器用 BGP 宣告可达性。邻居比较路径,在自己的路由信息库中安装最佳路由,数据包随后沿普通转发路径走向选定的下一跳。应用看到的仍是一个目的地址,实际上却有许多站点可以回答。客户端不需要知道副本列表,运维也可以独立扩容某个站点。 “最近”指路由策略和拓扑意义上的最近,并不保证地理距离最短。本地偏好较高的路径可以胜过物理上更短的路径;本地偏好相同时,通常再偏向 AS path 更短的路径,之后还有厂商和策略相关的决胜条件。运营者可以调整这些属性、做 AS path prepend 或撤回前缀来引流。真正的契约是可达性,而不是承诺每个客户端永远选择同一站点。不同 ISP 看到的路径不同,所以同一时刻两个用户落在不同城市并不矛盾。 路由完成故障切换 站点失去服务或上游时,应当由健康联动的会话撤回路由。上游传播撤回消息,选择仍在宣告的副本。转发路径无需等待新的 DNS 应答,因此整个边缘站点消失后地址仍然有效。BGP 收敛不是瞬时完成的:切换期间可能丢包,不同网络的收敛时间也不同,客户端仍要设置重试和合理的连接超时。把“最终会切过去”写进设计,并不等于承诺零丢包。 Anycast 特别适合无状态或容易重建的协议,例如 DNS、基于 UDP 的发现服务和 CDN 边缘。它也能把攻击面分散到多个站点,但容量和过滤策略必须协同。进程已经死了却仍在宣告的路由,比没有路由更糟,因为流量会被送进黑洞。应把宣告绑定到真实的就绪信号,同时监控控制面可达性和应用响应。撤回机制要有明确的延迟上限,不能只依赖人工登录路由器。 有状态会话会介怀 路由变化后,已经建立的 TCP 连接可能被送往另一个站点。新实例没有旧套接字状态,可能重置连接或丢弃报文。共享后端能保留应用状态,却不能把原来的 TCP 连接自动搬过去,因此客户端仍要接受重连并重建状态;幂等键可以防止重连后重发请求产生重复副作用。一次成功的跟踪并不等于具有粘性;拓扑变化不仅会改变下一条流的选择,也可能让当前流的后续报文到达另一个副本。 可观测性还应记录服务站点、路由年龄和撤回原因。一个全局成功率会掩盖某个区域的黑洞,因为其他区域仍然健康。应从多个网络探测,把 Anycast 地址和站点身份分开记录,并在“站点仍在宣告但服务检查失败”时告警。日志中保留连接建立时的站点,才能解释切换前后的重试和状态差异。 坏掉的样子 路由泄漏或误设的高本地偏好,会把流量吸引到很远的站点;只撤回一部分邻居,则会让一些网络停留在旧路径,表现为不对称丢包而不是干净切换。副本之间的 MTU、防火墙和 ACL 差异,也会制造看似随机的路由故障。服务带本地状态时,用户可能只在控制面已经收敛后才看到连接重置。把一个站点标记为健康,却没有检查回程路径,也会制造只进不出的黑洞。 ...

十一月 1, 2027 · 2 分钟 · XB Field Notes
把众多相邻网络前缀合并成一条汇总路由。

BGP 聚合收窄路由表

聚合用一条覆盖式通告,替掉一组彼此相邻的前缀:下游每一个对等体因此少存几十条表项,也不再逐条处理每个成员前缀的更新。表变小了,抖动也变少了,边缘网络的反复震荡不再向外传播。 代价是这条汇总本身就是一句关于可达性的承诺。一旦覆盖前缀进了表,转发引擎就会拿它去匹配那些成员路由已经消失的地址;表变小,换来的是一条更模糊的故障信号。 聚合到底买到了什么 一个对等体同时收到 10.1.4.0/24、10.1.7.0/24、10.1.9.0/24 和覆盖它们的 10.1.0.0/16,要存四条。配上 aggregate-address 10.1.0.0/16 summary-only,就只存一条。抑制发生在出方向:这些更具体前缀仍然留在聚合者自己的 BGP 表里,仍然参与它自己的转发,只是不再被通告出去。FRR 在这一点上很严格——必须先在 BGP 表里存在更具体前缀(光有 RIB 不算),聚合才会被生成。 第二个好处是抖动。成员前缀每抖一次,对等体都要解析、选路、重装一遍。躲在汇总后面,这些更新就止步于聚合者,一个每分钟抖两次的客户网对互联网其余部分的开销归零。在长期抖动的边缘,这才是真正想要的那个性质。 summary-only、suppress-map 与 as-set summary-only 对每个邻居抑制所有更长的前缀。suppress-map 只抑制被路由映射命中的那些更具体前缀,用于「这一整段都汇总,但那个例外继续单独通告」。as-set 改变的则是路径的样子:它不把成员路径塌缩成聚合者自己的 AS,而是生成一个装着真实起源 AS 的 AS_SET。 as-set 保住了环路检测,却毁掉了别的一切。AS_SET 会让路径在按 AS 路径长度比较时变长,让起源校验拿不到可用的东西,而且它正在被明确废弃——FRR 直接提供 bgp reject-as-sets,两个方向都丢弃这类路由。真正危险的配置,是跨越不同起源 AS 做聚合。把来自 AS64501 的 10.1.4.0/24 和来自 AS64502 的 10.1.7.0/24 折进由 AS64500 起源的 10.1.0.0/16,等于告诉所有对等体「这一整块都是 AS64500 起源的」。按起源过滤的邻居于是接受了一段本不该接受的前缀,按 AS 路径过滤的邻居则完全看不到那些客户。细节是被销毁,而不是被压缩。 ...

十月 8, 2027 · 1 分钟 · XB Field Notes
客户端默认路由保持有效,服务器缺少回复路由仍会令通信失败。

默认路由匹配最宽范围,不会挽救所有失败路径

IPv4 默认路由是前缀 0.0.0.0/0,匹配所有 IPv4 目的地,但具体程度最低。在已经选择的路由表中,更长的匹配前缀通常优先。默认路由不是一个“其他具体路径失败后再重试”的过程。路由策略决定相关表,最终路由描述怎样转发,而不保证完整往返一定成功。匹配范围与路径健康是两类不同属性。 坏掉的样子 没有默认路由的机器,仍能访问直连或明确路由覆盖的目的地;反过来,添加默认路由也不证明能够访问互联网。下一跳需要可达,转发与过滤要允许流量,回复还需要返回路径。显示一个网关,只满足契约的一部分。真正配置是否共同工作,应由实际应用通信证明,而不是从路由文件中有一行 default 就推导全部能力。 更具体的拒绝路由会胜过可工作的默认路由。Linux 的 unreachable 等路由类型,会有意停止匹配目的地的转发。选定网关的邻居查询失败,也不意味着内核自动寻找更健康、却更宽泛的路径。静态路由优先级和 metric 不是服务健康监控或自动故障转移机制,不能把配置顺序当成逐个尝试可用出口的流程。 返回路径错误尤其容易误导。客户端选中预期网关并把流量送往监听中的服务器,服务器却可能无法把回复路由回客户端。反复修改客户端默认路由,可能破坏其他正常目的地,却没有修复缺失的反向路径。将所有连接超时归因于发送方网关之前,应查看两个方向和真实源地址。请求能到与响应能回,需要分别成立。 快速验证 将脚本保存为 check-default-route.py,准备 Docker 后运行 python3 check-default-route.py。Python 3.11.16 和 iproute2 6.1.0 在一次性 Linux 容器内建立客户端、路由器、目标拓扑。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 routed(setting): wire('client','c0','router','r0',['10.201.1.2/30'],['10.201.1.1/30']) wire('router','r1','target','t0',['10.201.2.1/30'],['10.201.2.2/30']) setting('router','net.ipv4.ip_forward',1) ip('client','route','add','default','via','10.201.1.1') ip('target','route','add','default','via','10.201.2.1') with lab(['client','router','target']) as (root,stack,setting): routed(setting);ip('client','route','delete','default') ip('target','addr','add','198.51.100.9/32','dev','lo');ip('router','route','add','198.51.100.9/32','via','10.201.2.2') daemon=server(stack,'target','198.51.100.9') connected=ns('client','ping','-n','-c','1','-W','1','10.201.1.1');assert connected.returncode==0 missing=request('client','198.51.100.9');assert not missing['ok'] and missing['errno']==101 ip('client','route','add','default','via','10.201.1.1') chosen=json.loads(ip('client','-j','route','get','198.51.100.9').stdout)[0];assert chosen['gateway']=='10.201.1.1' healthy=request('client','198.51.100.9');assert healthy['ok'] ip('client','route','add','unreachable','198.51.100.9/32') specific=request('client','198.51.100.9');assert not specific['ok'] and specific['errno']==113 ip('client','route','delete','unreachable','198.51.100.9/32') recovered=request('client','198.51.100.9');assert recovered['ok'] ip('target','route','delete','default') bad_return=request('client','198.51.100.9');assert not bad_return['ok'] and bad_return['type']=='TimeoutError' assert json.loads(ip('client','-j','route','get','198.51.100.9').stdout)[0]['gateway']=='10.201.1.1' ip('target','route','add','default','via','10.201.2.1') final=request('client','198.51.100.9');assert final['ok'] print(json.dumps({'connected_ping_without_default':0,'no_default':missing,'chosen_route':chosen,'with_default':healthy, 'specific_unreachable_route':specific,'specific_removed':recovered,'missing_return_route':bad_return,'return_route_restored':final})) """ 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) 客户端使用 10.201.1.2,路由邻居为 10.201.1.1。目标位于路由器后的另一条链路,在回环接口拥有服务地址 198.51.100.9。路由器有到该服务的具体路由,目标起初具有返回默认路由。文档保留地址仅用于隔离拓扑,没有联系公网服务器。每项地址和下一跳都在脚本中明确配置,避免依赖宿主偶然存在的路由。 ...

九月 14, 2027 · 2 分钟 · XB Field Notes
跨独立网络的路由器交换可达性与策略通告。

BGP 组成了互联网的路由表

BGP 是唯一把几万个各自独立运营的网络缝成一张转发表的协议。它承载可达性,但它真正承载的是策略:每个网络说明自己起源哪些前缀、从哪个邻居学到它们,以及通过过滤器说明自己愿意把哪些继续传下去。 代价是这张表建立在彼此之间没有合同的信任之上。通告在扩散之前不会被任何东西校验,所以一处笔误或一条配错的过滤器,几分钟内就是全球性的,而且要等到有人注意到才会消失。 AS 路径同时是环路检测和策略信号 在出方向,eBGP 发言者会把自己的 AS 号前插,收到更新的一方如果发现 AS_PATH 里已经有自己的 AS 就直接丢弃。这就是环路防护的全部机制,也是路由绕成一圈后无法被重新引入的原因。iBGP 不做前插,所以一条 iBGP 学到的路由带着与把它带进本 AS 的那条 eBGP 路由相同的 AS_PATH;也正因如此,一个 AS 需要 iBGP 全互联或路由反射器才能把路由分发下去。 同一个字段还兼着另外两份工作。运营商用它做过滤——拒绝任何包含某个对等体 AS 的路径,只接受路径末端是客户 AS 的路由——而路径选择则比较它的长度。一个字段,三份职责,而且互相冲突:为了让一条路径显得不吸引人而做前插,同时也会让它看起来像是过滤器本该拒绝的路由。所以前插这类手段通常只在自家门口有效,一旦越过别人的过滤器边界就会失效。 为什么这张表大约是一百万条前缀 真实数字,取自本文核验时抓取的 CIDR Report 快照:IPv4 前缀 1,080,189 条;把能聚合的都塌缩掉则是 597,258 条。仅 Amazon 一家就起源 18,025 条前缀,其中 15,759 条是更大块的更具体前缀。整张表里大约有一半,是已经被通告过的地址空间上再切出来的更具体前缀。 ...

九月 12, 2027 · 1 分钟 · XB Field Notes
代理目标中的 URI 部分决定公共路径哪一部分会到达后端。

proxy_pass 既选择上游,也映射请求 URI

nginx proxy_pass 选择上游目标,也影响实际发送的请求 URI。关键区别是指令是否包含 URI 部分,而不只是文本末尾有没有斜杠。在普通前缀 location 中,字面目标 URI 会替换匹配到的规范化前缀;仅含地址的目标,在请求没有被重写时,可以传递原始请求 URI。理解这两个分支,才能准确预测后端看到的路径。 坏掉的样子 应用只处理 /users,代理却转发 /api/users,即使网络连接成功,也可能得到 404。相反,应用期待公共前缀,而代理把它移除,也会出现错误。只比较客户端状态和上游可达性,无法定位这类问题;需要检查后端真正收到的方法、路径、查询和主机名。成功建立连接并不等于协议内容符合应用契约。 斜杠边界属于字面替换的一部分。以 /v1 结尾的目标仍然包含 URI,所以在 /join/item 中替换 /join/ 后会得到 /v1item,不会因为目标没有末尾斜杠就保留原路径。按被替换前缀和剩余后缀推演,比“有没有末尾斜杠”这句通用口诀更准确,也能解释多段内部路径的映射。 变量会引入另一条文档规定的分支。变量提供的代理 URL 包含 URI 时,使用的是给定 URI,而不是普通字面前缀替换规则。正则和命名 location 也没有同样明确的前缀边界。在普通前缀中工作的配置,不能不检查契约就直接复制到这些上下文,否则语法或运行行为都可能发生变化。 快速验证 使用宿主 Python、Docker,以及固定 nginx 和 Python 镜像运行脚本。实测为 Linux arm64 上的 nginx 1.28.0、Python 3.11.16、Docker 29.3.1。它启动真正回显请求字段的 HTTP 后端,验证 nginx 配置,只把代理发布到宿主回环,结束时清理自己的镜像、容器和网络,不访问真实业务后端: ...

四月 2, 2027 · 2 分钟 · XB Field Notes