负载均衡重构: 子网感知选择 + 节点健康监控 + 节点注册上报API

- 新增 subnet-aware 负载策略: 根据客户端IP匹配最近/16子网节点
- 节点注册端点 (POST /api/nodes/register): 边缘节点自动注册
- 节点心跳端点 (POST /api/nodes/heartbeat): 上报CPU/RAM/带宽/连接数
- 健康汇总 (GET /api/nodes/health): 服务端汇总所有节点状态
- 配置节点新增subnet/is_origin字段
- 前端NodeManager: 实时健康状态/CPU/RAM进度条/带宽/连接数
- X-Forwarded-For/X-Real-IP透传支持客户端IP
This commit is contained in:
admin committed 2026-08-10 14:45:28 +08:00
1 parent 5f19fc6aa5
commit 980b91f41f
7 files changed
+319 -87

No files matched your search

+122 -25
View File
@@ -1,43 +1,140 @@
import json
import asyncio
import subprocess
import ipaddress
import time
from config import settings
class LoadBalancer:
def __init__(self):
self._rr_index = 0
self._conn_counts: dict[str, int] = {n["id"]: 0 for n in settings.edge_nodes}
self._conn_counts: dict[str, int] = {}
self._node_health: dict[str, dict] = {}
def get_enabled_nodes(self):
return [n for n in settings.edge_nodes if n.get("enabled", True)]
def select_nodes(self, strategy: str | None = None):
nodes = self.get_enabled_nodes()
strategy = strategy or settings.load_balance_strategy
def get_node_by_id(self, node_id: str):
for n in settings.edge_nodes:
if n["id"] == node_id:
return n
return None
def select_best_node(self, client_ip: str | None = None):
nodes = self.get_enabled_nodes()
if not nodes:
return None
if client_ip:
best = self._find_subnet_match(client_ip, nodes)
if best:
return best
strategy = settings.load_balance_strategy
if strategy == "round-robin":
if not nodes:
return []
chosen = nodes[self._rr_index % len(nodes)]
online = [n for n in nodes if self.is_online(n["id"])]
if not online:
online = nodes
chosen = online[self._rr_index % len(online)]
self._rr_index += 1
return [chosen]
return chosen
elif strategy == "weighted":
total = sum(n["weight"] for n in nodes)
if total == 0:
return nodes
import random
r = random.uniform(0, total)
cumulative = 0
for n in nodes:
cumulative += n["weight"]
if r <= cumulative:
return [n]
return [nodes[-1]]
return self._weighted_select(nodes)
elif strategy == "least-conn":
sorted_nodes = sorted(nodes, key=lambda n: self._conn_counts.get(n["id"], 0))
return [sorted_nodes[0]]
return nodes
return self._least_conn_select(nodes)
else:
return self._weighted_select(nodes)
def _find_subnet_match(self, client_ip: str, nodes: list[dict]):
try:
ip = ipaddress.ip_address(client_ip)
except ValueError:
return None
best = None
best_prefix = -1
for node in nodes:
if not self.is_online(node["id"]):
continue
subnet_str = node.get("subnet", "0.0.0.0/0")
if not subnet_str:
continue
try:
subnet = ipaddress.ip_network(subnet_str, strict=False)
except ValueError:
continue
if ip in subnet:
if subnet.prefixlen > best_prefix:
best_prefix = subnet.prefixlen
best = node
return best
def _weighted_select(self, nodes: list[dict]):
online = [n for n in nodes if self.is_online(n["id"])]
if not online:
online = nodes
total = sum(n["weight"] for n in online)
if total == 0:
return online[0] if online else None
import random
r = random.uniform(0, total)
cumulative = 0
for n in online:
cumulative += n["weight"]
if r <= cumulative:
return n
return online[-1]
def _least_conn_select(self, nodes: list[dict]):
online = [n for n in nodes if self.is_online(n["id"])]
if not online:
online = nodes
sorted_nodes = sorted(online, key=lambda n: self._conn_counts.get(n["id"], 0))
return sorted_nodes[0] if sorted_nodes else None
def report_health(self, node_id: str, metrics: dict):
self._node_health[node_id] = {
"last_seen": time.time(),
"online": True,
**metrics,
}
def is_online(self, node_id: str) -> bool:
h = self._node_health.get(node_id)
if h is None:
if any(n["id"] == node_id and n.get("is_origin") for n in settings.edge_nodes):
return True
return False
if time.time() - h.get("last_seen", 0) > 30:
return False
return h.get("online", False)
def get_health_summary(self):
result = {}
for node in settings.edge_nodes:
nid = node["id"]
h = self._node_health.get(nid)
result[nid] = {
"name": node["name"],
"host": node["host"],
"online": self.is_online(nid),
"weight": node["weight"],
"enabled": node.get("enabled", True),
"subnet": node.get("subnet"),
"is_origin": node.get("is_origin", False),
"last_seen": h.get("last_seen") if h else None,
"metrics": {
"cpu_percent": h.get("cpu_percent"),
"memory_percent": h.get("memory_percent"),
"bandwidth_mbps": h.get("bandwidth_mbps"),
"active_connections": h.get("active_connections"),
"hls_readers": h.get("hls_readers"),
} if h else None,
}
return result
def increment_connection(self, node_id: str):
self._conn_counts[node_id] = self._conn_counts.get(node_id, 0) + 1
+12 -16
View File
@@ -69,12 +69,6 @@ class StreamService:
status=StreamStatus.live,
started_at=datetime.utcnow(),
)
assigned = [n["id"] for n in lb.get_enabled_nodes()]
await self.update_stream(
stream.id,
assigned_nodes=json.dumps(assigned),
multicast_enabled=multicast_detector.is_available(),
)
rtmp_url = media_service.get_rtmp_url(stream_key)
transcode_mgr.start_transcode(stream_key, rtmp_url)
@@ -89,20 +83,22 @@ class StreamService:
)
transcode_mgr.stop_transcode(stream_key)
async def get_watch_urls(self, stream_key: str):
nodes = lb.get_enabled_nodes()
async def get_watch_urls(self, stream_key: str, client_ip: str | None = None):
best_node = lb.select_best_node(client_ip)
if not best_node:
best_node = lb.get_enabled_nodes()[0] if lb.get_enabled_nodes() else None
lines = []
for node in nodes:
if best_node:
base_hls = f"/hls/{stream_key}"
line_info = {
"node_id": node["id"],
"node_name": node["name"],
"weight": node["weight"],
"node_id": best_node["id"],
"node_name": best_node["name"],
"weight": best_node["weight"],
"type": "hls",
"url": f"{base_hls}/index.m3u8",
"qualities": [],
}
base_hls = f"/hls/{stream_key}"
line_info["type"] = "hls"
line_info["url"] = f"{base_hls}/index.m3u8"
for quality in settings.qualities:
label = quality["label"]
line_info["qualities"].append({