线上头像上传固定失败,返回 500 Internal server error;本地同一份代码却怎么传都成功。
中间还改过一版 nginx 配置,部署流水线是绿的,问题依旧。
事后发现,一次 500 背后叠着两个独立的故障:RustFS 凭据被部署流水线清空,以及 nginx 改动从未上线。
把排查过程整理下来,算是给下次遇到”本地好好的、线上不对”留条能照着走的路径。
正文
现象
1 | $ curl -X POST http://<服务器IP>:8888/api/system/user/profile/avatar \ |
三个容易误判的前提:
- 本地同一份代码一切正常;
- 上一轮改过 nginx(
client_max_body_size 12m),且部署流水线是绿的; - 错误体是标准 JSON,看起来像后端接口写错了。
实际上代码一行都没错,坏的是部署环节。
分清 500 是谁返回的
1 | {"statusCode":500,"message":"Internal server error"} |
这是 NestJS 未捕获异常的默认响应。
如果请求没到后端,nginx 返回的是 HTML。对照表:
| 响应体 | 出处 | 该看哪里 |
|---|---|---|
{"statusCode":500,…} |
NestJS 未捕获异常 | 后端容器日志里的 stack |
<html>…413 Request Entity Too Large…</html> |
nginx | 前端容器 nginx 配置、镜像是否最新 |
502 Bad Gateway |
nginx | 上游容器是否存活 |
于是不用再看 nginx,直接去后端要 stack:
1 | ❌ RustFS 初始化失败: Valid and authorized credentials required |
链路很清楚:上传接口 → RustFS → bucketExists 403。
对象存储认为凭据无效,代码把异常原样抛出,Nest 兜底成 500——症状和病因之间隔了一层默认异常处理,这是第一层”假装正常”。
第一层:被清空的 RustFS 凭据
比对配置:只看长度和哈希
要确认到底是”凭据错了”还是”根本没读到凭据”,三处都要看:
- 服务器上的
/opt/deploy/nest-admin/.env(部署生成) - 1Panel 里 RustFS 应用自己的
.env(真实凭据) - 后端容器环境变量(最终生效值)
不打明文,用”长度 + sha256 前 12 位”比对就够定位:
1 | hashval() { printf %s "$1" | sha256sum | cut -c1-12; } |
| 来源 | RUSTFS_ACCESS_KEY |
RUSTFS_SECRET_KEY |
|---|---|---|
服务器 .env |
空(长度 0) ← 问题 | 正常 |
| 1Panel RustFS 应用 | 正常 | 正常 |
| 后端容器环境变量 | 空 | 正常 |
Secret 在、Access Key 是空串,MinIO 客户端用空 AK 签名,服务端当然回 Valid and authorized credentials required。
顺带用 curl 裸访问一次 S3 端口,未认证返回
403,说明服务本身活着,可以排除”RustFS 挂了”这个方向。
空值哪来的:流水线每次都覆盖 .env
.env 不是手工维护的,而是 deploy.yml 在服务器上现生成的:
1 | cat > .env << EOF |
GitHub Actions 对不存在或为空的 Secret 不报错,直接渲染成空字符串。于是每次部署都会把原本可用的 .env 覆盖成坏版本。
这解释了本地与线上的矛盾:本地配置完整,线上坏的是”部署”这个动作本身——Secret 缺失,而流水线对此零校验、零提示。
止血:回填 .env 并重建容器
回填值从 1Panel 的实际配置里取(不手打,避免抄错):
1 | cd /opt/deploy/nest-admin |
注意:容器环境变量在创建时固化,改完 .env 只 restart 不够,必须重建。
重建后日志恢复 ✅ RustFS 文件存储初始化完成,小图上传立刻 201。
第二层:nginx 改了,但从未上线
定位:容器里跑的还是旧配置
止血后测大一点的图,又暴露一个问题:
| 链路 | 2MB 图上传 |
|---|---|
经 nginx :8888/api/... |
413 |
直连后端 :8080/... |
201 |
nginx 配置里明明有 client_max_body_size 12m,进容器核对:
1 | $ docker exec nest-admin-vue3 grep -nE 'client_max_body_size' /etc/nginx/conf.d/default.conf |
再看时间戳:镜像还是几个月前的,容器是三天前用旧镜像重建的。前一轮”部署成功”里,前端镜像压根没重建。
复现构建失败
手动执行部署里的构建命令:
1 | #12 90.18 Error: ERR_PNPM_IGNORED_BUILDS |
pnpm 10 在 Docker 构建这种非交互环境下,遇到未批准的依赖构建脚本会直接失败。而工作流里有这样的兜底:
1 | if docker compose build --no-cache admin-vue3; then |
初衷是不让前端构建失败阻塞后端上线,代价是构建失败被静音:流水线照样绿、页面照样开,配置悄悄没生效。
这是第二层”假装正常”,也是这次最花时间的部分——所有信号都在说”部署成功了”。
修复
三处改动,都在仓库里。
admin-vue3/package.json,批准这三个依赖的构建脚本:
1 | "pnpm": { |
admin-vue3/Dockerfile,固定 pnpm 主版本,避免 latest 某天改行为:
1 | RUN npm install -g pnpm@10 |
.github/workflows/deploy.yml,Secret 为空时兜底沿用旧值,并在日志标注来源(只标来源,不打印内容):
1 | RUSTFS_ACCESS_KEY_VALUE="${{ secrets.RUSTFS_ACCESS_KEY }}" |
已知取舍:兜底不校验值是否正确——若轮换凭据后忘了同步 Secret,写入的是”非空但错误”的值,兜底不会触发,只能靠日志里的来源标注定位。
验证
同一张图分两条路测,结果要一致:
| 验证项 | 修复前 | 修复后 |
|---|---|---|
70B 小图 → :8888/api/... |
500 | 201 + imgUrl |
2MB / 3MB 大图 → :8888/api/... |
413 | 201 + imgUrl |
大图直连 :8080/... |
201 | 201 |
取回头像 GET /profile/... |
200 | 200 image/png |
后端日志 S3Error |
每次上传都有 | 无 |
头像 URL 形如 /profile/nest-admin/avatar/xxx.png,由 nginx 反代到 RustFS。”上传成功”和”能显示”是两件事,两个都要验。
附:没有登录态时怎么测鉴权接口
排查时手上没有账号密码。鉴权逻辑是”JWT 验签 + 用 payload 里的 uuid 查 Redis”,所以从 Redis 拿一个会话 uuid,用容器里的 JWT_SECRETKEY 自己签一个合法 token:
1 | KEY=$(docker exec <redis容器> redis-cli -a "$REDIS_PASSWORD" -n 2 --no-auth-warning \ |
两点注意:这是越权手段,只用于自有服务器,用完不留 token;上传接口会真的写库——我的 admin 头像就被测试图覆盖了,最后查 sys_upload 找回真实记录还原。测试数据一定要清,否则下次排查先要面对自己上次制造的噪音。
结语
下次再遇到”本地好好的、线上不对”,按顺序问四个问题就够了:
- 这个错误是谁返回的——500 JSON 进了后端,413 HTML 被 nginx 拦在门口,502 上游不可达;
- 容器里的实际配置是什么——宿主机文件对不算数;
- 运行时是不是最新——看容器创建时间,别信镜像 tag 和流水线状态;
- 直连和经代理分别什么结果——分清后端问题和代理问题。
这次最难的地方不在技术,在信号被静音:
- 403 被包装成 500,指向了错误的层;
- 构建失败被兜底吞掉,指向了错误的状态。
把静音的信号重新打开——读容器里的实际配置、读日志里的原始异常、按链路分开测——问题就藏不住了。
另外,感谢 DeepSeek 协助整理这篇排障记录,把当晚零散的排查动作串成了一条能复走的路径。
最后的最后,再次感谢AI和这个好时代,本文大部分思路都是AI自己顺着链路追溯排查的,我个人几乎就是简单提问和定位了大概的方向。
对于报错定位这块,AI不仅迅速而且有效,节省了大部分人力和时间,为了了解发生了什么事,也是让AI写了这篇文档,供个人学习。
AI,太伟大了。
参考链接
本篇正文由 DeepSeek 的 AI 助手协助整理编辑,ZCODE的 GLM5.3辅助润色撰稿。