414 lines
12 KiB
JavaScript
414 lines
12 KiB
JavaScript
/**
|
||
* ═══════════════════════════════════════════════════════════
|
||
* 🔍 铸渊运维守卫 · 健康检查引擎
|
||
* ═══════════════════════════════════════════════════════════
|
||
*
|
||
* 编号: ZY-OPS-HC-001
|
||
* 签发: 铸渊 · ICE-GL-ZY001
|
||
* 版权: 国作登字-2026-A-00037559
|
||
* 更新: 2026-04-23 · 修复 MCP 跨服务器检测 + 告警冷却
|
||
*
|
||
* 三层巡检:
|
||
* - 快速巡检(5分钟): 端口存活检测
|
||
* - 深度巡检(1小时): 内存/磁盘/PM2重启次数/错误日志
|
||
* - 全量体检(每天): 完整健康报告
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const http = require('http');
|
||
const { execSync } = require('child_process');
|
||
const os = require('os');
|
||
|
||
// ── 服务器 IP 配置(环境变量覆盖)─────────
|
||
|
||
const SVR_002_HOST = process.env.ZY_SVR_002_HOST || '127.0.0.1'; // 面孔服务器(本机)
|
||
const SVR_005_HOST = process.env.ZY_SVR_005_HOST || '127.0.0.1'; // 大脑服务器(需配置真实 IP)
|
||
|
||
// ── 监控目标列表 ─────────────────────
|
||
|
||
const SERVICES = [
|
||
{ name: '铸渊主权服务器', host: SVR_002_HOST, port: 3800, path: '/api/health', critical: true, timeout: 8000 },
|
||
{ name: 'MCP大脑服务器', host: SVR_005_HOST, port: 3100, path: '/health', critical: true, timeout: 15000 },
|
||
{ name: 'GLADA自主开发Agent', host: SVR_002_HOST, port: 3900, path: '/api/glada/health', critical: false, timeout: 8000 },
|
||
{ name: '智库节点API', host: SVR_002_HOST, port: 4000, path: '/api/health', critical: false, timeout: 8000 },
|
||
{ name: '铸渊运维守卫', host: SVR_002_HOST, port: 3950, path: '/health', critical: false, timeout: 8000 },
|
||
];
|
||
|
||
const PM2_PROCESSES = [
|
||
'zhuyuan-server',
|
||
'zhuyuan-preview',
|
||
'novel-api',
|
||
'age-os-mcp',
|
||
'age-os-agents',
|
||
'glada-agent',
|
||
];
|
||
|
||
// ── 告警冷却机制(避免重复发邮件)───────
|
||
|
||
const alertCooldown = new Map(); // key: service name, value: { lastAlert: timestamp, count: number }
|
||
const ALERT_COOLDOWN_MS = 30 * 60 * 1000; // 30分钟内同一服务不重复告警
|
||
const MAX_ALERTS_PER_DAY = 6; // 每个服务每天最多6次告警
|
||
|
||
/**
|
||
* 检查是否应该发送告警(冷却期内不重复发)
|
||
* @param {string} serviceName
|
||
* @returns {boolean}
|
||
*/
|
||
function shouldAlert(serviceName) {
|
||
const now = Date.now();
|
||
const record = alertCooldown.get(serviceName);
|
||
|
||
if (!record) {
|
||
alertCooldown.set(serviceName, { lastAlert: now, count: 1, dayStart: now });
|
||
return true;
|
||
}
|
||
|
||
// 超过24小时重置计数
|
||
if (now - record.dayStart > 24 * 60 * 60 * 1000) {
|
||
alertCooldown.set(serviceName, { lastAlert: now, count: 1, dayStart: now });
|
||
return true;
|
||
}
|
||
|
||
// 每天超过最大次数,不再发
|
||
if (record.count >= MAX_ALERTS_PER_DAY) {
|
||
return false;
|
||
}
|
||
|
||
// 冷却期内不重复发
|
||
if (now - record.lastAlert < ALERT_COOLDOWN_MS) {
|
||
return false;
|
||
}
|
||
|
||
record.lastAlert = now;
|
||
record.count++;
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 获取告警冷却状态(调试用)
|
||
*/
|
||
function getAlertCooldownStatus() {
|
||
const status = {};
|
||
for (const [name, record] of alertCooldown.entries()) {
|
||
status[name] = {
|
||
lastAlert: new Date(record.lastAlert).toISOString(),
|
||
alertsToday: record.count,
|
||
maxPerDay: MAX_ALERTS_PER_DAY,
|
||
cooldownRemaining: Math.max(0, ALERT_COOLDOWN_MS - (Date.now() - record.lastAlert))
|
||
};
|
||
}
|
||
return status;
|
||
}
|
||
|
||
// ── HTTP 健康检查 ────────────────────
|
||
|
||
function probeService(name, host, port, probePath, timeoutMs = 8000) {
|
||
return new Promise((resolve) => {
|
||
const start = Date.now();
|
||
const req = http.get({
|
||
hostname: host,
|
||
port,
|
||
path: probePath,
|
||
timeout: timeoutMs
|
||
}, (res) => {
|
||
let body = '';
|
||
res.on('data', (c) => { body += c; });
|
||
res.on('end', () => {
|
||
const latency = Date.now() - start;
|
||
let data = null;
|
||
try { data = JSON.parse(body); } catch { /* ignore */ }
|
||
resolve({
|
||
name,
|
||
host,
|
||
port,
|
||
status: 'online',
|
||
statusCode: res.statusCode,
|
||
latency,
|
||
data,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
});
|
||
});
|
||
req.on('error', (err) => {
|
||
resolve({
|
||
name,
|
||
host,
|
||
port,
|
||
status: 'offline',
|
||
statusCode: 0,
|
||
latency: Date.now() - start,
|
||
error: err.message,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
});
|
||
req.on('timeout', () => {
|
||
req.destroy();
|
||
resolve({
|
||
name,
|
||
host,
|
||
port,
|
||
status: 'timeout',
|
||
statusCode: 0,
|
||
latency: timeoutMs,
|
||
error: `连接超时(${timeoutMs}ms)`,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── PM2 进程状态 ──────────────────
|
||
|
||
function getPM2Status() {
|
||
try {
|
||
const raw = execSync('pm2 jlist 2>/dev/null', { encoding: 'utf-8', timeout: 10000 });
|
||
const processes = JSON.parse(raw);
|
||
return processes.map(p => ({
|
||
name: p.name,
|
||
pm_id: p.pm_id,
|
||
status: p.pm2_env?.status || 'unknown',
|
||
restarts: p.pm2_env?.restart_time || 0,
|
||
uptime: p.pm2_env?.pm_uptime ? Date.now() - p.pm2_env.pm_uptime : 0,
|
||
memory: p.monit?.memory || 0,
|
||
cpu: p.monit?.cpu || 0,
|
||
pid: p.pid
|
||
}));
|
||
} catch (err) {
|
||
return [{ name: 'pm2', status: 'error', error: err.message }];
|
||
}
|
||
}
|
||
|
||
// ── 系统资源 ──────────────────────
|
||
|
||
function getSystemResources() {
|
||
const totalMem = os.totalmem();
|
||
const freeMem = os.freemem();
|
||
const usedPct = Math.round((1 - freeMem / totalMem) * 100);
|
||
|
||
let diskInfo = { total: 0, used: 0, available: 0, usePct: 0 };
|
||
try {
|
||
const dfOut = execSync('df -B1 / 2>/dev/null | tail -1', { encoding: 'utf-8', timeout: 5000 });
|
||
const parts = dfOut.trim().split(/\s+/);
|
||
if (parts.length >= 5) {
|
||
diskInfo = {
|
||
total: parseInt(parts[1]) || 0,
|
||
used: parseInt(parts[2]) || 0,
|
||
available: parseInt(parts[3]) || 0,
|
||
usePct: parseInt(parts[4]) || 0
|
||
};
|
||
}
|
||
} catch { /* ignore */ }
|
||
|
||
return {
|
||
memory: {
|
||
total_mb: Math.round(totalMem / 1024 / 1024),
|
||
free_mb: Math.round(freeMem / 1024 / 1024),
|
||
used_pct: usedPct
|
||
},
|
||
disk: {
|
||
total_gb: Math.round(diskInfo.total / 1024 / 1024 / 1024),
|
||
used_gb: Math.round(diskInfo.used / 1024 / 1024 / 1024),
|
||
available_gb: Math.round(diskInfo.available / 1024 / 1024 / 1024),
|
||
used_pct: diskInfo.usePct
|
||
},
|
||
load: os.loadavg(),
|
||
cpus: os.cpus().length,
|
||
uptime_hours: Math.round(os.uptime() / 3600),
|
||
hostname: os.hostname(),
|
||
platform: os.platform(),
|
||
node_version: process.version
|
||
};
|
||
}
|
||
|
||
// ── Nginx 状态 ─────────────────────
|
||
|
||
function getNginxStatus() {
|
||
try {
|
||
const status = execSync('systemctl is-active nginx 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
|
||
let configOk = false;
|
||
try {
|
||
execSync('nginx -t 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
||
configOk = true;
|
||
} catch { /* config test failed */ }
|
||
return { status, configOk };
|
||
} catch {
|
||
return { status: 'unknown', configOk: false };
|
||
}
|
||
}
|
||
|
||
// ── 快速巡检(5分钟一次)────────────
|
||
|
||
async function quickCheck() {
|
||
const results = await Promise.all(
|
||
SERVICES.map(s => probeService(s.name, s.host, s.port, s.path, s.timeout))
|
||
);
|
||
|
||
const issues = [];
|
||
for (let i = 0; i < results.length; i++) {
|
||
const r = results[i];
|
||
const s = SERVICES[i];
|
||
if (r.status !== 'online') {
|
||
issues.push({
|
||
service: s.name,
|
||
host: s.host,
|
||
port: s.port,
|
||
status: r.status,
|
||
error: r.error,
|
||
critical: s.critical,
|
||
severity: s.critical ? 'high' : 'medium',
|
||
shouldNotify: shouldAlert(s.name)
|
||
});
|
||
}
|
||
}
|
||
|
||
return {
|
||
type: 'quick',
|
||
timestamp: new Date().toISOString(),
|
||
services: results,
|
||
issues,
|
||
// 只返回需要通知的问题(过滤掉冷却期内的)
|
||
notifiableIssues: issues.filter(i => i.shouldNotify),
|
||
healthy: issues.filter(i => i.critical).length === 0,
|
||
summary: issues.length === 0
|
||
? '✅ 所有服务正常运行'
|
||
: `⚠️ ${issues.length}个服务异常: ${issues.map(i => i.service).join(', ')}`
|
||
};
|
||
}
|
||
|
||
// ── 深度巡检(1小时一次)───────────
|
||
|
||
async function deepCheck() {
|
||
const quick = await quickCheck();
|
||
const pm2Status = getPM2Status();
|
||
const resources = getSystemResources();
|
||
const nginx = getNginxStatus();
|
||
|
||
const issues = [...quick.issues];
|
||
|
||
// 检查 PM2 进程异常
|
||
for (const proc of pm2Status) {
|
||
if (proc.status === 'errored' || proc.status === 'stopped') {
|
||
issues.push({
|
||
service: `PM2:${proc.name}`,
|
||
status: proc.status,
|
||
error: `进程${proc.status === 'errored' ? '出错' : '已停止'}`,
|
||
critical: PM2_PROCESSES.includes(proc.name),
|
||
severity: 'high',
|
||
shouldNotify: shouldAlert(`PM2:${proc.name}`)
|
||
});
|
||
}
|
||
if (proc.restarts > 10) {
|
||
issues.push({
|
||
service: `PM2:${proc.name}`,
|
||
status: 'unstable',
|
||
error: `频繁重启(${proc.restarts}次)`,
|
||
critical: false,
|
||
severity: 'medium',
|
||
shouldNotify: shouldAlert(`PM2:${proc.name}:restarts`)
|
||
});
|
||
}
|
||
}
|
||
|
||
// 检查资源告警
|
||
if (resources.memory.used_pct > 90) {
|
||
issues.push({
|
||
service: '系统内存',
|
||
status: 'warning',
|
||
error: `内存使用率 ${resources.memory.used_pct}% (>90%)`,
|
||
critical: true,
|
||
severity: 'high',
|
||
shouldNotify: shouldAlert('系统内存')
|
||
});
|
||
}
|
||
if (resources.disk.used_pct > 85) {
|
||
issues.push({
|
||
service: '磁盘空间',
|
||
status: 'warning',
|
||
error: `磁盘使用率 ${resources.disk.used_pct}% (>85%)`,
|
||
critical: true,
|
||
severity: resources.disk.used_pct > 95 ? 'critical' : 'high',
|
||
shouldNotify: shouldAlert('磁盘空间')
|
||
});
|
||
}
|
||
|
||
// Nginx 状态
|
||
if (nginx.status !== 'active') {
|
||
issues.push({
|
||
service: 'Nginx',
|
||
status: nginx.status,
|
||
error: 'Nginx 未运行',
|
||
critical: true,
|
||
severity: 'critical',
|
||
shouldNotify: shouldAlert('Nginx')
|
||
});
|
||
}
|
||
|
||
return {
|
||
type: 'deep',
|
||
timestamp: new Date().toISOString(),
|
||
services: quick.services,
|
||
pm2: pm2Status,
|
||
resources,
|
||
nginx,
|
||
issues,
|
||
notifiableIssues: issues.filter(i => i.shouldNotify),
|
||
healthy: issues.filter(i => i.critical).length === 0,
|
||
summary: issues.length === 0
|
||
? '✅ 深度巡检通过 · 所有服务和资源正常'
|
||
: `⚠️ 发现${issues.length}个问题: ${issues.map(i => `${i.service}(${i.error})`).join('; ')}`
|
||
};
|
||
}
|
||
|
||
// ── 全量体检(每天一次)───────────
|
||
|
||
async function fullReport() {
|
||
const deep = await deepCheck();
|
||
return {
|
||
type: 'daily_report',
|
||
report_id: `RPT-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}`,
|
||
...deep,
|
||
alertCooldownStatus: getAlertCooldownStatus(),
|
||
recommendations: generateRecommendations(deep)
|
||
};
|
||
}
|
||
|
||
function generateRecommendations(checkResult) {
|
||
const recs = [];
|
||
for (const issue of checkResult.issues) {
|
||
if (issue.service.includes('内存') && issue.status === 'warning') {
|
||
recs.push('建议: 检查是否有进程内存泄漏,考虑增加 max_memory_restart 配置');
|
||
}
|
||
if (issue.service.includes('磁盘')) {
|
||
recs.push('建议: 运行 pm2 flush 清理日志,检查 /tmp 和 data/logs/ 目录');
|
||
}
|
||
if (issue.error?.includes('频繁重启')) {
|
||
recs.push(`建议: ${issue.service} 频繁重启,需要检查错误日志定位根因`);
|
||
}
|
||
if (issue.service === 'Nginx') {
|
||
recs.push('紧急: Nginx 异常,所有外部访问将受影响');
|
||
}
|
||
if (issue.service === 'MCP大脑服务器' && issue.status === 'timeout') {
|
||
recs.push('提示: MCP服务器在远程机器(ZY-SVR-005),请检查: 1)大脑服务器是否运行 2)网络连接是否正常 3)防火墙端口是否开放');
|
||
}
|
||
}
|
||
if (recs.length === 0) {
|
||
recs.push('系统运行良好,无需额外操作');
|
||
}
|
||
return recs;
|
||
}
|
||
|
||
module.exports = {
|
||
SERVICES,
|
||
PM2_PROCESSES,
|
||
probeService,
|
||
getPM2Status,
|
||
getSystemResources,
|
||
getNginxStatus,
|
||
quickCheck,
|
||
deepCheck,
|
||
fullReport,
|
||
shouldAlert,
|
||
getAlertCooldownStatus,
|
||
};
|