使用 Docker-compose 搭建SRS直播中转服务端

前言

我们知道, 直播的完整闭环核心链条是由推流端→中转端→播放端三个部分组成的:

  • 推流端(采集+编码+上传):负责把现场音视频或者录制好的文件"打包装箱",推到服务器。工具如 OBS、Kplayer、直播编码器。
  • 中转端(接收+复制+分发/CDN):负责接收推流端的"货物",然后复制成成千上万份,通过CDN广播给所有观众。比如抖音服务器、阿里云等流媒体平台。
  • 播放端(拉流+解码+渲染):观众手里的设备,负责向中转端"请求拿货",拿到后自动拆包装箱并播放。比如手机抖音App、电脑浏览器、电视大屏。

关于推流端的部署, 之前有写过相应文章介绍, 有兴趣可以参考:

今天我们来介绍如何使用Docker-compose来部署SRS中转端应用, 用于个人直播的测试

具体部署操作如下:

部署流程

假设你的机器已经安装了Docker, 关于Docker的安装, 有需要可以查看之前的文章:《ubuntu安装docker详细步骤》或者《Mac平台安装Docker》

1. 创建自定义网络

bash 复制代码
docker network create c_bridge

创建自定义网络,方便后期多容器交互。

2. 创建工作目录和数据映射目录

bash 复制代码
#创建持久化数据映射目录
mkdir -p /root/srs
#创建工作目录
mkdir srs-docker
#进入工作目录
cd srs-docker

3. 创建 docker-compose.yml

由于我们直接使用现成的镜像, 所以无需Dockerfile,只需在工作目录中执行以下指令创建 docker-compose.yml文件即可:

bash 复制代码
vim docker-compose.yml

并在文件中写入以下内容:

yaml 复制代码
version: '3.8'  
    
services:
  srs:
    image: ossrs/srs:latest
    container_name: c_srs
    hostname: c_srs  # 设置容器主机名
    restart: always  # 设置自动重启策略
    ports:
      - "1935:1935" # RTMP协议推流/拉流端口
      - "8088:8080" # HTTP协议拉流播放端口
      - "8085:1985" # api运维管理端口 容器1985映射到宿主8085
    volumes:
      - /root/srs/config:/usr/local/srs/conf
      - /root/srs/data:/usr/local/srs/objs/nginx/html
    command: ["./objs/srs", "-c", "conf/srs.conf"]
    networks:
      - c_bridge

networks:
  c_bridge:
    external: true

三个端口详细介绍:

  • 1935(TCP)--- RTMP 推流/拉流端口 主播端(OBS、FFmpeg、摄像头、手机 SDK)把流推上来用的就是它,地址形如 rtmp://你的IP:1935/live/流名。观众如果直接用 RTMP 播放器拉流也走这个口。也就是说在RTMP协议下, 它既充当推流口也充当拉流口
  • 1985(TCP)--- HTTP API 管理端口 给程序/运维用的,不是给普通观众用的。比如查版本 curl http://IP:1985/api/v1/versions、查在线流、查客户端列表、做 WebRTC 信令(WHIP/WHEP)、接监控和自动化脚本都走这。浏览器直接开 :1985 能看到 API 返回 JSON,不是漂亮后台页面。
  • 8080(TCP)--- HTTP 服务端口(播放 + 控制台)干两件事:
    1. 发 HTTP 协议直播流:http://IP:8080/live/流名.flv(HTTP-FLV)、http://IP:8080/live/流名.m3u8(HLS)
    2. 内置 Web 控制台/测试播放页:浏览器开 http://IP:8080/ 或 /console/ 能看到 SRS 自带界面、在线测试播放器。

4. 启动服务

执行以下指令会自动拉取镜像, 并按照docker-compose.yml中的配置启动容器:

bash 复制代码
docker compose up -d 
  • -d:后台运行

执行过程中, 如果提示 docker compose 命令不存在,则需要执行以下指令安装compose插件:

