解决线上头像上传 500 的问题(被清空的 RustFS 凭据)
2026-10-26 17:12:11

线上头像上传固定失败,返回 500 Internal server error;本地同一份代码却怎么传都成功。

中间还改过一版 nginx 配置,部署流水线是绿的,问题依旧。

事后发现,一次 500 背后叠着两个独立的故障:RustFS 凭据被部署流水线清空,以及 nginx 改动从未上线。

把排查过程整理下来,算是给下次遇到”本地好好的、线上不对”留条能照着走的路径。

正文

现象

1
2
3
4
5
$ curl -X POST http://<服务器IP>:8888/api/system/user/profile/avatar \
-H "Authorization: Bearer <token>" \
-F "avatarfile=@avatar.png"

{"statusCode":500,"message":"Internal server error"}

三个容易误判的前提:

  1. 本地同一份代码一切正常;
  2. 上一轮改过 nginx(client_max_body_size 12m),且部署流水线是绿的;
  3. 错误体是标准 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
2
3
4
5
6
7
❌ RustFS 初始化失败: Valid and authorized credentials required

[Nest] ERROR [ExceptionsHandler] Valid and authorized credentials required
S3Error: Valid and authorized credentials required
at async Client.bucketExists (/app/node_modules/minio/…)
at async RustFSFileStorage.uploadFile (…rustfs-file-storage.service.js:115)
at async UploadService.singleFileUpload (…upload.service.js:54)

链路很清楚:上传接口 → 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
2
3
4
cat > .env << EOF
RUSTFS_ACCESS_KEY=${{ secrets.RUSTFS_ACCESS_KEY }}
RUSTFS_SECRET_KEY=${{ secrets.RUSTFS_SECRET_KEY }}
EOF

GitHub Actions 对不存在或为空的 Secret 不报错,直接渲染成空字符串。于是每次部署都会把原本可用的 .env 覆盖成坏版本。

这解释了本地与线上的矛盾:本地配置完整,线上坏的是”部署”这个动作本身——Secret 缺失,而流水线对此零校验、零提示。

止血:回填 .env 并重建容器

回填值从 1Panel 的实际配置里取(不手打,避免抄错):

1
2
3
4
5
cd /opt/deploy/nest-admin
cp .env ".env.bak.$(date +%Y%m%d%H%M%S)"
AK=$(grep -E '^RUSTFS_ACCESS_KEY=' /opt/1panel/apps/rustfs/rustfs/.env | cut -d= -f2- | tr -d "'\"")
sed -i "s|^RUSTFS_ACCESS_KEY=.*|RUSTFS_ACCESS_KEY=${AK}|" .env
docker compose up -d --no-deps server

注意:容器环境变量在创建时固化,改完 .env 只 restart 不够,必须重建。

重建后日志恢复 ✅ RustFS 文件存储初始化完成,小图上传立刻 201。

第二层:nginx 改了,但从未上线

定位:容器里跑的还是旧配置

止血后测大一点的图,又暴露一个问题:

链路 2MB 图上传
经 nginx :8888/api/... 413
直连后端 :8080/... 201

nginx 配置里明明有 client_max_body_size 12m,进容器核对:

1
2
$ docker exec nest-admin-vue3 grep -nE 'client_max_body_size' /etc/nginx/conf.d/default.conf
# 无输出 —— 容器里跑的还是旧配置

再看时间戳:镜像还是几个月前的,容器是三天前用旧镜像重建的。前一轮”部署成功”里,前端镜像压根没重建。

复现构建失败

手动执行部署里的构建命令:

1
2
3
#12 90.18 Error: ERR_PNPM_IGNORED_BUILDS
#12 90.18 ╰─▶ Ignored build scripts: @parcel/watcher@2.6.0, esbuild@0.27.7, vue-demi@0.14.10
failed to solve: process "/bin/sh -c pnpm install --frozen-lockfile" did not complete successfully: exit code: 1

