m3u8播放失败排查:Nginx配置MIME类型、跨域与Range实战
1. 先把症状分清楚:m3u8 播不动,通常不是同一个病
做前端音视频这块,最消耗时间的不是写播放器,而是排查"为什么这个链接在别人电脑上能播,到我这儿就黑屏"。我经手过的项目里,m3u8 播放失败的原因分布大概是这样:MIME 类型不对占三成,跨域响应头缺失占三成,缓存与 Range 处理不当占两成,剩下两成才是切片本身和路径的问题。也就是说,八成的"播放故障"根子在 Nginx 那几十行配置上,而不是播放器代码写错了。
先建立一个基本认知:HLS 播放不是"下载一个文件然后播放",而是"播放器反复发请求"。一个视频被切成几十上百个.ts分片,配一个.m3u8索引文件,播放器先取索引,再按顺序取分片,播到中途要切换码率时还要再取一次索引。这条链路上任何一个请求的响应头不对,播放器就会安静地停在那里——它不会给你报错弹窗,最多在 console 里丢一行 warning。所以排查 HLS 的本质,是逐个请求检查响应头,而不是盯着播放器源码看。
1.1 黑屏、无限转圈、"格式不支持"分别意味着什么
这三种表现对应的故障点完全不同,先分清再动手,能省掉一半时间。
黑屏但控制条正常、时间在走:说明播放器已经解析了索引、拿到了时长,但分片解码失败。重点查.ts的Content-Type、有没有被 gzip 压缩、切片本身是否完整。这种情况下网络面板里通常能看到 200 或 206 的响应,但播放器就是不出画面。
无限转圈、加载动画不消失:播放器卡在"取索引"这一步,.m3u8请求大概率失败或者返回了 HTML。最常见的是路径不对导致的 404,或者服务器返回了一个自定义的错误页——播放器把 HTML 当 m3u8 解析,自然什么都读不出来。
直接提示格式不支持:这是canPlayType层面的判断失败。要么是 MIME 类型没配对,浏览器认为这不是它认识的 HLS;要么是 http 页面里嵌了 https 的视频流,或者反过来,被混合内容策略拦掉了。
还有一种容易被忽略的:播了十几秒突然停住,进度条还在,但再也不动了。这百分之百是分片请求出了问题,绝大多数是某个.ts返回了 403 或者 404——要么防盗链规则误伤,要么切片已经被清理但索引还是旧的。
1.2 动手前先用三个请求把范围圈死
我的习惯是先不碰配置文件,直接拿 curl 打三个请求出来看。这一步花两分钟,能省下后面半小时的瞎猜。
# 第一个:索引文件,重点看状态码和 Content-Type curl -I -H "Origin: https://example.com" https://example.com/vod/demo/index.m3u8 # 第二个:第一个分片,重点看 Content-Type 和 Accept-Ranges curl -I https://example.com/vod/demo/seg-000.ts # 第三个:带 Range 的分片请求,重点看是不是 206 curl -I -H "Range: bytes=0-1023" https://example.com/vod/demo/seg-000.ts三条命令下来,判断逻辑很清晰:
| 观察结果 | 指向的问题 |
|---|---|
m3u8 返回 200 但Content-Type是text/plain或application/octet-stream | MIME 未声明,Safari 可能能放,其他浏览器不行 |
| m3u8 返回 404 或 403 | 路径错、重写错、防盗链误伤 |
带Origin的请求头里没有Access-Control-Allow-Origin | 跨域配置缺失或add_header继承断裂 |
| ts 返回 200 而不是 206 | Range 没生效,拖进度条必卡 |
ts 的Content-Encoding: gzip | 分片被压缩,解码器读不了 |
Accept-Ranges: none | 服务器关掉了 Range 支持 |
注意:带
Origin去测跨域响应头这一步很多人会跳过。浏览器里看 network 面板时,跨域失败的请求有时候会被折叠或者标红成 CORS error,看不到完整的响应头,用 curl 反而更干净。
2. MIME 类型:一行默认配置就能让整条链路停摆
Nginx 自带的mime.types文件里,默认没有.m3u8和.ts的映射。这不是配置写错了,是这两个扩展名本来就不在通用 Web 服务器的默认清单里——.ts在别的语境下是 TypeScript 源文件,.m3u8更是流媒体专用的。所以你把切片目录往root下面一放,Nginx 找不到映射,就统统按application/octet-stream返回。
这一步为什么致命?分两种情况看。iOS Safari 是原生支持 HLS 的,它对 MIME 的容忍度相对高一些,有时候application/octet-stream也能硬放;但换到 Chrome、Edge、Firefox 上用 hls.js 播放,情况就完全不同了。hls.js 内部会检查MediaSource.isTypeSupported(),而这个判断依赖于分片的实际编码信息,一旦响应头给的类型让浏览器认为"这不是媒体资源",它连解析都不会开始。表现出来就是加载完索引之后彻底静默,连错误都不抛。
2.1 该声明哪些类型,为什么不止两个
很多人只知道补.m3u8和.ts,实际项目里往往会用到更多。下面这份清单是我在不同项目里逐渐攒出来的,建议整段抄走:
types { application/vnd.apple.mpegurl m3u8; application/x-mpegURL m3u; video/mp2t ts; video/iso.segment m4s; application/dash+xml mpd; video/mp4 m4v; } # 兜底:未识别的类型不要强行当二进制下发 default_type application/octet-stream;有几个细节值得展开说。application/vnd.apple.mpegurl是 m3u8 的官方注册类型,绝大多数播放器都认;application/x-mpegURL是老写法,有些老版本的播放 SDK 只认这个,所以我一般两个都写上,用扩展名区分不同目录。video/mp2t是 MPEG-TS 的标准类型,这个写错的话普通浏览器必挂。.m4s出现在 fMP4 封装的 HLS 里,video/iso.segment是通用写法,也有服务端用video/mp4的,实测都能用,关键是别漏。
default_type这一行容易被误解成"随便兜底"。它真正的价值是:当某个扩展名没在 types 里声明时,你至少知道自己拿到的是application/octet-stream而不是一个误导性的类型,排查时一眼能看出"哦,这里没配"。
2.2 配置放的位置决定了它会不会生效
types块可以放在http、server、location三个层级,但只有在当前 location 没有自己的types时才向下继承。如果你在某个location里写了types { }空块或者只声明了一两个类型,父级的映射就全丢了。这个坑我踩过一次:为了给某个目录单独加.json类型,顺手写了个types块,结果那个目录下所有视频全部黑屏,查了快一个小时才反应过来。
更稳妥的做法是统一放在http层,或者单独抽一个mime.types文件用include引进来,location里绝对不要再写types。验证方式很简单,改完nginx -t检查语法,nginx -s reload之后再用前面的 curl 命令看Content-Type有没有变。
还有一个隐蔽点:有些团队会把静态资源统一交给上游应用服务处理,配置里写的是proxy_pass。这时候 Nginx 只是个转发层,MIME 是由上游返回的,你在 Nginx 里补 types 完全没用。判断方法是看响应头里有没有Server字段的差异,或者直接看 Nginx 的access.log里这个请求是不是走到了 proxy 的 location。如果是走代理,要么在上游改,要么用proxy_hide_header Content-Type加add_header Content-Type强改——但后者属于补救手段,长远看还是应该在上游解决。
3. 跨域:hls.js 发出的请求为什么被浏览器按下不表
这是最容易让人困惑的一类故障。表现是:你在浏览器地址栏直接粘贴 m3u8 的 URL,能正常下载或者能播;但把它塞进页面里的<video>或者 hls.js,就是放不出来,console 里一行红字blocked by CORS policy。
原因在于请求的发起方不一样。地址栏直接访问是顶层导航,不受同源策略约束;而video标签加载媒体、hls.js 用fetch/XMLHttpRequest拉索引和分片,都是受同源策略管的跨域请求。只要视频域名和页面域名不一致,就必须有Access-Control-Allow-Origin。
3.1add_header的继承规则是这块最大的雷
先看一段看起来没问题、实际有问题的配置:
server { listen 80; server_name static.example.com; add_header Access-Control-Allow-Origin *; location /vod/ { add_header Cache-Control "public, max-age=3600"; root /data/media; } }这段配置的意图很清楚:全局允许跨域,/vod/目录加缓存。但实际效果是——/vod/下的响应完全没有Access-Control-Allow-Origin。因为 Nginx 的add_header继承规则是"子层级一旦定义了自己的add_header,父层级的全部不继承",而不是叠加。这是add_header和很多人的直觉最大的分歧点,也是我在排查跨域问题时第一个怀疑的地方。
正确写法是把需要的头在同一个层级写全,或者用include把公共头抽出来在每个location里引一次:
# /etc/nginx/snippets/cors.conf add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always; add_header Access-Control-Allow-Headers "Range, If-Range, Origin, Accept" always; add_header Access-Control-Expose-Headers "Content-Length, Content-Range, Accept-Ranges" always; add_header Access-Control-Allow-Credentials true always;然后每个需要跨域的location里都include snippets/cors.conf;。注意always参数——不加它,4xx 和 5xx 响应不会带上这些头,跨域失败时你连错误原因都看不到,只看到一句笼统的 network error。
Access-Control-Expose-Headers这一行经常被漏掉。分片请求的Content-Range、Content-Length如果不暴露给前端 JS,hls.js 在处理 seek 和缓冲判断时会拿不到准确信息,表现就是"能播但拖进度条不准"。
3.2 用$http_origin而不是写死星号
Access-Control-Allow-Origin: *写起来爽,但如果你的播放地址带 Cookie 或者签名,*和Allow-Credentials: true是冲突的,浏览器会直接拒绝。而且把来源全放开,在有防盗链需求的场景下等于白做。
更实际的做法是按来源白名单回显:
map $http_origin $cors_ok { default 0; "~^https://www\.example\.com$" 1; "~^https://m\.example\.com$" 1; "~^https://player\.example\.com$" 1; } server { location /vod/ { if ($cors_ok = 1) { add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Credentials true always; } add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always; add_header Access-Control-Allow-Headers "Range, If-Range, Origin" always; add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always; ... } }这里有个细节:if块里的add_header和外面的add_header又是两个层级,虽然 Nginx 在if里的行为比较特殊,但为了避免踩坑,建议把白名单判断也交给map,在map里直接算出该给什么值,add_header只用一次:
map $http_origin $cors_origin { default ""; "~^https://www\.example\.com$" $http_origin; "~^https://m\.example\.com$" $http_origin; }当来源不在白名单时,$cors_origin是空字符串,Nginx 会直接不下发这个头,浏览器的跨域请求自然失败——这正是我们想要的效果,而且不用写if。
另外别忘了OPTIONS预检。虽然浏览器对Range这种"简单请求"列表外的头会发预检,但GET带Range实际上属于简单请求范围内的扩展,多数情况不触发预检;可一旦你的前端加了自定义鉴权头,预检就来了。所以在 location 里加一段if ($request_method = OPTIONS) { return 204; },省得预检请求走完整业务流程被鉴权拦掉。
4. Range 与缓存:拖进度条卡死、二次播放白屏的根子
HLS 的分片虽然是独立文件,但播放器在加载每个.ts时依然会用Range请求做分段读取,特别是在做预加载、快进快退、切换码率的时候。如果服务器不支持 Range,每次都会把整个文件从头返回一遍,网络开销翻几倍不说,播放器内部的时间戳映射也会乱掉。
Nginx 默认是支持 Range 的,前提是底层是静态文件并且开了sendfile。但如果你加了gzip,或者走了proxy_pass到上游而没转发Range头,Range 就失效了。判断标准特别简单:日志里看到的是 206 就对了,看到 200 就是没生效。
4.1 别让 gzip 碰到视频分片
这是我在一个项目里被坑得最惨的一次。当时为了优化首屏,全局开了 gzip,gzip_types里图省事写了text/plain application/javascript application/xml——注意这里有text/plain。而.ts文件因为 MIME 没配,返回的正是text/plain(上游服务的默认行为),于是所有分片都被 gzip 压缩了。播放器拿到的Content-Length是压缩后的长度,解出来的字节流对不上时间戳,表现是画面花屏、声音断续。
所以第一条铁律:gzip_types里绝对不能出现text/plain,也不能出现任何video/*。显式地只列文本类:
gzip on; gzip_vary on; gzip_comp_level 4; gzip_min_length 1024; gzip_types text/css text/xml application/javascript application/json image/svg+xml;第二条:如果你确实需要给.m3u8做压缩(索引是纯文本,压缩收益不小),单独用location匹配,别让它连带把.ts也带上:
location ~* \.m3u8$ { gzip on; gzip_types application/vnd.apple.mpegurl application/x-mpegURL; } location ~* \.(ts|m4s|mp4)$ { gzip off; add_header Accept-Ranges bytes; sendfile on; tcp_nopush on; aio on; }sendfile on配合tcp_nopush on能让大文件走内核零拷贝,分片密集请求时 CPU 占用能降不少。aio on在机械盘或者网络存储上收益更明显,SSD 上差别不大,但开了没坏处。
4.2 索引可以短缓存,分片必须长缓存,两者别搞反
这个问题的典型症状是:视频更新了,用户那边还在放旧的;或者反过来,每次播放都要重新拉几百个分片,流量成本居高不下。
原则很清楚。.m3u8是索引,内容会随直播推进或者点播切片更新而变化,必须不缓存或者极短缓存;.ts分片一旦生成,内容就固定了,文件名通常带序号或者哈希,可以放心长缓存。
location ~* \.m3u8$ { add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; expires -1; etag off; if_modified_since off; } location ~* \.(ts|m4s)$ { expires 30d; add_header Cache-Control "public, max-age=2592000, immutable"; access_log off; }immutable这个指令值得单独说。它告诉浏览器"这个 URL 的内容永远不会变,别发条件请求验证了"。HLS 分片天生满足这个条件(只要你的文件名不重复使用),加上之后,用户在进度条上来回拖动时,浏览器直接从本地拿,不发任何请求,体验差异非常明显。
access_log off是给分片单独关掉日志。一个 10 分钟的视频如果切成 4 秒一片,就是 150 个请求,一个用户一次播放就能刷出 150 行日志,量大起来日志文件会很夸张,而且这些日志对排查的帮助远不如索引请求的日志大。真要排查某个分片的问题,临时打开就行。
反过来的坑:有些团队为了"保险",给 m3u8 也加了
expires 1h。结果点播视频更新后,用户一小时之内看到的还是旧版本;直播场景更糟,播放器一直拿着开局那个索引,永远播不到新内容。这个错误我在两个项目里都见过,属于典型的"缓存策略抄了一半"。
5. 切片本身的问题:Nginx 配得再对也救不了上游
配置全对、跨域全通、Range 正常,视频还是有问题,这时候基本可以确定问题在切片生成环节。这类故障的特点是"时好时坏"或者"只有部分设备有问题",排查起来最费劲,因为你会反复怀疑自己的 Nginx 配置。
5.1 关键帧间隔、切片时长与起播速度的三角关系
HLS 的切片点必须落在关键帧(I 帧)上,这是硬约束。如果你的转码参数里 GOP 设置成 250 帧、切片时长设成 4 秒,而视频是 25fps,那一个 GOP 就是 10 秒——切片器为了让每个切片都从关键帧开始,只能把切片拉到 10 秒,你配置的 4 秒根本没生效。
这带来的连锁反应是:起播要等第一个 10 秒的切片下载完,用户感知就是"点了播放转半天"。而且切片太大,弱网下很容易超时。
经验参数是这样:切片时长 4 到 6 秒,GOP 严格等于切片时长乘以帧率。1080p 25fps 的视频,切 4 秒就是 GOP=100。这样每个切片刚好一个 GOP,切片边界和关键帧完全对齐,起播只需要下载一个切片。
| 场景 | 建议切片时长 | 建议 GOP | 说明 |
|---|---|---|---|
| 点播长视频 | 6s | 帧率×6 | 切片少,索引小,请求数低 |
| 短视频/教育课件 | 4s | 帧率×4 | 起播快,拖动定位准 |
| 直播低延迟 | 2s | 帧率×2 | 延迟低,但请求数翻倍 |
| 移动端弱网 | 4s | 帧率×4 | 兼顾起播和卡顿率 |
还有一个经常被忽略的点:多码率自适应时,各档位的切片边界必须严格对齐。如果 720p 切在 4.0s、480p 切在 4.03s,播放器在切换码率时找不到对应时间点的分片,就会短暂黑屏或者跳帧。这个对齐工作要靠转码参数保证,不是 Nginx 能解决的。
5.2 相对路径与目录结构:一个斜杠引发的 404
m3u8 索引文件内部引用分片时,可以写相对路径也可以写绝对路径。写相对路径的话,播放器会以索引文件的 URL 为基准拼接。这里出问题的场景通常是 CDN 回源或者路径重写。
举个真实遇到的例子:索引里写的是seg-000.ts,页面请求的是https://cdn.example.com/vod/2024/demo/index.m3u8(经过 CDN 重写后的地址),而实际文件在源站是/data/media/demo/seg-000.ts。CDN 回源时把路径改成了/demo/index.m3u8,播放器就按/vod/2024/demo/seg-000.ts去拼,直接 404。
解决办法有两个方向。一是让切片工具直接生成绝对路径,但如果域名不固定就不合适;二是在 Nginx 里保证索引和分片在同一个目录层级下,路径重写规则保持一致。我倾向于第二个,因为绝对路径会绑死域名,后期换域名或者加 CDN 都要重新生成切片,成本更高。
location /vod/ { alias /data/media/; try_files $uri $uri/ =404; autoindex off; } # 拒绝访问隐藏文件,但别误伤正常目录 location ~ /\.(?!well-known) { deny all; }那个location ~ /\.的正则要小心。有些团队写的location ~ /\.会把路径里任何带点的目录都拦掉,包括.开头的正常业务目录。上面这个写法用负向断言排除了.well-known,是更稳的版本。同理,如果你有以点开头的分片目录命名习惯,这条规则会直接让你的视频全挂,排查时记得看一眼。
6. 线上排查:把日志和网络面板串起来读
本地调试和线上的差别在于,线上你没法随便改配置重启,也没法让用户按 F12。所以排查能力要建立在日志上。Nginx 的默认combined格式对 HLS 排查不够用,至少要把$request_time、$upstream_response_time和完整的$request加进去。
log_format hls '$remote_addr - [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" rt=$request_time ' 'urt=$upstream_response_time range="$http_range"'; access_log /var/log/nginx/hls.log hls;range="$http_range"这个字段是关键。拿到日志之后,判断逻辑就变得很机械:
| 日志特征 | 结论 |
|---|---|
m3u8 请求 200,但body_bytes_sent很小(几百字节)且下一条是 ts 的 404 | 路径拼接错,分片不在预期位置 |
ts 请求大量 200,range字段为空 | 播放器没发 Range,或者 Nginx 没透传 |
ts 请求有range但状态是 200 | Range 未生效,检查 gzip 和 proxy 配置 |
| ts 请求 403 | 防盗链规则命中,或者权限问题 |
| ts 请求 499 | 客户端主动断开,通常是播放器等不及了,切片太大或网络差 |
同一 IP 短时间大量 206 但urt很高 | 磁盘 IO 瓶颈或者上游慢 |
6.1 499 这个状态码值得单独说
499 是 Nginx 自定义的"客户端关闭连接"。在 HLS 场景里它的含义很明确:播放器等得不耐烦,自己把请求掐了。出现原因通常有三个——切片太大导致下载时间超过播放器缓冲阈值、磁盘或者网络带宽打满、上游服务响应慢。
我在一个项目里遇到过 499 飙升的情况,查下来是分片放在网络存储上,单个分片 8MB,弱网用户下载要十几秒,播放器等 10 秒就放弃。改成 4 秒切片之后,单分片降到 1MB 左右,499 直接降了一个数量级。这个改动的收益比调 Nginx 缓冲参数大得多,也说明了排查时不能只盯着 Web 服务器。
6.2 浏览器侧的三个必看位置
服务端日志只能告诉你"请求到了没、返回了什么",前端的问题还得在浏览器里看。固定的三个位置:
Network 面板过滤m3u8和ts,看每个请求的状态码和响应头。重点确认Content-Type、Access-Control-Allow-Origin、Content-Range这三个。
Console 里的 hls.js 日志。hls.js 默认日志级别比较安静,可以在初始化时打开 debug:
const hls = new Hls({ debug: true, enableWorker: true });打开之后能看到每个分片的加载状态、缓冲区变化、码率切换决策。排查"播到某处卡住"这类问题时,这行日志的价值极高。
Media 面板(Chrome DevTools 里有)。能看到实际的缓冲区间、丢帧数、解码器状态。如果缓冲区一直填不满,说明分片下载跟不上;如果缓冲区满了但画面不动,那是解码环节的问题,跟 Nginx 无关了。
7. 上线前的自检清单和几条踩出来的经验
配置这东西,写完能跑通和线上稳定是两回事。我现在的习惯是上线前跑一遍固定清单,五分钟能挡掉大部分低级问题。
7.1 一份可以直接照着核对的清单
# 1. MIME 是否正确 curl -sI https://your.domain/vod/demo/index.m3u8 | grep -i content-type curl -sI https://your.domain/vod/demo/seg-000.ts | grep -i content-type # 2. 跨域头是否下发(带 Origin 测) curl -sI -H "Origin: https://www.your-page.com" \ https://your.domain/vod/demo/index.m3u8 | grep -i access-control # 3. Range 是否生效,必须是 206 curl -sI -H "Range: bytes=0-1023" \ https://your.domain/vod/demo/seg-000.ts | head -1 # 4. 分片有没有被压缩,不应出现 Content-Encoding curl -sI https://your.domain/vod/demo/seg-000.ts | grep -i content-encoding # 5. 缓存策略是否符合预期 curl -sI https://your.domain/vod/demo/index.m3u8 | grep -i cache-control curl -sI https://your.domain/vod/demo/seg-000.ts | grep -i cache-control第五条经常查出问题。缓存策略配错不会导致播放失败,但会带来"更新不生效"和"流量账单异常"这两类更难处理的麻烦,而且它们往往要过几天才被发现。
7.2 几个用时间换来的经验
别在location里写types块。这一条前面说过,但我还是要重复一遍,因为它造成的故障现象是"视频全黑屏"而不是"某个类型不对",误导性极强。
add_header的继承断裂要养成条件反射。只要你在某个location里加了一个add_header,就顺手检查一下这个 location 是不是也需要父级的跨域头。我现在的做法是全部用include引入公共头,从根上避免这个问题。
if在location里的行为要谨慎。Nginx 官方文档明确说过if在 location 里属于"如果实在没办法才用"的范畴,因为它的执行时机和配置上下文有自己的规则。能用map解决的判断,一律用map。
分片数量要控制。一个两小时的电影切 4 秒一片就是 1800 个文件,索引文件会有几十 KB,播放器解析索引本身就要花时间。长视频建议切 6 到 10 秒,短视频再切细。这不是 Nginx 的参数,但它直接影响你配置的缓存和并发策略是否合理。
HTTPS 页面里别混 http 的视频流。这个错误在新项目里出现频率意外地高,表现是接口全通、链接单独打开也能播、就是页面里放不出来。Chrome 会把混合内容的请求直接标成 blocked,而且不显示响应头,非常容易误判成跨域。上线前统一检查一遍协议,播放地址用相对协议或者根据location.protocol动态拼。
留一个可以临时关掉缓存的调试入口。线上排查时最怕的就是"改了配置但浏览器还在用缓存",导致你看到的现象和实际配置对不上。我会在测试环境留一个带随机参数的入口,或者用 curl 加-H "Cache-Control: no-cache"绕过,避免自己被缓存骗。
多码率场景下先测切片对齐,再测播放器。如果不同码率的切片时间戳对不齐,切码率时必卡,而这个现象跟网络状况高度相关,很容易被误判成"用户网不好"。测试方法很简单:手动把播放器锁到最低码率,播一分钟再放开,看有没有跳帧或者黑屏。
最后一条,也是我觉得最值钱的:遇到"播放不正常"先别改 Nginx。先用 curl 打那三个请求,把Content-Type、Access-Control-Allow-Origin、状态码是不是 206 这三件事确认清楚。这三项对了,问题基本不在 Web 服务器层,往切片和播放器方向找会快得多。我见过太多人一上来就改一堆配置参数,改到最后连最初的现象都复现不了了。