短新鲜度寿命与缓存填充锁,分别处理突发流量中的不同问题。

微缓存短暂复用新鲜条目,并显式合并并发未命中

微缓存让可复用响应保持很短的新鲜度寿命,常见尺度是几秒。在这个窗口里,请求同一选中表示的后续访问可以复用新鲜条目。响应可能落后于源站刚发生的更新,但不能仅因为它来自缓存,就称它在 HTTP 意义上已经过期。何时过期、是否允许提供过期内容、如何协调并发填充,是三个不同策略,需要分别定义。 坏掉的样子 TTL 短并不意味着共享响应对所有用户都安全。如果个性化请求读取或填充通用缓存条目,一个用户的表示就可能在另一个上下文里被交付。proxy_cache_bypass 根据条件禁止从缓存读取;proxy_no_cache 根据条件禁止保存响应。只控制其中一侧,可能让另一条路径不符合预期隐私边界。读取已有公开条目和保存新私有响应都需要考虑。 缓存键决定哪些请求共享响应。忽略有意义的查询参数或主机维度,可能合并不同表示;把任意变化数据全部放进键,又可能让复用失效。下面实验只有一个后端 authority,并保留包含参数的完整请求 URI。真实服务如果按租户、语言或其他输入改变内容,就必须确定自己的表示身份,不能把示例键当成适用于所有应用的通用公式。 仅仅启用缓存,也不保证冷启动突发只产生一次回源。在任何响应保存完成前,多个请求可能同时未命中同一条目。proxy_cache_lock 负责协调新条目的填充,并有有限的超时和年龄控制。这个锁不是 TTL,缩短新鲜度寿命也不会隐式开启锁。新鲜条目复用与并发未命中合并,需要通过不同对照才能验证。 快速验证 将脚本保存为 check-microcache.py,准备 Python、Docker 和固定镜像后执行。记录环境为 Linux arm64 上的 nginx 1.28.0。可丢弃后端报告每条路径的请求计数,nginx 使用两秒有效期,并显式设置 Cookie 与 Authorization 绕过条件。监听仅发布到宿主回环,使用的会话和授权值都是公开测试标记,不连接真实账户: from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory import http.client,json,socket,subprocess,time,uuid NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609' PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def wait(predicate): deadline=time.monotonic()+15 while time.monotonic()<deadline: try: if predicate():return except OSError:pass time.sleep(.1) raise AssertionError('fixture did not become ready') @contextmanager def lab(configuration,files=None,backend=None): name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend'] try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration) for path,content in (files or {}).items(): target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True) target.write_bytes(content if isinstance(content,bytes) else content.encode()) docker('network','create',name) if backend is not None: (root/'backend.py').write_text(backend) (root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n') docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root)) docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1]) wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0) (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n') docker('build','--network','none','-t',tags[0],str(root)) docker('run','--rm','--network',name,tags[0],'nginx','-t') docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0]) details=json.loads(docker('inspect',containers[0]).stdout)[0] binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort']) def ready(): connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True wait(ready) def request(path,method='GET',headers=None,body=None): connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5) try: connection.request(method,path,body=body,headers=headers or {}) response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read() finally:connection.close() request.port=port yield containers[0],request finally: docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False) def config(locations,extra=''): return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n' from concurrent.futures import ThreadPoolExecutor import threading backend=r''' from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer from urllib.parse import urlsplit,parse_qs import json,threading lock=threading.Lock();counts={};gates={'/lock':threading.Event(),'/nolock':threading.Event()} class Handler(BaseHTTPRequestHandler): protocol_version='HTTP/1.1' def log_message(self,*args):pass def do_GET(self): parsed=urlsplit(self.path) if parsed.path=='/control/state': with lock:data=dict(counts) elif parsed.path=='/control/release': gates[parse_qs(parsed.query)['path'][0]].set();data={'released':True} else: with lock:counts[self.path]=counts.get(self.path,0)+1;count=counts[self.path] if parsed.path in gates:assert gates[parsed.path].wait(15) data={'path':self.path,'count':count,'cookie':self.headers.get('Cookie'),'authorization_present':bool(self.headers.get('Authorization'))} body=json.dumps(data,sort_keys=True).encode();self.send_response(200) if parsed.path=='/cookie':self.send_header('Set-Cookie','fixture=public-example; Path=/') if parsed.path=='/private':self.send_header('Cache-Control','private') if parsed.path=='/no-store':self.send_header('Cache-Control','no-store') self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body) ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever() ''' extra=''' proxy_cache_path /tmp/cache levels=1:2 keys_zone=pages:1m inactive=30s max_size=10m; map $http_cookie $skip_cookie { default 1; "" 0; } map $http_authorization $skip_auth { default 1; "" 0; } proxy_cache pages; proxy_cache_key "$scheme|$proxy_host|$request_uri"; proxy_cache_valid 200 2s; proxy_cache_bypass $skip_cookie $skip_auth; proxy_no_cache $skip_cookie $skip_auth; proxy_cache_lock on; proxy_cache_lock_timeout 5s; proxy_cache_lock_age 5s; add_header X-Cache $upstream_cache_status always; ''' locations=''' location /control/ { proxy_cache off; proxy_pass http://backend:8081; } location = /nolock { proxy_cache_lock off; proxy_pass http://backend:8081; } location / { proxy_pass http://backend:8081; } ''' with lab(config(locations,extra),backend=backend) as (_,request): def get(path,headers=None): status,h,b=request(path,headers=headers);assert status==200;return h.get('x-cache'),json.loads(b) results={} first=get('/item?x=1');second=get('/item?x=1');assert first[0]=='MISS' and second==('HIT',first[1]) other=get('/item?x=2');assert other[0]=='MISS' and other[1]['path']!=first[1]['path'] results['fresh']=[first[0],second[0]];results['different_query']=other[0] for name,headers in [('cookie',{'Cookie':'session=public-fixture'}),('authorization',{'Authorization':'Bearer public-fixture'})]: private=get('/item?x=1',headers);assert private[0]=='BYPASS' and private[1]!=first[1] assert get('/item?x=1')==('HIT',first[1]) new='/new-'+name;assert get(new,headers)[0]=='BYPASS' public=get(new);assert public[0]=='MISS' and public[1]['cookie'] is None and not public[1]['authorization_present'] results[name]={'warm_key':'BYPASS','public_after_private_miss':public[0]} for path in ['/cookie','private','no-store']: path='/'+path.lstrip('/');a=get(path);b=get(path) assert a[0]==b[0]=='MISS' and a[1]['count']==1 and b[1]['count']==2 results[path]=[a[0],b[0]] deadline=time.monotonic()+6 while time.monotonic()<deadline: value=get('/item?x=2') if value[0]=='EXPIRED':break assert value[0]=='HIT';time.sleep(.15) else:raise AssertionError('TTL did not expire') assert value[1]['count']==2;results['after_ttl']={'status':value[0],'count':value[1]['count']} for path,number,origin_count in [('/lock',6,1),('/nolock',4,4)]: barrier=threading.Barrier(number+1) def client():barrier.wait();return get(path) with ThreadPoolExecutor(max_workers=number) as pool: futures=[pool.submit(client) for _ in range(number)];barrier.wait() wait(lambda:get('/control/state')[1].get(path)==origin_count) time.sleep(.3);before=get('/control/state')[1][path] assert before==origin_count and not any(f.done() for f in futures) get('/control/release?path='+path);responses=[f.result(timeout=10) for f in futures] assert get('/control/state')[1][path]==origin_count statuses=sorted(r[0] for r in responses) if path=='/lock':assert statuses==['HIT']*5+['MISS'] and all(r[1]==responses[0][1] for r in responses) else:assert statuses==['MISS']*4 results[path]={'origin_requests_while_held':before,'cache_statuses':statuses} print(json.dumps(results,sort_keys=True)) 首次公开请求 /item?x=1 报告 MISS,下一次报告 HIT,并保留相同后端结果。/item?x=2 有自己的 MISS,响应中的路径也不同。持续检查第二个条目,最终观察到 EXPIRED,后端计数变为二。这依据服务器时钟观察实际过期,不假定精确到亚秒的切换时刻;测试关注状态变化及重新计算,而不是某个固定睡眠值。 ...

