release: nexus btpanel session fix and app-v2
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Nexus 负载压测脚本
|
||||
测试: /health, /health/services, /api/servers/stats, /api/agent/heartbeat, /api/servers/exec
|
||||
|
||||
用法:
|
||||
NEXUS_API_KEY=your-key python tests/load_test.py [--concurrency 50] [--duration 30]
|
||||
NEXUS_TEST_BASE=http://127.0.0.1:8600 (可选)
|
||||
|
||||
注意 (BL-06): /api/agent/heartbeat 仅接受 per-server agent_api_key,不再接受 global API_KEY。
|
||||
心跳压测需设置该服务器的 per-server key(与 SERVER_ID 对应),或跳过 heartbeat worker。
|
||||
"""
|
||||
import asyncio
|
||||
import httpx
|
||||
import time
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
|
||||
BASE = os.environ.get("NEXUS_TEST_BASE", "http://127.0.0.1:8600")
|
||||
API_KEY = os.environ.get("NEXUS_API_KEY", "")
|
||||
|
||||
# 取一台在线服务器做 exec 测试
|
||||
SERVER_ID = 1 # 默认,会动态查找
|
||||
|
||||
stats = {"total": 0, "ok": 0, "fail": 0, "times": [], "errors": []}
|
||||
|
||||
|
||||
async def hit(client: httpx.AsyncClient, name: str, method: str, path: str, body=None, need_key=True):
|
||||
headers = {}
|
||||
if need_key and API_KEY:
|
||||
headers["X-API-Key"] = API_KEY
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
if method == "GET":
|
||||
r = await client.get(f"{BASE}{path}", headers=headers, timeout=30)
|
||||
elif method == "POST":
|
||||
r = await client.post(f"{BASE}{path}", json=body, headers=headers, timeout=30)
|
||||
elapsed = time.perf_counter() - t0
|
||||
if r.status_code == 200:
|
||||
stats["ok"] += 1
|
||||
else:
|
||||
stats["fail"] += 1
|
||||
stats["errors"].append(f"[{r.status_code}] {name} {path}")
|
||||
except Exception as e:
|
||||
elapsed = time.perf_counter() - t0
|
||||
stats["fail"] += 1
|
||||
stats["errors"].append(f"[EXCEPTION] {name}: {str(e)[:80]}")
|
||||
stats["total"] += 1
|
||||
stats["times"].append(elapsed)
|
||||
|
||||
|
||||
async def health_worker(client: httpx.AsyncClient, duration: float):
|
||||
"""轻量级 health check 持续请求"""
|
||||
end = time.time() + duration
|
||||
while time.time() < end:
|
||||
await hit(client, "health", "GET", "/health", need_key=False)
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
async def health_services_worker(client: httpx.AsyncClient, duration: float):
|
||||
"""health/services 持续请求"""
|
||||
end = time.time() + duration
|
||||
while time.time() < end:
|
||||
await hit(client, "health/services", "GET", "/health/services", need_key=False)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
async def stats_worker(client: httpx.AsyncClient, duration: float):
|
||||
"""仪表盘 stats (Redis 缓存)"""
|
||||
end = time.time() + duration
|
||||
while time.time() < end:
|
||||
await hit(client, "stats", "GET", "/api/servers/stats")
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
async def heartbeat_worker(client: httpx.AsyncClient, duration: float):
|
||||
"""Agent 心跳压测"""
|
||||
end = time.time() + duration
|
||||
while time.time() < end:
|
||||
await hit(client, "heartbeat", "POST", "/api/agent/heartbeat", body={
|
||||
"cpu": 25.0, "memory": 60.5, "disk": 45.2,
|
||||
"load_1": 0.5, "load_5": 0.3, "load_15": 0.2,
|
||||
"uptime": 86400, "os": "Ubuntu 24.04"
|
||||
}, need_key=False)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
|
||||
async def exec_worker(client: httpx.AsyncClient, duration: float, server_id: int):
|
||||
"""群发命令压测 (走 ThreadPoolExecutor SSH)"""
|
||||
end = time.time() + duration
|
||||
while time.time() < end:
|
||||
await hit(client, "exec", "POST", f"/api/servers/{server_id}/exec", body={
|
||||
"command": "echo 'load-test' && date"
|
||||
})
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
||||
def print_report(concurrency: int, duration: float):
|
||||
times = sorted(stats["times"]) if stats["times"] else [0]
|
||||
n = len(times)
|
||||
p50 = times[int(n * 0.50)] if n > 0 else 0
|
||||
p95 = times[min(int(n * 0.95), n - 1)] if n > 0 else 0
|
||||
p99 = times[min(int(n * 0.99), n - 1)] if n > 0 else 0
|
||||
rps = stats["total"] / duration if duration > 0 else 0
|
||||
err_rate = stats["fail"] / stats["total"] * 100 if stats["total"] > 0 else 0
|
||||
|
||||
print(f"""
|
||||
{'='*60}
|
||||
Nexus 压测报告
|
||||
{'='*60}
|
||||
并发: {concurrency}
|
||||
持续时间: {duration}s
|
||||
总请求: {stats['total']}
|
||||
成功: {stats['ok']}
|
||||
失败: {stats['fail']}
|
||||
错误率: {err_rate:.1f}%
|
||||
RPS: {rps:.1f}
|
||||
延迟 P50: {p50*1000:.1f}ms
|
||||
延迟 P95: {p95*1000:.1f}ms
|
||||
延迟 P99: {p99*1000:.1f}ms
|
||||
最慢: {max(times)*1000:.1f}ms
|
||||
最快: {min(times)*1000:.1f}ms
|
||||
{'='*60}
|
||||
""")
|
||||
if stats["errors"]:
|
||||
print("错误采样 (前10):")
|
||||
for e in stats["errors"][:10]:
|
||||
print(f" {e}")
|
||||
|
||||
|
||||
async def main():
|
||||
global BASE, API_KEY, SERVER_ID, stats
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--concurrency", type=int, default=50)
|
||||
parser.add_argument("--duration", type=int, default=30)
|
||||
parser.add_argument("--no-exec", action="store_true", help="跳过 SSH exec 测试")
|
||||
args = parser.parse_args()
|
||||
|
||||
global BASE, API_KEY
|
||||
BASE = os.environ.get("NEXUS_TEST_BASE", "http://127.0.0.1:8600")
|
||||
API_KEY = os.environ.get("NEXUS_API_KEY", "")
|
||||
if not API_KEY:
|
||||
print("WARN: NEXUS_API_KEY unset — stats/exec workers may be skipped", file=sys.stderr)
|
||||
|
||||
concurrency = args.concurrency
|
||||
duration = args.duration
|
||||
print(f"压测配置: BASE={BASE}, 并发={concurrency}, 时长={duration}s")
|
||||
|
||||
# 动态获取一台在线服务器
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
if not args.no_exec and API_KEY:
|
||||
try:
|
||||
r = await client.get(f"{BASE}/api/servers/", headers={"X-API-Key": API_KEY})
|
||||
servers = r.json() if r.status_code == 200 else []
|
||||
if isinstance(servers, dict):
|
||||
servers = servers.get("data", [])
|
||||
online = [s for s in servers if s.get("is_online")]
|
||||
if online:
|
||||
SERVER_ID = online[0]["id"]
|
||||
print(f"exec 目标服务器: id={SERVER_ID} name={online[0].get('name', '?')}")
|
||||
else:
|
||||
print("无在线服务器,跳过 exec 测试")
|
||||
args.no_exec = True
|
||||
except Exception as e:
|
||||
print(f"获取服务器列表失败: {e},跳过 exec 测试")
|
||||
args.no_exec = True
|
||||
else:
|
||||
args.no_exec = True
|
||||
|
||||
stats = {"total": 0, "ok": 0, "fail": 0, "times": [], "errors": []}
|
||||
|
||||
async with httpx.AsyncClient(limits=httpx.Limits(max_connections=concurrency + 100, max_keepalive_connections=concurrency)) as client:
|
||||
tasks = []
|
||||
|
||||
# 分配权重: health 40%, stats 20%, health-services 10%, heartbeat 20%, exec 10%
|
||||
n_health = max(1, int(concurrency * 0.4))
|
||||
n_stats = max(1, int(concurrency * 0.2))
|
||||
n_hs = max(1, int(concurrency * 0.1))
|
||||
n_hb = max(1, int(concurrency * 0.2))
|
||||
n_exec = max(1, int(concurrency * 0.1))
|
||||
|
||||
print(f"Worker: health={n_health} stats={n_stats} health-svc={n_hs} heartbeat={n_hb} exec={n_exec}")
|
||||
|
||||
for _ in range(n_health):
|
||||
tasks.append(health_worker(client, duration))
|
||||
for _ in range(n_stats):
|
||||
tasks.append(stats_worker(client, duration))
|
||||
for _ in range(n_hs):
|
||||
tasks.append(health_services_worker(client, duration))
|
||||
for _ in range(n_hb):
|
||||
tasks.append(heartbeat_worker(client, duration))
|
||||
if not args.no_exec:
|
||||
for _ in range(n_exec):
|
||||
tasks.append(exec_worker(client, duration, SERVER_ID))
|
||||
|
||||
print(f"启动 {len(tasks)} 个 worker...")
|
||||
t0 = time.time()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
elapsed = time.time() - t0
|
||||
print_report(concurrency, elapsed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user