bash 复制代码
sudo apt-get update
sudo apt-get install docker-compose-plugin

使用compose启动容器的目的无它, 就是为了方便维护, 免得时间一长忘了🤣

5. 测试服务

  1. 第一步. 在电脑端打开OBS, 使用rtmp://srs所在IP:1935/live/test地址进行推流

    或者使用ffmpeg命令行的方式, 推送桌面屏幕:

    bash 复制代码
    ffmpeg -f avfoundation -framerate 25 -pix_fmt nv12 -i "0:none" \
      -vf "scale=854:480,fps=25,format=yuv420p" \
      -c:v libx264 -preset veryfast -tune zerolatency \
      -profile:v main -level 3.1 \
      -b:v 700k -maxrate 700k -bufsize 1400k \
      -g 50 -keyint_min 50 -sc_threshold 0 -bf 0 \
      -f flv rtmp://SRS_IP:1935/live/test

    或者循环推送本地视频:

    bash 复制代码
    ffmpeg -re -stream_loop -1 -i ./11.mp4 \
           -c:v libx264 -preset veryfast -tune zerolatency -pix_fmt yuv420p \
           -b:v 700k -maxrate 700k -bufsize 1400k -g 50 \
           -c:a aac -b:a 64k -ac 1 -ar 44100 \
           -f flv rtmp://SRS_IP:1935/live/test
  2. 第二步. 在电脑端或者手机打开支持直播源的播放器, 填入rtmp://srs所在IP:1935/live/test地址看是否可以正常播放

如果正常播放, 说明部署成功, 至于播放卡顿之类的, 需要根据你服务器的带宽调整相应的码率,详见下文

关于播放卡顿

由于SRS默认不转码, 推流和播流的码率是一致的, 因此想要流畅播放, 需要确保推流码率不超过带宽, 比如你服务器是1M的出网带宽, 那么码率应设置在800-900kbps甚至500kbps, 当然了码率越低, 清晰度越差, 都是有所牺牲的

有几个概念需要理清楚:

  • 上行带宽: 从电脑传出的方向, 比如我们日常上传文件到网盘, 走的是上行带宽
  • 下行带宽: 传入电脑的方向, 比如我们从网上下载电影
  • 出网带宽: 从服务器传出去的方向, 比如从服务器上传文件到网盘或者用户从网站下载资源, 它们走的是出网带宽
  • 入网带宽: 传入服务器的方向, 比如下载docker镜像到服务器或者用户往服务器上传文件, 或往服务器推流

那么很多人会把下行带宽和出网带宽混为一谈, 同样是下载文件, 但是动作视角不同, 将电脑/服务器当成第一人称视角, 你会发现, 下行带宽和入网带宽是一个意思, 上行带宽和出网带宽是一个意思.

以阿里云1M带宽服务器为例, 这个1M指的是出网带宽, 而不是入网带宽,入网带宽一般会比出网带宽高, 通常在10M左右乃至更高, 方便用户在服务器下载软件部署服务

由于我们在直播闭环中涉及到推流端→中转端→播放端三个端的协作, 所以我们在推流的时候, 需要考虑三个端的带宽, 假设使用本地的OBS推流, 那么需要考虑本地网络的上行带宽以及中转服务器的入网带宽, 而观众在手机上播放直播时需要考虑到本地网络的下行带宽以及中转服务器的出网带宽.

所以, 视频播放卡顿, 有可能出在推流或者服务器那边, 也有可能出在观众那边, 观众的带宽层次不齐我们没法控制, 我们只需要调整推流端和中转端的传输码率, 以确保流畅度即可

因此, 在中转端不进行转码的情况下, 码率设置如下:

  • OBS推流码率: 本地网络的上行带宽和中转服务器的入网带宽以及中转服务器的出网带宽, 三者取最小值设置

