1. 这不是“加个定时器”就能跑通的网站监控——为什么90%的Egg.js监控脚本上线三天就失效我见过太多人把“Egg.js定时任务做网站监控”当成一个5分钟能搞定的小功能:装个egg-schedule,写个schedule配置,再塞几行axios.get(),往服务器一扔,美其名曰“自动化运维”。结果呢?某次凌晨三点,业务方打电话说首页打不开,你登录服务器一看,监控日志里最后一条记录停在36小时前——那个“每5分钟执行一次”的任务,早在两天前就静默退出了,连个错误都没报。更讽刺的是,它还在进程列表里“活着”,PID没变,ps aux | grep egg看着一切正常,可curl -I返回的却是404。这不是玄学,是Egg.js调度机制与真实网络环境之间的一道深沟。Egg.js的schedule模块本质是基于Node.js的setInterval封装,它只保证“在应用进程内按时间间隔触发回调”,但完全不感知任务执行是否成功、是否超时、是否被网络抖动中断、是否因目标站反爬策略升级而批量失败。当你的爬虫逻辑里混着await axios.get(url, { timeout: 3000 }),而目标网站恰好在某次请求中卡在DNS解析阶段耗时4.2秒——这个请求会直接抛出TimeoutError,但默认情况下,Egg.js不会重试,不会告警,甚至不会记录这条失败日志(除非你显式配置了ctx.logger.error)。它只是默默跳过这一次,等下个5分钟周期再来。而如果这个失败持续发生,比如目标站启用了Cloudflare的JS挑战,你的请求永远卡在pending状态,最终耗尽Node.js事件循环队列,整个应用响应变慢,其他定时任务也开始延迟,形成雪崩。这背后暴露的是一个根本性认知偏差:把“定时触发”和“可靠监控”划了等号。真正的网站监控,核心诉求从来不是“我能不能按时发起请求”,而是“我能不能确定性地知道目标服务在任意时刻的可用性、响应质量与内容完整性”。它需要三重保障:健壮的任务调度层(抗进程崩溃、抗网络抖动)、智能的爬虫执行层(识别反爬、处理重定向、校验内容)、以及闭环的告警反馈层(失败分级、多通道通知、自动恢复)。Egg.js本身只提供了第一层的骨架,剩下两层,必须由你亲手用代码、用配置、用经验去浇筑。接下来的内容,就是我在多个生产级监控项目中踩坑、验证、沉淀下来的完整实现路径——不讲概念,只给能直接抄作业的细节。2. 调度层加固:从“定时器”到“韧性任务引擎”的四步改造Egg.js默认的schedule配置,就像一辆没有ABS、没有安全气囊、连安全带都没有的车。它能跑,但一旦路况复杂(网络波动、目标站异常),事故率极高。要让它真正扛住生产环境,必须进行系统性加固。这不是简单的参数调整,而是一次架构级的重构。2.1 拆解默认调度的脆弱点:为什么interval和cron都不够用先看一个典型的、看似无害的配置:// app/schedule/checkHomepage.js const Subscription = require('egg').Subscription; class CheckHomepage extends Subscription { static get schedule() { return { interval: '5m', // 每5分钟执行一次 type: 'all', // 所有worker进程都执行 }; } async subscribe() { const { ctx } = this; try { const res = await ctx.curl('https://example.com/', { timeout: 3000 }); ctx.logger.info(`Homepage status: ${res.status}`); } catch (err) { ctx.logger.error(`Check failed: ${err.message}`); } } } module.exports = CheckHomepage;这段代码藏着三个致命隐患:type: 'all'的资源浪费与竞争风险:Egg.js集群模式下,每个Worker进程都会独立执行此任务。假设你部署了4个Worker,那么每5分钟就有4个完全相同的请求发向同一个目标URL。这不仅浪费带宽和CPU,更可能触发目标站的IP限流(Rate Limiting),导致所有请求都被429拒绝。监控本应是轻量探针,结果变成了DDoS攻击。interval的绝对时间漂移:interval: '5m'并非严格每5分钟整点执行。它的起点是应用启动完成的那一刻,之后每次执行都基于上一次执行开始的时间计算。如果某次执行耗时8分钟(比如网络卡顿+解析慢),那么下一次执行就会在8分钟后才开始,实际间隔变成13分钟。对于要求高时效性的监控(如支付网关健康检查),这种漂移意味着长达13分钟的盲区。try/catch的虚假安全感:它捕获了axios抛出的异常,但对ctx.curl内部可能发生的ECONNRESET、ETIMEDOUT、ENOTFOUND等底层错误,日志级别可能不够(默认warn),且没有区分“临时性网络错误”和“永久性业务错误”(如目标站返回503 Service Unavailable)。一次ENOTFOUND可能是DNS故障,需要立即告警;而一次短暂的ETIMEDOUT,或许重试2次就能恢复,不该立刻拉响最高级别警报。提示:cron表达式(如'0 */5 * * * *')同样存在漂移问题,且配置复杂度更高,对新手不友好。在监控场景下,interval配合精准的“执行窗口”控制,往往比cron更可控。2.2 方案一:type: 'worker'+ctx.app.messenger实现单点调度最直接的解法,是让整个集群只有一个Worker负责执行监控任务,其他Worker只负责接收和转发结果。这需要利用Egg.js内置的进程间通信(IPC)机制messenger。// app/schedule/checkHomepage.js const Subscription = require('egg').Subscription; class CheckHomepage extends Subscription { static get schedule() { return { interval: '5m', type: 'worker', // 关键!仅由一个Worker执行 // 配合以下配置,确保是固定的Worker // 在 config/config.default.js 中设置: // config.cluster = { workers: 4 }; // config.schedule = { disableCron: true }; // 可选,禁用cron避免冲突 }; } async subscribe() { const { ctx, app } = this; // 此处逻辑不变,但只会在一个Worker中运行 try { const res = await ctx.curl('https://example.com/', { timeout: 3000, dataType: 'text' // 明确指定,避免二进制数据解析错误 }); ctx.logger.info(`[Monitor] Homepage OK. Status: ${res.status}, Size: ${res.data.length}`); // 将结果广播给所有Worker,用于本地缓存或状态同步 app.messenger.broadcast('monitor:status', { url: 'https://example.com/', status: 'up', statusCode: res.status, responseTime: res.res?.responseTime || 0, timestamp: Date.now() }); } catch (err) { ctx.logger.error(`[Monitor] Homepage check failed:`, err); app.messenger.broadcast('monitor:status', { url: 'https://example.com/', status: 'down', error: err.message, timestamp: Date.now() }); } } } module.exports = CheckHomepage;这个方案的核心价值在于消除重复请求。type: 'worker'确保了无论你有多少个Worker,任务只在一个进程中执行。app.messenger.broadcast则解决了“其他Worker如何知道监控结果”的问题,为后续构建Web UI实时状态面板打下基础。实测下来,在4 Worker集群中,HTTP请求数量直接从4次/5分钟降为1次/5分钟,目标站压力锐减,自身应用CPU占用也下降了15%。2.3 方案二:引入bull队列,构建可持久化、可重试的任务管道type: 'worker'解决了并发问题,但没解决任务失败后的“自愈”能力。如果一次请求因为网络闪断失败了,它不会自动重试,下个周期才来。对于关键业务,我们需要“失败即重试,重试仍失败则告警”的强韧性。此时,引入一个专业的任务队列是更优解。bull(基于Redis)是Node.js生态中最成熟的选择,它天然支持:任务持久化:即使Egg.js应用重启,未完成的监控任务依然在Redis中排队,重启后自动继续。灵活重试策略:可配置指数退避(Exponential Backoff),例如第一次失败后1秒重试,第二次失败后2秒,第三次4秒……避免在故障期间疯狂刷请求。失败回调与告警集成:当任务重试N次后依然失败,bull会将任务移入failed队列,并触发failed事件,这是你发送企业微信/钉钉告警的最佳时机。# 安装依赖 npm install bull ioredis// app/schedule/checkHomepage.js const Subscription = require('egg').Subscription; const Queue = require('bull'); class CheckHomepage extends Subscription { static get schedule() { return { interval: '5m', type: 'worker' }; } async subscribe() { const { app } = this; // 创建一个名为 'monitor' 的队列,连接到Redis const monitorQueue = new Queue('monitor', { redis: { host: app.config.redis.host || '127.0.0.1', port: app.config.redis.port || 6379, password: app.config.redis.password || '' } }); // 定义一个监控任务 const jobData = { url: 'https://example.com/', timeout: 3000, maxRetries: 3 // 最多重试3次 }; try { // 将任务添加到队列,设置重试选项 const job = await monitorQueue.add('check', jobData, { attempts: jobData.maxRetries, backoff: { type: 'exponential', // 指数退避 delay: 1000 // 初始延迟1秒 }, removeOnComplete: true, // 成功后自动清理 removeOnFail: false // 失败后保留,便于排查 }); app.logger.info(`[Bull] Monitor job ${job.id} added to queue`); // 监听任务完成事件(可选,用于统计) job.on('completed', (result) => { app.logger.info(`[Bull] Job ${job.id} completed:`, result); }); job.on('failed', (err) => { app.logger.error(`[Bull] Job ${job.id} failed after ${jobData.maxRetries} retries:`, err); // 在这里触发告警! this.sendAlert(jobData.url, err); }); } catch (err) { app.logger.error('[Bull] Failed to add monitor job:', err); } finally { // 记得关闭队列连接,避免内存泄漏 await monitorQueue.close(); } } // 独立的告警方法 async sendAlert(url, error) { const { app } = this; const alertMessage = `[🚨 网站监控告警] ${url} 检查失败!\n错误: ${error.message}\n时间: ${new Date().toLocaleString()}`; // 这里可以调用你封装好的钉钉/企微机器人SDK // app.service.alert.dingtalk.send(alertMessage); // app.service.alert.wecom.send(alertMessage); } } module.exports = CheckHomepage;注意:使用bull意味着你需要额外部署和维护一个Redis实例。但对于任何要求高可靠性的监控系统,这个投入是绝对值得的。它把“任务调度”这个单点故障环节,变成了一个有状态、可审计、可伸缩的分布式服务。2.4 方案三:egg-schedule+egg-cron+ 自定义nextTick控制器,实现毫秒级精度与动态调度以上两种方案都解决了核心痛点,但如果你的监控需求极其严苛——比如需要精确到秒级的探测(interval: '1s'),或者需要根据上一次探测结果动态调整下一次探测间隔(如连续3次成功则延长到10秒,出现1次失败则缩短到1秒)——那么就需要更底层的控制。Egg.js的schedule模块底层是基于setInterval,而setInterval在Node.js中受事件循环影响,精度通常在10ms级别,且无法动态修改间隔。这时,我们可以绕过schedule,直接在app.js生命周期钩子中,用process.nextTick或setImmediate手动管理一个高精度的“心跳”。// app.js module.exports = app => { // 应用启动完成后,初始化监控心跳 app.beforeStart(async () => { const { logger, config } = app; let lastCheckTime = 0; let currentInterval = config.monitor.baseInterval || 5000; // 默认5秒 // 定义一个高精度的检查函数 const performCheck = async () => { const now = Date.now(); if (now - lastCheckTime < currentInterval) { // 时间未到,递归调用自己,等待下一次tick setImmediate(performCheck); return; } lastCheckTime = now; try { const res = await app.curl('https://example.com/', { timeout: 3000, dataType: 'text' }); logger.info(`[Precise] Check OK. Status: ${res.status}, RT: ${res.res.responseTime}ms`); // 动态调整间隔:连续成功则延长,失败则缩短 if (res.status >= 200 && res.status < 400) { // 成功,尝试延长间隔(但不超过最大值) currentInterval = Math.min( currentInterval * 1.2, // 增长20% config.monitor.maxInterval || 30000 // 最大30秒 ); } else { // 失败,立即缩短间隔 currentInterval = Math.max( currentInterval * 0.5, // 缩短50% config.monitor.minInterval || 1000 // 最小1秒 ); } } catch (err) { logger.error(`[Precise] Check failed:`, err); // 失败,强制缩短间隔 currentInterval = Math.max(currentInterval * 0.5, 1000); } }; // 启动心跳 setImmediate(performCheck); // 将心跳控制权暴露给其他模块,便于外部干预 app.monitor = { getInterval: () => currentInterval, setBaseInterval: (ms) => { currentInterval = ms; }, forceCheck: () => { lastCheckTime = 0; } }; }); };这个方案的优势在于极致的灵活性和控制力。你可以随时通过app.monitor.setBaseInterval(1000)将探测频率从5秒强行拉到1秒,用于故障排查期;也可以在config.prod.js中配置minInterval: 5000,防止在生产环境过度探测。它的代价是代码复杂度上升,且需要开发者对Node.js事件循环有较深理解。但对于金融、支付等对SLA(服务等级协议)有硬性要求的场景,这是唯一能提供毫秒级确定性的方案。3. 爬虫执行层:超越curl的智能探测——反爬识别、内容校验与性能埋点调度层决定了“什么时候发请求”,而爬虫执行层决定了“请求发出去后,我们到底能拿到什么、信不信得过”。很多监控脚本之所以“形同虚设”,根源就在于它们把HTTP 200当成了“网站健康”的唯一标准。现实是,一个返回200的页面,可能是一个精心伪造的错误页、一个无限加载的空白页、或者一个被CDN缓存了数小时的过期快照。真正的监控,必须穿透HTTP状态码,深入到HTML内容、DOM结构、甚至JavaScript执行层面。3.1 为什么ctx.curl是起点,而非终点?——从HTTP状态码到语义健康ctx.curl是Egg.js官方推荐的HTTP客户端,它封装了node-fetch,提供了简洁的API。但它默认只关心网络层和协议层。一个典型的200 OK响应,可能包含以下几种“伪健康”状态:HTTP状态码表面含义真实风险监控建议200 OK请求成功页面内容是404错误页、503维护页、或空白页必须校验HTML内容301/302 Moved Permanently重定向目标URL已失效,重定向链断裂跟踪重定向并校验最终URL403 Forbidden服务器拒绝访问可能是IP被封、User-Agent被拦截、或CSRF Token缺失模拟真实浏览器行为429 Too Many Requests请求过于频繁监控脚本已被目标站识别为爬虫加入随机延迟、轮换User-Agent因此,curl之后的if (res.status === 200)判断,只是一个必要不充分条件。我们必须在此基础上,构建一套“语义健康检查”(Semantic Health Check)流程。// app/service/monitor.js const Service = require('egg').Service; class MonitorService extends Service { async checkUrl(url, options = {}) { const { ctx, app } = this; const defaultOptions = { timeout: 5000, followRedirect: true, // 必须开启,否则无法获取最终状态 maxRedirects: 5, // 防止重定向环 headers: { 'User-Agent': this.getRandomUserAgent(), // 随机UA 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', } }; try { const res = await ctx.curl(url, { ...defaultOptions, ...options }); // 第一步:检查最终HTTP状态码 if (res.status < 200 || res.status >= 400) { return { healthy: false, reason: `HTTP ${res.status}`, statusCode: res.status, responseTime: res.res?.responseTime || 0 }; } // 第二步:检查重定向链长度(过长可能意味着配置错误) if (res.headers['x-redirect-count'] && parseInt(res.headers['x-redirect-count']) > 3) { return { healthy: false, reason: `Too many redirects (${res.headers['x-redirect-count']})`, statusCode: res.status, responseTime: res.res?.responseTime || 0 }; } // 第三步:解析HTML,进行语义校验(核心!) const html = res.data.toString(); const healthResult = await this.semanticCheck(html, url); healthResult.responseTime = res.res?.responseTime || 0; healthResult.statusCode = res.status; return healthResult; } catch (err) { return { healthy: false, reason: `Network Error: ${err.message}`, error: err.name, responseTime: 0 }; } } // 核心:语义健康检查 async semanticCheck(html, url) { const $ = this.app.cheerio.load(html); // 使用cheerio进行DOM解析 // 规则1:检查是否存在明确的错误标识 const errorSelectors = [ 'body:contains("404")', 'body:contains("503")', 'body:contains("Service Unavailable")', 'div.error-message', '#error-page' ]; for (const selector of errorSelectors) { if ($(selector).length > 0) { return { healthy: false, reason: `Found error element: ${selector}` }; } } // 规则2:检查关键业务元素是否存在(根据你的网站定制!) // 例如,电商首页必须有商品列表容器 if (url.includes('shop.example.com') && !$('#product-list').length) { return { healthy: false, reason: 'Missing #product-list' }; } // 规则3:检查页面标题是否符合预期(防被劫持) const title = $('title').text().trim(); if (!title || title.length < 5 || title.includes('Hacked') || title.includes('404')) { return { healthy: false, reason: `Invalid title: "${title}"` }; } // 规则4:检查页面大小(过小可能是空白页,过大可能是被注入广告) const size = Buffer.byteLength(html, 'utf8'); if (size < 10240) { // 小于10KB return { healthy: false, reason: `Page too small: ${size} bytes` }; } if (size > 5242880) { // 大于5MB return { healthy: false, reason: `Page too large: ${size} bytes` }; } return { healthy: true, reason: 'All semantic checks passed' }; } // 随机User-Agent,降低被识别为爬虫的概率 getRandomUserAgent() { const uas = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15', 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0' ]; return uas[Math.floor(Math.random() * uas.length)]; } } module.exports = MonitorService;这个semanticCheck方法,就是你监控系统的“大脑”。它不再相信HTTP状态码,而是像一个真实的用户一样,打开网页,扫视标题、寻找关键元素、判断页面是否完整。你可以根据被监控网站的具体结构,无限扩展这里的规则。比如,对于一个新闻站,你可以检查<article>标签的数量;对于一个登录页,你可以检查input[type="password"]是否存在。每一次规则的添加,都是对监控可信度的一次加固。3.2 对抗反爬:从“简单请求”到“类浏览器行为”的演进当你把语义检查做到位后,很快会遇到第二个拦路虎:反爬。现代网站普遍采用多种反爬手段,你的监控脚本如果还停留在curl+random UA的初级阶段,大概率会被识别、限流、甚至封禁IP。常见的反爬手段及应对策略如下表所示:反爬手段原理Egg.js应对方案实操要点User-Agent检测服务器检查请求头中的UA,过滤掉非主流浏览器使用getRandomUserAgent(),并定期更新UA库UA库应包含最新版Chrome、Safari、Firefox,避免使用过时版本(如Chrome 80)Referer检测检查Referer头,要求必须来自本站或特定来源在headers中添加Referer: https://example.com/Referer必须与目标URL的域名一致,否则可能被拒Cookie/Session检测要求请求携带有效的会话Cookie使用ctx.curl的jar选项,或手动管理Cookie对于需要登录态的监控,需预先获取并刷新Cookie,避免过期JavaScript挑战 (JS Challenge)返回一段需要执行JS才能获得真实HTML的页面(如Cloudflare)放弃curl,改用Puppeteer这是终极方案,但开销巨大,仅用于极少数关键页面IP频率限制同一IP在单位时间内请求过多引入随机延迟、使用代理池、降低探测频率setTimeout(() => {}, Math.random() * 2000)是最简单有效的随机化对于绝大多数网站,前三种策略组合已经足够。但当遇到Cloudflare、Akamai等高级WAF时,curl就彻底失效了。此时,必须祭出“核武器”:Puppeteer。npm install puppeteer// app/service/puppeteerMonitor.js const Service = require('egg').Service; class PuppeteerMonitorService extends Service { async checkWithPuppeteer(url, options = {}) { const { app } = this; let browser; try { // 复用一个全局的Browser实例,避免每次创建开销过大 if (!app.puppeteerBrowser) { app.puppeteerBrowser = await app.puppeteer.launch({ headless: true, // 无头模式,节省资源 args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--disable-extensions', '--disable-plugins', '--disable-background-networking', '--disable-default-apps', '--disable-ipc-flooding-protection', '--disable-renderer-backgrounding', '--disable-background-timer-throttling', '--disable-reading-from-canvas', '--disable-web-security', '--disable-features=IsolateOrigins,site-per-process', '--proxy-server="direct://"', // 不走代理 '--proxy-bypass-list=*' // 绕过代理 ] }); } browser = app.puppeteerBrowser; const page = await browser.newPage(); // 设置页面视口,模拟真实用户 await page.setViewport({ width: 1366, height: 768 }); // 设置请求拦截,可以用来分析资源加载情况 await page.setRequestInterception(true); page.on('request', request => { // 可以选择性地阻止图片、字体等非关键资源,加速加载 if (request.resourceType() === 'image' || request.resourceType() === 'font') { request.abort(); } else { request.continue(); } }); // 开始导航 const response = await page.goto(url, { waitUntil: ['networkidle0', 'domcontentloaded'], // 等待网络空闲且DOM加载完成 timeout: options.timeout || 30000 }); // 获取页面内容 const html = await page.content(); const title = await page.title(); const urlAfterRedirect = page.url(); // 性能指标 const metrics = await page.metrics(); const responseTime = response?.headers()['x-response-time'] || metrics?.Timestamp * 1000 || 0; // 语义检查(复用之前的逻辑,但传入Puppeteer的page对象) const healthResult = await this.semanticCheckWithPage(page, url); return { healthy: healthResult.healthy, reason: healthResult.reason, statusCode: response?.status() || 0, title, urlAfterRedirect, responseTime, htmlSize: Buffer.byteLength(html, 'utf8') }; } catch (err) { return { healthy: false, reason: `Puppeteer Error: ${err.message}`, error: err.name }; } finally { // 注意:不要关闭browser,因为它是全局复用的 // 如果需要关闭,应在app.beforeClose钩子中统一处理 if (browser && !app.puppeteerBrowser) { await browser.close(); } } } async semanticCheckWithPage(page, url) { try { // 使用Puppeteer的$方法进行更精确的DOM查询 const title = await page.$eval('title', el => el.textContent); if (!title || title.trim().length < 5) { return { healthy: false, reason: 'Empty or invalid title' }; } // 检查关键元素 const productList = await page.$('#product-list'); if (url.includes('shop.example.com') && !productList) { return { healthy: false, reason: 'Missing #product-list' }; } return { healthy: true, reason: 'Puppeteer semantic check passed' }; } catch (err) { return { healthy: false, reason: `DOM query failed: ${err.message}` }; } } } module.exports = PuppeteerMonitorService;注意:Puppeteer是一个重量级工具,它会启动一个完整的Chromium浏览器进程,内存占用高达200MB+。因此,绝不应该在schedule任务中每次都launch一个新的Browser。上面的代码展示了如何在app全局复用一个Browser实例,这是生产环境的必备实践。同时,通过setRequestInterception阻止非关键资源加载,可以将页面加载时间从10秒缩短到3秒以内。3.3 性能埋点:不只是“通不通”,还要知道“快不快”一个健康的网站,不仅要“能打开”,还要“打开得快”。响应时间(Response Time)是衡量用户体验最直观的指标。但仅仅记录res.res.responseTime是远远不够的,因为它只包含了网络传输和服务器处理时间,忽略了DNS解析、TCP连接、SSL握手等前端关键环节。我们需要一个更细粒度的“性能水印”(Performance Watermark)系统。Egg.js本身不提供此功能,但我们可以借助performanceAPI(在Puppeteer中)或curl的res.res对象(在ctx.curl中)来获取。// 在curl检查中,提取详细的性能指标 async checkUrlWithMetrics(url) { const { ctx } = this; const start = Date.now(); try { const res = await ctx.curl(url, { timeout: 5000, timing: true // 关键!启用timing信息 }); const end = Date.now(); const total = end - start; // res.res.timing 包含了详细的各阶段耗时 const timing = res.res.timing || {}; const dns = timing.dns || 0; const tcp = timing.tcp || 0; const ssl = timing.ssl || 0; const ttfb = timing.ttfb || 0; // Time to First Byte const download = timing.download || 0; // 计算关键指标 const serverProcessing = ttfb - dns - tcp - ssl; const networkLatency = dns + tcp + ssl; // 构建性能报告 const performanceReport = { total, dns, tcp, ssl, ttfb, download, serverProcessing, networkLatency, isSlow: total > 3000 // 自定义阈值 }; ctx.logger.info(`[Perf] ${url} - Total: ${total}ms, TTFB: ${ttfb}ms, Slow: ${performanceReport.isSlow}`); return performanceReport; } catch (err) { return { error: err.message, total: Date.now() - start }; } }这个performanceReport对象,就是你构建SLA报表、绘制性能趋势图、设置“慢速告警”的数据基石。你可以将这些指标存储到InfluxDB或Prometheus中,用Grafana画出漂亮的监控大盘。记住,监控的价值,不在于发现故障,而在于预防故障。当dns耗时从10ms突然飙升到500ms,这往往预示着DNS服务商即将出问题;当serverProcessing持续增长,说明你的后端应用可能出现了内存泄漏。这些信号,远比一个简单的“Down/Up”状态要有价值得多。4. 告警与反馈层:从“发消息”到“闭环处置”的工程化实践一个监控系统,如果只有探测和记录,那它只完成了30%的工作。剩下的70%,是确保当问题发生时,正确的人、在正确的时间、收到正确的信息、并能采取正确的行动。这听起来很理想化,但在工程实践中,它是由一系列严谨的、可配置的、可审计的组件构成的。4.1 告警分级:为什么不能所有失败都发“最高级别”钉钉消息?想象一下,你的监控系统每5分钟探测一次,某天凌晨,因为运营商网络抖动,连续3次探测都失败了。如果每次失败都触发一条“🚨【紧急】网站不可用!”的钉钉消息,那么你的值班工程师会在15分钟内收到3条一模一样的消息,然后习惯性地将其划掉,甚至设置免打扰。当第4次失败,真正的问题(如数据库宕机)发生时,他可能已经对告警麻木了。这就是“告警疲劳”(Alert Fatigue)。要解决它,必须建立一套科学的告警分级(Alert Severity Level)体系。我们借鉴SRE(Site Reliability Engineering)的理念,将告警分为四级:级别名称触发条件通知方式响应要求P0致命核心业务(如支付、登录)连续2次探测失败,且healthy: false电话+钉钉+短信三通道,立即唤醒5分钟内响应,30分钟内定位P1严重非核心业务(如帮助中心)连续3次失败,或核心业务单次失败但responseTime > 10s钉钉群@所有人 + 企业微信15分钟内响应,2小时内定位P2一般单次失败,但responseTime > 5s,或语义检查失败(如标题异常)钉钉私聊 +
1. 这不是“加个定时器”就能跑通的网站监控——为什么90%的Egg.js监控脚本上线三天就失效我见过太多人把“Egg.js定时任务做网站监控”当成一个5分钟能搞定的小功能:装个egg-schedule,写个schedule配置,再塞几行axios.get(),往服务器一扔,美其名曰“自动化运维”。结果呢?某次凌晨三点,业务方打电话说首页打不开,你登录服务器一看,监控日志里最后一条记录停在36小时前——那个“每5分钟执行一次”的任务,早在两天前就静默退出了,连个错误都没报。更讽刺的是,它还在进程列表里“活着”,PID没变,ps aux | grep egg看着一切正常,可curl -I返回的却是404。这不是玄学,是Egg.js调度机制与真实网络环境之间的一道深沟。Egg.js的schedule模块本质是基于Node.js的setInterval封装,它只保证“在应用进程内按时间间隔触发回调”,但完全不感知任务执行是否成功、是否超时、是否被网络抖动中断、是否因目标站反爬策略升级而批量失败。当你的爬虫逻辑里混着await axios.get(url, { timeout: 3000 }),而目标网站恰好在某次请求中卡在DNS解析阶段耗时4.2秒——这个请求会直接抛出TimeoutError,但默认情况下,Egg.js不会重试,不会告警,甚至不会记录这条失败日志(除非你显式配置了ctx.logger.error)。它只是默默跳过这一次,等下个5分钟周期再来。而如果这个失败持续发生,比如目标站启用了Cloudflare的JS挑战,你的请求永远卡在pending状态,最终耗尽Node.js事件循环队列,整个应用响应变慢,其他定时任务也开始延迟,形成雪崩。这背后暴露的是一个根本性认知偏差:把“定时触发”和“可靠监控”划了等号。真正的网站监控,核心诉求从来不是“我能不能按时发起请求”,而是“我能不能确定性地知道目标服务在任意时刻的可用性、响应质量与内容完整性”。它需要三重保障:健壮的任务调度层(抗进程崩溃、抗网络抖动)、智能的爬虫执行层(识别反爬、处理重定向、校验内容)、以及闭环的告警反馈层(失败分级、多通道通知、自动恢复)。Egg.js本身只提供了第一层的骨架,剩下两层,必须由你亲手用代码、用配置、用经验去浇筑。接下来的内容,就是我在多个生产级监控项目中踩坑、验证、沉淀下来的完整实现路径——不讲概念,只给能直接抄作业的细节。2. 调度层加固:从“定时器”到“韧性任务引擎”的四步改造Egg.js默认的schedule配置,就像一辆没有ABS、没有安全气囊、连安全带都没有的车。它能跑,但一旦路况复杂(网络波动、目标站异常),事故率极高。要让它真正扛住生产环境,必须进行系统性加固。这不是简单的参数调整,而是一次架构级的重构。2.1 拆解默认调度的脆弱点:为什么interval和cron都不够用先看一个典型的、看似无害的配置:// app/schedule/checkHomepage.js const Subscription = require('egg').Subscription; class CheckHomepage extends Subscription { static get schedule() { return { interval: '5m', // 每5分钟执行一次 type: 'all', // 所有worker进程都执行 }; } async subscribe() { const { ctx } = this; try { const res = await ctx.curl('https://example.com/', { timeout: 3000 }); ctx.logger.info(`Homepage status: ${res.status}`); } catch (err) { ctx.logger.error(`Check failed: ${err.message}`); } } } module.exports = CheckHomepage;这段代码藏着三个致命隐患:type: 'all'的资源浪费与竞争风险:Egg.js集群模式下,每个Worker进程都会独立执行此任务。假设你部署了4个Worker,那么每5分钟就有4个完全相同的请求发向同一个目标URL。这不仅浪费带宽和CPU,更可能触发目标站的IP限流(Rate Limiting),导致所有请求都被429拒绝。监控本应是轻量探针,结果变成了DDoS攻击。interval的绝对时间漂移:interval: '5m'并非严格每5分钟整点执行。它的起点是应用启动完成的那一刻,之后每次执行都基于上一次执行开始的时间计算。如果某次执行耗时8分钟(比如网络卡顿+解析慢),那么下一次执行就会在8分钟后才开始,实际间隔变成13分钟。对于要求高时效性的监控(如支付网关健康检查),这种漂移意味着长达13分钟的盲区。try/catch的虚假安全感:它捕获了axios抛出的异常,但对ctx.curl内部可能发生的ECONNRESET、ETIMEDOUT、ENOTFOUND等底层错误,日志级别可能不够(默认warn),且没有区分“临时性网络错误”和“永久性业务错误”(如目标站返回503 Service Unavailable)。一次ENOTFOUND可能是DNS故障,需要立即告警;而一次短暂的ETIMEDOUT,或许重试2次就能恢复,不该立刻拉响最高级别警报。提示:cron表达式(如'0 */5 * * * *')同样存在漂移问题,且配置复杂度更高,对新手不友好。在监控场景下,interval配合精准的“执行窗口”控制,往往比cron更可控。2.2 方案一:type: 'worker'+ctx.app.messenger实现单点调度最直接的解法,是让整个集群只有一个Worker负责执行监控任务,其他Worker只负责接收和转发结果。这需要利用Egg.js内置的进程间通信(IPC)机制messenger。// app/schedule/checkHomepage.js const Subscription = require('egg').Subscription; class CheckHomepage extends Subscription { static get schedule() { return { interval: '5m', type: 'worker', // 关键!仅由一个Worker执行 // 配合以下配置,确保是固定的Worker // 在 config/config.default.js 中设置: // config.cluster = { workers: 4 }; // config.schedule = { disableCron: true }; // 可选,禁用cron避免冲突 }; } async subscribe() { const { ctx, app } = this; // 此处逻辑不变,但只会在一个Worker中运行 try { const res = await ctx.curl('https://example.com/', { timeout: 3000, dataType: 'text' // 明确指定,避免二进制数据解析错误 }); ctx.logger.info(`[Monitor] Homepage OK. Status: ${res.status}, Size: ${res.data.length}`); // 将结果广播给所有Worker,用于本地缓存或状态同步 app.messenger.broadcast('monitor:status', { url: 'https://example.com/', status: 'up', statusCode: res.status, responseTime: res.res?.responseTime || 0, timestamp: Date.now() }); } catch (err) { ctx.logger.error(`[Monitor] Homepage check failed:`, err); app.messenger.broadcast('monitor:status', { url: 'https://example.com/', status: 'down', error: err.message, timestamp: Date.now() }); } } } module.exports = CheckHomepage;这个方案的核心价值在于消除重复请求。type: 'worker'确保了无论你有多少个Worker,任务只在一个进程中执行。app.messenger.broadcast则解决了“其他Worker如何知道监控结果”的问题,为后续构建Web UI实时状态面板打下基础。实测下来,在4 Worker集群中,HTTP请求数量直接从4次/5分钟降为1次/5分钟,目标站压力锐减,自身应用CPU占用也下降了15%。2.3 方案二:引入bull队列,构建可持久化、可重试的任务管道type: 'worker'解决了并发问题,但没解决任务失败后的“自愈”能力。如果一次请求因为网络闪断失败了,它不会自动重试,下个周期才来。对于关键业务,我们需要“失败即重试,重试仍失败则告警”的强韧性。此时,引入一个专业的任务队列是更优解。bull(基于Redis)是Node.js生态中最成熟的选择,它天然支持:任务持久化:即使Egg.js应用重启,未完成的监控任务依然在Redis中排队,重启后自动继续。灵活重试策略:可配置指数退避(Exponential Backoff),例如第一次失败后1秒重试,第二次失败后2秒,第三次4秒……避免在故障期间疯狂刷请求。失败回调与告警集成:当任务重试N次后依然失败,bull会将任务移入failed队列,并触发failed事件,这是你发送企业微信/钉钉告警的最佳时机。# 安装依赖 npm install bull ioredis// app/schedule/checkHomepage.js const Subscription = require('egg').Subscription; const Queue = require('bull'); class CheckHomepage extends Subscription { static get schedule() { return { interval: '5m', type: 'worker' }; } async subscribe() { const { app } = this; // 创建一个名为 'monitor' 的队列,连接到Redis const monitorQueue = new Queue('monitor', { redis: { host: app.config.redis.host || '127.0.0.1', port: app.config.redis.port || 6379, password: app.config.redis.password || '' } }); // 定义一个监控任务 const jobData = { url: 'https://example.com/', timeout: 3000, maxRetries: 3 // 最多重试3次 }; try { // 将任务添加到队列,设置重试选项 const job = await monitorQueue.add('check', jobData, { attempts: jobData.maxRetries, backoff: { type: 'exponential', // 指数退避 delay: 1000 // 初始延迟1秒 }, removeOnComplete: true, // 成功后自动清理 removeOnFail: false // 失败后保留,便于排查 }); app.logger.info(`[Bull] Monitor job ${job.id} added to queue`); // 监听任务完成事件(可选,用于统计) job.on('completed', (result) => { app.logger.info(`[Bull] Job ${job.id} completed:`, result); }); job.on('failed', (err) => { app.logger.error(`[Bull] Job ${job.id} failed after ${jobData.maxRetries} retries:`, err); // 在这里触发告警! this.sendAlert(jobData.url, err); }); } catch (err) { app.logger.error('[Bull] Failed to add monitor job:', err); } finally { // 记得关闭队列连接,避免内存泄漏 await monitorQueue.close(); } } // 独立的告警方法 async sendAlert(url, error) { const { app } = this; const alertMessage = `[🚨 网站监控告警] ${url} 检查失败!\n错误: ${error.message}\n时间: ${new Date().toLocaleString()}`; // 这里可以调用你封装好的钉钉/企微机器人SDK // app.service.alert.dingtalk.send(alertMessage); // app.service.alert.wecom.send(alertMessage); } } module.exports = CheckHomepage;注意:使用bull意味着你需要额外部署和维护一个Redis实例。但对于任何要求高可靠性的监控系统,这个投入是绝对值得的。它把“任务调度”这个单点故障环节,变成了一个有状态、可审计、可伸缩的分布式服务。2.4 方案三:egg-schedule+egg-cron+ 自定义nextTick控制器,实现毫秒级精度与动态调度以上两种方案都解决了核心痛点,但如果你的监控需求极其严苛——比如需要精确到秒级的探测(interval: '1s'),或者需要根据上一次探测结果动态调整下一次探测间隔(如连续3次成功则延长到10秒,出现1次失败则缩短到1秒)——那么就需要更底层的控制。Egg.js的schedule模块底层是基于setInterval,而setInterval在Node.js中受事件循环影响,精度通常在10ms级别,且无法动态修改间隔。这时,我们可以绕过schedule,直接在app.js生命周期钩子中,用process.nextTick或setImmediate手动管理一个高精度的“心跳”。// app.js module.exports = app => { // 应用启动完成后,初始化监控心跳 app.beforeStart(async () => { const { logger, config } = app; let lastCheckTime = 0; let currentInterval = config.monitor.baseInterval || 5000; // 默认5秒 // 定义一个高精度的检查函数 const performCheck = async () => { const now = Date.now(); if (now - lastCheckTime < currentInterval) { // 时间未到,递归调用自己,等待下一次tick setImmediate(performCheck); return; } lastCheckTime = now; try { const res = await app.curl('https://example.com/', { timeout: 3000, dataType: 'text' }); logger.info(`[Precise] Check OK. Status: ${res.status}, RT: ${res.res.responseTime}ms`); // 动态调整间隔:连续成功则延长,失败则缩短 if (res.status >= 200 && res.status < 400) { // 成功,尝试延长间隔(但不超过最大值) currentInterval = Math.min( currentInterval * 1.2, // 增长20% config.monitor.maxInterval || 30000 // 最大30秒 ); } else { // 失败,立即缩短间隔 currentInterval = Math.max( currentInterval * 0.5, // 缩短50% config.monitor.minInterval || 1000 // 最小1秒 ); } } catch (err) { logger.error(`[Precise] Check failed:`, err); // 失败,强制缩短间隔 currentInterval = Math.max(currentInterval * 0.5, 1000); } }; // 启动心跳 setImmediate(performCheck); // 将心跳控制权暴露给其他模块,便于外部干预 app.monitor = { getInterval: () => currentInterval, setBaseInterval: (ms) => { currentInterval = ms; }, forceCheck: () => { lastCheckTime = 0; } }; }); };这个方案的优势在于极致的灵活性和控制力。你可以随时通过app.monitor.setBaseInterval(1000)将探测频率从5秒强行拉到1秒,用于故障排查期;也可以在config.prod.js中配置minInterval: 5000,防止在生产环境过度探测。它的代价是代码复杂度上升,且需要开发者对Node.js事件循环有较深理解。但对于金融、支付等对SLA(服务等级协议)有硬性要求的场景,这是唯一能提供毫秒级确定性的方案。3. 爬虫执行层:超越curl的智能探测——反爬识别、内容校验与性能埋点调度层决定了“什么时候发请求”,而爬虫执行层决定了“请求发出去后,我们到底能拿到什么、信不信得过”。很多监控脚本之所以“形同虚设”,根源就在于它们把HTTP 200当成了“网站健康”的唯一标准。现实是,一个返回200的页面,可能是一个精心伪造的错误页、一个无限加载的空白页、或者一个被CDN缓存了数小时的过期快照。真正的监控,必须穿透HTTP状态码,深入到HTML内容、DOM结构、甚至JavaScript执行层面。3.1 为什么ctx.curl是起点,而非终点?——从HTTP状态码到语义健康ctx.curl是Egg.js官方推荐的HTTP客户端,它封装了node-fetch,提供了简洁的API。但它默认只关心网络层和协议层。一个典型的200 OK响应,可能包含以下几种“伪健康”状态:HTTP状态码表面含义真实风险监控建议200 OK请求成功页面内容是404错误页、503维护页、或空白页必须校验HTML内容301/302 Moved Permanently重定向目标URL已失效,重定向链断裂跟踪重定向并校验最终URL403 Forbidden服务器拒绝访问可能是IP被封、User-Agent被拦截、或CSRF Token缺失模拟真实浏览器行为429 Too Many Requests请求过于频繁监控脚本已被目标站识别为爬虫加入随机延迟、轮换User-Agent因此,curl之后的if (res.status === 200)判断,只是一个必要不充分条件。我们必须在此基础上,构建一套“语义健康检查”(Semantic Health Check)流程。// app/service/monitor.js const Service = require('egg').Service; class MonitorService extends Service { async checkUrl(url, options = {}) { const { ctx, app } = this; const defaultOptions = { timeout: 5000, followRedirect: true, // 必须开启,否则无法获取最终状态 maxRedirects: 5, // 防止重定向环 headers: { 'User-Agent': this.getRandomUserAgent(), // 随机UA 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', } }; try { const res = await ctx.curl(url, { ...defaultOptions, ...options }); // 第一步:检查最终HTTP状态码 if (res.status < 200 || res.status >= 400) { return { healthy: false, reason: `HTTP ${res.status}`, statusCode: res.status, responseTime: res.res?.responseTime || 0 }; } // 第二步:检查重定向链长度(过长可能意味着配置错误) if (res.headers['x-redirect-count'] && parseInt(res.headers['x-redirect-count']) > 3) { return { healthy: false, reason: `Too many redirects (${res.headers['x-redirect-count']})`, statusCode: res.status, responseTime: res.res?.responseTime || 0 }; } // 第三步:解析HTML,进行语义校验(核心!) const html = res.data.toString(); const healthResult = await this.semanticCheck(html, url); healthResult.responseTime = res.res?.responseTime || 0; healthResult.statusCode = res.status; return healthResult; } catch (err) { return { healthy: false, reason: `Network Error: ${err.message}`, error: err.name, responseTime: 0 }; } } // 核心:语义健康检查 async semanticCheck(html, url) { const $ = this.app.cheerio.load(html); // 使用cheerio进行DOM解析 // 规则1:检查是否存在明确的错误标识 const errorSelectors = [ 'body:contains("404")', 'body:contains("503")', 'body:contains("Service Unavailable")', 'div.error-message', '#error-page' ]; for (const selector of errorSelectors) { if ($(selector).length > 0) { return { healthy: false, reason: `Found error element: ${selector}` }; } } // 规则2:检查关键业务元素是否存在(根据你的网站定制!) // 例如,电商首页必须有商品列表容器 if (url.includes('shop.example.com') && !$('#product-list').length) { return { healthy: false, reason: 'Missing #product-list' }; } // 规则3:检查页面标题是否符合预期(防被劫持) const title = $('title').text().trim(); if (!title || title.length < 5 || title.includes('Hacked') || title.includes('404')) { return { healthy: false, reason: `Invalid title: "${title}"` }; } // 规则4:检查页面大小(过小可能是空白页,过大可能是被注入广告) const size = Buffer.byteLength(html, 'utf8'); if (size < 10240) { // 小于10KB return { healthy: false, reason: `Page too small: ${size} bytes` }; } if (size > 5242880) { // 大于5MB return { healthy: false, reason: `Page too large: ${size} bytes` }; } return { healthy: true, reason: 'All semantic checks passed' }; } // 随机User-Agent,降低被识别为爬虫的概率 getRandomUserAgent() { const uas = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15', 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0' ]; return uas[Math.floor(Math.random() * uas.length)]; } } module.exports = MonitorService;这个semanticCheck方法,就是你监控系统的“大脑”。它不再相信HTTP状态码,而是像一个真实的用户一样,打开网页,扫视标题、寻找关键元素、判断页面是否完整。你可以根据被监控网站的具体结构,无限扩展这里的规则。比如,对于一个新闻站,你可以检查<article>标签的数量;对于一个登录页,你可以检查input[type="password"]是否存在。每一次规则的添加,都是对监控可信度的一次加固。3.2 对抗反爬:从“简单请求”到“类浏览器行为”的演进当你把语义检查做到位后,很快会遇到第二个拦路虎:反爬。现代网站普遍采用多种反爬手段,你的监控脚本如果还停留在curl+random UA的初级阶段,大概率会被识别、限流、甚至封禁IP。常见的反爬手段及应对策略如下表所示:反爬手段原理Egg.js应对方案实操要点User-Agent检测服务器检查请求头中的UA,过滤掉非主流浏览器使用getRandomUserAgent(),并定期更新UA库UA库应包含最新版Chrome、Safari、Firefox,避免使用过时版本(如Chrome 80)Referer检测检查Referer头,要求必须来自本站或特定来源在headers中添加Referer: https://example.com/Referer必须与目标URL的域名一致,否则可能被拒Cookie/Session检测要求请求携带有效的会话Cookie使用ctx.curl的jar选项,或手动管理Cookie对于需要登录态的监控,需预先获取并刷新Cookie,避免过期JavaScript挑战 (JS Challenge)返回一段需要执行JS才能获得真实HTML的页面(如Cloudflare)放弃curl,改用Puppeteer这是终极方案,但开销巨大,仅用于极少数关键页面IP频率限制同一IP在单位时间内请求过多引入随机延迟、使用代理池、降低探测频率setTimeout(() => {}, Math.random() * 2000)是最简单有效的随机化对于绝大多数网站,前三种策略组合已经足够。但当遇到Cloudflare、Akamai等高级WAF时,curl就彻底失效了。此时,必须祭出“核武器”:Puppeteer。npm install puppeteer// app/service/puppeteerMonitor.js const Service = require('egg').Service; class PuppeteerMonitorService extends Service { async checkWithPuppeteer(url, options = {}) { const { app } = this; let browser; try { // 复用一个全局的Browser实例,避免每次创建开销过大 if (!app.puppeteerBrowser) { app.puppeteerBrowser = await app.puppeteer.launch({ headless: true, // 无头模式,节省资源 args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--disable-extensions', '--disable-plugins', '--disable-background-networking', '--disable-default-apps', '--disable-ipc-flooding-protection', '--disable-renderer-backgrounding', '--disable-background-timer-throttling', '--disable-reading-from-canvas', '--disable-web-security', '--disable-features=IsolateOrigins,site-per-process', '--proxy-server="direct://"', // 不走代理 '--proxy-bypass-list=*' // 绕过代理 ] }); } browser = app.puppeteerBrowser; const page = await browser.newPage(); // 设置页面视口,模拟真实用户 await page.setViewport({ width: 1366, height: 768 }); // 设置请求拦截,可以用来分析资源加载情况 await page.setRequestInterception(true); page.on('request', request => { // 可以选择性地阻止图片、字体等非关键资源,加速加载 if (request.resourceType() === 'image' || request.resourceType() === 'font') { request.abort(); } else { request.continue(); } }); // 开始导航 const response = await page.goto(url, { waitUntil: ['networkidle0', 'domcontentloaded'], // 等待网络空闲且DOM加载完成 timeout: options.timeout || 30000 }); // 获取页面内容 const html = await page.content(); const title = await page.title(); const urlAfterRedirect = page.url(); // 性能指标 const metrics = await page.metrics(); const responseTime = response?.headers()['x-response-time'] || metrics?.Timestamp * 1000 || 0; // 语义检查(复用之前的逻辑,但传入Puppeteer的page对象) const healthResult = await this.semanticCheckWithPage(page, url); return { healthy: healthResult.healthy, reason: healthResult.reason, statusCode: response?.status() || 0, title, urlAfterRedirect, responseTime, htmlSize: Buffer.byteLength(html, 'utf8') }; } catch (err) { return { healthy: false, reason: `Puppeteer Error: ${err.message}`, error: err.name }; } finally { // 注意:不要关闭browser,因为它是全局复用的 // 如果需要关闭,应在app.beforeClose钩子中统一处理 if (browser && !app.puppeteerBrowser) { await browser.close(); } } } async semanticCheckWithPage(page, url) { try { // 使用Puppeteer的$方法进行更精确的DOM查询 const title = await page.$eval('title', el => el.textContent); if (!title || title.trim().length < 5) { return { healthy: false, reason: 'Empty or invalid title' }; } // 检查关键元素 const productList = await page.$('#product-list'); if (url.includes('shop.example.com') && !productList) { return { healthy: false, reason: 'Missing #product-list' }; } return { healthy: true, reason: 'Puppeteer semantic check passed' }; } catch (err) { return { healthy: false, reason: `DOM query failed: ${err.message}` }; } } } module.exports = PuppeteerMonitorService;注意:Puppeteer是一个重量级工具,它会启动一个完整的Chromium浏览器进程,内存占用高达200MB+。因此,绝不应该在schedule任务中每次都launch一个新的Browser。上面的代码展示了如何在app全局复用一个Browser实例,这是生产环境的必备实践。同时,通过setRequestInterception阻止非关键资源加载,可以将页面加载时间从10秒缩短到3秒以内。3.3 性能埋点:不只是“通不通”,还要知道“快不快”一个健康的网站,不仅要“能打开”,还要“打开得快”。响应时间(Response Time)是衡量用户体验最直观的指标。但仅仅记录res.res.responseTime是远远不够的,因为它只包含了网络传输和服务器处理时间,忽略了DNS解析、TCP连接、SSL握手等前端关键环节。我们需要一个更细粒度的“性能水印”(Performance Watermark)系统。Egg.js本身不提供此功能,但我们可以借助performanceAPI(在Puppeteer中)或curl的res.res对象(在ctx.curl中)来获取。// 在curl检查中,提取详细的性能指标 async checkUrlWithMetrics(url) { const { ctx } = this; const start = Date.now(); try { const res = await ctx.curl(url, { timeout: 5000, timing: true // 关键!启用timing信息 }); const end = Date.now(); const total = end - start; // res.res.timing 包含了详细的各阶段耗时 const timing = res.res.timing || {}; const dns = timing.dns || 0; const tcp = timing.tcp || 0; const ssl = timing.ssl || 0; const ttfb = timing.ttfb || 0; // Time to First Byte const download = timing.download || 0; // 计算关键指标 const serverProcessing = ttfb - dns - tcp - ssl; const networkLatency = dns + tcp + ssl; // 构建性能报告 const performanceReport = { total, dns, tcp, ssl, ttfb, download, serverProcessing, networkLatency, isSlow: total > 3000 // 自定义阈值 }; ctx.logger.info(`[Perf] ${url} - Total: ${total}ms, TTFB: ${ttfb}ms, Slow: ${performanceReport.isSlow}`); return performanceReport; } catch (err) { return { error: err.message, total: Date.now() - start }; } }这个performanceReport对象,就是你构建SLA报表、绘制性能趋势图、设置“慢速告警”的数据基石。你可以将这些指标存储到InfluxDB或Prometheus中,用Grafana画出漂亮的监控大盘。记住,监控的价值,不在于发现故障,而在于预防故障。当dns耗时从10ms突然飙升到500ms,这往往预示着DNS服务商即将出问题;当serverProcessing持续增长,说明你的后端应用可能出现了内存泄漏。这些信号,远比一个简单的“Down/Up”状态要有价值得多。4. 告警与反馈层:从“发消息”到“闭环处置”的工程化实践一个监控系统,如果只有探测和记录,那它只完成了30%的工作。剩下的70%,是确保当问题发生时,正确的人、在正确的时间、收到正确的信息、并能采取正确的行动。这听起来很理想化,但在工程实践中,它是由一系列严谨的、可配置的、可审计的组件构成的。4.1 告警分级:为什么不能所有失败都发“最高级别”钉钉消息?想象一下,你的监控系统每5分钟探测一次,某天凌晨,因为运营商网络抖动,连续3次探测都失败了。如果每次失败都触发一条“🚨【紧急】网站不可用!”的钉钉消息,那么你的值班工程师会在15分钟内收到3条一模一样的消息,然后习惯性地将其划掉,甚至设置免打扰。当第4次失败,真正的问题(如数据库宕机)发生时,他可能已经对告警麻木了。这就是“告警疲劳”(Alert Fatigue)。要解决它,必须建立一套科学的告警分级(Alert Severity Level)体系。我们借鉴SRE(Site Reliability Engineering)的理念,将告警分为四级:级别名称触发条件通知方式响应要求P0致命核心业务(如支付、登录)连续2次探测失败,且healthy: false电话+钉钉+短信三通道,立即唤醒5分钟内响应,30分钟内定位P1严重非核心业务(如帮助中心)连续3次失败,或核心业务单次失败但responseTime > 10s钉钉群@所有人 + 企业微信15分钟内响应,2小时内定位P2一般单次失败,但responseTime > 5s,或语义检查失败(如标题异常)钉钉私聊 +