NGINX 媒体分发与 RTMP/HLS

以 NGINX 与 nginx-rtmp 模块搭建直播与点播分发链路,覆盖 RTMP 接收与转推、HLS 切片参数与低延迟优化、切片目录落盘、推流鉴权与防盗链、多码率转码、边缘节点分发与回源缓存,以及带宽、卡顿与延迟问题的排查与调优的实践思路。

用 NGINX 做媒体分发是老牌方案:nginx-rtmp-module 负责接收推流并切片,NGINX 本体负责把切片文件高效地吐给成千上万个观众。它没有商业 CDN 的运维自动化,但胜在可控、成本低、能与既有 NGINX 体系无缝衔接。本文从推流接收讲到边缘分发,重点落在 HLS 的延迟与切片参数、鉴权链路设计,以及那些只在真实流量下才暴露的坑。

1. 直播分发的整体链路

一条完整的直播链路可以拆成四段,每一段的关注点完全不同。

  1. 采集与推流:主播端(OBS、ffmpeg、移动端 SDK)通过 RTMP 或 SRT 把编码后的音视频推到接入节点。
  2. 接入与处理:接入节点接收流,做鉴权、转码、切片,产出 HLS 或 DASH 分片。
  3. 分发:切片文件通过 HTTP 被播放器拉取,这一层是纯静态文件服务,可以横向扩展并加缓存。
  4. 播放:浏览器用 hls.js、移动端用原生播放器拉取 m3u8 索引与 ts 分片。

NGINX 在这条链路里主要承担第 2 段和第 3 段。RTMP 协议本身是长连接、基于 TCP 的私有协议,适合上行推流;HLS 是「HTTP + 切片文件」,适合下行分发。把上行和下行分开,是理解整个架构的关键。

1.1 为什么不用 RTMP 直接分发

RTMP 播放需要 Flash 或专用播放器,浏览器早已不支持。更重要的是,RTMP 的长连接模型无法利用 HTTP 缓存与 CDN 的边缘节点,扩容成本远高于 HLS。因此现代方案几乎都是「RTMP 上行 + HLS/DASH 下行」。

HLS 的代价是延迟:切片长度决定了理论最小延迟。一个 6 秒的切片意味着播放器至少要缓冲 3 个切片才能稳定播放,端到端延迟普遍在 1530 秒。要把延迟压到 35 秒,需要把切片切到 1~2 秒并开启 LL-HLS,代价是请求数暴增与切片边界处理更复杂。

2. 编译与基础配置

nginx-rtmp-module 是第三方模块,官方 NGINX 包不含,需要自行编译或使用带模块的镜像。

./configure --with-compat \
  --add-dynamic-module=../nginx-rtmp-module \
  --with-http_ssl_module --with-http_v2_module

make && make modules
sudo cp objs/ngx_rtmp_module.so /etc/nginx/modules/

--with-compat 让动态模块能匹配官方包编译的 NGINX 二进制,是避免 ABI 不匹配的必备开关。

主配置里加载模块并引入 rtmp 块:

load_module modules/ngx_rtmp_module.so;

# rtmp 块与 http 块平级,不能嵌套
rtmp {
    server {
        listen 1935;
        chunk_size 4096;

        application live {
            live on;
            record off;
        }
    }
}

rtmp 块是顶层块,与 http、stream 平级。把它写进 http 里是最常见的配置错误,NGINX 会直接报 unknown directive "rtmp"。

2.1 推流与播放验证

# 用 ffmpeg 推一个本地文件到 live 应用
ffmpeg -re -i input.mp4 -c copy -f flv \
  rtmp://push.example.com/live/teststream

# 拉流验证(需要支持 RTMP 的播放器,如 VLC 或 ffplay)
ffplay rtmp://push.example.com/live/teststream

推流地址的最后一段是 stream key,它既是流的唯一标识,也是后续鉴权的载体。

3. HLS 切片与低延迟

RTMP 模块的 hls 指令负责把直播流转成 HLS。

application live {
    live on;

    hls on;
    hls_path /var/media/hls;
    hls_fragment 2s;
    hls_playlist_length 12s;
    hls_nested on;
}

关键参数的含义与取舍:

参数含义调小的影响调大的影响
hls_fragment单个 ts 切片时长延迟降低、请求数增加延迟升高、请求数减少
hls_playlist_lengthm3u8 中保留的切片总时长回看窗口变短、起播快回看窗口长、起播慢
hls_nested每路流独立目录目录结构清晰需配合 hls_path 权限

经验值:hls_fragment 2s + hls_playlist_length 10s 可以把延迟压到 6~10 秒,同时对带宽的冲击可控。若追求更低延迟,hls_fragment 1s 会让请求数翻倍,需要确认切片目录所在磁盘的 IOPS 与文件系统 inode 足够。

3.1 切片目录的落盘与清理

