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 清楚区分:
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 = /old { return 308 /destination$is_args$args; }
location = /destination { return 200 "$request_method|$uri|$args"; }
location /last/ { rewrite ^/last/(.*)$ /target/$1 last; }
location /break/ { rewrite ^/break/(.*)$ /target/$1 break; add_header X-Route break always; }
location /target/ { add_header X-Route target always; return 200 "$uri|$args"; }
location /append/ { rewrite ^/append/(.*)$ /target/$1?new=1 last; }
location /discard/ { rewrite ^/discard/(.*)$ /target/$1? last; }
location /loop/ { rewrite ^ /loop/again last; }
'''
with lab(config(locations,'absolute_redirect off;'),{'target/item':'static-file'}) as (container,request):
status,headers,body=request('/old?x=1',method='POST',body='public-body')
assert status==308 and headers['location']=='/destination?x=1'
status,headers,body=request(headers['location'],method='POST',body='public-body');assert status==200 and body==b'POST|/destination|x=1'
status,headers,body=request('/last/item?x=1');assert status==200 and headers['x-route']=='target' and body==b'/target/item|x=1'
status,headers,body=request('/break/item?x=1');assert status==200 and headers['x-route']=='break' and body==b'static-file'
status,headers,body=request('/append/item?old=2');assert status==200 and body==b'/target/item|new=1&old=2'
status,headers,body=request('/discard/item?old=2');assert status==200 and body==b'/target/item|'
assert request('/loop/start')[0]==500
assert 'rewrite or internal redirection cycle' in docker('exec',container,'cat','/tmp/error.log').stdout
print('return 308 creates a client-visible redirect; the explicit follow-up retains POST and the query')
print('last restarts location selection; break keeps the location while changing the URI used by its static handler')
print('rewrite appends old query arguments unless discarded explicitly; an internal rewrite cycle is stopped with HTTP 500')
向 /old?x=1 发送 POST,得到 308 和 Location: /destination?x=1。实验关闭绝对跳转生成,让本地目标保持相对形式,不依赖自动分配的宿主端口。任何后续请求之前,都会先检查原响应,从而明确 nginx 实际告诉客户端什么,不让自动跟随把第一跳隐藏起来。
随后,脚本显式向该 Location 再发送一次 POST,得到 POST|/destination|x=1。这验证按预期方法跟随时的目标和查询,但不声称覆盖所有浏览器的自动跳转或缓存行为。测试客户端有意控制第二次请求,而不是默默执行它。因此,实验结果应与 HTTP 状态契约结合理解,不能扩展成未经执行的客户端兼容性结论。
/last/item?x=1 被重写为 /target/item,并重新选择 location。目标块返回诊断 URI,X-Route 为 target。相比之下,/break/item?x=1 改变 URI 后仍留在原 location,其静态处理器读取 /srv/target/item,响应标记为 break。相同目标路径因此可以经过不同配置处理,不能只看重写后的字符串。
这些结果说明 break 不会停止全部请求处理,它只停止当前重写序列,内容处理仍继续。同样,last 不是外部跳转:客户端直接收到最终 200,不会先获得一条 Location 跳到内部目标。状态、响应头与正文共同证明转换类型,避免仅凭规则名字猜测。
/append/item?old=2 的替换包含 new=1,它与原参数组合成 new=1&old=2。丢弃示例在替换末尾使用问号,最终没有参数。两种情况都断言实际结果,避免跳转或重写测试漏掉重复、泄漏或意外删除的查询数据。路径正确不代表参数同样正确,两者应分别比较。
循环路径不断重写回自身匹配家族。nginx 最终以 HTTP 500 停止循环,并在错误日志记录内部跳转循环诊断。因此,语法检查成功不证明某个请求不会循环;只有实际执行相关匹配路径,才能验证转换按预期终止。错误日志还为区分后端故障与本地规则循环提供了直接证据。
测试客户端真正收到的转换
简单公开迁移可以使用带明确状态的 return,需要模式匹配时再采用对应 rewrite。目标路径和查询处理应清楚表达。避免让不可信输入任意决定跳转地址;绝对目标依赖传入主机信息时,还要验证规范主机策略。配置不应因为修复一个路径,顺便允许请求者选择完全无关的目标。
公开迁移在选择缓存策略前,应验证状态、Location、方法预期和最终目标。需要禁止复用时,临时状态不能替代明确缓存控制。内部映射则应检查最终 location、文件或上游路径、参数及错误情况。两类验证关注不同结果,不应把其中一种成功直接代替另一种。
回归应包含近似路径和已经规范化的目标。规则第一次工作,不代表不会再次跳转自己的目标,或与后续 location 冲突。保留原始响应和最终结果两侧证据,才能在部署后解释路由改变到底发生在哪一步,而不是只看到页面最终能打开就结束检查。
延伸阅读
来源
- nginx rewrite module,return 跳转、last 与 break、查询处理及重写次数限制,已核验 2026-09-12。
- RFC 9110 HTTP semantics,跳转状态含义与方法保留要求,已核验 2026-09-12。
- RFC 9111 HTTP caching,响应语义及缓存控制共同决定的缓存行为,已核验 2026-09-12。