三月 12, 2028 · 3 分钟 · XB Field Notes
验证器匹配可以省去正文传输,但不变的文件元数据也可能掩盖已经变化的字节。

nginx 验证器是否可靠,取决于表示身份是否准确

验证器让接收方询问:自己保存的表示,是否仍与当前选中的表示相符。条件 GET 验证成功时,可以返回没有正文的 304,避免重新传输内容。新鲜度是另一个决定,它控制已存响应何时能直接复用而不发送这次请求。ETag 本身不指定缓存寿命,304 也不是一份替代正文;客户端需要保留原响应,才能利用成功验证的结果。 坏掉的样子 把所有 ETag 都叫作正文哈希,会掩盖 nginx 的一个重要实现细节。在这里测试的 1.28.0 静态文件实现中,生成的标签组合了修改时间和内容长度,并不计算文件内容哈希。如果替换字节时保留这两个输入,就可能保留同一个标签。于是,持有不同旧字节的客户端仍收到看似成功的验证,错误不能仅靠“响应里有 ETag”来排除。 强弱验证器适用于不同比较。对于需要字节身份的用途,强验证器必须能区分表示数据变化;弱验证器则可以把字节不同、但被认为语义等价的表示归在一起。W/ 并不是“接受字节等价变化”的标志,也不意味着这个标签适用于所有前提条件或字节范围续传。使用哪种比较,需要依据具体请求条件,而不是凭标签外观猜测。 条件请求不保证省掉应用计算。服务器可能先生成表示,之后才判断验证器是否匹配。这个静态 nginx 示例根本没有应用渲染器。较小的响应在这里证明节省了正文传输,却不能证明数据库查询、渲染成本或回源请求数必然减少。评估优化效果时,应把实际减少的数据传输和实际减少的后台工作分别测量。 快速验证 把脚本保存为 check-validators.py,先取得固定摘要的 nginx 镜像,再用 Python 和 Docker 运行。它通过宿主回环提供一个可丢弃的四字节静态文件,实测 nginx 版本为 1.28.0。实验只控制容器内文件的时间属性,不修改宿主时间,也不修改共享 Docker 虚拟机时钟: from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory import http.client,json,socket,subprocess,time,uuid NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609' PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def wait(predicate): deadline=time.monotonic()+15 while time.monotonic()<deadline: try: if predicate():return except OSError:pass time.sleep(.1) raise AssertionError('fixture did not become ready') @contextmanager def lab(configuration,files=None,backend=None): name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend'] try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration) for path,content in (files or {}).items(): target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True) target.write_bytes(content if isinstance(content,bytes) else content.encode()) docker('network','create',name) if backend is not None: (root/'backend.py').write_text(backend) (root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n') docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root)) docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1]) wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0) (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n') docker('build','--network','none','-t',tags[0],str(root)) docker('run','--rm','--network',name,tags[0],'nginx','-t') docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0]) details=json.loads(docker('inspect',containers[0]).stdout)[0] binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort']) def ready(): connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True wait(ready) def request(path,method='GET',headers=None,body=None): connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5) try: connection.request(method,path,body=body,headers=headers or {}) response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read() finally:connection.close() request.port=port yield containers[0],request finally: docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False) def config(locations,extra=''): return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n' files={'asset.txt':'AAAA','noetag.txt':'AAAA'} with lab(config('location = /noetag.txt { etag off; }'),files) as (proxy,request): def timestamp(value):docker('exec',proxy,'touch','-m','-d','@'+str(value),'/srv/asset.txt') timestamp(1577836800) status,headers,body=request('/asset.txt');assert status==200 and body==b'AAAA' tag=headers['etag'];modified=headers['last-modified'];results={'etag':tag,'last_modified':modified} cases={ 'matching_tag':({'If-None-Match':tag},304), 'weak_comparison':({'If-None-Match':'W/'+tag},304), 'matching_date':({'If-Modified-Since':modified},304), 'tag_takes_precedence':({'If-None-Match':'"different"','If-Modified-Since':modified},200), 'weak_if_match_rejected':({'If-Match':'W/'+tag},412), } for name,(conditions,expected) in cases.items(): status,_,body=request('/asset.txt',headers=conditions);assert status==expected,(name,status) if status==304:assert body==b'' results[name]=status docker('exec',proxy,'sh','-c','printf BBBB > /srv/asset.txt');timestamp(1577836800) status,current,body=request('/asset.txt');assert status==200 and body==b'BBBB' and current['etag']==tag status,_,body=request('/asset.txt',headers={'If-None-Match':tag});assert status==304 and body==b'' results['changed_bytes_preserved_metadata']=status timestamp(1577836802) status,current,body=request('/asset.txt',headers={'If-None-Match':tag}) assert status==200 and body==b'BBBB' and current['etag']!=tag and current['last-modified']!=modified results['changed_metadata']={'status':status,'etag':current['etag']} status,current,_=request('/noetag.txt');assert status==200 and 'etag' not in current print(json.dumps(results,sort_keys=True)) 最初文件内容是 AAAA,修改时间固定为 2020-01-01,产生 ETag "5e0be100-4"。GET 在 If-None-Match 中携带这个标签时,得到正文为空的 304。把请求标签加上 W/ 仍返回 304,因为该条件采用弱比较。在本次配置中,携带相等 If-Modified-Since 日期,也会得到 304。这些结果展示标签与日期两条验证路径。 ...

