请求速率限制作用于 key 表达的身份,不会自动理解人、账户或端点。limit_req_zone 为这些 key 值保存共享状态,limit_req 则在处理请求时应用对应区域。两个 location 使用相同区域和 key,可以消耗同一份预算。因此,选择变量实际上是在定义公平性与执行边界,并不是只给配置找一个方便填写的字符串。
坏掉的样子
客户端可控标识可能把一个表面上的限制变成许多独立额度。如果修改查询参数就能改变 key,不可信客户端就能选择另一份预算。空 key 更直接:nginx 不会统计 key 求值为空的请求。身份变量缺失时,请求可能无声地离开原本计划的限流范围。只测试正常携带身份的访问,会漏掉这条重要边界。
IP key 衡量的是地址,它可能代表 NAT 或中间层后面的很多用户。转发地址头要先经过明确信任策略,才能成为身份输入。相信任意 X-Forwarded-For,会让调用者制造身份;忽略真实可信代理的地址链,又可能把所有用户放进代理自身的一份预算。两种错误都不能靠提高速率解决,因为问题首先在于限制对象选错了。
共享内存的范围也很重要。区域在同一 nginx 实例的 worker 之间共享,不会自动形成跨无关进程或机器的全局计数器。兼容的平滑 reload 可以保留区域状态。如果假定每次重载配置都会重置全部额度,就可能在测试或恢复操作中,把继续出现的拒绝误判为部署坏了。应区分配置换代、进程换代与共享状态生命周期。
快速验证
下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。
将脚本保存为 check-limit-keys.py,准备 Python、Docker 和固定镜像后执行。记录环境使用带两个 worker 的 nginx 1.28.0,以及可丢弃 HTTP 后端。额度刻意设为每分钟一个请求,使各次观察落在同一预算区间;不同公开测试 key 隔离案例。监听只发布到宿主回环,结束后清理实验资源,不修改现有站点配置:
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
extra='''
limit_req_zone $arg_client zone=identity:1m rate=1r/m;
limit_req_zone $binary_remote_addr zone=address:1m rate=1r/m;
limit_req_status 429;
add_header X-Limit $limit_req_status always; add_header X-Worker $pid always;
'''
locations='''
location /a { limit_req zone=identity; proxy_pass http://backend:8081; }
location /b { limit_req zone=identity; proxy_pass http://backend:8081; }
location /dry { limit_req zone=identity; limit_req_dry_run on; proxy_pass http://backend:8081; }
location /ip { limit_req zone=address; proxy_pass http://backend:8081; }
location /open { proxy_pass http://backend:8081; }
'''
configuration=config(locations,extra).replace('worker_processes 1;','worker_processes 2;')
with lab(configuration,backend=backend) as (proxy,request):
results={}
assert request('/a?client=one')[0]==200 and request('/b?client=one')[0]==429
assert request('/a?client=two')[0]==200
results['same_zone_cross_location']=[200,429];results['changed_untrusted_key']=200
empty=[request('/a') for _ in range(2)];assert all(r[0]==200 for r in empty)
assert all(r[1].get('x-limit') is None for r in empty)
results['empty_keys']=[{'status':r[0],'limit':r[1].get('x-limit')} for r in empty]
first=request('/ip',headers={'X-Forwarded-For':'192.0.2.1'})
second=request('/ip',headers={'X-Forwarded-For':'192.0.2.2'})
assert first[0]==200 and second[0]==429;results['forged_forwarding_header']=[200,429]
first=request('/dry?client=dry');second=request('/dry?client=dry')
assert first[0]==second[0]==200 and second[1]['x-limit']=='REJECTED_DRY_RUN'
results['dry_run']={'status':second[0],'limit':second[1]['x-limit']}
first=request('/a?client=shared');assert first[0]==200;workers={first[1]['x-worker']}
deadline=time.monotonic()+8
while time.monotonic()<deadline and len(workers)<2:
with ThreadPoolExecutor(max_workers=8) as pool:
replies=list(pool.map(lambda _:request('/b?client=shared'),range(8)))
assert all(r[0]==429 for r in replies);workers.update(r[1]['x-worker'] for r in replies)
assert len(workers)==2,workers
results['shared_budget_worker_ids']=sorted(workers)
docker('exec',proxy,'nginx','-s','reload')
wait(lambda:request('/open')[1]['x-worker'] not in workers)
reply=request('/a?client=shared');assert reply[0]==429 and reply[1]['x-worker'] not in workers
results['graceful_reload']={'status':reply[0],'worker':reply[1]['x-worker']}
print(json.dumps(results,sort_keys=True))
/a?client=one 成功,随后 /b?client=one 返回 429,因为两个 location 使用同一区域。把查询身份改为 two,请求又成功。这适合受控演示,却恰好说明未经验证的查询参数不适合作为抵抗滥用的唯一生产身份。系统确实执行了它收到的 key 规则,但规则选择的主体可能不符合业务意图。
两次不带 client 参数的请求都成功,而且没有 X-Limit 决定值,因为空 key 没有被计数。当身份来自可选请求头或认证结果时,这个对照尤其重要:变量非空的正常流量看起来可能正确受限,而缺少变量的请求却绕过了原本的计数。必须显式测试身份缺失,不能仅从正常路径推断所有请求都在预算内。
按 IP 限制的 location 收到两次携带不同伪造 X-Forwarded-For 的请求,第一次成功,第二次返回 429。这里没有安装 real-IP 信任配置,因此地址 key 仍来自套接字对端。这证明在本次设置中,仅发送不同转发头不会改变配置身份;它并没有认证某个原始互联网用户,也不能被解释为任意代理链的客户端地址恢复已经正确。
dry-run location 对原本会拒绝的请求仍返回 200,但决定头是 REJECTED_DRY_RUN。计数依然发生,只是在该处关闭执行拒绝。仅观察成功响应会隐藏这一区别。评估候选策略时,应读取 dry-run 决定变量,而不是只统计客户端可见的拒绝数,否则容易把“正在记录超额但未执行”误当成“没有超额”。
对于新的共享 key,第一次请求成功,后续从两个已观察 worker 进程 ID 返回的请求都得到 429。随后 nginx 用同一区域配置执行平滑 reload,新 worker 仍拒绝该 key,证明预算状态被保留,没有重置。测试先等待新 worker 身份出现,再把结果归因于重载,避免实际仍由旧 worker 处理却声称已验证新进程行为。
一起定义身份、范围和观测方式
先决定预算应属于地址、已认证账户、路由还是它们的组合,并验证输入在限流阶段已经存在且可信。跨 location 共享区域可以有意建立一份服务预算,分开区域则产生不同计数。不要以为 location 名称会自动成为 key 的一部分;如果表达式没有包含那个维度,就不能期待它提供对应隔离。
区域内存需要按 key 基数规划,不能只看到请求量。文档说明每个 key 的状态在六十四位平台占一百二十八字节,在三十二位平台占六十四字节;这里的位数指平台架构,不是 key 的位数。存储耗尽可能淘汰旧状态,如果仍无法分配,则可能拒绝请求。这个实验验证执行语义,没有测量最大 key 容量,也没有把文档中的近似容量当成本机压测结果。
变更之后还要检查有效配置与决定遥测,区分空 key 不计数、dry run 放行、真正拒绝和 reload 保留状态。单实例限制正常工作,并不能证明全局租户配额已经实现;一个请求返回 429,也不能独自说明究竟消耗了哪个预期用户或路由预算。限流效果需要与身份语义一起解释,才有实际约束价值。
来源
nginx request limit module,共享 key 状态、空 key、dry run、内存大小与决定变量,已核验 2026-10-15。
nginx real IP module,从代理信息推导客户端地址时的信任边界,已核验 2026-10-15。
RFC 6585 additional HTTP status codes,429 与受限主体识别,已核验 2026-10-15。
nginx 1.28.0 request-limit source,兼容 reload 过程中共享区域状态的复用,已核验 2026-10-15。