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

前言

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

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

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

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

具体部署操作如下:

部署流程

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

1. 创建自定义网络

1
docker network create c_bridge

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

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

1
2
3
4
5
6
#创建持久化数据映射目录
mkdir -p /root/srs
#创建工作目录
mkdir srs-docker
#进入工作目录
cd srs-docker

3. 创建 docker-compose.yml

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

1
vim docker-compose.yml

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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中的配置启动容器:

1
docker compose up -d
  • -d:后台运行

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

1
2
sudo apt-get update
sudo apt-get install docker-compose-plugin

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

5. 测试服务

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

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

    1
    2
    3
    4
    5
    6
    7
    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

    或者循环推送本地视频:

    1
    2
    3
    4
    5
    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
  1. 第二步. 在电脑端或者手机打开支持直播源的播放器, 填入rtmp://srs所在IP:1935/live/test地址看是否可以正常播放

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

关于播放卡顿

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

有几个概念需要理清楚:

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

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

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

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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播放器, 希望能任意浏览器直接播放, 那么我们可以专门写一个前端页面放置在SRShtml目录下

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
<!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/即可实现浏览器观看直播

image-20260831135506370

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

1
keyint=50:min-keyint=50:scenecut=0:bframes=0:profile=main:pix_fmt=yuv420p

image-20260831171330174

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

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

0%