二月 17, 2028 · 2 分钟 · XB Field Notes
QUIC over UDP 与 TCP 各得其所的传输选择

HTTP3 走 UDP,HTTP2 仍走 TCP 兜底

HTTP/3 把 QUIC 跑在 UDP:443 上,值得在意的理由是丢一个包的代价。TCP 连接上所有流共用一个字节流,丢一个段就会挡住它后面所有数据的交付,直到重传到达。QUIC 让每条流各自拥有丢失恢复,同一个丢包只挡住数据真的在那个包里的流。 别扭的地方在于这不是一个开关。客户端必须先知道服务器会说 HTTP/3 才可能去试,而它得知这件事所走的那条路径,恰恰是你想优化的那条。 两种传输,一个端口号 UDP:443 和 TCP:443 属于彼此独立的命名空间,所以一个监听可以同时占住两者。这就是为什么 nginx 的 server 块可以把 listen 443 quic reuseport; 和 listen 443 ssl; http2 on; 并排放着,用同一个端口号同时服务 QUIC 和 HTTP/2。reuseport 比看起来更重要:没有它,第二次绑定 UDP:443 会拿到 EADDRINUSE,而这正是两个 server 块都声称要占用 QUIC 监听时看到的报错。 ...

二月 12, 2028 · 1 分钟 · XB Field Notes
在握手中把主机名映射到证书的提示。