如果中转端会进行转码, 码率设置如下:

  • OBS推流码率: 本地网络的上行带宽和中转服务器的入网带宽, 两者取最小值设置
  • 转码输出码率: 不超过中转服务器的出网带宽

如果想要开启SRS的转码功能, 需要在SRS的conf配置文件中的加入以下配置:

bash 复制代码
transcode {
        enabled on; # 开启转码
        ffmpeg ./objs/ffmpeg/bin/ffmpeg;

        # 只开一路低码率,专门适配 1M 出网
        engine ld {
            enabled on;
            vcodec libx264;
            vbitrate 700;          # 转码输出码率 700kbps,出网占用就这数
            vfps 25;               # 降帧到 25,省带宽
            vwidth 854;            # 480p 宽(16:9)
            vheight 480;           # 480p 高
            vprofile baseline;     # 兼容老播放器
            vpreset veryfast;      # 省 CPU
            vthreads 1;
            acodec aac;
            abitrate 64;          # 音频 64kbps
            asample_rate 44100;
            achannels 1;          # 单声道再省一点
            output rtmp://127.0.0.1:[port]/[app]/[stream]_ld;
        }
    }

关于浏览器播放

如果你不想到处安装RTMP播放器, 希望能任意浏览器直接播放, 那么我们可以专门写一个前端页面放置在SRS的html目录下

