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 配置,只把代理发布到宿主回环,结束时清理自己的镜像、容器和网络,不访问真实业务后端:

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'

backend=r'''from http.server import BaseHTTPRequestHandler,HTTPServer
import json
class Handler(BaseHTTPRequestHandler):
 def do_GET(self):
  body=self.rfile.read(int(self.headers.get('Content-Length','0')))
  document={'path':self.path,'method':self.command,'body':body.decode(),'headers':{key:self.headers.get(key) for key in ['Host','X-Forwarded-For','X-Real-IP','X-Forwarded-Proto','X-Forwarded-Host']}}
  data=json.dumps(document).encode()
  self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(data)));self.send_header('X-Backend-Info','fixture-node');self.end_headers();self.wfile.write(data)
 do_POST=do_GET
 def log_message(self,*args):pass
HTTPServer(('0.0.0.0',8081),Handler).serve_forever()
'''

locations=r'''
location /keep/ { proxy_pass http://backend:8081; }
location /strip/ { proxy_pass http://backend:8081/; }
location /replace/ { proxy_pass http://backend:8081/v1/; }
location /join/ { proxy_pass http://backend:8081/v1; }
location /variable/ { set $destination "http://fixture_pool/fixed?chosen=1"; proxy_pass $destination; }
'''
invalid=config(r'location ~ ^/bad/ { proxy_pass http://backend:8081/replacement/; }')
with lab(config(locations,'upstream fixture_pool { server backend:8081; }'),{'invalid.conf':invalid},backend) as (container,request):
    cases={'/keep/item?x=1':'/keep/item?x=1','/strip/item?x=1':'/item?x=1','/replace/item?x=1':'/v1/item?x=1','/join/item?x=1':'/v1item?x=1','/keep/a%2Fb?x=1':'/keep/a%2Fb?x=1','/strip/a%2Fb?x=1':'/a/b?x=1','/variable/item?original=1':'/fixed?chosen=1'}
    for path,expected in cases.items():
        status,headers,body=request(path);assert status==200 and json.loads(body)['path']==expected,(path,status,body)
    invalid_result=docker('exec',container,'nginx','-t','-c','/srv/invalid.conf',check=False)
    assert invalid_result.returncode!=0 and 'cannot have URI part in location given by regular expression' in invalid_result.stderr
    print('no URI preserves the original path; a literal URI replaces the normalized matching prefix, including its slash boundaries')
    print('a variable-supplied URI is used as specified; regex-location URI replacement is rejected by nginx -t')

/keep/ 中仅含地址的目标,把 /keep/item?x=1 原样转发。/strip/ 中以 / 作为目标 URI,则转发 /item?x=1。这两个字面示例都保留查询参数。证据来自后端 JSON 响应,而不是根据 nginx 处理前的公共 URL 猜测。回显字段让代理转换直接可见,也便于把路径错误与连接失败分开。

/replace/ 把前缀映射为 /v1/,所以 /replace/item?x=1 变成 /v1/item?x=1。/join/ 目标故意少一个分隔斜杠,结果为 /v1item?x=1。两个请求都成功到达后端,但路径不同。这说明 TCP 或 HTTP 成功不能证明路由映射正确,后端如果存在宽泛默认路由,还可能把错误进一步隐藏起来。

编码分隔符揭示规范化边界。仅含地址时,/keep/a%2Fb?x=1 仍以编码形式到达后端;通过规范化前缀替换,/strip/a%2Fb?x=1 则变成 /a/b?x=1。这会改变应用划分路径段的方式。当编码标识符或访问边界依赖这些分隔符时,需要让代理和应用的路径解释保持一致,并用实际字节请求验证。

变量示例引用预先定义的上游组,并提供 /fixed?chosen=1 作为目标 URI。请求 /variable/item?original=1 最终恰好到达这个固定 URI,原后缀和查询不会自动追加。使用已知上游组,也让这个实验与任意逐请求主机名解析及其 resolver 要求区分开,不把不同目标选择方式混成一个行为结论。

第二份配置故意在正则 location 中使用带 URI 部分的代理目标,nginx -t 会报告文档规定的 URI 限制并拒绝配置。实验不会用这个无效文件替换正在工作的配置。因此,除了成功路径,还验证了真实配置失败,而不是把所有错误都归结成运行后才出现的后端 404。

把每条公共到后端的映射作为契约

对每个代理 location,写清公共前缀、是否有替换 URI,以及预期后端路径和查询。测试应包含边界斜杠和编码形式。如果代理前有 rewrite 改变 URI,也要重新检查仅地址形式:转发原始请求取决于是否仍在处理原始 URI,不是对所有重写请求都成立的无条件承诺。配置组合会改变分支,不能只孤立看一行。

目标选择应限制在预期后端内。来自不可信请求数据的变量,不应成为任意访问其他主机的权限。命名上游组、显式映射和拒绝行为,应依据应用真实路由需求配置,不能默默接受未知目的地。路径转换正确与目标选择安全,是相关但不同的检查项,需要分别有证据。

转发头、连接复用、超时和上游 TLS 应按各自契约配置。resolver 不是能让变量目标或 keepalive 一概正确的开关。应分别测试这些行为,并保留后端观察来证明 URI 映射,避免用未经测量的性能或解析假设替代本来可以直接验证的请求内容。

延伸阅读

来源