SNI 让一个 IP 承载多个 HTTPS 站点

Server Name Indication(简称 SNI)在 TLS 握手中把目标主机名发给服务器,于是同一个 IP 地址能同时承载多张证书、多个站点。在它之前算法很残酷:一张证书占一个地址,一个站点占一个地址,地址像水一样花出去。基于名字的虚拟主机对明文超文本管用,对加密流量却不行——服务器必须在加密请求暴露目标站点之前先选好证书。SNI 把悖论解开了:主机名提前一步,进握手本身,跑在加密前面。 现实成果就是今天的共享边缘:内容分发、平台路由、单机多租户,全靠它把几千张证书复用到几个地址上。但机制有三处利齿值得认识——隐私外泄、老客户端、缺省证书陷阱——每一处都在新租户上线或老客户端投诉时准时咬人。 名字最先报出 在交换密钥之前,客户端就在握手里发出想要的主机名,服务器据此选对虚拟主机和证书。因为名字先于加密到达,nginx 才能在一条连接里同时完成路由与选证书。扩展住在第一条握手报文里,不多花一次往返——选证书和本来就要做的握手在同一趟车里完成。 这个顺序也解释了它为什么不能只靠服务端单方面升级。客户端必须发这个扩展;没学过的客户端拿到缺省证书,拿它去校验自己想要的名字。老旧嵌入库、十年前的支付终端、工业控制器,真实机群里至今还有不带它的——认定全员支持之前,先看清自己的客户端构成。 用 server_name 匹配 主机名是路由元数据,而不是秘密:它以明文往返,负载均衡器或中间设备都能读到。nginx 拿它去和 server_name 匹配,决定走哪个 server 块、用哪张证书。每个站点块自带证书与私钥,共享一个监听套接字的各块按规则比对:精确名先行,再通配头尾,最后正则,和超文本主机头的匹配同一套路。 证书选对还不等于请求一定到对的应用。客户端会先验证证书的 SAN 是否覆盖它发送的名字,握手成功后才发送加密的 HTTP Host;反向代理若在第二层使用了另一套名字,证书看似正确,响应仍可能来自错误租户。把每个租户的 SNI 名、证书 SAN、server_name 和上游路由放在同一份清单里,新增或续期时逐项核对。SNI 解决的是证书选择,不是主机名保密,也不是应用授权。 传输层的站点表和应用层的主机预期必须一致,否则重定向和规范化会和证书打架。典型错配是证书对了、应用错了:握手命中甲块,主机头却进了乙块的逻辑,吐出签名正确、内容错误的响应。两边名字照镜子,两层都测,有分歧就当缺陷修,别当灵活用。 把缺省回退控制好 当虚拟主机一个都没命中时,nginx 会抛出第一个配置的证书作为缺省。因此 listen 443 ssl 块的顺序决定未知名字回退到哪个站点,别人直接敲 IP 也会命中这块。缺省块要招待三种客人:扫地址的扫描器、不带扩展的老客户端、域名过期或打错字误指过来的名字。每种都值得一个刻意的回答——中性落地页、直接断开、静默丢弃——而不是谁先配置谁待客。 ...