切片文件写入磁盘再被 HTTP 服务读取,这一步有真实的 IO 压力。假设 1000 路流、每路 2 秒一个切片,每秒产生 500 个文件。若切片目录和日志目录在同一块盘上,很容易把 IO 打满。

# 把切片目录挂到 tmpfs,减少磁盘抖动(代价是内存占用)
mount -t tmpfs -o size=4G tmpfs /var/media/hls

# 观察目录文件数增长
watch -n 5 'ls -1 /var/media/hls | wc -l; df -h /var/media/hls'

模块自身会按 hls_playlist_length 清理过期切片,但如果进程被强杀,残留文件不会被回收,需要一个定时任务兜底:

# 清理 10 分钟未修改的切片
find /var/media/hls -name '*.ts' -mmin +10 -delete

3.2 用 HTTP 服务切片

切片本身是静态文件,交给 NGINX 的 http 块即可:

server {
    listen 80;
    server_name media.example.com;

    root /var/media;

    location /hls/ {
        alias /var/media/hls/;
        add_header Cache-Control "no-cache";

        # 允许跨域,hls.js 需要
        add_header Access-Control-Allow-Origin "*";
        add_header Access-Control-Allow-Methods "GET, OPTIONS";

        types {
            application/vnd.apple.mpegurl m3u8;
            video/mp2t ts;
        }
    }
}

m3u8 索引必须 no-cache,否则播放器会拿到过期的切片列表。ts 切片本身是不可变的,可以设长缓存,但要注意 URL 复用问题——同一路流重推后切片文件名可能重复,此时需要版本化路径或改 hls_path。

静态文件服务本身的优化(sendfile、open_file_cache、gzip 取舍)参见 Nginx 静态资源性能优化 。

4. 鉴权与防盗链

直播的鉴权分两个环节:推流鉴权(谁能推)与播放鉴权(谁能看)。两者的威胁模型不同。

4.1 推流鉴权

RTMP 模块提供 on_publish 回调,在推流开始时向一个 HTTP 端点发请求,返回 2xx 才允许推流。

application live {
    live on;
    hls on;
    hls_path /var/media/hls;

    on_publish http://127.0.0.1:8080/auth/publish;
    on_publish_done http://127.0.0.1:8080/auth/publish_done;
}

回调会带上 app、name(stream key)、addr(推流端 IP)等参数。鉴权服务据此判断 stream key 是否有效、是否已过期、是否与 IP 绑定。

from flask import Flask, request

app = Flask(__name__)

@app.post("/auth/publish")
def auth_publish():
    name = request.form.get("name", "")
    addr = request.form.get("addr", "")
    if not verify_stream_key(name, addr):
        return "forbidden", 403
    return "ok", 200

注意 on_publish 是同步阻塞的:回调超时会导致推流失败。鉴权服务必须做本地缓存(如 Redis 缓存 stream key),避免每次推流都查数据库。

4.2 播放防盗链

HLS 的播放鉴权通常用「签名 URL」:给每个观众生成一个带过期时间与签名的 URL,NGINX 用 secure_link 模块校验。

location /hls/ {
    secure_link $arg_st,$arg_e;
    secure_link_md5 "$secure_link_expires$uri$remote_addr secret_key";

    if ($secure_link = "") {
        return 403;
    }
    if ($secure_link = "0") {
        return 410;
    }

    alias /var/media/hls/;
}

签名 URL 形如 /hls/live/test.m3u8?st=xxxx&e=1700000000。secure_link_md5 里的变量组合决定了签名的粒度——把 $remote_addr 纳入签名可以防止链接被分享,但会破坏 CDN 边缘节点的缓存(每个 IP 的签名不同)。这是安全与成本的经典取舍:点播用 URI 签名换取缓存命中,直播用 IP 绑定换取防分享。

生成签名的服务端代码:

import base64, hashlib, time

def sign_url(uri: str, client_ip: str, ttl: int = 300) -> str:
    expires = int(time.time()) + ttl
    raw = f"{expires}{uri}{client_ip} secret_key"
    digest = hashlib.md5(raw.encode()).digest()
    st = base64.urlsafe_b64encode(digest).decode().rstrip("=")
    return f"{uri}?st={st}&e={expires}"

secure_link_md5 的原文必须与生成端完全一致,包括变量顺序与分隔符。任何一处不同都会得到 403,且没有有用的错误信息——这是 secure_link 调试时最耗时的地方。

更完整的鉴权体系(JWT、OAuth、会话校验)可参考 Nginx 鉴权体系:JWT 与 OAuth 。

5. 多码率与转码

单码率无法同时满足弱网手机与宽带大屏。多码率需要把一路输入转成多档清晰度,再生成一个 master playlist。

RTMP 模块的 exec 指令可以在推流开始时拉起 ffmpeg 做转码:

