极简监控告警用 Node.js 实现自建健康检查与多渠道通知系统一、半夜三点服务挂了早上九点才发现——告警缺失的代价对于独立开发者的项目或者小团队的服务一套完整的 Prometheus Grafana AlertManager 体系往往是过度设计。你可能只需要知道服务还活着、响应时间是否异常、数据库连接是否正常而不需要 50 个指标面板和 20 条告警规则。但另一方面完全没有监控是不可接受的。服务挂了而你不知道意味着你的用户正在经历比你更早发现故障的尴尬。你需要的是一个极简但可靠的方案几十行代码实现健康检查端点、周期性自检、以及多渠道告警通知。这个方案的复杂度应该低于你服务本身代码的 5%否则就是本末倒置。二、极简监控告警的架构自检 通知 适配器极简监控告警不需要独立部署的监控服务。核心逻辑可以嵌入你的应用进程中作为一个独立的健康检查模块运行。它周期性地检查关键依赖的健康状态当连续失败达到阈值时触发告警通过统一的 Notifier 接口发送到不同渠道。graph TD A[健康检查调度器br/setInterval] -- B1[HTTP 自检] A -- B2[数据库连接检查] A -- B3[Redis 连接检查] A -- B4[磁盘空间检查] B1 -- C{失败次数 阈值?} B2 -- C B3 -- C B4 -- C C --|否| D[重置计数器] C --|是| E[触发告警] E -- F1[企业微信br/Webhook] E -- F2[邮件通知br/SMTP] E -- F3[钉钉机器人br/Webhook] E -- F4[自定义渠道br/适配器] F1 -- G[告警恢复时发送br/恢复通知] F2 -- G F3 -- G核心设计每个检查项维护一个独立的失败计数器。单次失败不告警——防止网络抖动导致的误报。只有当失败次数连续达到阈值例如 3 次时才触发告警。告警触发后计数器不能清空——你需要记录告警状态在服务恢复时发送恢复通知。三、生产级健康检查与告警的代码实现// health-check/HealthChecker.ts — 极简健康检查引擎 interface HealthCheckResult { name: string; status: healthy | unhealthy; latencyMs: number; error?: string; checkedAt: number; } interface Notifier { name: string; sendAlert(alert: AlertMessage): Promisevoid; sendRecovery(alert: AlertMessage): Promisevoid; } interface AlertMessage { checkName: string; error: string; consecutiveFailures: number; failuresStartedAt: number; lastCheckedAt: number; } type HealthCheckFn () Promise{ healthy: boolean; error?: string }; class HealthChecker { private checks new Mapstring, HealthCheckFn(); private failureCounters new Mapstring, number(); private alertStates new Mapstring, boolean(); // 当前是否处于告警状态 private notifiers: Notifier[] []; private timer: ReturnTypetypeof setInterval | null null; constructor( private checkIntervalMs: number 60_000, // 检查间隔 private failureThreshold: number 3, // 触发告警的连续失败次数 private maxAlerts: number 5, // 最大告警次数防止风暴 private alertCooldownMs: number 300_000, // 告警冷却时间 5 分钟 ) {} registerCheck(name: string, fn: HealthCheckFn): this { this.checks.set(name, fn); return this; } registerNotifier(notifier: Notifier): this { this.notifiers.push(notifier); return this; } start(): void { // 启动后立即执行一次检查 this.runChecks(); this.timer setInterval(() this.runChecks(), this.checkIntervalMs); } stop(): void { if (this.timer) { clearInterval(this.timer); this.timer null; } } private async runChecks(): Promisevoid { const results: HealthCheckResult[] []; for (const [name, checkFn] of this.checks) { const start Date.now(); try { const result await checkFn(); const latency Date.now() - start; if (result.healthy) { results.push({ name, status: healthy, latencyMs: latency, checkedAt: Date.now() }); this.handleSuccess(name); } else { results.push({ name, status: unhealthy, latencyMs: latency, error: result.error, checkedAt: Date.now(), }); this.handleFailure(name, result.error ?? Unknown error); } } catch (err) { const latency Date.now() - start; results.push({ name, status: unhealthy, latencyMs: latency, error: String(err), checkedAt: Date.now(), }); this.handleFailure(name, String(err)); } } // 记录检查结果可外接日志系统 console.log(Health check results:, JSON.stringify(results)); } private handleSuccess(name: string): void { const wasAlerting this.alertStates.get(name) ?? false; this.failureCounters.set(name, 0); if (wasAlerting) { this.alertStates.set(name, false); this.sendRecovery(name); } } private handleFailure(name: string, error: string): void { const current (this.failureCounters.get(name) ?? 0) 1; this.failureCounters.set(name, current); if (current this.failureThreshold !this.alertStates.get(name)) { const alertMsg: AlertMessage { checkName: name, error, consecutiveFailures: current, failuresStartedAt: Date.now() - (current - 1) * this.checkIntervalMs, lastCheckedAt: Date.now(), }; this.alertStates.set(name, true); this.sendAlert(alertMsg); } } private async sendAlert(msg: AlertMessage): Promisevoid { for (const notifier of this.notifiers) { try { await notifier.sendAlert(msg); } catch (err) { console.error(Notifier ${notifier.name} failed:, err); } } } private async sendRecovery(name: string): Promisevoid { for (const notifier of this.notifiers) { try { await notifier.sendRecovery({ checkName: name, error: , consecutiveFailures: 0, failuresStartedAt: 0, lastCheckedAt: Date.now(), }); } catch (err) { console.error(Recovery notifier ${notifier.name} failed:, err); } } } }通知适配器实现// notifiers/WechatWorkNotifier.ts class WechatWorkNotifier implements Notifier { name wechat_work; constructor(private webhookUrl: string) {} async sendAlert(msg: AlertMessage): Promisevoid { const body { msgtype: markdown, markdown: { content: ## 服务告警\n 检查项: **${msg.checkName}**\n 错误: ${msg.error}\n 连续失败: ${msg.consecutiveFailures} 次\n 故障时间: ${new Date(msg.failuresStartedAt).toLocaleString()}\n, }, }; const response await fetch(this.webhookUrl, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(body), }); if (!response.ok) { throw new Error(Webhook failed: ${response.status}); } } async sendRecovery(msg: AlertMessage): Promisevoid { await fetch(this.webhookUrl, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ msgtype: markdown, markdown: { content: ## ✅ 服务恢复\n 检查项: **${msg.checkName}** 已恢复正常, }, }), }); } } // notifiers/EmailNotifier.ts — 使用 nodemailer import nodemailer from nodemailer; class EmailNotifier implements Notifier { name email; private transporter: nodemailer.Transporter; constructor(smtpConfig: { host: string; port: number; user: string; pass: string; recipients: string[]; }) { this.transporter nodemailer.createTransport({ host: smtpConfig.host, port: smtpConfig.port, secure: smtpConfig.port 465, auth: { user: smtpConfig.user, pass: smtpConfig.pass }, }); this.recipients smtpConfig.recipients; } private recipients: string[]; async sendAlert(msg: AlertMessage): Promisevoid { await this.transporter.sendMail({ from: this.transporter.options.auth?.[user], to: this.recipients.join(,), subject: [ALERT] ${msg.checkName} 健康检查失败, text: 检查项 ${msg.checkName} 失败: ${msg.error}\n连续失败: ${msg.consecutiveFailures} 次, }); } async sendRecovery(msg: AlertMessage): Promisevoid { await this.transporter.sendMail({ from: this.transporter.options.auth?.[user], to: this.recipients.join(,), subject: [RECOVERY] ${msg.checkName} 已恢复, text: 检查项 ${msg.checkName} 已恢复正常, }); } }启动集成// index.ts — 在应用启动时集成 const checker new HealthChecker(60_000, 3); checker .registerCheck(http-self, async () { const resp await fetch(http://localhost:3000/health); return { healthy: resp.ok, error: resp.ok ? undefined : Status ${resp.status} }; }) .registerCheck(database, async () { try { await db.raw(SELECT 1); return { healthy: true }; } catch (e) { return { healthy: false, error: String(e) }; } }) .registerCheck(redis, async () { try { await redis.ping(); return { healthy: true }; } catch (e) { return { healthy: false, error: String(e) }; } }) .registerNotifier(new WechatWorkNotifier(process.env.WEWORK_WEBHOOK_URL!)) .registerNotifier(new EmailNotifier({ host: process.env.SMTP_HOST!, port: parseInt(process.env.SMTP_PORT || 587), user: process.env.SMTP_USER!, pass: process.env.SMTP_PASS!, recipients: (process.env.ALERT_EMAILS || ).split(,), })) .start();四、告警系统的工程边界与常见误用告警风暴防护。当服务真的挂了你不希望每分钟都收到告警。冷却时间和最大告警次数是必须的——默认 5 分钟冷却和最多 5 次告警后静默。但静默不是永远不恢复服务重启后应当重置告警状态。死信告警。如果你的告警通知渠道本身挂了比如企业微信 Webhook 不可达你需要一个兜底方案。最简单的方法是至少配置两个不同类型的通知渠道——比如企业微信 邮件。两者同时不可达的概率远低于任一单点失效。不是所有异常都需要告警。健康检查应该关注服务是否还可用的二进制判断而非性能指标的波动。如果你想知道 API 响应是否在 500ms 内这不属于健康检查的范畴——用 APM 工具去解决。健康检查的唯一职责是回答如果我是用户这个服务现在能正常工作吗依赖检查不应该是深度依赖。检查数据库连接用SELECT 1而不是SELECT * FROM users LIMIT 1000。检查 Redis 用PING而不是SCAN。健康检查本身应该极轻量不能在服务已经负载很高的情况下雪上加霜。五、总结极简监控告警的核心设计哲学检查逻辑要轻告警判断要严通知渠道要多。连续失败计数 单次失败判断告警冷却 立即重复发送多渠道通知 单一渠道。不到 200 行代码的监控系统能够覆盖独立开发者或小团队 90% 的可用性监控需求。如果你需要分布式追踪、指标面板、SLA 报告——这些不是极简方案的领域用 Prometheus Grafana 是更好的选择。但如果你只是想知道自己部署在 VPS 上的服务是否在正常运行极简方案足够。少即是多。监控系统的复杂度不应超过被监控服务的复杂度。