一次线上媒体资源失效排查: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 nginx5. 看生产构建是否完整
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。