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 请求,最后清理自己的容器、镜像标签和网络,不改动已有站点配置:
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_text(content)
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()
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'
locations=r'''
location / { return 200 "fallback"; }
location /api/ { return 200 "api-slash"; }
location /api/admin/ { return 200 "admin"; }
location = /api { return 200 "exact"; }
location /api { return 200 "api-string"; }
location ^~ /static/ { return 200 "static"; }
location ~ \.php$ { return 200 "php-regex"; }
'''
with lab(config(locations)) as (container,request):
cases={'/api':'exact','/api?x=1':'exact','/apix':'api-string','/api/users':'api-slash','/api/admin/log':'admin','/api/admin/test.php':'php-regex','/static/test.php':'static','/unmatched':'fallback'}
for path,expected in cases.items():
status,headers,body=request(path);assert status==200 and body.decode()==expected,(path,status,body)
print('exact matching ignores the query; ordinary prefixes use longest string match, including /api matching /apix')
print('a regex overrides the ordinary longest prefix, while the selected ^~ prefix skips that regex')
/api 与 /api?x=1 都返回 exact。查询字符串不参与 location 匹配,因此添加参数不会使精确路径停止匹配。所选块显式返回该标记。精确匹配的作用是选择配置,不天然意味着读取某个文件系统根目录,也不会自动决定调用哪个应用;具体处理动作仍由块内配置确定。
/apix 返回 api-string,证明普通 /api 前缀会超出完整 API 路径段。/api/users 返回 api-slash,因为 /api/ 是更长的匹配前缀。/api/admin/log 返回 admin,进一步展示普通最长前缀选择,不需要依赖这些块在文件中的排列顺序。不同标记使每次选择可被直接观察。
/api/admin/test.php 返回 php-regex。较长的 /api/admin/ 曾是候选,但正则阶段选择了另一块。这正是错误假定“前缀更长就一定胜过通用脚本处理器”时会出现的失败方式。如果只检查状态码成功,错误可能完全被掩盖,因此实验让每个处理器返回可区分正文,而不是都返回同一个正常页面。
/static/test.php 返回 static,因为被选中的 /static/ 带有 ^~,同一个后缀正则不会接管请求。不匹配其他规则的路径则返回宽泛的默认标记。这些案例共同验证真实选择边界,而不仅是证明配置语法被 nginx 接受。语法检查和行为检查回答的问题不同,两者都需要保留。
客户端明确发送路径,并同时核对响应正文和状态。实验不包含真实认证策略或脚本执行,所以标记只证明路由选择。将模式用于受保护端点时,还应测试选择之后真正执行的处理器和访问行为。不能因为路由标记正确,就声称认证、授权或后端功能也已经验证。
审阅所选处理器及其后续转换
先明确目标 URL 家族,再列出精确端点、后代前缀和可能重叠的正则。加入 /apix 等近似路径、相关大小写变体,以及编码或规范化形式。nginx 使用规范化 URI 匹配,仅根据原始请求文本做安全审查,可能漏掉最终生效配置。验证用例应来自真实边界,而不是只挑最普通的成功路径。
检查有效配置,包括被包含的文件和实际选中的虚拟服务器。请求没有进入的 server 中,即使某个 location 写得正确,也无法修复另一个 server 的路由。验证时使用受控响应标记或日志,而不是根据多个处理器都可能返回的熟悉页面来猜测选择。诊断证据需要能区分竞争配置。
最后,考虑 rewrite、index 处理和其他可能触发新 location 搜索的内部跳转。首次选中的块不一定是最终处理器。应测试完整请求路径,并保留足够证据解释初次匹配和最终响应,避免把局部正确误认为整个处理链都符合预期。
来源
- nginx core location directive,规范化 URI、精确匹配、前缀与正则选择,已核验 2026-10-15。
- How nginx processes a request,虚拟服务器选择与 location 请求处理,已核验 2026-10-15。