二月 7, 2028 · 1 分钟 · XB Field Notes
传输协议版本和响应内容编码是两项需要分别检查的事实。

gzip、HTTP/2 与 Brotli 需要分别验证

HTTP/2 改变 HTTP 传输分帧和多路复用方式;gzip 与 Brotli 编码的是表示数据。启用 HTTP/2 不会自动压缩响应正文,启用 gzip 也不能证明客户端协商出了 HTTP/2。HTTP/2 的头部压缩又是另一种机制。可靠检查应分别记录协商协议、响应 Content-Encoding、传输正文字节数,以及实际解码后得到的内容,不能用其中一项代替其他结果。 坏掉的样子 发送 Accept-Encoding: gzip, br,然后把结果保存成 .br,并不能证明使用了 Brotli。服务器可能选择 gzip,也可能发送仍被请求接受的原始表示。真实编码由响应头说明。根据请求列表或输出文件名猜测,会导致使用错误解码器,也可能让报告声称获得了实际上根本没有发生的压缩结果。文件名只是本地命名,不是协议证据。 Brotli 支持还属于部署能力,并不是安装 nginx 后自然具备的功能。Brotli 过滤和预压缩文件处理由独立模块提供,需要针对实际二进制与配置确认可用性、构建兼容性。添加不受支持的指令可能让配置验证失败,而客户端声明接受 Brotli 不会替服务器安装功能。检查模块时,应以当前程序行为为准,不能从另一台机器的配置推断。 客户端自动解码会制造另一种测量陷阱。curl --compressed 收到较小编码正文后,可以保存成较大的解码文件。把这个文件大小与原始压缩输出比较,实际比较的是不同对象。反过来,只使用 --http2 却不检查协商结果,也可能把允许的协议回落误当成 HTTP/2 已经成功验证。命令选项表达请求意图,结果字段才说明本次发生了什么。 快速验证 将脚本保存为 check-h2-coding.py,准备 Docker、Python、OpenSSL 和支持 HTTP/2 的 curl 后运行。记录客户端为带 nghttp2 的 curl 8.7.1,服务器为 Linux arm64 上的 nginx 1.28.0。脚本生成短期本地证书,并通过 --cacert 显式信任该证书,只发布宿主回环端口,不需要连接真实公网服务: ...

一月 28, 2028 · 3 分钟 · XB Field Notes
客户端跳转产生另一次请求,内部重写则改变当前请求里的处理路径。

先决定让 nginx 跳转客户端,还是在内部重写

