一个人做个人网站(六):OSS 直传全攻略——让文件流绕开你的服务器
这是整个系列最硬核的一篇。文件上传看起来是个小功能——一个 <input type="file"> 的事,但当文件从头像大小变成几百兆的视频,当存储从本地磁盘变成对象存储,一连串架构、安全、成本问题会同时冒出来。这一篇把我做「小欢喜」上传体系时趟过的整条链路完整摊开:为什么直传、STS 怎么发、RAM 权限怎么收、前端怎么分片续传、回退通道怎么留、图片地址为什么还要 Nginx 剥一层皮。
一、先算账:300MB 的视频为什么不能经过 ECS
最朴素的上传方案是"服务器中转":浏览器把文件 POST 给 Flask,Flask 再转存 OSS。小文件没问题,但视频场景下它有四宗罪:
- 带宽双倍计费。文件从用户到 ECS 走一遍公网入流量,ECS 再传到 OSS 走一遍(同地域内网虽免流量费,但用户到 ECS 这段要占服务器公网带宽);
- 占住一个 worker 十分钟。第 1 篇讲过,为了兼容中转大视频,Gunicorn 和 Nginx 的超时都被迫放宽到 600 秒。2 个 worker 的小站,两个人同时传视频,整个 API 就堵住了;
- 内存/磁盘风险。一个上传请求要在服务器落临时文件或攒内存,300MB 的并发上传会把小规格 ECS 的内存瞬间吃紧;
- 体验差。中转只能拿到整体 HTTP 进度,做不了分片级别的断点续传,网络一抖,传了 90% 的视频从头再来。
对象存储的标准答案是浏览器直传:文件字节流从浏览器直接到 OSS,你的 ECS 只负责发一张"限时通行证"。字节流不过服务器,上面四个问题全部消失。
二、三种直传方案,我选 STS 临时凭证
直传也有不同的安全姿态,演进三代:
| 方案 | 做法 | 问题 |
|---|---|---|
| 长期 AK 放前端 | 直接把 AccessKey 写进 JS | 等于把账号密码发给全世界,绝不可行 |
| 后端签名直传 | 后端用长期 AK 对每次上传签一个 policy/URL | 可用,但策略表达能力弱,分片上传、断点续传的签名很繁琐 |
| STS 扮演角色 | 后端调 AssumeRole 换一套临时、最小权限凭证下发浏览器 | 权限精确到目录和动作,SDK 原生支持分片/续传/自动续期 |
我选第三种。它的核心思想是让浏览器拿到的凭证"天生残废":有效期最长 1 小时,只能往 image/ 和 video/ 两个目录写,不能读、不能删、不能列举。即使这套凭证从浏览器里被扒走,攻击者能做的极限也只是在一小时内往两个目录灌文件——而对象路径由后端分配、文件有类型白名单、业务记录还没建,灌进来的只是无人引用的垃圾,生命周期规则会定期清理。
三、全链路时序:四次对话完成一次上传
① GET /api/admin/oss/capability → 直传开没开?(决定走直传还是中转)
② GET /api/admin/oss/credentials → 后端用长期 AK 扮演角色,下发 STS 临时凭证
③ POST /api/admin/oss/assign → 上报"文件名+类型",后端校验并分配对象 key
④ PUT/分片 浏览器 ───────────────────► OSS(字节流直传,ECS 不参与)
完成后前端把返回的 URL 连同文章/相册表单一起提交,业务记录才落库
注意第 ③ 步的设计:对象的 key(存储路径)永远由后端生成,客户端无权决定。这是直传安全里非常关键的一条——如果允许前端自己指定 key,攻击者可以覆盖任意路径下的已有文件(比如把别人文章的封面图替换掉),也可以用 ../../ 之类的名字制造混乱。后端按 目录/年月/uuid.扩展名 分配,文件名与对象路径彻底解耦,用户上传的文件叫什么都无所谓。
四、RAM 身份体系:三把钥匙,各有边界
阿里云侧的身份设计是这套方案的灵魂,一张图:
主账号(永远不用来调 API)
└─ RAM 子用户 web-server(持有长期 AccessKey,只存在后端 .env)
├─ 桶读写权限 → 后端中转上传、删除回收
└─ sts:AssumeRole → 允许扮演角色 oss-direct-upload
│
浏览器 ◄── STS 临时凭证(≤1 小时)─────┘
临时凭证的权限 = 角色策略 = 只能 Put image/* 和 video/*
挂在角色上的"只写策略"是整个体系的核心,每一行都有讲究:
{
"Version": "1",
"Statement": [
{
"Effect": "Allow",
"Action": ["oss:PutObject", "oss:AbortMultipartUpload", "oss:ListParts"],
"Resource": [
"acs:oss:*:*:self-web-lh/image/*",
"acs:oss:*:*:self-web-lh/video/*"
]
}
]
}
PutObject:图片简单上传;AbortMultipartUpload和ListParts:视频分片上传和断点续传的必需品,少一个续传就 403;- 刻意不给
GetObject(不能读)、DeleteObject(不能删)、ListBucket(不能列举); - Resource 用官方通配写法
acs:oss:*:*:<bucket>/<前缀>/*。这里有个坑:写成"精确地域+账号 ID"的形式反而会被 OSS RAM 引擎隐式拒绝,控制台策略摘要还会弹"无效授权"的黄三角——那是误报,以实际自检结果为准。
还有两个容易忽略的阿里云规则:主账号 AK 不能 AssumeRole,所以必须建 RAM 子用户;角色的"最大会话时间"上限是 1 小时,STS 有效期不能超过它。我把实际有效期设为 1800 秒(30 分钟),短命凭证的泄露窗口更小。
五、后端三个接口:探测、发证、分配
后端代码非常薄,重逻辑都在协议设计上。开关判定集中在一个函数里——三个条件同时满足才算直传可用:
def _direct_enabled():
cfg = current_app.config
return bool(
cfg['STORAGE_TYPE'] == 'oss'
and cfg.get('OSS_DIRECT_ENABLED')
and cfg.get('OSS_ROLE_ARN')
)
凭证接口只对登录管理员开放,失败记完整日志、给前端返回可提示的信息:
@bp.get('/credentials')
@login_required
def credentials():
creds = sts_service.assume_role()
region = sts_service.region_from_endpoint(cfg['OSS_ENDPOINT'])
return ok({
**creds,
'bucket': cfg['OSS_BUCKET'],
'region': f'oss-{region}', # SDK 只要 region,公网域名它自己拼
'cdnDomain': (cfg['OSS_CDN_DOMAIN'] or '').rstrip('/'),
})
分配接口负责类型白名单和 key 生成,校验在服务端重来一遍——前端的类型过滤只是体验,后端的白名单才是安全边界:
ext = os.path.splitext(filename)[1].lower()
if kind == 'video':
if ext not in cfg['ALLOWED_VIDEO_EXT']: # .mp4 .mov .webm .m4v
return fail(f'不支持的视频类型:{ext}')
folder = 'video'
else:
if ext not in cfg['ALLOWED_IMAGE_EXT']: # .jpg .jpeg .png .gif .webp .bmp
return fail(f'不支持的图片类型:{ext}')
kind, folder = 'image', 'image'
key = _build_key(folder, filename) # image/202609/<uuid>.jpg
thumb_key = _thumb_key(key) if folder == 'image' and ext in _IMAGE_EXTS else ''
assign 同时返回最终的 url 和 contentType。视频会显式标注 video/mp4 等 MIME——这是另一个实战细节:OSS 对象如果缺少正确的 Content-Type,浏览器访问时会触发下载而不是播放。
STS 服务本身做了两件工程化的事。一是进程内缓存:批量传十张图只应调一次 AssumeRole,加锁判断过期时间,提前 5 分钟就视为失效,避免凭证在上传途中到期:
with _cache_lock:
if _cache['creds'] and now < _cache['expire_ts'] - _REFRESH_LEEWAY:
return dict(_cache['creds'])
# ... 调用 AssumeRole 并写缓存
二是内网 endpoint 的剥后缀处理。ECS 上 OSS 走 -internal 内网域名免流量费,但 STS 根本没有 sts.cn-shanghai-internal.aliyuncs.com 这种地址,直接拼会 DNS 解析失败。所以从 oss-cn-shanghai-internal.aliyuncs.com 推导 region 时,先剥 oss- 前缀再剥 -internal 后缀:
prefix = (endpoint or '').split('.')[0] # oss-cn-shanghai-internal
return prefix[4:].removesuffix('-internal') # cn-shanghai
另外 STS SDK 和 oss2 全部延迟导入,这样本地开发用 local 存储模式时,不装阿里云 SDK 也能正常启动。
六、前端:一个统一入口,三种能力
前端我把所有上传逻辑收口到一个 uploadFile(file, opts),全站(封面、相册、编辑器插图、视频)只调它。它先探测能力,直传不可用时自动回退服务器中转,业务页面完全不感知存储模式:
export async function uploadFile(file, opts = {}) {
if (await isDirectEnabled()) {
return directUpload(file, opts) // 直传失败不自动回退,避免大视频重复占带宽
}
return relayUpload(file, opts) # 旧的 POST /api/admin/upload 通道
}
ali-oss SDK 用动态 import() 按需加载,不进主包;客户端单例缓存,并配置 refreshSTSToken 让长视频传几小时也不怕凭证过期——SDK 每隔 5 分钟或在过期前自动回调,向后端重新换一套凭证。
图片:Canvas 压缩,原图缩略图各传一次
直传不等于把原图甩给 OSS。手机拍的照片动辄 4000px、好几 MB,网页展示最长边 1920 完全够用。我在浏览器端用 Canvas 复刻了原来服务端的压缩语义:createImageBitmap 读入时按 EXIF 自动摆正(手机竖拍照旋转问题)、长边压到 1920(质量 0.85)、同时再生成一份 500px 缩略图(质量 0.8),PNG 保留透明通道,JPEG 先铺白底防黑边。然后原图和缩略图分别 PUT 到 key 和 thumbKey——相册瀑布流用缩略图,点开灯箱才加载原图,流量立省一个数量级。gif/bmp 等浏览器无法高质量编码的格式则原样直传,缩略图字段返回空串,前端自动跳传。
压缩失败时静默回退为原文件上传——压缩是优化,不能因为压缩异常让用户连图都传不了。
视频:5MB 分片 + localStorage 断点续传
视频走 ali-oss 的 multipartUpload,5MB 一片。真正的亮点是断点续传:SDK 每上传完一片都会回调一个 checkpoint(记录已传分片和 uploadId),我把它按"文件指纹"(文件名+大小+修改时间)存进 localStorage;下次重新选择同一个文件,读出 checkpoint 接着传,已传分片不重传。传完立即清除 checkpoint:
const ckStorageKey = `oss-upload-ck:${fingerprint(file)}`
let checkpoint = JSON.parse(localStorage.getItem(ckStorageKey) || 'null')
await client.multipartUpload(a.key, file, {
partSize: 5 * 1024 * 1024,
checkpoint,
progress: (p, cpt) => {
if (cpt) localStorage.setItem(ckStorageKey, JSON.stringify(cpt))
onProgress?.(Math.round(p * 100))
},
})
localStorage.removeItem(ckStorageKey)
文件指纹而不是只用文件名做 key,是为了防止同名不同文件误续传到错误的 uploadId 上。
七、回退通道与 kill switch:直传不是孤注一掷
直传链路依赖 STS 服务、RAM 配置、浏览器到 OSS 的 CORS,任何一个环节出问题都可能让上传瘫痪。我保留了老的中转接口 POST /api/admin/upload 作为永久回退,前端探测到 capability=false 自动切换,用 XHR 手动实现以拿到上传进度。
更重要的是留了一个服务端 kill switch:.env 里 OSS_DIRECT_ENABLED=false 重启后端,所有上传立刻回到中转链路,不用改代码、不用重新构建前端。直传上线初期我就是先开开关观察,确认稳定后才全量切换。注意一个取舍:直传过程中失败(比如 CORS 配错)我不自动回退中转——否则一个 300MB 视频会在用户不知情下同时吃掉 OSS 直传和服务器中转两份带宽,错误直接抛给 UI 提示重试更诚实。
八、文件能传上去,还要能正常显示:下载头之战
直传解决了"进","出"还有个 2025 年底才大规模踩到的坑:OSS 默认域名对所有响应强制附加 Content-Disposition: attachment 和 x-oss-force-download: true,应用层无法覆盖。后果是图片在浏览器里被强制下载、微信爬虫读不到 og:image,分享卡片彻底退化。
我的方案(第 1 篇亮过)是 Nginx 同域反代 /ossimg/:走上海内网 endpoint 回源(免外网流量费)、剥掉两个下载头、关缓冲让视频流式支持 Range 拖动、用本站自己的 HTTPS 证书对外:
location /ossimg/ {
proxy_pass http://self-web-lh.oss-cn-shanghai-internal.aliyuncs.com/;
proxy_set_header Host self-web-lh.oss-cn-shanghai-internal.aliyuncs.com;
proxy_hide_header Content-Disposition;
proxy_hide_header x-oss-force-download;
proxy_buffering off;
expires 30d;
}
后端拼 URL 时统一加 OSS_CDN_DOMAIN=https://wulala.cc/ossimg 前缀,于是数据库里存的图片地址天然是同域 HTTPS、可预览、可被微信抓取。代价是图片出流占 ECS 带宽——对这个小流量站完全可接受;将来视频流量大了,可以平滑切换到绑定自定义域名直连 OSS 的方案,只需改一个环境变量并替换库里的旧 URL,业务代码零改动。
九、配套设施:CORS、碎片清理、删除回收
直传能跑通,还依赖三个容易忘的配置:
- Bucket CORS 规则。浏览器直传是跨域请求,必须在 OSS 控制台配置允许来源(线上域名 + 本地 5174 端口,注意 http/https 和有无结尾斜杠)、方法(PUT/POST/GET/HEAD)、暴露
ETag等头。少配一个来源就是一个莫名其妙的 CORS 报错; - 生命周期规则清理碎片。传到一半放弃的视频分片、原图成功但缩略图失败的半成品,临时凭证没有删除权限、代码也不该越权清理。在 OSS 控制台配一条"分片创建超过 7 天自动删除"的生命周期规则,让对象存储自己回收;
- 业务删除时的引用检查。删文章/换封面时后端用长期 AK 回收对象,但删之前先全库扫描所有媒体字段——同一张图可能既是 A 的封面又是 B 相册里的照片,确认零引用才删,缩略图按命名约定(
.thumb.jpg)一并回收;外部粘贴的 URL(网图、视频外链)通过域名归属判断,一律不碰。
十、踩过的坑(这一篇的密度最高)
- endpoint 四种用途别搞混:ECS 后端读写用
-internal;STS 必须剥成公网域名;浏览器 SDK 只填 region;拼给用户的 URL 永远不能带-internal(公网打不开)。 - RAM 策略 Resource 必须用
acs:oss:*:*:通配形式,精确地域形式反被拒绝,别被控制台的"无效授权"提示骗了。 - 改完 RAM 策略一定要跑自检脚本:验证能 AssumeRole、能写
image/、越权写别的前缀返回 403、删除返回 403。403 响应里的EncodedDiagnosticMessage可以贴到 RAM"权限诊断"解码,是定位拒绝原因的最快路径。 - Content-Type 必须在上传时设置,手工传到 OSS 的对象默认 MIME 不对,视频会变下载。
- RAM 无法按文件大小授权,300MB 上限只能靠前端校验和中转链路的
MAX_CONTENT_LENGTH约束。 - 凭证缓存要带锁且留提前量:Gunicorn 多 worker 下不加锁会重复 AssumeRole;不提前 5 分钟过期,长传输出错概率高。
- 本地开发端口写死 5174,OSS 的 CORS 来源清单也要对应放行 5174,否则本地传图全挂。
- BMP 不要走 Canvas 压缩:浏览器编码不了 BMP,早期压完的 PNG 顶着 .bmp 扩展名,后来改为不可压缩格式原样直传。
十一、小结
这套上传体系的设计原则可以提炼成四条:
- 字节流离业务服务器越远越好,ECS 只发通行证、不碰文件;
- 前端拿到的任何凭证都要假设会泄露,用短时效、最小权限、服务端分配路径把爆炸半径锁死;
- 关键链路必须有开关和回退,直传与中转共存,一个环境变量切换;
- "传上去"只是一半,访问域名、下载头、Content-Type、碎片回收、删除引用检查共同构成完整闭环。
一个人做项目,最容易犯的错是在安全配置上图省事(AK 直出、权限给满)。这一篇多花的所有功夫——RAM 分层、策略收敛、自检脚本——本质都是在用一次性的配置成本,换长期的"可以睡得着觉"。
下一篇回到前台,解决 SPA 的先天短板:搜索引擎和微信爬虫不执行 JavaScript,怎么让同一篇文章对人是丝滑单页、对爬虫是带完整 Open Graph 标签的服务端渲染页——第 7 篇:SPA 做 SEO 与微信分享,给爬虫单独开一扇门。
系列导航:① 开篇与总体架构 → ② Flask 后端骨架 → ③ 登录与安全 → ④ 博客内容模块 → ⑤ Markdown 编辑器 → ⑥ OSS 直传全攻略(本文)→ ⑦ SPA 的 SEO 与微信分享 → ⑧ 打卡热力图与小游戏 → ⑨ AI 网页宠物与收官