在/root/srs/data目录下创建index.html文件, 并将以下内容写入到该文件中(记得要替换掉代码中的主机地址和端口号):

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
    <title>直播观看</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
        html, body {
            width: 100%; height: 100%;
            background: #000;
            font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
            color: #fff;
            overflow: hidden;
        }
        #app { display: flex; flex-direction: column; width: 100%; height: 100%; }

        /* 视频区域:移动端铺满,PC 居中限宽 */
        .stage {
            position: relative;
            flex: 1;
            display: flex;
            align-items: center;
            justify-content: center;
            background: #000;
            width: 100%;
        }
        video {
            width: 100%;
            height: 100%;
            object-fit: contain;
            background: #000;
        }
        /* PC 端限制视频最大宽度,居中显示 */
        @media (min-width: 820px) {
            .stage { padding: 16px; }
            video { max-width: 960px; max-height: 540px; border-radius: 12px; }
        }

        /* 加载/提示遮罩 */
        .overlay {
            position: absolute; inset: 0;
            display: flex; flex-direction: column;
            align-items: center; justify-content: center;
            gap: 14px;
            background: rgba(0,0,0,.55);
            z-index: 10;
            padding: 24px;
            text-align: center;
        }
        .overlay.hidden { display: none; }
        .spinner {
            width: 40px; height: 40px;
            border: 4px solid rgba(255,255,255,.25);
            border-top-color: #4facfe;
            border-radius: 50%;
            animation: spin 1s linear infinite;
        }
        @keyframes spin { to { transform: rotate(360deg); } }
        .msg { font-size: 15px; line-height: 1.6; opacity: .92; }
        .btn {
            margin-top: 6px;
            padding: 10px 26px;
            font-size: 15px;
            color: #fff;
            background: linear-gradient(135deg, #4facfe, #00f2fe);
            border: none; border-radius: 24px;
            cursor: pointer;
        }

        /* 顶部状态栏 */
        .bar {
            position: absolute; top: 0; left: 0; right: 0;
            display: flex; align-items: center; gap: 8px;
            padding: 12px 14px;
            padding-top: calc(12px + env(safe-area-inset-top));
            background: linear-gradient(180deg, rgba(0,0,0,.6), transparent);
            font-size: 13px;
            z-index: 5;
        }
        .dot { width: 8px; height: 8px; border-radius: 50%; background: #ff4d4f; }
        .dot.on { background: #52c41a; box-shadow: 0 0 8px #52c41a; }
    </style>
</head>
<body>
<div id="app">
    <div class="stage">
        <video id="player" playsinline webkit-playsinline x5-playsinline controls autoplay></video>

        <div class="bar">
            <span id="dot" class="dot"></span>
            <span id="status">连接中...</span>
        </div>

        <div id="overlay" class="overlay">
            <div class="spinner" id="spinner"></div>
            <div class="msg" id="msg">正在加载直播流...</div>
            <button class="btn hidden" id="retryBtn">点击重试</button>
        </div>
    </div>
</div>

<!-- 关键:全部走公共 CDN,本地无需任何文件 -->
<script src="https://cdn.jsdelivr.net/npm/flv.js@1.6.2/dist/flv.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.13/dist/hls.min.js"></script>

<script>
(function () {
    'use strict';

    // ===== 配置区:改成你自己的 =====
    var HOST = window.location.hostname || '你的服务器IP'; // 若与本页同域可留空自动取
    var API_PORT = '8085';
    var HTTP_PORT = '8088';
    // 流名优先级:地址栏 ?stream=xxx > API 当前第一条流 > 默认 'abc'
    var params = new URLSearchParams(window.location.search);
    var streamParam = params.get('stream');
    // ============================

    var video = document.getElementById('player');
    var overlay = document.getElementById('overlay');
    var spinner = document.getElementById('spinner');
    var msgEl = document.getElementById('msg');
    var retryBtn = document.getElementById('retryBtn');
    var dotEl = document.getElementById('dot');
    var statusEl = document.getElementById('status');

    function showOverlay(text, showRetry) {
        msgEl.textContent = text;
        overlay.classList.remove('hidden');
        spinner.style.display = showRetry ? 'none' : 'block';
        retryBtn.classList.toggle('hidden', !showRetry);
    }
    function hideOverlay() {
        overlay.classList.add('hidden');
    }
    function setStatus(text, on) {
        statusEl.textContent = text;
        dotEl.classList.toggle('on', !!on);
    }

    // 由流名拼出两种地址
    function buildUrls(name) {
        return {
            flv: 'http://' + HOST + ':' + HTTP_PORT + '/live/' + name + '.flv',
            hls: 'http://' + HOST + ':' + HTTP_PORT + '/live/' + name + '.m3u8'
        };
    }

    // 判断平台:iOS 走 HLS,其余走 FLV
    function isIOS() {
        return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
               (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
    }

    var currentPlayer = null;

    function playFLV(url) {
        if (window.flvjs && flvjs.isSupported()) {
            var player = flvjs.createPlayer({
                type: 'flv',
                isLive: true,
                url: url
            }, { enableStashBuffer: false, stashInitialSize: 128 });
            player.attachMediaElement(video);
            player.load();
            player.play();
            currentPlayer = player;
            bindEvents(player, 'flv');
        } else {
            showOverlay('当前浏览器不支持 FLV,请使用 Chrome / Edge / Firefox,或换用 HLS 地址。'+url, true);
        }
    }

    function playHLS(url) {
        if (window.flvjs &&Hls.isSupported()) {
            var hls = new Hls({ liveSyncDurationCount: 3 });
            hls.loadSource(url);
            hls.attachMedia(video);
            currentPlayer = hls;
            hls.on(Hls.Events.MANIFEST_PARSED, function () { video.play(); });
            bindHLSEvents(hls);
        } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
            // iOS Safari 原生
            video.src = url;
            video.play();
        } else {
            showOverlay('当前浏览器不支持 HLS 播放。', true);
        }
    }

    function bindEvents(player, tag) {
        player.on('statistics_info', function () { setStatus('直播中 · ' + tag.toUpperCase(), true); });
        player.on('media_info', function () { hideOverlay(); });
        player.on('error', function (e) {
            console.error(tag + ' error', e);
            setStatus('播放异常', false);
            showOverlay('播放出错,可能是流已中断。', true);
        });
        player.on('loading_complete', function () { /* live 一般不到这 */ });
    }
    function bindHLSEvents(hls) {
        hls.on(Hls.Events.MEDIA_ATTACHED, function () { setStatus('缓冲中...', false); });
        hls.on(Hls.Events.FRAG_LOADED, function () { hideOverlay(); setStatus('直播中 · HLS', true); });
        hls.on(Hls.Events.ERROR, function (_, data) {
            if (data && data.fatal) {
                setStatus('播放异常', false);
                showOverlay('播放出错:' + (data.details || '未知错误'), true);
            }
        });
    }

    function start(name) {
        var urls = buildUrls(name);
        hideOverlay();
        setStatus('缓冲中...', false);
        if (isIOS()) {
            playHLS(urls.hls);
        } else {
            playFLV(urls.flv);
        }
    }

    retryBtn.addEventListener('click', function () {
        if (streamParam) { start(streamParam); return; }
        // 无指定流名时重新拉取 API
        fetchStreamAndStart();
    });

    // 优先用地址栏指定流;否则查 SRS API 取当前第一条在线流
    function fetchStreamAndStart() {
        var apiUrl = 'http://' + HOST + ':' + API_PORT + '/api/v1/streams/';
        fetch(apiUrl)
            .then(function (r) { return r.json(); })
            .then(function (data) {
                var list = (data && data.streams) || [];
                if (list.length === 0) {
                    setStatus('无直播', false);
                    showOverlay('当前没有正在直播的流,请先推流后再刷新。', true);
                    return;
                }
                // 取第一条的 name(rtmp app/stream 中的 stream 部分)
                var name = list[0].name;
                start(name);
            })
            .catch(function (e) {
                console.error(e);
                setStatus('连接失败', false);
                showOverlay('无法连接直播服务(' + apiUrl + '),请检查服务器地址、端口与安全组。', true);
            });
    }

    // 入口
    if (streamParam) {
        start(streamParam);
    } else {
        fetchStreamAndStart();
    }

    // 页面隐藏时暂停,恢复时重连(省流量、避黑屏)
    document.addEventListener('visibilitychange', function () {
        if (document.hidden) {
            try { video.pause(); } catch (e) {}
        }
    });
})();
</script>
</body>
</html>

此时在浏览器中输入http://中转服务器IP:8088/即可实现浏览器观看直播

如果使用ffmpeg推流时浏览器可以正常播放, 但是换成OBS推流却播放不了, 那问题大概率出在OBS直播配置上, 需要在直播设置→输出→高级输出模式页面中, 在x264选项栏中填入以下内容, 用于兼容浏览器设备:

bash 复制代码
keyint=50:min-keyint=50:scenecut=0:bframes=0:profile=main:pix_fmt=yuv420p

音乐+技术公众号《乱码三千》,欢迎关注~

本文为作者原创 转载时请注明出处 谢谢

乱码三千 -- 点滴积累 ,欢迎来到乱码三千技术博客站

相关推荐
Gust of wind13 分钟前
串与KMP模式匹配:存储结构、基本操作、next数组手算
c语言·数据结构·后端·算法
endswel21 分钟前
Spring bean 注册多种方式
java·后端·spring
铁皮饭盒25 分钟前
还是网页端, 46mb模型, 抠图功能升级了, 抠任意主体, 还是不要显卡, 不要python, 满意吗?
前端·javascript·后端
小林coding32 分钟前
Flash模型又添一员大将:实测MiniMax M3.1
后端
欣欣之王来了43 分钟前
Python入门:Jupyter Notebook安装与使用指南
学习·架构·面向对象·项目·python教程
颜进强1 小时前
25 · NestJs DurableProviders 持久化 Provider:把 ContextId 当缓存键
前端·后端·ai编程
来自于狂人1 小时前
GitHub 开源趋势日报 | 2026年10月7日用 AI Agent 逆向工程一切
人工智能·开源·github
tltwuyulw1 小时前
Java的函数式编程(四)
后端
我的xiaodoujiao1 小时前
Django 基础知识详细图文教程 14-Django 表单定义与使用
开发语言·后端·python·django