nginx 跳转与内部重写改变请求流程的不同部分。跳转返回状态和 Location,由客户端决定是否发起新请求;内部重写则在处理现有请求时改变 nginx 当前 URI。两者的选择决定客户端是否看到新地址、location 是否重新搜索,以及最终处理器收到怎样的路径和查询。不能因为都能让某个 URL 得到内容,就把它们当成同一种操作。 坏掉的样子 本来需要内部映射,却改成跳转,会增加客户端往返并暴露新 URL;本来需要公开永久迁移,却只做内部重写,客户端仍会继续使用旧地址。return 和 rewrite 也不能被当成总是默认相同跳转行为的通用缩写。明确状态码与重写标志,才能让预期转换可审阅,避免一行看似简单的配置隐藏行为差异。 状态选择影响方法语义和缓存。307、308 在自动跳转时保留请求方法,301、302 则允许历史上的 POST 改为 GET。永久不意味着每个客户端立刻更新所有已保存链接,临时也不意味着响应绝对不能缓存。缓存指令、请求上下文和客户端行为仍然重要,不能用状态名称替代明确的复用策略。 内部重写还可能选择错误处理器,或反复匹配自己。last 会为改变后的 URI 开始新 location 搜索;break 结束当前重写序列后,继续在现有 location 处理。查询参数可以按替换语法追加或丢弃。把这些区别视为可以互换,可能导致读取错误文件、重复参数,或进入返回 HTTP 500 的循环。 快速验证 使用宿主 Python、Docker 和固定 nginx 镜像运行脚本。实测为 Linux arm64 上的 nginx 1.28.0、Docker 29.3.1。它构建并检查可丢弃配置,只发布回环端口,不自动跟随跳转地验证真实响应,最后清理自身资源。一个小型静态文件让 break 所用处理器可以与目标 location 清楚区分: ...

一月 21, 2028 · 2 分钟 · XB Field Notes
精确匹配立即结束搜索,普通最长前缀仍可能被匹配的正则替代。

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
允许的超额请求可以等待或立即通过,超过突发额度的请求才被拒绝。

nginx 突发容量决定哪些请求等待、哪些被拒绝

