私有化部署WebRTC:基于aiortc实现Web浏览器直接预览远程摄像头
-
- 引言:为什么需要这个方案?
- 一、WebRTC与aiortc:技术基础
-
- 什么是WebRTC?
- 为什么选择aiortc?
- 二、系统架构设计
-
- 组件说明:
- 三、详细部署步骤
-
- 步骤1:环境准备
- 步骤2:部署STUN/TURN服务器
-
- 2.1 安装coturn
- 2.2 配置防火墙
- 2.3 测试STUN/TURN服务
- 步骤3:部署信令服务器
-
- 3.1 创建信令服务器脚本
- 3.2 安装依赖并运行
- 步骤4:部署Web服务器
-
- 4.1 创建Web服务器
- 4.2 创建JavaScript客户端
- 4.3 设置环境变量并运行
- 步骤5:部署设备端程序
-
- 5.1 创建设备端Python程序
- 5.2 运行设备端
- 步骤6:Web预览
引言:为什么需要这个方案?
在现代物联网和安防监控领域,远程查看摄像头视频流是一个常见需求。传统的解决方案通常采用RTSP协议,但这种方案存在一个致命缺陷:现代Web浏览器(如Chrome、Firefox)无法直接播放RTSP流。
之前我尝试了以下方案:
- 第三方P2P穿透方案:仅支持Windows/Linux Qt客户端,不支持Web浏览器
- EasyRTC等现成方案:无法私有化部署,稳定性欠佳
- 直接使用libwebrtc:编译复杂,依赖庞大
今天,我介绍一个基于aiortc的简化方案,它能够:
一、WebRTC与aiortc:技术基础
什么是WebRTC?
WebRTC(Web实时通信)是一个开源项目,允许Web浏览器和移动应用进行实时音视频通信和数据传输,而无需安装任何插件。它的核心优势在于:
为什么选择aiortc?
aiortc是一个基于Python asyncio的WebRTC实现库,相比原生libwebrtc,它有如下优势:
| 安装复杂度 | pip一键安装 | 需要复杂编译 |
| 语言友好性 | Python,易于集成 | C++,集成复杂 |
| 代码简洁性 | 高级API,代码简洁 | 低级API,代码冗长 |
| 灵活性 | 易于自定义和扩展 | 修改困难 |
二、系统架构设计
我们的解决方案包含四个核心组件:
#mermaid-svg-s2abkVpUFa8EHZ3G{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-s2abkVpUFa8EHZ3G .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-s2abkVpUFa8EHZ3G .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-s2abkVpUFa8EHZ3G .error-icon{fill:#552222;}#mermaid-svg-s2abkVpUFa8EHZ3G .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-s2abkVpUFa8EHZ3G .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-s2abkVpUFa8EHZ3G .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-s2abkVpUFa8EHZ3G .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-s2abkVpUFa8EHZ3G .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-s2abkVpUFa8EHZ3G .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-s2abkVpUFa8EHZ3G .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-s2abkVpUFa8EHZ3G .marker{fill:#333333;stroke:#333333;}#mermaid-svg-s2abkVpUFa8EHZ3G .marker.cross{stroke:#333333;}#mermaid-svg-s2abkVpUFa8EHZ3G svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-s2abkVpUFa8EHZ3G p{margin:0;}#mermaid-svg-s2abkVpUFa8EHZ3G .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-s2abkVpUFa8EHZ3G .cluster-label text{fill:#333;}#mermaid-svg-s2abkVpUFa8EHZ3G .cluster-label span{color:#333;}#mermaid-svg-s2abkVpUFa8EHZ3G .cluster-label span p{background-color:transparent;}#mermaid-svg-s2abkVpUFa8EHZ3G .label text,#mermaid-svg-s2abkVpUFa8EHZ3G span{fill:#333;color:#333;}#mermaid-svg-s2abkVpUFa8EHZ3G .node rect,#mermaid-svg-s2abkVpUFa8EHZ3G .node circle,#mermaid-svg-s2abkVpUFa8EHZ3G .node ellipse,#mermaid-svg-s2abkVpUFa8EHZ3G .node polygon,#mermaid-svg-s2abkVpUFa8EHZ3G .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-s2abkVpUFa8EHZ3G .rough-node .label text,#mermaid-svg-s2abkVpUFa8EHZ3G .node .label text,#mermaid-svg-s2abkVpUFa8EHZ3G .image-shape .label,#mermaid-svg-s2abkVpUFa8EHZ3G .icon-shape .label{text-anchor:middle;}#mermaid-svg-s2abkVpUFa8EHZ3G .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-s2abkVpUFa8EHZ3G .rough-node .label,#mermaid-svg-s2abkVpUFa8EHZ3G .node .label,#mermaid-svg-s2abkVpUFa8EHZ3G .image-shape .label,#mermaid-svg-s2abkVpUFa8EHZ3G .icon-shape .label{text-align:center;}#mermaid-svg-s2abkVpUFa8EHZ3G .node.clickable{cursor:pointer;}#mermaid-svg-s2abkVpUFa8EHZ3G .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-s2abkVpUFa8EHZ3G .arrowheadPath{fill:#333333;}#mermaid-svg-s2abkVpUFa8EHZ3G .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-s2abkVpUFa8EHZ3G .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-s2abkVpUFa8EHZ3G .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-s2abkVpUFa8EHZ3G .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-s2abkVpUFa8EHZ3G .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-s2abkVpUFa8EHZ3G .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-s2abkVpUFa8EHZ3G .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-s2abkVpUFa8EHZ3G .cluster text{fill:#333;}#mermaid-svg-s2abkVpUFa8EHZ3G .cluster span{color:#333;}#mermaid-svg-s2abkVpUFa8EHZ3G div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-s2abkVpUFa8EHZ3G .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-s2abkVpUFa8EHZ3G rect.text{fill:none;stroke-width:0;}#mermaid-svg-s2abkVpUFa8EHZ3G .icon-shape,#mermaid-svg-s2abkVpUFa8EHZ3G .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-s2abkVpUFa8EHZ3G .icon-shape p,#mermaid-svg-s2abkVpUFa8EHZ3G .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-s2abkVpUFa8EHZ3G .icon-shape rect,#mermaid-svg-s2abkVpUFa8EHZ3G .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-s2abkVpUFa8EHZ3G .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-s2abkVpUFa8EHZ3G .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-s2abkVpUFa8EHZ3G :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
视频流
信令交换
ICE协商
SDP/ICE转发
SDP/ICE转发
远程设备
WebRTC PeerConnection
Web浏览器
信令服务器
STUN/TURN服务器
组件说明:
STUN/TURN服务器(coturn)
- STUN:帮助设备发现自己的公网IP和端口
- TURN:当P2P无法建立时,作为数据中转服务器
信令服务器(Python WebSocket)
- 交换WebRTC连接所需的SDP(会话描述协议)和ICE候选地址
- 管理设备注册和发现
Web服务器(Python HTTP)
- 提供Web页面给用户访问
- 集成WebRTC JavaScript客户端
设备端(Python aiortc)
- 捕获视频流(摄像头、文件、虚拟视频)
- 通过WebRTC传输视频到Web端
三、详细部署步骤
步骤1:环境准备
在开始之前,请确保你有一台具有公网IP的云服务器(如阿里云ECS)。我们将在这台服务器上部署所有服务。
首先设置环境变量(这些变量将在后续脚本中使用):
export ECS_IP_ADDR=替换为你的云服务器实际IP地址
export STUN_SRV=$ECS_IP_ADDR
export STUN_PORT=3478
export SIGNALING_SRV=$ECS_IP_ADDR #信令服务器,用来交换WebRTC连接需要的信息
export SIGNALING_PORT=9901
export WEB_SRV=$ECS_IP_ADDR # Web服务器
export WEB_PORT=9900
重要提示:将这些变量添加到你的~/.bashrc文件中,以便每次登录时自动加载。
步骤2:部署STUN/TURN服务器
STUN/TURN服务器是WebRTC的\”网络向导\”,帮助两个设备找到彼此并建立连接。
2.1 安装coturn
# 在CentOS/RHEL系统上安装
yum install coturn -y
# 配置coturn
cat > /etc/coturn/turnserver.conf << EOF
listening-ip=0.0.0.0
listening-port=$STUN_PORT
relay-ip=$(ifconfig eth0 | grep -w inet | awk \'{print $2}\’)
external-ip=$ECS_IP_ADDR
log-file=/var/tmp/turn.log
min-port=40000
max-port=65535
EOF
systemctl restart coturn
systemctl status coturn
2.2 配置防火墙
云服务器需要开放以下端口:
- 3478:STUN/TURN服务端口
- 9900:Web服务端口
- 9901:信令服务器端口
- 40000-65535:TURN中继端口范围
在阿里云控制台的安全组中,添加入站规则允许这些端口的TCP和UDP流量。

2.3 测试STUN/TURN服务
- 访问Trickle ICE

stun:你的服务器IP:3478
turn:你的服务器IP:3478
使用用户名webrtc,密码webrtc。如果测试成功,你会看到类似以下结果:
- srflx:STUN穿透成功
- relay:TURN中转可用
步骤3:部署信令服务器
信令服务器是WebRTC连接的\”媒人\”,负责交换连接信息。它使用WebSocket协议进行实时通信。
3.1 创建信令服务器脚本
cat > signaling_server.py << EOF
import asyncio
import json
import logging
from aiohttp import web
import aiohttp_cors
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class SignalingServer:
def __init__(self):
self.app = web.Application()
self.clients = {
} # 存储连接的用户
self.setup_routes()
def setup_routes(self):
cors = aiohttp_cors.setup(self.app, defaults={
\”*\”: aiohttp_cors.ResourceOptions(
allow_credentials=True,
expose_headers=\”*\”,
allow_headers=\”*\”,
)
})
# 添加WebSocket路由
resource = cors.add(self.app.router.add_resource(\’/ws\’))
cors.add(resource.add_route(\”GET\”, self.websocket_handler))
# 健康检查路由
self.app.router.add_get(\’/health\’, self.health_check)
# 获取在线设备列表
self.app.router.add_get(\’/devices\’, self.get_devices)
async def health_check(self, request):
return web.Response(text=\’OK\’)
async def get_devices(self, request):
\”\”\”获取所有在线的设备\”\”\”
devices = []
for client_id, client_info in self.clients.items():
if client_info[\’type\’] == \’device\’:
devices.append({
\’id\’: client_id,
\’type\’: client_info[\’type\’]
})
return web.json_response({
\’devices\’: devices})
async def websocket_handler(self, request):
ws = web.WebSocketResponse()
await ws.prepare(request)
client_id = None
client_type = None
try:
async for msg in ws:
if msg.type == web.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
message_type = data.get(\’type\’)
if message_type == \’register\’:
# 客户端注册
client_id = data.get(\’client_id\’)
client_type = data.get(\’client_type\’) # \’web\’ 或 \’device\’
if client_id in self.clients:
try:
await self.clients[client_id][\’ws\’].close()
except:
pass
self.clients[client_id] = {
\’ws\’: ws,
\’type\’: client_type,
\’client_id\’: client_id
}
logger.info(f\”Client registered: {
client_id} ({
client_type})\”)
await ws.send_str(json.dumps({
\’type\’: \’registered\’,
\’status\’: \’success\’
}))
elif message_type == \’offer\’:
# 转发 offer 到对应的设备端
target_device = data.get(\’target_device\’)
offer_data = data.get(\’offer\’)
if target_device in self.clients:
await self.clients[target_device][\’ws\’].send_str(json.dumps({
\’type\’: \’offer\’,
\’offer\’: offer_data,
\’from_client\’: client_id
}))
logger.info(f\”Offer forwarded from {
client_id} to {
target_device}\”)
else:
logger.warning(f\”Target device not found: {
target_device}\”)
await ws.send_str(json.dumps({
\’type\’: \’error\’,
\’message\’: f\’Device {
target_device} not found\’
}))
elif message_type == \’answer\’:
# 转发 answer 到对应的网页端
target_web = data.get(\’target_web\’)
answer_data = data.get(\’answer\’)
if target_web in self.clients:
await self.clients[target_web][\’ws\’].send_str(json.dumps({
\’type\’: \’answer\’,
\’answer\’: answer_data,
\’from_client\’: client_id
}))
logger.info(f\”Answer forwarded from {
client_id} to {
target_web}\”)
elif message_type == \’ice_candidate\’:
# 转发 ICE candidate
target = data.get(\’target\’)
candidate = data.get(\’candidate\’)
if target in self.clients:
await self.clients[target][\’ws\’].send_str(json.dumps({
\’type\’: \’ice_candidate\’,
\’candidate\’: candidate,
\’from_client\’: client_id
}))
elif message_type == \’ping\’:
# 心跳检测
await ws.send_str(json.dumps({
\’type\’: \’pong\’}))
except json.JSONDecodeError as e:
logger.error(f\”JSON解析错误: {
e}\”)
elif msg.type == web.WSMsgType.ERROR:
logger.error(f\’WebSocket error: {
ws.exception()}\’)
except Exception as e:
logger.error(f\”WebSocket error: {
e}\”)
finally:
# 清理客户端
if client_id and client_id in self.clients:
del self.clients[client_id]
logger.info(f\”Client disconnected: {
client_id}\”)
return ws
def main():
server = SignalingServer()
logger.info(\”Starting signaling server on http://0.0.0.0:$SIGNALING_PORT\”)
web.run_app(server.app, host=\’0.0.0.0\’, port=$SIGNALING_PORT)
if __name__ == \’__main__\’:
main()
EOF
3.2 安装依赖并运行
# 安装Python环境(如果使用conda)
yum install conda -y
conda create -n myenv python=3.12
source ~/.bashrc
conda activate myenv
# 安装Python依赖
pip install aiohttp
pip install aiohttp_cors
# 注意:脚本中的环境变量需要替换为实际值
# 运行信令服务器
python signaling_server.py
步骤4:部署Web服务器
Web服务器提供用户访问的界面,包含WebRTC JavaScript客户端。
4.1 创建Web服务器
cat > web_server.py << EOF
from aiohttp import web
import aiohttp_cors
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class WebServer:
def __init__(self):
self.app = web.Application()
self.setup_routes()
def setup_routes(self):
cors = aiohttp_cors.setup(self.app, defaults={
\”*\”: aiohttp_cors.ResourceOptions(
allow_credentials=True,
expose_headers=\”*\”,
allow_headers=\”*\”,
)
})
# 主页面
self.app.router.add_get(\’/\’, self.index_handler)
self.app.router.add_get(\’/index.html\’, self.index_handler)
# 设备端页面
self.app.router.add_get




