多Worker守护 + 告警服务器名 + 清理PHP残留
- main.py: Redis主Worker选举,防止多Worker重复执行后台任务
- agent.py: 告警/恢复广播使用真实服务器名(查MySQL)替代"server-{id}"
- sync_v2.py: 修复browse目录解析死代码,正确取parts[8]
- install.html: 移除过时install.php引用,更新为"删除.env重装"
- nginx配置: 移除PHP-FPM路由(install.html纯静态无需PHP)
- schedule_runner: 清理未使用的get_redis_sync导入
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,6 @@
|
||||
# {{SERVER_NAME}} = your domain or IP (e.g. nexus.example.com or 192.168.1.100)
|
||||
# {{DEPLOY_DIR}} = installation root (e.g. /opt/nexus or /www/wwwroot/nexus.example.com)
|
||||
# {{LOG_DIR}} = nginx log directory (e.g. /var/log/nginx or /www/wwwlogs)
|
||||
# {{PHP_SOCK}} = PHP-FPM socket path (e.g. unix:/tmp/php-cgi-82.sock) — only if install.php is used
|
||||
|
||||
# ── Upstream: Python FastAPI backend ──
|
||||
upstream nexus_api {
|
||||
@@ -51,20 +50,6 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# ── install.php (PHP-FPM for installer, root = /web/) ──
|
||||
location ~ ^/install\.php$ {
|
||||
root {{DEPLOY_DIR}}/web;
|
||||
fastcgi_pass {{PHP_SOCK}};
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
# ── Installer + Agent assets (root = /web/) ──
|
||||
location ~ ^/(css|js|vendor|agent)/ {
|
||||
root {{DEPLOY_DIR}}/web;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# ── Static assets ──
|
||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 7d;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
# {{LOG_DIR}} = nginx log directory (e.g. /var/log/nginx or /www/wwwlogs)
|
||||
# {{SSL_CERT}} = SSL certificate fullchain path
|
||||
# {{SSL_KEY}} = SSL private key path
|
||||
# {{PHP_SOCK}} = PHP-FPM socket path (e.g. unix:/tmp/php-cgi-82.sock) — only if install.php is used
|
||||
|
||||
# ── Upstream: Python FastAPI backend ──
|
||||
upstream nexus_api {
|
||||
@@ -42,20 +41,6 @@ server {
|
||||
root {{DEPLOY_DIR}}/web/app;
|
||||
index index.html;
|
||||
|
||||
# ── install.php (PHP-FPM for installer, root = /web/) ──
|
||||
location ~ ^/install\.php$ {
|
||||
root {{DEPLOY_DIR}}/web;
|
||||
fastcgi_pass {{PHP_SOCK}};
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
# ── Installer + Agent assets (root = /web/) ──
|
||||
location ~ ^/(css|js|vendor|agent)/ {
|
||||
root {{DEPLOY_DIR}}/web;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# ── Python API proxy ──
|
||||
location /api/ {
|
||||
proxy_pass http://nexus_api;
|
||||
|
||||
+11
-2
@@ -115,6 +115,15 @@ async def receive_heartbeat(
|
||||
logger.error(f"Redis heartbeat write failed for server {server_id}: {e}")
|
||||
# Redis write failed — still continue with MySQL update
|
||||
|
||||
# ── 1b. Look up server name for alert broadcasts ──
|
||||
server_name = f"server-{server_id}"
|
||||
try:
|
||||
server = await service.get_server(server_id)
|
||||
if server:
|
||||
server_name = server.name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 2. Alert detection ──
|
||||
alert_thresholds = {
|
||||
"cpu": settings.CPU_ALERT_THRESHOLD,
|
||||
@@ -126,7 +135,7 @@ async def receive_heartbeat(
|
||||
alerts = _detect_alerts(system_info, alert_thresholds)
|
||||
for alert_type, alert_value in alerts:
|
||||
logger.warning(f"Alert: server {server_id} {alert_type}={alert_value}%")
|
||||
await broadcast_alert(server_id, alert_type, alert_value, f"server-{server_id}")
|
||||
await broadcast_alert(server_id, alert_type, alert_value, server_name)
|
||||
|
||||
# Track active alert in Redis
|
||||
try:
|
||||
@@ -145,7 +154,7 @@ async def receive_heartbeat(
|
||||
recoveries = _detect_recovery(system_info, prev_alerts, alert_thresholds)
|
||||
for metric, value in recoveries:
|
||||
logger.info(f"Recovery: server {server_id} {metric}={value}%")
|
||||
await broadcast_recovery(server_id, metric, value, f"server-{server_id}")
|
||||
await broadcast_recovery(server_id, metric, value, server_name)
|
||||
|
||||
# Remove recovered alert from Redis tracking
|
||||
for prev in prev_alerts:
|
||||
|
||||
@@ -27,7 +27,7 @@ router = APIRouter(prefix="/api/install", tags=["install"])
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
ENV_FILE = ROOT_DIR / ".env"
|
||||
LOCK_FILE = ROOT_DIR / "web" / "install.php.locked"
|
||||
LOCK_FILE = ROOT_DIR / "web" / "install.php.locked" # Legacy compat — primary lock is .install_locked
|
||||
INSTALL_LOCK = ROOT_DIR / ".install_locked"
|
||||
CONFIG_PHP_DIR = ROOT_DIR / "web" / "data"
|
||||
CONFIG_PHP = CONFIG_PHP_DIR / "config.php"
|
||||
|
||||
@@ -135,13 +135,9 @@ async def browse_directory(
|
||||
continue
|
||||
parts = line.split(None, 8)
|
||||
if len(parts) >= 9:
|
||||
perms, _, owner, group, size, *date_parts, name = (
|
||||
parts[0], parts[1], parts[2], parts[3], parts[4],
|
||||
parts[5:-1], parts[-1]
|
||||
)
|
||||
is_dir = parts[0].startswith("d")
|
||||
entries.append({
|
||||
"name": parts[-1],
|
||||
"name": parts[8], # Last field after split(None, 8) — handles spaces in names
|
||||
"is_dir": is_dir,
|
||||
"size": parts[4],
|
||||
"perms": parts[0],
|
||||
|
||||
@@ -8,7 +8,6 @@ import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.redis.client import get_redis_sync
|
||||
from server.domain.models import PushSchedule, SyncLog, AuditLog
|
||||
|
||||
logger = logging.getLogger("nexus.schedule_runner")
|
||||
|
||||
+62
-6
@@ -67,6 +67,55 @@ logger = logging.getLogger("nexus")
|
||||
# Track background tasks for clean shutdown
|
||||
_background_tasks: list[asyncio.Task] = []
|
||||
|
||||
# Redis-based primary worker lock (prevents duplicate background tasks with multi-worker uvicorn)
|
||||
_PRIMARY_LOCK_KEY = "nexus:primary_worker"
|
||||
_PRIMARY_LOCK_TTL = 30 # seconds — must renew periodically
|
||||
|
||||
|
||||
async def _acquire_primary_lock() -> bool:
|
||||
"""Try to become the primary worker via Redis SETNX.
|
||||
|
||||
Only one uvicorn worker will hold this lock at a time.
|
||||
The lock auto-expires after PRIMARY_LOCK_TTL seconds (safety net for crashes).
|
||||
Primary worker renews the lock periodically via _renew_primary_lock().
|
||||
"""
|
||||
try:
|
||||
from server.infrastructure.redis.client import get_redis
|
||||
import os
|
||||
redis = get_redis()
|
||||
worker_id = str(os.getpid())
|
||||
# SET with NX (only if not exists) + EX (TTL)
|
||||
acquired = await redis.set(_PRIMARY_LOCK_KEY, worker_id, nx=True, ex=_PRIMARY_LOCK_TTL)
|
||||
if acquired:
|
||||
# Start lock renewal background task
|
||||
asyncio.create_task(_renew_primary_lock(), name="primary_lock_renewal")
|
||||
logger.info(f"Primary worker lock acquired (pid={worker_id})")
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
# If Redis fails, assume primary (single-worker mode)
|
||||
logger.warning(f"Primary lock check failed, assuming primary: {e}")
|
||||
return True
|
||||
|
||||
|
||||
async def _renew_primary_lock():
|
||||
"""Periodically renew the primary worker lock in Redis."""
|
||||
import os
|
||||
worker_id = str(os.getpid())
|
||||
while True:
|
||||
await asyncio.sleep(_PRIMARY_LOCK_TTL // 2) # Renew at half the TTL
|
||||
try:
|
||||
from server.infrastructure.redis.client import get_redis
|
||||
redis = get_redis()
|
||||
current = await redis.get(_PRIMARY_LOCK_KEY)
|
||||
if current == worker_id:
|
||||
await redis.expire(_PRIMARY_LOCK_KEY, _PRIMARY_LOCK_TTL)
|
||||
else:
|
||||
logger.warning("Primary worker lock lost — another worker took over")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Primary lock renewal failed: {e}")
|
||||
|
||||
|
||||
# ── D7: DB Session Middleware (fixes session leak in 4 service factories) ──
|
||||
|
||||
@@ -150,12 +199,19 @@ async def lifespan(app: FastAPI):
|
||||
from server.api.websocket import start_redis_subscriber, stop_redis_subscriber
|
||||
await start_redis_subscriber()
|
||||
|
||||
# 5. Launch background tasks
|
||||
task_flush = asyncio.create_task(heartbeat_flush_loop(), name="heartbeat_flush")
|
||||
task_monitor = asyncio.create_task(self_monitor_loop(), name="self_monitor")
|
||||
task_schedule = asyncio.create_task(schedule_runner_loop(), name="schedule_runner")
|
||||
task_retry = asyncio.create_task(retry_runner_loop(), name="retry_runner")
|
||||
_background_tasks.extend([task_flush, task_monitor, task_schedule, task_retry])
|
||||
# 5. Launch background tasks (only on primary worker to avoid duplicate execution)
|
||||
# When running with --workers N, each worker gets its own lifespan.
|
||||
# Use Redis-based leader election to ensure only one worker runs background tasks.
|
||||
is_primary = await _acquire_primary_lock()
|
||||
if is_primary:
|
||||
task_flush = asyncio.create_task(heartbeat_flush_loop(), name="heartbeat_flush")
|
||||
task_monitor = asyncio.create_task(self_monitor_loop(), name="self_monitor")
|
||||
task_schedule = asyncio.create_task(schedule_runner_loop(), name="schedule_runner")
|
||||
task_retry = asyncio.create_task(retry_runner_loop(), name="retry_runner")
|
||||
_background_tasks.extend([task_flush, task_monitor, task_schedule, task_retry])
|
||||
logger.info("Primary worker — background tasks launched")
|
||||
else:
|
||||
logger.info("Secondary worker — background tasks skipped (primary worker handles them)")
|
||||
|
||||
# 6. Start asyncssh connection pool (W1)
|
||||
from server.infrastructure.ssh.asyncssh_pool import ssh_pool
|
||||
|
||||
@@ -314,7 +314,7 @@
|
||||
<div class="w-16 h-16 rounded-full bg-green-100 text-green-500 text-3xl flex items-center justify-center mx-auto mb-4">✓</div>
|
||||
<h2 class="text-xl font-bold text-slate-800 mb-2">安装完成!</h2>
|
||||
<p class="text-slate-500 mb-1">Nexus 6.0 已成功安装。</p>
|
||||
<p class="text-slate-400 text-xs">install.php 已重命名为 install.php.locked — 防止重复安装</p>
|
||||
<p class="text-slate-400 text-xs">安装向导已锁定 — 防止重复安装(如需重装请删除 .env 文件后重启服务)</p>
|
||||
|
||||
<!-- Guardian results -->
|
||||
<div x-show="initResult?.guardian_results?.length" class="text-left mt-6 mb-4 rounded-lg p-4"
|
||||
@@ -457,7 +457,7 @@ location / {
|
||||
安全收尾
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 space-y-1">
|
||||
<p>✓ <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">install.php</code> 已自动重命名为 <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">install.php.locked</code></p>
|
||||
<p>✓ 安装向导已锁定 — 删除 <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">.env</code> 文件可重新安装</p>
|
||||
<p>✓ <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">.env</code> 包含 SECRET_KEY/API_KEY — <b>安装后不可修改</b>(加密一致性)</p>
|
||||
<p>✓ 防火墙限制端口 <span x-text="form.api_port"></span> (仅允许本机和子服务器IP)</p>
|
||||
<p>✓ 修改管理员默认密码</p>
|
||||
|
||||
Reference in New Issue
Block a user