nginx 请求限流使用漏桶方法,依据配置速率计算超额请求。burst 决定可以接受多少超额,nodelay 和 delay 则影响已经被接受的超额请求何时继续处理。这里涉及准入与调度,并不是所有超过名义速率的请求都会失败,也不是直接测量应用里有多少并发工作。理解这几个不同维度,才能解释一次突发里为何同时出现成功、等待和拒绝。 坏掉的样子 把 nodelay 解释成“立即丢弃超额”,正好说反了它的作用。在突发额度以内,它允许超额请求不经过通常的延迟,直接继续处理;超过额度才拒绝。已经接受的突发仍消耗需要随时间恢复的容量。后端很快完成,并不会让下一批突发自动获得全新额度,因此不能把 nodelay 理解成只管单次批次、不累计速率债务。 被延迟的请求也不会自动变成一串 503。突发范围内获准等待的请求,可以在等待之后得到成功应用响应;超过配置额度才会按所选状态拒绝。客户端自己的截止时间可能在等待中到期,所以服务器能接受的排队,不一定满足客户端延迟契约。讨论结果时应区分服务端拒绝、获准等待和客户端主动超时。 配置语法同样需要验证。在 nginx 1.28.0 中,显式写 burst=0 会被拒绝,尽管省略 burst 的默认超额额度就是零。无效配置不是一种更严格但正常工作的限流策略。应同时做语法检查和真实请求验证,并确认受保护 location 确实进入限流阶段,而不是在更早的 rewrite 阶段通过 return 提前结束。 快速验证 将脚本保存为 check-burst.py,准备 Python、Docker 和固定镜像后执行。记录环境使用 nginx 1.28.0 和真实 HTTP 后端。每个案例采用独立的公开测试 key、每秒一个请求的速率,并同时放行六个客户端。nginx 在响应头中暴露本次限流决定,入口只发布到宿主回环,结束后清理自己的实验资源: from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory import http.client,json,socket,subprocess,time,uuid NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609' PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def wait(predicate): deadline=time.monotonic()+15 while time.monotonic()<deadline: try: if predicate():return except OSError:pass time.sleep(.1) raise AssertionError('fixture did not become ready') @contextmanager def lab(configuration,files=None,backend=None,extra_ports=()): name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend'] try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration) for path,content in (files or {}).items(): target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True) target.write_bytes(content if isinstance(content,bytes) else content.encode()) docker('network','create',name) if backend is not None: (root/'backend.py').write_text(backend) (root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n') docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root)) docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1]) wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0) (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n') docker('build','--network','none','-t',tags[0],str(root)) docker('run','--rm','--network',name,tags[0],'nginx','-t') published=[arg for value in (8080,*extra_ports) for arg in ('-p','127.0.0.1::'+str(value))] docker('run','-d','--name',containers[0],'--network',name,*published,tags[0]) details=json.loads(docker('inspect',containers[0]).stdout)[0] binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort']) def ready(): connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True wait(ready) def request(path,method='GET',headers=None,body=None): connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5) try: connection.request(method,path,body=body,headers=headers or {}) response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read() finally:connection.close() request.port=port;request.network=name request.ports={int(key.split('/')[0]):int(value[0]['HostPort']) for key,value in details['NetworkSettings']['Ports'].items() if value} yield containers[0],request finally: docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False) def config(locations,extra=''): return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n' backend=r''' from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer import json class Handler(BaseHTTPRequestHandler): protocol_version='HTTP/1.1' def log_message(self,*args):pass def do_GET(self): body=json.dumps({'path':self.path,'method':self.command}).encode() self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body) ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever() ''' from concurrent.futures import ThreadPoolExecutor import threading extra='limit_req_zone $arg_k zone=budget:1m rate=1r/s; add_header X-Limit $limit_req_status always;' locations='location / { return 404; }\n' for path,policy in [('delay','burst=2'),('nowait','burst=2 nodelay'),('partial','burst=2 delay=1'),('strict','')]: locations+=f'location /{path} {{ limit_req zone=budget {policy}; limit_req_status 429; proxy_pass http://backend:8081; }}\n' locations+='location /default { limit_req zone=budget; proxy_pass http://backend:8081; }' invalid=config('location / { limit_req zone=b burst=0 nodelay; proxy_pass http://backend:8081; }', 'limit_req_zone $binary_remote_addr zone=b:1m rate=1r/s;') with lab(config(locations,extra),{'invalid.conf':invalid},backend) as (proxy,request): results={} for mode in ['delay','nowait','partial','strict']: barrier=threading.Barrier(7) def client(): barrier.wait();start=time.monotonic();status,headers,_=request('/'+mode+'?k='+mode) return {'status':status,'limit':headers.get('x-limit'),'elapsed':round(time.monotonic()-start,3)} with ThreadPoolExecutor(max_workers=6) as pool: futures=[pool.submit(client) for _ in range(6)];barrier.wait();observed=[f.result() for f in futures] good=[r for r in observed if r['status']==200];bad=[r for r in observed if r['status']==429] assert len(good)==(1 if mode=='strict' else 3) and len(good)+len(bad)==6,observed assert all(r['limit']=='REJECTED' for r in bad) assert all(r['limit'] in {'PASSED','DELAYED'} for r in good) delayed=sum(r['limit']=='DELAYED' for r in good) assert delayed=={'delay':2,'nowait':0,'partial':1,'strict':0}[mode],observed if mode=='delay':assert sorted(r['elapsed'] for r in good)[-1]>=1.7 if mode=='partial':assert max(r['elapsed'] for r in good)>=.7 results[mode]=observed if mode=='nowait': assert request('/nowait?k=nowait')[0]==429 time.sleep(1.1);assert request('/nowait?k=nowait')[0]==200 results['nodelay_debt_then_recovery']=[429,200] first=request('/default?k=default');second=request('/default?k=default') assert first[0]==200 and second[0]==503 and second[1]['x-limit']=='REJECTED' results['default_rejection']=503 rejected=docker('exec',proxy,'nginx','-t','-c','/srv/invalid.conf',check=False) assert rejected.returncode!=0 and 'invalid burst value' in rejected.stderr results['literal_burst_zero_valid']=False print(json.dumps(results,sort_keys=True)) 设置 burst=2 时,一个请求立即通过,另外两个获准超额请求被延迟。记录中的成功响应分别在接近零、一、两秒时完成;其余三个请求返回 429。响应头区分 PASSED、DELAYED 和 REJECTED。这证明获准等待的工作会正常完成,不能把每个延迟请求都描述为最终错误。具体完成时间是本次观察,状态分类才是主要断言。 ...

一月 5, 2028 · 3 分钟 · XB Field Notes