application live {
    live on;

    # 原流转发到内部应用,由 ffmpeg 消费
    exec ffmpeg -i rtmp://127.0.0.1/live/$name \
      -c:v libx264 -preset veryfast -b:v 1500k -s 1280x720 \
      -c:a aac -b:a 128k -f flv rtmp://127.0.0.1/hls/$name_720p \
      -c:v libx264 -preset veryfast -b:v 800k -s 854x480 \
      -c:a aac -b:a 96k -f flv rtmp://127.0.0.1/hls/$name_480p;
}

application hls {
    live on;
    hls on;
    hls_path /var/media/hls;
    hls_nested on;
    hls_variant _720p BANDWIDTH=1600000,RESOLUTION=1280x720;
    hls_variant _480p BANDWIDTH=896000,RESOLUTION=854x480;
}

hls_variant 会生成 master m3u8,播放器根据带宽自动切换档位。要点:

  • 转码是 CPU 密集型,一台 8 核机器用 veryfast 大约能转 3~5 路 720p。超过就要用独立转码集群或 GPU。
  • exec 启动的 ffmpeg 由 NGINX 管理生命周期,推流断开时会收到信号退出,但异常退出不会自动重启,需要 exec_publish/exec_publish_done 配合监控。
  • 时间戳对齐:多档转码的切片边界必须对齐,否则切换档位时会花屏。ffmpeg 的 -force_key_frames "expr:gte(t,n_forced*2)" 可以把关键帧强制对齐到 2 秒边界,与 hls_fragment 2s 匹配。

6. 边缘分发与回源

单机接入无法支撑大规模观众,标准做法是「中心源站 + 边缘节点」。

# 边缘节点:只做 HLS 分发与回源,不接收推流
proxy_cache_path /var/cache/hls levels=1:2
                 keys_zone=hls_cache:100m
                 max_size=200g inactive=1h;

server {
    listen 80;
    server_name media.example.com;

    location /hls/ {
        proxy_pass http://origin.example.com;
        proxy_cache hls_cache;
        proxy_cache_valid 200 2s;      # m3u8 短缓存
        proxy_cache_valid 200 1h;      # ts 长缓存,按后缀区分
        proxy_cache_use_stale error timeout updating;
        proxy_cache_lock on;
        add_header X-Cache-Status $upstream_cache_status;
    }
}

关键设计点:

  • m3u8 与 ts 用不同的缓存时长。m3u8 会持续更新,缓存超过切片时长就会导致观众卡住;ts 一旦生成就不再变化,可以长缓存。
  • proxy_cache_lock on 防止回源风暴。热门流的第一批观众会同时请求同一个切片,没有锁会导致大量并发回源。
  • proxy_cache_use_stale updating 让源站短暂抖动时用旧切片兜底,避免大面积 5xx。

边缘缓存的分层存储与回源保护策略,在 Nginx 边缘缓存与 CDN 架构 里有更完整的展开。

7. 排错与调优

直播问题大多表现为「卡顿」「黑屏」「延迟忽大忽小」,需要按链路分段定位。

观众卡顿但推流正常:先看边缘节点带宽与 X-Cache-Status。如果大量 MISS,说明缓存没生效;如果带宽打满,需要扩容或降码率。

切片生成延迟:ls -l /var/media/hls/<stream>/ 观察最新 ts 的 mtime,与当前时间比较。若差值持续大于 2 倍 hls_fragment,说明切片线程被阻塞,通常是磁盘 IO 或 ffmpeg 转码跟不上。

播放器反复重连:检查 m3u8 是否被错误缓存。用 curl -I 看响应头里的 Cache-Control,以及 X-Cache-Status 是否为 HIT。

# 观察 RTMP 连接数
ss -tn state established '( sport = :1935 )' | wc -l

# 观察 HLS 请求量与状态码分布
tail -f /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c

延迟持续增大:检查 hls_playlist_length 是否过大,以及播放器是否在追赶直播边缘。有些播放器为了流畅会主动加大缓冲,需要在播放器侧配置 maxBufferLength。

8. 总结

用 NGINX 搭建 RTMP 上行加 HLS 下行的分发链路,核心是把「接入」与「分发」解耦:接入节点负责鉴权、转码与切片,分发节点只做静态文件服务与缓存。延迟、成本、画质三者不可能同时最优,切片时长是调节这三者的主旋钮。

需要提醒的是,RTMP 模块是社区维护的第三方模块,更新频率低于 NGINX 本体,且不支持 QUIC 与 LL-HLS 的新特性。若业务对延迟要求进入 1 秒区间,应评估 SRT、WebRTC 或商业 CDN 方案;RTMP + HLS 的定位是「延迟可接受、成本可控、运维简单」的通用直播分发。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nginx」更多文章

  1. NGINX 容器镜像精简与加固
  2. 多租户虚拟主机与配置生成
  3. 从 Apache 与 Traefik 迁移到 NGINX