一次上传经过两条连接:客户端到 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,但实际测试的是定长正文。如果真实端点还接受分块正文或需要处理中途断开,就应针对这些契约补充验证,不能从本次定长上传结果直接推断。
来源
- nginx proxy module,请求缓冲、流式请求重放限制与上游 HTTP 版本,已核验 2026-10-15。
- nginx HTTP core module,请求正文大小限制、内存缓冲与临时存储,已核验 2026-10-15。
- RFC 9110 HTTP Semantics,Content-Length 与 413 响应的语义,已核验 2026-10-15。