火山引擎 TOS 视频 Content-Disposition: attachment 排查实录
本文记录为什么上传时已经写了
inline、浏览器却还是attachment,火山 TOS 的设计动机,以及自定义域名 与后端代理两种解法。文中桶名、域名、用户 ID、剧集 ID 均已替换为占位符,可直接转发。
目录
- 现象
- 时间线:我们改了什么、为什么没用
- 根因(三层,缺一不可)
- [火山 TOS 为什么这么做](#火山 TOS 为什么这么做)
- 修复方案(两档)
- 如何验证
- 三条媒体通路对照
- [避坑 Checklist](#避坑 Checklist)
1. 现象
- 某剧集镜头视频已生成并归档至 TOS
- 前端
<video>能显示首帧缩略图,但点击播放无反应 - DevTools Network 响应头示例:
http
HTTP/1.1 206 Partial Content
Content-Type: video/mp4
Content-Disposition: attachment ← 问题所在
Content-Length: 120219
Server: TosServer
对应对象路径示例:users/<user_id>/series/<series_id>/episode_01/shot_01_tail_2s.mp4。
2. 时间线:我们改了什么、为什么没用
| 轮次 | 改动点 | 预期效果 | 实际结果 |
|---|---|---|---|
| 第 1~2 遍 | 上传时设 content_disposition="inline" |
对象元数据为 inline,浏览器可播 | Network 仍是 attachment |
| 第 3 遍 | 预签名 URL 加 response-content-disposition=inline |
签名 query 覆盖元数据 | 仍 attachment(且公共直链分支根本没走到预签名) |
| 第 4 遍 | copy_object(REPLACE) 批量回填 inline |
修旧对象 | 元数据 API 显示 inline,公网 HTTP 仍 attachment |
| 第 5~6 遍 | 继续微调上传 / 预签名参数 | 同上 | 浏览器仍 attachment |
关键转折 :用 TOS API head_object 与公网 curl 对比同一对象 ,发现元数据层和 HTTP 响应层不一致 ------问题不在上传,而在访问域名。
对比实测(同一 mp4 文件)
| 探测方式 | content_disposition |
说明 |
|---|---|---|
SDK head_object |
inline | 对象元数据已正确 |
默认桶域名公网 GET |
attachment | 浏览器实际收到的头 |
预签名 URL + ?response-content-disposition=inline |
attachment | query 覆盖也无效 |
自定义域名 公网 GET |
inline ✅ | 见下文 §5.2 |
当时 .env 配置的是默认桶域名:
env
# ❌ 问题配置:走默认桶域名
TOS_PUBLIC_BASE_URL=https://<your-bucket>.tos-cn-beijing.volces.com
导致预签名逻辑直接返回公网直链、跳过预签名,前几次针对预签名的修复等于打在空气上。
3. 根因(三层,缺一不可)
┌─────────────────────────────────────────────────────────────┐
│ Layer 1:上传元数据 │
│ put_object(content_disposition="inline") ✅ 已生效 │
│ head_object 可验证 │
└──────────────────────────┬──────────────────────────────────┘
│ 默认桶域名公网读不一定会原样透出
▼
┌─────────────────────────────────────────────────────────────┐
│ Layer 2:TOS 公网边缘(默认桶域名) │
│ 匿名 GET / Range → 强制 Content-Disposition: attachment │
│ 与对象元数据、预签名 query 均无关 ❌ │
└──────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Layer 3:前端播放链路 │
│ TOS_PUBLIC_BASE_URL = 默认桶域名 → <video> 直链 TOS ❌ │
└─────────────────────────────────────────────────────────────┘
结论:
- 上传没失败 ------
inline已写入对象元数据。 - 预签名修不了------公共直链分支 bypass 预签名;带 inline query 的签名 URL 实测仍返回 attachment。
- 默认桶域名不能指望 inline 播视频------必须换域名或走代理。
4. 火山 TOS 为什么这么做
4.1 官方公开策略:默认桶域名的「安全合规」限制
火山引擎自 2022 年 10 月 18 日 起,对通过存储桶默认域名 访问的网页类型文件 (text/html 及 HTM、HTML、JSP 等),在响应头中自动追加 Content-Disposition: attachment,防止浏览器直接渲染托管页面。
官方说明:静态网站托管
社区同类(HTML 被强制下载):博客园讨论------解法包括 CDN 改响应头,或绑定自定义域名。
4.2 为什么 mp4 在默认桶域名也是 attachment?
官方文档明确列举的是 HTML ;video/mp4 不在列表中。但我们实测:
| 访问方式 | Content-Disposition |
|---|---|
head_object |
inline |
https://<bucket>.tos-cn-beijing.volces.com/...mp4 |
attachment |
https://<your-custom-domain>/...mp4 |
inline ✅ |
说明:默认桶域名 下,边缘节点对 mp4 存在「元数据 inline、HTTP attachment」的覆盖行为;自定义域名则尊重对象元数据。
可能动机(推测):
| 推测 | 说明 |
|---|---|
| 边缘策略扩展 | 默认域名对可嵌入媒体统一加 attachment,文档未逐一枚举 |
| 防盗链 / 热链 | 默认域名易被当作公开文件直链滥用 |
| 合规分层 | 自定义域名视为「自有业务域名」,策略更宽松 |
可操作结论:
不要指望在默认桶域名 上,仅靠「上传 inline」或「预签名 query」让
<video>稳定播放。
4.3 为什么 <img> 没事、<video> 有事?
<img>对attachment通常仍能显示。<video>更严格,Chromium 可能拒绝 attach 响应流 →「有首帧、点播放无反应」。
5. 修复方案(两档)
5.1 方案 A(推荐):绑定自定义域名
步骤:
- TOS 控制台 → 桶 → 域名管理 → 绑定自定义域名(如
https://media.example.com) - DNS 配置 CNAME 指向
<bucket>.tos-cn-beijing.volces.com - 开启 HTTPS 证书
- (可选)挂火山 CDN,在「HTTP 响应头」中将
Content-Disposition设为inline作双保险 - 修改应用配置:
env
# ✅ 推荐:自定义域名
TOS_PUBLIC_BASE_URL=https://media.example.com
实测对比(同一对象):
bash
# 默认桶域名 → attachment
curl -sI "https://<bucket>.tos-cn-beijing.volces.com/users/.../shot_01.mp4"
# 自定义域名 → inline
curl -sI "https://media.example.com/users/.../shot_01.mp4"
自定义域名生效后,图片和视频均可走公网直链,无需后端转发字节,减轻应用服务器压力。
代码侧注意 :若之前为兜底写了「视频强制走 /api/media 代理」,应改为仅默认桶域名才代理,自定义域名下视频允许直链:
python
def _is_default_tos_public_host(self) -> bool:
base = (settings.tos_public_base_url or "").rstrip("/").lower()
default = f"https://{settings.tos_bucket}.tos-<region>.volces.com".lower()
return base == default
def storage_ref(...):
# 仅默认桶域名时,视频走 /api/media;自定义域名可直链
use_public = settings.tos_public_base_url and (
not is_video(rel) or not self._is_default_tos_public_host()
)
...
typescript
// 仅默认桶域名(*.tos-cn-beijing.volces.com)才强制代理
export const isTosDefaultBucketUrl = (url: string) =>
url.includes('.tos-cn-beijing.volces.com/')
function videoProxyUrl(path, seriesId, token) {
if (!isVideo(path) || !isTosDefaultBucketUrl(path)) return null
return proxyMediaUrl(toMediaRef(path, seriesId), token)
}
5.2 方案 B(兜底):后端 /api/media 代理
若暂时无法绑自定义域名,让视频预览走应用自己的媒体 API,由后端发 inline:
python
# 默认桶域名下,视频 storage_ref 返回 /api/media/series/{id}/...
return scope.media_url(rel)
typescript
// 默认桶域名的 mp4 → /api/media/...?token=...
代理层示例(FastAPI):
python
return FileResponse(
path=local_path,
media_type="video/mp4",
content_disposition_type="inline", # 非 download 模式
)
5.3 上传侧(两方案都需要)
无论走哪条访问路径,上传时都应显式指定:
python
client.put_object_from_file(
bucket, key, file_path,
content_type="video/mp4",
content_disposition="inline",
acl=tos.ACLType.ACL_Public_Read,
)
旧对象可批量 copy_object + metadata_directive="REPLACE" 回填。
6. 如何验证
自定义域名方案
- 硬刷新前端
- Network 中视频请求应为
https://media.example.com/users/.../shot_xx.mp4 - 响应头:
Content-Disposition: inline
代理方案
- Network 中视频请求应为
https://your-api.example.com/api/media/series/<series_id>/...mp4?token=... - 响应头:
Content-Disposition: inline(或无 attachment)
自助诊断
bash
# 1. 对象元数据(通常应为 inline)
# 使用你所用语言的 TOS SDK 调用 head_object
# 2. 默认桶域名 HTTP 头(预期 attachment --- 所以不要用来播 <video>)
curl -sI "https://<bucket>.tos-cn-beijing.volces.com/users/.../test.mp4"
# 3. 自定义域名 HTTP 头(预期 inline)
curl -sI "https://media.example.com/users/.../test.mp4"
7. 三条媒体通路对照
| 通路 | 用途 | URL 形态 | Content-Disposition |
|---|---|---|---|
| A | 送大模型视频 API | https:// / asset:// / tos:// |
与浏览器无关 |
| B | 前端 <img> / <video> |
自定义域名直链,或 /api/media/... |
自定义域名 / 代理控制 |
| C | DB 持久化 | 相对路径 / tos:// / API 路径 |
存储引用 |
踩坑本质 :B 通路视频走了默认桶域名直链,而默认域名在 HTTP 层不保证 inline。
8. 避坑 Checklist
- 上传时设
content_disposition="inline"+acl=Public_Read - 不要 假设
head_object=inline⇒ 默认桶域名 GET 也是 inline - 生产环境优先 TOS 自定义域名 ,
TOS_PUBLIC_BASE_URL填自定义域名而非默认桶域名 - 仅默认桶域名时,视频走
/api/media代理兜底 - 排查时同时看 SDK 元数据 和 curl 公网响应头
- 图片可直链;视频与图片按域名策略分流
- 可选:CDN 层将
Content-Disposition设为inline双保险 - 容器化部署:改代码后需 rebuild 镜像,仅 restart 不生效