pnpm 10 在 Docker 构建这种非交互环境下,遇到未批准的依赖构建脚本会直接失败。而工作流里有这样的兜底:

1
2
3
4
5
if docker compose build --no-cache admin-vue3; then
docker compose up -d admin-vue3
else
docker compose up -d --no-build admin-vue3 # 构建失败就保留旧前端
fi

初衷是不让前端构建失败阻塞后端上线,代价是构建失败被静音:流水线照样绿、页面照样开,配置悄悄没生效。

这是第二层”假装正常”,也是这次最花时间的部分——所有信号都在说”部署成功了”。

修复

三处改动,都在仓库里。

admin-vue3/package.json,批准这三个依赖的构建脚本:

1
2
3
"pnpm": {
"onlyBuiltDependencies": ["@parcel/watcher", "esbuild", "vue-demi"]
}

admin-vue3/Dockerfile,固定 pnpm 主版本,避免 latest 某天改行为:

1
2
RUN npm install -g pnpm@10
RUN pnpm install --frozen-lockfile

.github/workflows/deploy.yml,Secret 为空时兜底沿用旧值,并在日志标注来源(只标来源,不打印内容):

1
2
3
4
5
RUSTFS_ACCESS_KEY_VALUE="${{ secrets.RUSTFS_ACCESS_KEY }}"
if [ -z "$RUSTFS_ACCESS_KEY_VALUE" ]; then
RUSTFS_ACCESS_KEY_VALUE=$(grep -E '^RUSTFS_ACCESS_KEY=' .env | cut -d= -f2- || true)
echo "⚠️ GitHub Secret RUSTFS_ACCESS_KEY 为空,沿用现有 .env 中的 Access Key"
fi

已知取舍:兜底不校验值是否正确——若轮换凭据后忘了同步 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
2
3
4
5
KEY=$(docker exec <redis容器> redis-cli -a "$REDIS_PASSWORD" -n 2 --no-auth-warning \
--scan --pattern 'login_tokens:*' | head -1)
UUID=${KEY#login_tokens:}
TOKEN=$(docker exec -e UUID="$UUID" -e SECRET="$JWT_SECRETKEY" nest-admin-server \
node -e "console.log(require('jsonwebtoken').sign({uuid:process.env.UUID, userId:1}, process.env.SECRET))")

两点注意:这是越权手段,只用于自有服务器,用完不留 token;上传接口会真的写库——我的 admin 头像就被测试图覆盖了,最后查 sys_upload 找回真实记录还原。测试数据一定要清,否则下次排查先要面对自己上次制造的噪音。

结语

下次再遇到”本地好好的、线上不对”,按顺序问四个问题就够了:

  1. 这个错误是谁返回的——500 JSON 进了后端,413 HTML 被 nginx 拦在门口,502 上游不可达;
  2. 容器里的实际配置是什么——宿主机文件对不算数;
  3. 运行时是不是最新——看容器创建时间,别信镜像 tag 和流水线状态;
  4. 直连和经代理分别什么结果——分清后端问题和代理问题。

这次最难的地方不在技术,在信号被静音:

  • 403 被包装成 500,指向了错误的层;
  • 构建失败被兜底吞掉,指向了错误的状态。

把静音的信号重新打开——读容器里的实际配置、读日志里的原始异常、按链路分开测——问题就藏不住了。

另外,感谢 DeepSeek 协助整理这篇排障记录,把当晚零散的排查动作串成了一条能复走的路径。

最后的最后,再次感谢AI和这个好时代,本文大部分思路都是AI自己顺着链路追溯排查的,我个人几乎就是简单提问和定位了大概的方向。

对于报错定位这块,AI不仅迅速而且有效,节省了大部分人力和时间,为了了解发生了什么事,也是让AI写了这篇文档,供个人学习。

AI,太伟大了。

参考链接

本篇正文由 DeepSeek 的 AI 助手协助整理编辑,ZCODE的 GLM5.3辅助润色撰稿。