上传一张 4MB 图片,我先后撞上了三层请求体限制

一次从 413 到 500、再回到正常的完整排障记录。涉及 nginx 反向代理、SvelteKit adapter-node 运行时、以及业务代码各自的 body 大小上限。

0. 现象

后台管理系统的媒体库上传一张 4.3MB 的 JPG,前端提示"文件太大"。

三个让人困惑的点:

  1. 业务代码里明明写着 MAX_FILE_SIZE = 10MB,4.3MB 远没到上限;
  2. 翻遍 /etc/nginx/conf.d/ 下的配置文件,一条大小限制都没找到;
  3. 给 nginx 加完限制后,错误从 413 变成了 500。

最后发现,请求体在到达业务代码之前,要穿过三层各自独立的大小限制,而每一层都会改写错误码。

链路大致是这样:

markdown 复制代码
浏览器 → 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,要看真实生效的全量配置

bash 复制代码
nginx -T 2>/dev/null | grep -n "client_max_body_size"
# 没有任何输出

nginx -T 会 dump 出主配置 + 所有 include 展开后的完整配置,比逐个文件翻找可靠得多。本例里主配置 nginx.conf 的 http 块、各 server 块都没有这条指令,所以全局生效的就是默认值 1m。

日志实锤

lua 复制代码
# /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 的边界、编码留出余量:

nginx 复制代码
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;
    }
}
bash 复制代码
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 错误日志:

less 复制代码
[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 为例):

js 复制代码
// .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':

js 复制代码
function env(name, fallback) {
    const prefixed = env_prefix + name;
    return prefixed in process.env ? process.env[prefixed] : fallback;
}

单位解析支持 K / M / G(1024 进制):

js 复制代码
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:

ts 复制代码
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 里加一个环境变量:

js 复制代码
module.exports = {
  apps: [
    {
      name: 'admin',
      script: 'build/index.js',
      env: {
        NODE_ENV: 'production',
        PORT: 3000,
        BODY_SIZE_LIMIT: '12M',   // ← 新增,12582912 bytes
        // ...其它环境变量
      }
    }
  ]
};
bash 复制代码
# 注意传配置文件本身,才会重新读取 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 可直接查到构建产物里的实际值(本项目为 "")。
  • 验证进程是否真的拿到了值:
bash 复制代码
tr '\0' '\n' < /proc/$(pm2 pid admin)/environ | grep BODY_SIZE_LIMIT
# BODY_SIZE_LIMIT=12M

3. 第三层:业务代码

ts 复制代码
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 预检查

bash 复制代码
# 这种写法是 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」既能把体积撑大,又能在放行后让解析成功,从而把"被大小限制拦下"和"正常进入业务逻辑"区分开:

bash 复制代码
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,说明大小限制还在生效。

再配合三项静态检查:

bash 复制代码
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 时才会触发。真要改就写:

ts 复制代码
} 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 的端点
  • 改完让真实用户走一遍完整链路

排查这类问题最有用的一个习惯是:先把请求从浏览器到业务代码的完整链路画出来,逐层问"这一层有没有自己的大小限制、默认值是多少、拒绝时返回什么"。错误码被层层改写时,顺着链路从上往下对,比盯着某一个报错文案猜要快得多。

相关推荐
IT_陈寒23 分钟前
Vite的HMR把我整不会了:一个生产环境的热更新坑
前端·人工智能·后端
OLRainM30 分钟前
实习复盘:大模型网关渠道质量评分实战(二)—— 报错了到底算谁的锅?流式归因与 Token 补账踩过的 8 个真坑
后端
梦想恒远42 分钟前
Spring Boot + LangChain4j 统计 token 成本:ChatModelListener 实测与单价口径
spring boot·后端
努力的小郑2 小时前
数据查到了,回答去哪儿了?一次百炼 + LangChain 排查实录
后端·langchain·agent
isfox2 小时前
Python 虚拟环境:别再把所有项目塞进一个"大仓库"了
后端
易师傅3 小时前
技术面试已死?AI 时代,真正拉开程序员差距的是什么?
前端·后端·程序员
苍何3 小时前
办公 Agent 权威评测榜单出炉了,豆包第一
后端
AiMan3 小时前
别再把秘塔会话逐个点开:这可把秘塔多个会话一次导出?AI导出鸭
前端·后端·面试
nice_lcj5204 小时前
【从零理解 Spring AI · 12】MCP 的两种角色:做客户端还是做服务端
java·后端·spring·spring ai·ai应用开发