一次从 413 到 500、再回到正常的完整排障记录。涉及 nginx 反向代理、SvelteKit adapter-node 运行时、以及业务代码各自的 body 大小上限。
0. 现象
后台管理系统的媒体库上传一张 4.3MB 的 JPG,前端提示"文件太大"。
三个让人困惑的点:
- 业务代码里明明写着
MAX_FILE_SIZE = 10MB,4.3MB 远没到上限; - 翻遍
/etc/nginx/conf.d/下的配置文件,一条大小限制都没找到; - 给 nginx 加完限制后,错误从 413 变成了 500。
最后发现,请求体在到达业务代码之前,要穿过三层各自独立的大小限制,而每一层都会改写错误码。
链路大致是这样:
浏览器 → nginx(反向代理) → Node(SvelteKit adapter-node) → 业务代码
① 1m 默认 ② 512K 默认 ③ 10MB
任何一层拒绝,请求都到不了下一层。排查时最容易迷路的地方在于:后一层的日志里根本没有这次请求,因为请求压根没走到那儿。
1. 第一层:nginx —— "找不到限制"本身就是答案
关键认知
client_max_body_size 没写在配置文件里,不等于不限制。nginx 的内置默认值是 1m(1MB)。配置文件里 grep 不到它,正是因为它没有被显式声明。
想不限制要显式写
client_max_body_size 0;,不写就是 1m。
别只翻 conf.d,要看真实生效的全量配置
nginx -T 2>/dev/null | grep -n "client_max_body_size"
# 没有任何输出
nginx -T 会 dump 出主配置 + 所有 include 展开后的完整配置,比逐个文件翻找可靠得多。本例里主配置 nginx.conf 的 http 块、各 server 块都没有这条指令,所以全局生效的就是默认值 1m。
日志实锤
# /var/log/nginx/error.log
client intended to send too large body: 4503763 bytes,
server: admin.example.com, request: "POST /api/upload HTTP/1.1"
# /var/log/nginx/access.log
"POST /api/upload HTTP/1.1" 413 585
4503763 ≈ 4.3MB > 1MB,nginx 在收 body 阶段直接返回 413。
修复
在对应的 server 块里加一行,值要大于业务上限,给 multipart/form-data 的边界、编码留出余量:
server {
listen 80;
server_name admin.example.com;
client_max_body_size 12m; # 业务上限 10MB,留 2MB 余量
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
nginx -t && nginx -s reload
这一层的坑
- 只看
conf.d不够:主配置、默认 server 块、其它 include 都可能设值,nginx -T才是真相。 - 默认值查不到:1m 是编译进 nginx 的内置默认,不会出现在任何配置文件里。
- 作用域选 server 而不是 http:放在
nginx.conf的http块会影响同机所有站点,这里只需要放行后台。 proxy_request_buffering默认开启:nginx 会先把整个 body 收完再转发给上游。所以 413 阶段请求完全没到 Node,PM2 日志里查不到这次上传是正常的,别去后端白找。
2. 第二层:改完 nginx,怎么变成 500 了
reload 之后 4.3MB 的请求不再 413,改报 500。看 PM2 错误日志:
[ERROR] [upload] 内部错误: SvelteKitError:
Content-length of 4503763 exceeds limit of 524288 bytes.
at Object.start (.../@sveltejs/kit/src/exports/node/index.js)
at get_raw_body (...)
at getRequest (...)
{ status: 413, text: 'Payload Too Large' }
524288 = 512 × 1024,也就是 512KB。原来 SvelteKit 的 adapter-node 自己也有一道 body 上限。
默认 512K 藏在哪
在构建产物里(以 SvelteKit 2.x / adapter-node 5.x 为例):
// .svelte-kit/adapter-node/entries/handler.js
import { env } from './env.js';
const body_size_limit = parse_as_bytes(env('BODY_SIZE_LIMIT', '512K'));
// ...
bodySizeLimit: body_size_limit
env() 读的是 process.env,兜底值就是 '512K':
function env(name, fallback) {
const prefixed = env_prefix + name;
return prefixed in process.env ? process.env[prefixed] : fallback;
}
单位解析支持 K / M / G(1024 进制):
function parse_as_bytes(value) {
const multiplier =
{ K: 1024, M: 1024 * 1024, G: 1024 * 1024 * 1024 }
[value[value.length - 1]?.toUpperCase()] ?? 1;
return Number(multiplier != 1 ? value.substring(0, value.length - 1) : value) * multiplier;
}
最重要的一个结论:这是运行时读环境变量,不是构建期写死的常量。所以修复不需要重新 vite build,改启动环境 + 重启进程即可。
顺带一提:adapter-node 确实会把
bodySizeLimit传给getRequest(),但传的值是它自己从BODY_SIZE_LIMIT读出来的,并没有采用svelte.config.js里的kit.bodySizeLimit。所以生产端想改上限,直接设环境变量,别去改 SvelteKit 配置。
413 为什么会显示成 500
get_raw_body 返回的是一个 lazy 的 ReadableStream,超限异常要等真正读 body 时才抛出。业务代码调用 request.formData() 触发读取,抛出的 SvelteKitError(413) 落进了兜底 catch:
export const POST: RequestHandler = async ({ request }) => {
try {
const formData = await request.formData(); // ← 这里抛出 status 413
// ...
} catch (err) {
if (err instanceof UploadError) {
return json({ error: err.message, code: err.code }, { status: 400 });
}
logger.error('[upload] 内部错误:', err);
return json({ error: '服务器内部错误' }, { status: 500 }); // ← 413 被吞成 500
}
};
于是框架给出的 413,被降级成了"服务器内部错误",前端只能看到 500,排查方向一度被引到后端业务逻辑上。
教训:兜底 catch 不要无差别地把框架抛出的 HTTP 错误改成 500。
修复
PM2 的 ecosystem.config.cjs 里加一个环境变量:
module.exports = {
apps: [
{
name: 'admin',
script: 'build/index.js',
env: {
NODE_ENV: 'production',
PORT: 3000,
BODY_SIZE_LIMIT: '12M', // ← 新增,12582912 bytes
// ...其它环境变量
}
}
]
};
# 注意传配置文件本身,才会重新读取 ecosystem 里改过的 env
pm2 restart ecosystem.config.cjs --update-env
# 持久化到 dump,否则机器重启 / PM2 复活后又会退回 512K
pm2 save
这一层的坑
pm2 restart <app_name> --update-env是用当前 shell 环境刷新进程;改了ecosystem.config.cjs后,要把配置文件作为参数传给restart才会重新读取文件里的env。- 一定要
pm2 save。--update-env改的是运行中的进程,不写回 PM2 的 dump,重启就丢。 env()取的是env_prefix + name这个 key。前缀来自adapterNode({ envPrefix })选项(默认空字符串),不是kit.env.publicPrefix;若设置过它,变量名要写成<前缀>BODY_SIZE_LIMIT。用grep env_prefix build/env.js可直接查到构建产物里的实际值(本项目为"")。- 验证进程是否真的拿到了值:
tr '\0' '\n' < /proc/$(pm2 pid admin)/environ | grep BODY_SIZE_LIMIT
# BODY_SIZE_LIMIT=12M
3. 第三层:业务代码
export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
if (file.size > MAX_FILE_SIZE) {
return { error: `文件过大: ${(file.size / 1024 / 1024).toFixed(2)}MB,上限 10MB` };
}
这层从头到尾都没问题,但它是整条链路的上限基准:nginx 和 SvelteKit 的值都必须 ≥ 它,并且要额外留出 multipart 编码的开销。任何一层比它小,业务里那句友好的中文提示就永远没机会显示出来。
4. 三层对齐后的结果
| 层 | 限制来源 | 默认值 | 修后 | 是否要重新构建 |
|---|---|---|---|---|
| nginx | client_max_body_size | 1m | 12m | 否,nginx -s reload |
| SvelteKit adapter-node | BODY_SIZE_LIMIT | 512K | 12M | 否,运行时环境变量 |
| 业务代码 | MAX_FILE_SIZE | 10MB | 10MB(未动) | — |
两个"没写就是有限制"的默认值(1m / 512K),正好是这次故障的全部根因。
5. 怎么确认真的通了,而不是"以为通了"
这一步比改配置更容易出错,因为探针请求很容易给出假阳性。
陷阱:用管道喂 curl 会绕过 Content-Length 预检查
# 这种写法是 chunked 传输,没有 Content-Length
head -c 5000000 /dev/zero | curl -X POST --data-binary @- http://127.0.0.1/probe
- nginx 和 SvelteKit 基于
Content-Length的预检查会被跳过; - 如果目标路由不消费 body(比如一个没有 form actions 的页面路由),流式累加的检查也不会触发;
- 结果可能返回一个看起来"没被拦"的 405,让你误以为已经修好了。
正确姿势:带 Content-Length,且打到会真正读 body 的端点
用文件让 curl 自动带上 Content-Length,并选一个免鉴权、且会读取 body 的接口(登录接口通常合适;被 hooks 短路的接口不行,因为它根本不读 body)。
一个小技巧:JSON 允许前导空白,所以用「5MB 空格 + 一段合法 JSON」既能把体积撑大,又能在放行后让解析成功,从而把"被大小限制拦下"和"正常进入业务逻辑"区分开:
node -e "require('fs').writeFileSync('/tmp/probe.json',
' '.repeat(5000000) + JSON.stringify({email:'x',password:'y'}))"
curl -s -w "\nHTTP %{http_code}\n" -X POST \
-H 'Content-Type: application/json' \
--data-binary @/tmp/probe.json \
http://127.0.0.1/api/admin/login
本文示例的登录接口恰好是 const body = await request.json().catch(() => null),参数不合法时返回 400「请填写邮箱和密码」。这个细节正好能当探针:body 读取一旦失败,会被 .catch 吞成 null,直接落到参数校验分支。
判读方式:
- 返回 401「邮箱或密码错误」 → body 已完整读入,走到了登录逻辑,限制已放行;
- 返回 400「请填写邮箱和密码」 → body 读取被拒,解析失败被吞成
null,说明大小限制还在生效。
再配合三项静态检查:
nginx -T 2>/dev/null | grep client_max_body_size # 生效值
pm2 env 0 | grep BODY_SIZE_LIMIT # 进程环境
grep -i "too large\|exceeds limit" /var/log/nginx/error.log ~/.pm2/logs/*-error.log | tail
最后让真实用户在页面上重传一次,看两端日志是否干净。
6. 还留着的一个小坑
更正一处:三层对齐后,超过 12M 的文件不会变成 500。nginx 的 12m 和 SvelteKit 的 12M 都是 12582912 字节,超限时最外层的 nginx 直接返回 413,请求根本到不了
Node。第 2 节那个「413 被兜底 catch 吞成 500」只出现在修好 nginx、还没修 SvelteKit 的中间状态。
给 catch 加 413 判断因此是防御性改动:只有以后 nginx 上限调大却没同步调 BODY_SIZE_LIMIT、或绕过 nginx 直连 3000 时才会触发。真要改就写:
} catch (err) {
if (err instanceof UploadError) {
return json({ error: err.message, code: err.code }, { status: 400 });
}
if (err instanceof Error && 'status' in err && err.status === 413) {
return json({ error: '文件过大,请压缩后重试' }, { status: 413 });
}
logger.error('[upload] 内部错误:', err);
return json({ error: '服务器内部错误' }, { status: 500 });
}
这属于代码改动,要重新构建 + 重启服务,风险比改配置大。线上紧急修复时不要把它和前两步混在一起做——先让上传能用,再单独排期改错误处理。
另外,当前超限前端拿到的是 nginx 默认的英文 HTML 413 页面而非 JSON,fetch().json() 会解析失败。想返回友好的 JSON 413,得让 SvelteKit 当守门员(nginx 上限设得比 BODY_SIZE_LIMIT 大),或在 nginx 层用 error_page 413 返回 JSON。
7. 小结:一份可复用的 checklist
- "配置文件里没写限制" ≠ 没有限制,先查框架 / 中间件的内置默认值
- 看 nginx 用
nginx -T,别只翻conf.d - 请求没到后端时,后端日志查不到很正常(
proxy_request_buffering) - 反向代理、框架运行时、业务代码,三层上限要对齐且留余量
- 兜底
catch不要吞掉框架抛出的 HTTP 状态码 - 确认某个限制是「构建期常量」还是「运行时环境变量」,决定要不要重新构建
- 环境变量类修复记得
pm2 save持久化 - 验证探针要带
Content-Length,并打到真正会读 body 的端点 - 改完让真实用户走一遍完整链路
排查这类问题最有用的一个习惯是:先把请求从浏览器到业务代码的完整链路画出来,逐层问"这一层有没有自己的大小限制、默认值是多少、拒绝时返回什么"。错误码被层层改写时,顺着链路从上往下对,比盯着某一个报错文案猜要快得多。