一次上传经过两条连接:客户端到 nginx,以及 nginx 到应用。proxy_request_buffering on 会先读完客户端请求正文,再把该请求发送给上游。关闭它后,nginx 可以随着正文到达逐步转发。这个选择改变应用何时开始占用资源,却不会创造无限容量,也不会使慢发送方消失。应先明确应用是否需要边收边处理,再决定正文在哪一层等待。

坏掉的样子

常见错误是关错指令。proxy_buffering 控制响应,proxy_request_buffering 控制上传请求。仅关闭响应缓冲,流式上传仍可能停在代理处。反过来,如果应用原本应接收完整的小型提交,却在全站关闭请求缓冲,又没有考虑后端并发能力,慢客户端就可能长时间占住应用处理资源。两个方向的策略不能因为名称相似就混为一谈。

缓冲把资源压力转移到 nginx。请求正文超过配置的内存缓冲后,可以使用客户端正文临时存储。因此,大量并发上传会占用代理连接,并可能消耗磁盘空间或磁盘读写能力。增大某个缓冲值不能代替正文大小、上传超时、并发限制和临时文件系统容量规划。缓冲区也不需要一律扩大到能在内存中容纳完整上传。

流式转发还改变失败处理。nginx 一旦已经开始向上游发送请求正文,就不能简单把这个流式请求交给下一台服务器。应用可能已经处理前缀数据,甚至产生副作用。缓冲可以为某些重试路径保留正文,但不能让原本不安全的业务操作突然变得可安全重放。是否允许重试,还取决于实际方法、应用语义以及已经完成的工作。

快速验证

下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。

将脚本保存为 check-upload.py,先取得其中固定摘要的镜像,再用宿主 Python 和 Docker 执行。记录环境为 Linux arm64 上的 nginx 1.28.0、Python 3.11.16 容器。脚本建立专用后端网络,只把 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_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'
import hashlib
backend=r'''
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
import hashlib,json,threading
state={};lock=threading.Lock()
class Handler(BaseHTTPRequestHandler):
    protocol_version='HTTP/1.1'
    def log_message(self,*args):pass
    def reply(self,data):
        data=json.dumps(data).encode();self.send_response(200)
        self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data)
    def do_GET(self):
        with lock:self.reply(dict(state))
    def do_POST(self):
        length=int(self.headers['Content-Length'])
        with lock:state[self.path]={'entered':True,'bytes':0}
        first=self.rfile.read(1)
        with lock:state[self.path]['bytes']=len(first)
        body=first+self.rfile.read(length-1)
        with lock:state[self.path]['bytes']=len(body)
        self.reply({'length':len(body),'sha256':hashlib.sha256(body).hexdigest()})
ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever()
'''
locations='''
location /on { proxy_request_buffering on; proxy_pass http://backend:8081; }
location /off { proxy_request_buffering off; proxy_pass http://backend:8081; }
location /state { proxy_pass http://backend:8081; }
'''
with lab(config(locations,'client_max_body_size 1m; proxy_http_version 1.1;'),backend=backend) as (_,request):
    payload=b'x'*65536;results={}
    def state():return json.loads(request('/state')[2])
    for mode in ('on','off'):
        connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=10)
        connection.putrequest('POST','/'+mode);connection.putheader('Content-Length',str(len(payload)))
        connection.endheaders();connection.send(payload[:1024])
        if mode=='off':wait(lambda:state().get('/off',{}).get('bytes')==1)
        else:
            until=time.monotonic()+1
            while time.monotonic()<until:
                assert '/on' not in state();time.sleep(.05)
        before=state().get('/'+mode)
        connection.send(payload[1024:]);response=connection.getresponse();data=json.loads(response.read());connection.close()
        assert response.status==200 and data=={'length':65536,'sha256':hashlib.sha256(payload).hexdigest()}
        results[mode]={'before_completion':before,'result':data}
    connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=5)
    connection.putrequest('POST','/off-too-large');connection.putheader('Content-Length',str(2*1024*1024))
    connection.endheaders();response=connection.getresponse();response.read();connection.close()
    assert response.status==413 and '/off-too-large' not in state()
    results['oversize_status']=response.status
    print(json.dumps(results,sort_keys=True))

客户端声明正文共 65,536 字节,起初只发送 1,024 字节。开启请求缓冲时,在受控暂停期间反复读取后端状态,都看不到上传处理器被进入。客户端已经开始发送,但应用还没有被要求消费不完整正文。直接观察后端状态,比只比较整个请求耗时更能说明等待发生在哪一层,也避免把一次快速响应误当成流式处理证据。

关闭请求缓冲时,后端在客户端补齐正文之前就进入处理器,并记录已经读到第一个字节,随后等待声明长度中的剩余部分。这验证的是真实上传过程中的转发,而不仅是 nginx 能接受某行配置。后端故意采用简单读取顺序,使“已经开始处理、尚未收完正文”这个中间状态可以明确观察和断言。

发送剩余数据后,两种模式都返回长度 65,536,并得到同一个预期 SHA-256 摘要。因此,虽然处理时机不同,本次缓冲与流式路径都保留了原正文。断言核对实际收到的字节,不只检查成功状态码。它也不代表所有应用解析器、数据保存操作或断线恢复策略已经验证;这些需要根据真实端点的行为另行覆盖。

另一个请求在一兆字节限制下声明两兆字节正文。客户端尚未发送正文,nginx 就返回 413,后端也没有对应处理记录。这是代理执行正文大小策略,关闭请求缓冲并不会关闭这项限制。实验刻意使用已知 Content-Length 的定长消息,让拒绝时机可以直接观察,不把未知长度流的接收过程混进这一结论。

先确定上传契约,再调整缓冲

如果应用工作资源稀缺,希望让慢发送方先在代理等待,且代理存储成本可接受,可以选择缓冲上传。如果应用必须逐步处理传入数据,就选择流式路径,同时承担后端资源占用和不完整提交的处理责任。两种模式都不保证写入方永远不会被下游容量限制;缓冲只能吸收一定程度的速度差。

请求和响应策略应分别决定。某个端点可以先接收缓冲上传,再流式返回进度;也可以流式接收上传,最后返回很小的缓冲结果。上游响应中的 X-Accel-Buffering 控制响应缓冲,不能取代这里验证的请求正文策略。检查配置时应明确每条指令影响哪条数据流。

扩展实验时,还要检查消息分帧和上游协议。在 nginx 1.28.0 中,客户端采用 HTTP/1.1 chunked 上传时,需要启用 HTTP/1.1 代理,才能避免该路径强制缓冲。这里显式选择了上游 HTTP/1.1,但实际测试的是定长正文。如果真实端点还接受分块正文或需要处理中途断开,就应针对这些契约补充验证,不能从本次定长上传结果直接推断。

来源