返回文章列表
技术文章2026年7月3日·20 次阅读

一次线上媒体资源失效排查:COS 签名、跨域与挂载模式

这次 SummerField 的线上问题很典型:后台发布动态时,图片和媒体文件明明已经成功上传到了对象存储桶,但前台访问时却加载失败。更绕的是,切换不同访问模式后,问题表现还不一样:

  • 使用 cos-signed 后,图片可以正常访问,但音频因为跨域被浏览器拦截。
  • 使用 mounted 后,图片上传完成后第一次访问失败,执行 pm2 reload 之后又能正常加载。
  • 服务端构建期间还遇到了内存不足导致 next build 被系统杀掉的问题。

这篇文章记录一次完整排查过程,也沉淀一下 Next.js、PM2、Nginx、COS 挂载模式组合部署时需要注意的边界。

背景

SummerField 的媒体资源支持两种存储方式:

  • 本地 public 目录,比如 /uploads/.../audio/...
  • 腾讯 COS 对象存储,比如 summer-field/audio/xxx.mp3

线上环境使用的是腾讯 COS。数据库里保存的是稳定 object key,接口返回给前端之前,再根据环境变量转换成可访问 URL。

关键配置大概是这样:

OBJECT_STORAGE_PROVIDER=tencent-cos
OBJECT_STORAGE_ACCESS_MODE=cos-signed
TENCENT_COS_BUCKET=summer-field-1305015674
TENCENT_COS_REGION=ap-beijing
TENCENT_COS_UPLOAD_PREFIX=summer-field

问题出现时,我已经确认文件确实存在于存储桶内,所以方向不再是“上传有没有成功”,而是:

数据库里的 key 最终被转换成了什么 URL?浏览器有没有权限访问这个 URL?

第一阶段:文件在桶里,但前端打不开

排查这类问题,第一步不要看页面表现,先看接口实际返回什么。

curl -s "https://linche.me/api/public/tracks"
curl -s "https://linche.me/api/public/moments?limit=1"

如果返回的是这种值:

summer-field/audio/xxx.mp3

说明接口没有把 COS key 转成可访问 URL,通常是环境变量不对,或者线上代码没有重新 build。

如果返回的是这种值:

https://summer-field-1305015674.cos.ap-beijing.myqcloud.com/summer-field/audio/xxx.mp3?...

说明签名 URL 已经生成了。接下来就要把这个 URL 单独拿到浏览器或 curl 里测试。

这次图片在 cos-signed 下可以访问,说明签名生成逻辑本身没问题。但音频播放时报错:

Access to audio at 'https://xxx.cos.ap-beijing.myqcloud.com/...' from origin 'https://linche.me' has been blocked by CORS policy.
No 'Access-Control-Allow-Origin' header is present on the requested resource.

浏览器请求音频时通常会带 Range 头,COS 返回 206 Partial Content。如果存储桶没有正确配置 CORS,即使签名 URL 是有效的,浏览器也会拦截响应。

可以这样验证:

curl -I \
  -H "Origin: https://linche.me" \
  -H "Range: bytes=0-1" \
  "https://your-cos-domain/summer-field/audio/xxx.mp3?signature=..."

正常情况下响应头里应该有:

HTTP/1.1 206 Partial Content
Access-Control-Allow-Origin: https://linche.me
Content-Range: bytes 0-1/...
Accept-Ranges: bytes

但当前使用的轻量存储暂时不支持配置 COS 跨域,所以 cos-signed 对图片没问题,对音频和视频就不够稳。

第二阶段:要不要单独开一个支持 CORS 的桶

当时有三个选择。

第一种是新建一个支持 CORS 的标准 COS 桶,只存音频和视频。这样图片继续留在旧桶,音视频走新桶。

这个方案可行,但后期维护成本会上来:

  • 代码需要按文件类型或目录选择不同 bucket。
  • 数据库里只存 summer-field/audio/xxx.mp3 这种 key 时,单看 key 分不出资源在哪个桶。
  • 删除资源时也要知道它属于哪个桶,否则容易漏删。
  • 后续排查会变成“图片桶一套规则,音视频桶一套规则”。

第二种是把所有媒体都迁到一个支持 CORS 的标准 COS 桶。这个方案长期最干净,图片、音频、视频、封面、头像都走同一套配置。

第三种是使用挂载模式,让浏览器访问同源地址:

OBJECT_STORAGE_ACCESS_MODE=mounted
TENCENT_COS_MOUNT_PUBLIC_PATH=/lighthouse

在这个模式下,数据库里仍然保存:

summer-field/audio/xxx.mp3

接口返回给前端时会变成:

/lighthouse/summer-field/audio/xxx.mp3

浏览器访问的是 https://linche.me/lighthouse/...,它和站点同源,因此不再触发 COS 跨域问题。

最后我选择继续使用 mounted,因为它能绕过当前轻量存储不支持 CORS 的限制,也不需要立刻拆成双桶。

第三阶段:mounted 为什么上传后还要 PM2 reload

切到 mounted 后,又出现了一个很容易误判的问题:

上传图片成功后,文件在挂载目录里存在,但页面仍然加载失败。执行 npm run pm2:reload 后,图片又能加载了。

这说明问题不是上传,也不是路径生成,而是运行时服务文件的方式。

挂载模式下,资源路径类似:

/lighthouse/summer-field/moments/xxx.webp

如果 /lighthouse 位于项目的 public/lighthouse 下,而请求最终由 next start 处理,那么它本质上是在让 Next 生产服务托管 public 里的静态文件。

