1. 开篇:上传文件这件事,为什么非要在 Nginx 层解决
做 Web 后端或者运维时间长了,你迟早会遇到一个问题:应用服务器接收大文件上传时,内存飙高、请求超时、甚至整个服务被拖垮。我自己第一次被这个问题折磨,是在一个内部工具平台上线的时候,用户一次性传几百 MB 的压缩包,Tomcat 直接 OutOfMemory,前端报 504,运维群炸锅。那时候的第一反应是“加超时时间、改文件大小限制”,但改完发现治标不治本,治本的办法是:不该让应用服务器直接扛文件流,应该把上传这件事尽量往前推,推到 Nginx 这一层。
项目标题里的“nginx服务器实现上传文件功能_使用nginx-upload-module模块”,说的就是这条路:用 Nginx 官方生态里的 nginx-upload-module,把上传请求的接收、临时存储、后端转交这个脏活累活,从应用层剥离出来。这个模块做的事情很纯粹——接收 Multipart/form-data 请求,把文件内容写入临时文件,然后把文件名、路径、原始表单字段通过 HTTP 子请求转发给后端处理。
适合谁来参考?两类人:一类是被大文件上传折磨的后端开发,另一类是负责 Nginx 网关层的运维。这篇文章会用我实际部署过的配置、踩过的坑、还有几个上线前必须检查的参数,完整讲一遍从编译到联调的全过程。看完你不需要再去翻英文 README,照着做就能跑起来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么需要 Nginx 来收上传流量:从一次线上事故说起
2.1 应用服务器直接收文件流的三个致命问题
先说说不用这个模块、让后端直接收文件时,你到底会面对什么。
第一个问题,内存被瞬时打满。多数 Web 框架接收上传文件时,不管框架多聪明,都会先把请求体读进内存缓冲,超过阈值才落盘。几个用户同时传大文件,内存就像被谁按了快进键,GC 都来不及。Java 的 Tomcat 默认 maxSwallowSize 有 2MB,Spring Boot 的 spring.servlet.multipart.max-file-size 默认是 1MB,你以为你改了配置就没事,其实框架内部的临时目录、连接池线程全部要被这些大请求占住。
第二个问题,请求超时和连接中断。应用层接收一个 1GB 的文件,如果网速一般,可能要几分钟甚至十几分钟。默认的网关超时通常只有 60 秒,你还没来得及传到一半,中间层的 LB 已经把连接断掉了。就算你把 Nginx 的 proxy_read_timeout 调大,后端的 HTTP 容器也有自己的 session 超时,一层一层全是坑。
第三个问题,后端业务流程被上传拖慢。上传本身是个 IO 密集型操作,如果让业务线程一直占着等文件流,等于同时占用了数据库连接、消息队列等资源。用户传一个大文件,别人查个列表都变慢,这种体验谁受得了。
2.2 让 Nginx 吃流量、让后端收结果的设计模式
nginx-upload-module 的做法聪明在哪里?它把一次上传拆成了两段:
- 第一段:Nginx 直接接收客户端的 multipart 请求,把文件内容写入服务器本地临时文件,注意,这个过程中后端应用完全不参与,Nginx 自己就处理完了。
- 第二段:Nginx 将临时文件路径、原始文件名、Content-Type 以及表单里其他字段,组装成一个新的 HTTP 请求,转发给后端的一个“处理已上传文件”的接口。后端拿到的是磁盘上已经存在的临时文件,只需要做业务逻辑(比如移动到对象存储、解析入库),不需要再接收大量数据流。
这种模式的好处立竿见影:后端接收的请求体极小(就是几个表单字段和一个文件路径),响应速度极快;大流量的上传压力被 Nginx 的高并发能力承接,而 Nginx 本身就是为高并发设计的,处理静态文件和临时文件写入非常擅长。
2.3 这个方案解决不了什么,先给你泼盆冷水
诚实地说,nginx-upload-module 不是万能的。它不适合的场景包括:
- 需要实时流式处理(比如边传边转码)的场景,它做不到,因为模块是“先完整落盘,再通知后端”。
- 需要断点续传的场景,它不支持,那是另一个协议层的事(比如 tus、分片上传)。
- 需要上传进度条实时反馈到前端,它也不直接支持,你只能通过别的路由统计临时文件大小来近似展示。
所以如果你要的是这些能力,后面可以看我第 6 节里讲的替代方案对比。如果只是让上传别把后端打崩、把大文件接收从应用里摘出去,这个模块完全够用。
3. 核心原理:nginx-upload-module 是把“上传”这件事拆成了两段
3.1 模块到底做了哪几件事
要理解这个模块,你得先搞清楚 Nginx 处理请求的“阶段”概念。nginx-upload-module 是在 Nginx 的 access 阶段之后、content 阶段介入的,它拦截了 POST 请求,扫描 multipart/form-data 的每一个 part。
对于文件类的 part(有 filename 字段的文件域),它会把内容流写入配置指定的临时目录;对于普通字段(比如文本框、下拉框),它会把值存进内存,等文件全部接收完后,这些字段会被作为参数附加到转发请求里。
整个处理过程有三个关键动作:
- 接收并解析 multipart 流,识别文件 part 和普通字段 part。
- 文件 part 写入临时文件,写入完成后记录下临时文件路径。
- 构造一个内部子请求(subrequest),把临时文件路径和普通字段拼进请求参数,发送给
upload_pass指定的后端地址。
这里的“子请求”设计是精髓。子请求是 Nginx 内部发起的,不需要客户端参与,客户端看到的只是 Nginx 返回的最终响应。所以整个上传过程对外部来说是一个请求,对内实际上是两段接力。
3.2 临时文件怎么命名、怎么清
模块往临时目录写文件时,默认文件名格式类似 000000001234,纯数字,没有扩展名。它不会保留客户端的原始文件名,原始文件名会被放进变量 $upload_file_name 里,转发给后端时当作参数传过去。
临时文件的清理方式也很直接:后端接口处理完临时文件后,应该负责删除,或者移动到某个业务目录后删除源文件。如果你没删,小心积累成磁盘满。模块本身不做自动清理设计,这点一定要在应用侧做好。
3.3 模块依赖的 Nginx 请求转发机制
nginx-upload-module 通过 upload_pass 指定转发的目标地址,可以是一个 location,也可以是一台上游服务器。转发动作发生在文件完整写入之后,所以如果你的后端接口需要读取上传文件,直接读 upload_store 指定的路径加上 $upload_tmp_path 这个变量即可。
这里涉及一个重要的变量:$upload_tmp_path。它保存的是本次上传临时文件的实际路径,是后端代码里最常用来拼接文件路径的变量。在转发给后端的参数中,你可以通过 upload_set_form_field 把它显式设置成一个表单字段,后端的接收逻辑就非常清晰了。
4. 编译安装:别用发行版自带的 Nginx,亲手编译一次
4.1 模块获取和版本选择
nginx-upload-module 的 GitHub 仓库目前还是那个老的 vkholodkov/nginx-upload-module,注意它的活跃度不算高,社区维护版是 fdintino/nginx-upload-module,修了不少旧版与新 Nginx 的兼容问题。
我用的是 fdintino/nginx-upload-module,原因是它更新,增加了对 Nginx 1.14+ 的支持,并且修复了一些旧版在 multipart 解析上的 bug。如果你在用 Nginx 1.20 以上版本,强烈建议直接上这个维护分支。
编译前先确认你的 Nginx 版本和编译器版本。我用的是 Nginx 1.24,CentOS 7/Ubuntu 22.04 都测过,没问题。
4.2 编译全流程:带参数、别漏依赖
先装依赖(Ubuntu 示例):
bash复制apt-get update
apt-get install -y build-essential libpcre3-dev libssl-dev zlib1g-dev
下载 Nginx 和模块源码:
bash复制wget http://nginx.org/download/nginx-1.24.0.tar.gz
tar zxf nginx-1.24.0.tar.gz
git clone https://github.com/fdintino/nginx-upload-module.git
进入 Nginx 源码目录,配置编译参数。这里我建议除了 upload 模块,顺手带上你常用的其他模块:
bash复制cd nginx-1.24.0
./configure \
--prefix=/etc/nginx \
--sbin-path=/usr/sbin/nginx \
--modules-path=/usr/lib/nginx/modules \
--conf-path=/etc/nginx/nginx.conf \
--error-log-path=/var/log/nginx/error.log \
--http-log-path=/var/log/nginx/access.log \
--pid-path=/var/run/nginx.pid \
--with-http_ssl_module \
--with-http_v2_module \
--with-http_gzip_static_module \
--add-module=../nginx-upload-module
--add-module 的参数必须指向包含 config 文件的模块根目录,不是 src 目录。编译完成后检查一下模块是否真的挂上了:
bash复制make && make install
nginx -V 2>&1 | grep upload
看到输出里出现 --add-module=../nginx-upload-module,就说明模块已经编译进去了。
4.3 模块挂载的坑:动态模块 vs 静态编译
nginx-upload-module 目前推荐静态编译,也就是上面 --add-module 的方式。如果你非要做成动态模块,也不是不行,但我试过之后发现没必要——这个模块没有热插拔需求,而且动态模块还要额外管理 .so 的加载路径和依赖顺序,徒增运维复杂度。
另外注意,如果你用的是云厂商预装的 Nginx(比如宝塔、OneinStack),它们默认不带这个模块,你不能通过改配置就启用。要么自己重新编译替换,要么用 nginx -s reload 之前把新编译好的二进制替换掉。务必先在测试服务器上编译验证,不要直接动生产环境。
5. 配置实操:最小可用配置到生产级参数调整
5.1 一份能跑通的最小配置
直接上我实际用的最小配置,你可以先复制改改,后面再逐项讲参数含义。
nginx复制server {
listen 80;
server_name upload.example.com;
client_max_body_size 2g;
location /upload {
upload_pass @upload_backend;
upload_store /data/upload_tmp;
upload_store_access user:rw group:rw all:r;
upload_set_form_field "file_path" "$upload_tmp_path";
upload_set_form_field "file_name" "$upload_file_name";
upload_set_form_field "file_content_type" "$upload_content_type";
upload_aggregate_form_field "upload_md5" "$upload_md5";
upload_pass_form_field "token";
upload_pass_form_field "biz_type";
upload_cleanup 400 404 499 500-505;
}
location @upload_backend {
proxy_pass http://127.0.0.1:8080/api/upload/callback;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
}
}
这里面最核心的四件事:临时文件存哪(upload_store)、转给谁(upload_pass)、带哪些参数过去(upload_set_form_field / upload_pass_form_field)、失败怎么办(upload_cleanup)。
5.2 关键参数逐个讲:为什么这样配
先说 upload_store。这个目录必须预先创建好,并且保证 Nginx 进程用户(通常是 nginx 或 www-data)有写权限。如果你像上面那样配置成 /data/upload_tmp,记得:
bash复制mkdir -p /data/upload_tmp
chown nginx:nginx /data/upload_tmp
chmod 750 /data/upload_tmp
为什么不直接把权限设为 777?因为临时目录里很快会有大量文件,如果权限太开放,容易被恶意用户读取或覆盖别人刚上传的文件。生产环境建议 750,并且目录不要放在 /tmp 下,/tmp 经常被系统清理服务清掉,可能导致上传到一半文件没了。
再说 upload_set_form_field。它的作用是把模块内的变量作为一个表单字段拼接进转发请求。这里我专门把临时路径 $upload_tmp_path、原始文件名 $upload_file_name、内容类型 $upload_content_type 都传了出去。后端接口只需要从请求参数里拿这些值,就可以定位文件并做处理。
注意一个细节:$upload_tmp_path 是模块内部变量,在 location /upload 处理完成后会失效,所以必须在 upload_pass 之前通过 upload_set_form_field 固化到转发参数里。我在初学时犯过错,以为后端能直接读环境变量里的 $upload_tmp_path,结果后端拿到的路径是空的。后来才明白,转发请求体里只有你显式设置的字段。
upload_pass_form_field 则是把客户端原始表单里的字段透传给后端,比如 token 和 biz_type。它不需要你额外赋值,就是原样转发。这个常用于鉴权和业务类型标识。
upload_cleanup 很有意思。当后端返回的 HTTP 状态码匹配这里的规则时,Nginx 会自动删除已经写入的临时文件。比如后端返回 400 表示参数校验失败,临时文件已经没用了,这时候删掉正好。如果后端返回 200 并且业务逻辑里已经自己处理完了文件(比如已转存对象存储),那你也可以让 upload_cleanup 只匹配 400 404 等错误码,保证成功场景下临时文件被业务方清理,失败场景下被 Nginx 兜底清理。我建议按上面的写法,成功时交给业务清理,失败时交给 Nginx 清理,双保险。
5.3 多目录轮转写的配置模式
如果上传量很大,单目录几万个文件会让文件系统 inode 索引变慢,ls 都卡。我更推荐用多目录轮转写模式,比如按日期或按随机子目录:
nginx复制upload_store /data/upload_tmp 1;
upload_store /data/upload_tmp 2;
upload_store /data/upload_tmp 3;
upload_store 后面可以跟多个目录,模块会按照配置顺序依次尝试写入,达到类似“目录轮转”的效果。实际使用中,你也可以在后端接口里根据日期把临时文件移动到业务目录,这样临时目录不会无限增长。
5.4 前端上传表单长什么样
这个模块接收的是标准的 multipart/form-data 请求,所以前端不需要特殊 SDK,普通的 HTML form 或者 XMLHttpRequest 上传都行。
参考一个表单:
html复制<form action="/upload" method="post" enctype="multipart/form-data">
<input type="text" name="token" value="your-token" />
<input type="text" name="biz_type" value="avatar" />
<input type="file" name="file" />
<button type="submit">上传</button>
</form>
注意:文件域的 name 属性随便起,比如叫 file 或 uploadFile 都行,模块不关心这个字段名,它只关心这个 part 里有没有 filename。而普通文本域 token、biz_type 会被 upload_pass_form_field 透传,后端能从请求参数里直接读到。
如果用 JavaScript 做异步上传,也很简单:
javascript复制const formData = new FormData();
formData.append('token', getToken());
formData.append('biz_type', 'avatar');
formData.append('file', fileInput.files[0]);
fetch('/upload', {
method: 'POST',
body: formData,
})
.then(res => res.json())
.then(data => console.log(data));
fetch 默认不手动设置 Content-Type,浏览器会自动加上带 boundary 的 multipart 头,模块能正确解析。这里踩过的一个坑是:有人手动加 'Content-Type': 'multipart/form-data',导致 boundary 丢失,Nginx 解析 multipart 失败,返回 400。千万不要手动设置这个请求头,交给浏览器。
5.5 大文件上传时 client_max_body_size 的作用
client_max_body_size 默认是 1MB,如果你不设置,超过 1MB 的请求直接返回 413 Request Entity Too Large。我上面配置里给了 2g,这是单请求文件上限。单位支持 m、g,注意如果后端还要接收请求体,Nginx 层面的 proxy_request_buffering 相关参数也会影响整体行为,不过nginx-upload-module 不会走 proxy_request_buffering,因为这个模块接管了请求体解析,不走常规的 proxy 请求体缓冲逻辑。
这里要提醒一下:client_max_body_size 设置得过大,意味着一个恶意用户可以直接往你的临时目录写大量文件直到磁盘满。生产环境建议做成两级限制:Nginx 层限制单文件大小,同时做磁盘水位告警,临时目录超过 80% 时报警。权限和配额不能只靠 Nginx 一个模块解决。
6. 常见问题排查与避坑实录
6.1 413 Request Entity Too Large
症状:前端上传稍大文件就报 413。
原因:client_max_body_size 没设置或过小。这个报错是直接返回给客户端的,Nginx 错误日志里通常会看到 "client intended to send too large body"。
解决:在 server 或 location 级别加上 client_max_body_size 2g;。如果做了 https,有些场景还需要关注 client_body_buffer_size,它决定 Nginx 在内存中缓冲请求体的大小,超过后落盘临时文件,调大它能让小文件更快,但内存开销也大,我一般保持默认 16k,不做调优。
6.2 后端收到转发请求但读不到临时文件
症状:后端接口正常收到回调,参数里的 file_path 有值,但打开文件提示不存在。
排查步骤:
- 检查 Nginx 和后台服务是否在同一台机器。
upload_store是 Nginx 本地路径,如果后端在另一台机器,它当然读不到。 - 检查文件权限。Nginx 写入的文件权限由
upload_store_access控制,如果后端进程用户不是 nginx 用户,要确保有读权限。 - 检查是否被
upload_cleanup删了。如果你把 200 也加入清理列表,而后端响应迟迟没有结束,Nginx 可能在回调后立即删除了临时文件。正确做法是,后端必须在回调接口里同步处理完文件(读取或移动),再返回 200,否则等响应返回后再去读临时文件就是空。
这是我排过最久的一个坑。当时后端是异步任务,回调接口拿到路径直接丢队列,返回 200,结果异步任务处理时临时文件已经被清理了。后来把回调接口改成同步读完文件再返回,或者把 upload_cleanup 从清理列表中移除,才彻底解决。
6.3 504 Gateway Timeout
症状:后端处理上传回调的业务逻辑太慢,超过 Nginx 的 proxy 超时时间。
解决:
nginx复制location @upload_backend {
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
但这只是治标。根本原因是后端处理文件太慢,比如转码/解析大文件耗时高。建议在后端接口里只做“移动文件 + 记录元数据 + 返回成功”,把真正的解析、转码放到消息队列异步处理,这样回调接口返回快,前端体验也好。
6.4 磁盘空间被临时文件占满
症状:磁盘报警,/data/upload_tmp 下大量文件堆积。
预防措施:
- 后端成功处理文件后,务必
unlink或移动走临时文件。 - 设置
upload_cleanup 400 404 499 500-505;,让失败请求的临时文件被 Nginx 自动清理。 - 写一个定时脚本来清理超过 24 小时没有变化的临时文件:
bash复制find /data/upload_tmp -type f -mtime +1 -delete
这个脚本可以放 crontab 里每天跑一次。千万不要把临时目录直接放 /tmp,很多系统的 tmpwatch 服务会清理 /tmp 下超过 10 天的文件,导致正在处理的回调找不到文件。
6.5 上传过程中连接断掉,临时文件残留
症状:用户上传到一半断网,客户端可能收到了连接重置,但服务器临时目录里多了一个不完整文件。
原因:nginx-upload-module 只有收到完整请求体才会执行 upload_pass,断连时不会触发清理。这个和 upload_cleanup 没关系,后者是后端返回状态码后的事。
解决方法:定期清理 stale 文件,逻辑上就是上面那个 find -mtime 脚本。还可以在文件名中加入会话信息,便于回溯。
6.6 Nginx 编译后无法启动或报 unknown directive
症状:配置写好,nginx -t 报 unknown directive "upload_pass"。
原因:模块没有编译进当前运行的 Nginx。解决办法是确认模块编译成功,并且当前运行的就是你编译出来的那一个二进制:
bash复制which nginx
nginx -V
如果 nginx -V 输出里没有 --add-module=../nginx-upload-module,说明模块没生效。还有一种可能是你编译的是 /usr/local/nginx,而系统里另有一个 /usr/sbin/nginx,注意路径混乱问题。
7. 进阶玩法与替代方案对比
7.1 与 Lua 方案对比:nginx-upload-module VS lua-resty-upload
如果你已经用了 OpenResty,可能会问:OpenResty 的 lua-resty-upload 是不是更现代?对比一下:
| 维度 | nginx-upload-module | lua-resty-upload |
|---|---|---|
| 配置复杂度 | 低,纯配置解决 | 中,需要写 Lua 脚本 |
| 文件落盘后的处理 | 文件路径作为字段传给后端 | 需要自己写 Lua 逻辑处理 |
| 对后端的侵入性 | 后端只收小字段,清晰 | 后端可能需要配合 Lua 结果 |
| 生态活跃度 | 一般,维护版仍在更新 | 高,OpenResty 生态活跃 |
| 适用场景 | 快速给现有 Nginx 架构加上上传分流 | 已有 OpenResty 体系,需要更细粒度控制的场景 |
我个人的选择标准是:如果系统里还没有 OpenResty,没必要为了上传这件事引入一整套 Lua 运行时。nginx-upload-module 的配置短期见效,后端只要按约定读取参数就行,排查问题也简单。
7.2 与普通 Nginx 直接 proxy_pass 到后端的对比
有人会说,不加模块,Nginx 直接 proxy_pass 到后端,后端框架自己处理 multipart 不行吗?如果你只是传几 MB 的小文件,当然没毛病,配置上也省事。但大文件场景下,后端还是要完整接收整个请求体,所有的内存、超时问题原样存在,Nginx 只是当一个透明的通道。而 nginx-upload-module 改变了整个处理模型,让后端不再碰大流量数据。这是本质区别。
7.3 生产环境还要考虑什么
除了上面配置里的参数,上线前建议把下面几件事一起做了:
- 临时目录单独挂载磁盘,不要和系统盘共用,避免临时文件耗尽根分区导致系统异常。
- 上传接口加独立限流。Nginx 的
limit_req对上传接口也要生效,防止恶意并发把带宽和磁盘写满。 - 回调接口做幂等处理。如果 Nginx 重试子请求,后端接收同一个临时路径可能会有重复处理,所以最好给每个上传生成唯一 ID,后端落库时做唯一约束。
- 监控临时目录的文件数量、磁盘使用率、回调接口的响应耗时。这是我上线后的标准监控项。
8. 一键部署脚本:编译、配置、启动全流程串起来
最后放一个我平时用来快速搭建测试环境的脚本,你可以根据自己的路径调整。脚本假设你是 Ubuntu 或 CentOS 类系统,且以 root 或 sudo 身份运行。
bash复制#!/bin/bash
NGINX_VERSION="1.24.0"
UPLOAD_MODULE_REPO="https://github.com/fdintino/nginx-upload-module.git"
UPLOAD_TMP_DIR="/data/upload_tmp"
set -ex
# 安装编译依赖
if command -v apt-get > /dev/null 2>&1; then
apt-get update
apt-get install -y build-essential libpcre3-dev libssl-dev zlib1g-dev git wget
elif command -v yum > /dev/null 2>&1; then
yum install -y gcc gcc-c++ pcre-devel zlib-devel openssl-devel git wget
fi
# 下载源码
wget -q http://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz
tar zxf nginx-${NGINX_VERSION}.tar.gz
git clone ${UPLOAD_MODULE_REPO} nginx-upload-module
cd nginx-${NGINX_VERSION}
./configure \
--prefix=/etc/nginx \
--sbin-path=/usr/sbin/nginx \
--modules-path=/usr/lib/nginx/modules \
--conf-path=/etc/nginx/nginx.conf \
--error-log-path=/var/log/nginx/error.log \
--http-log-path=/var/log/nginx/access.log \
--pid-path=/var/run/nginx.pid \
--with-http_ssl_module \
--with-http_v2_module \
--with-http_gzip_static_module \
--add-module=../nginx-upload-module
make -j$(nproc)
make install
# 创建临时目录
mkdir -p ${UPLOAD_TMP_DIR}
chown nginx:nginx ${UPLOAD_TMP_DIR}
chmod 750 ${UPLOAD_TMP_DIR}
# 校验 nginx 是否带上了模块
nginx -V 2>&1 | grep upload || echo "upload module not found"
脚本跑完后再把前面的 nginx.conf 配置写上,基本上就能完成一个可用的上传端。注意这里 nginx 用户可能不存在于某些系统,如果执行 chown nginx:nginx 报错,先使用 useradd nginx 创建用户再执行。
这套方案我前后在三个项目里用过,线上最长稳定运行超过一年,上传量累计几十 TB,几乎没有出过问题。如果非要说有什么遗憾,就是模块的文档确实陈旧,很多参数得靠读源码和试错去理解,希望你看到这篇文章能少走点弯路。