但这些文件不是 build 前就存在的,而是运行中通过 COS 挂载目录动态出现的。Next 生产服务对 public 目录的动态新增文件并不适合作为长期依赖。于是就出现了:

上传成功 -> 文件出现 -> Next 当前进程不一定能稳定返回 -> pm2 reload 后重新加载 -> 文件可访问

pm2 reload 不是修好了存储,而是重启了 Next,让它重新看到这些静态资源。

这也是为什么不能把“每次上传后重启 PM2”当成解决方案。它只是刷新了运行状态,不能作为稳定架构。

最终方案:让 Nginx 直接托管挂载目录

真正稳定的处理方式是:

/lighthouse/ 这类动态挂载资源不要交给 Next,直接让 Nginx 从挂载目录读取。

Nginx 配置可以这样写:

location ^~ /lighthouse/ {
    alias /www/wwwroot/linche.me/summer-field/public/lighthouse/;
    try_files $uri =404;
 
    add_header Cache-Control "public, max-age=31536000, immutable";
}

注意两点:

  • location ^~ /lighthouse/ 末尾有 /
  • alias .../public/lighthouse/ 末尾也有 /

这样浏览器访问:

https://linche.me/lighthouse/summer-field/audio/xxx.mp3

会由 Nginx 直接读取:

/www/wwwroot/linche.me/summer-field/public/lighthouse/summer-field/audio/xxx.mp3

请求不再经过 Next,也就不需要上传后重启 PM2。

修改后检查并重载 Nginx:

nginx -t && systemctl reload nginx

再测试资源是否能访问:

curl -I "https://linche.me/lighthouse/summer-field/audio/xxx.mp3"
curl -I -H "Range: bytes=0-1" "https://linche.me/lighthouse/summer-field/audio/xxx.mp3"

同源挂载模式下,音频请求不再跨域,Range 请求也由 Nginx 直接处理。

部署过程中的另一个坑:next build 被系统杀掉

这次更新线上环境时,还遇到过 PM2 502:

Error: Could not find a production build in the '.next' directory.

原因是 next build 中途被系统杀掉了,没有生成完整 .next 产物。服务器日志里能看到 OOM:

dmesg -T | tail -n 80 | grep -i -E "killed process|out of memory|oom"

机器只有 1.7 GiB 内存,swap 也只有 1 GiB。Next 构建到后半段时内存不够,就会出现:

Compiled successfully
Killed

这种不是代码错误,而是内存不够。解决方式是扩 swap,并在构建时限制 Node 内存:

pm2 stop summer-field
 
swapoff /swapfile
rm -f /swapfile
 
fallocate -l 4G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
 
export NODE_OPTIONS="--max-old-space-size=1536"
npm run build
npm run pm2:reload

构建完成后再确认:

pm2 list
curl -I http://127.0.0.1:3000

如果 127.0.0.1:3000 没有响应,Nginx 前面再怎么配置也会返回 502。

我的排查清单

以后再遇到“文件上传成功但页面打不开”,我会按这个顺序查。

1. 看接口返回的最终 URL

curl -s "https://linche.me/api/public/tracks"
curl -s "https://linche.me/api/public/moments?limit=1"

判断返回的是:

  • COS key:说明接口没转换。
  • COS 签名 URL:继续查签名和 CORS。
  • /lighthouse/...:继续查挂载目录和 Nginx alias。

2. 看本地服务是否还活着

pm2 logs summer-field --lines 100
curl -I http://127.0.0.1:3000

如果本地 3000 端口不通,线上域名 502 是正常结果。

3. 看挂载目录是否真有文件

ls -l /www/wwwroot/linche.me/summer-field/public/lighthouse/summer-field/audio/
ls -l /www/wwwroot/linche.me/summer-field/public/lighthouse/summer-field/moments/

如果文件不存在,就不是 Next 或 Nginx 的问题,而是 COS 挂载没有同步到这个路径。

4. 看 Nginx 是否绕过了 Next

curl -I "https://linche.me/lighthouse/summer-field/audio/xxx.mp3"

如果修改了 Nginx 配置,记得:

nginx -t && systemctl reload nginx

5. 看生产构建是否完整

npm run build
pm2 logs summer-field --lines 100

如果看到:

Could not find a production build in the '.next' directory

就是 .next 构建产物不存在或不完整,先重新 build。

这次的结论

“文件在桶里”只说明写入成功,不代表浏览器一定能读到。前端能不能加载,取决于接口最终返回的 URL、浏览器同源策略、对象存储 CORS、Nginx 静态资源规则,以及 Next 生产服务是否适合托管这类动态新增文件。

这次最关键的几个判断是:

  • cos-signed 可以解决私有桶访问,但音视频会遇到 CORS 和 Range 请求问题。
  • 如果存储服务不支持 CORS,mounted 是一个可行替代方案。
  • mounted 要稳定使用,最好让 Nginx 直接托管挂载目录,不要依赖 Next 的 public 静态资源处理。
  • pm2 reload 后资源能访问,说明问题大概率是运行时静态文件服务刷新了,不代表架构已经稳定。

最终我选择:

OBJECT_STORAGE_PROVIDER=tencent-cos
OBJECT_STORAGE_ACCESS_MODE=mounted
TENCENT_COS_MOUNT_PUBLIC_PATH=/lighthouse

并让 Nginx 直接处理:

/lighthouse/*

这样图片、音频和视频都走站点同源路径,绕开 COS 跨域限制,也避免每次上传后重启 PM2。