← 中文资源

简体中文版本

构建自托管可用性监控服务

按间隔轮询端点、跟踪连续失败、在阈值处开启事故并在恢复时关闭,同时暴露 JSON 状态端点。

阅读英文原文

项目目标

本项目把监控看作包裹周期性检查的状态机,重点在转移而非请求本身:抖动到何种程度算宕机,又何时算恢复。服务跟踪连续失败,只有达到阈值才开启事故,并为每次转移记录时间戳,使事故历史有意义而非嘈杂。

核心特性

  • 按固定间隔带超时轮询每个目标
  • 跟踪连续失败并在阈值处开启事故
  • 目标恢复时关闭事故并记录时长
  • 提供 JSON 状态端点并持久化事故历史

架构与分层

调度器以定时器触发周期检查;检查器分类每次响应;状态机跟踪目标状态与转移;事故日志持久化转移;HTTP 层提供当前状态。时钟被注入,使间隔与阈值行为可确定性测试。

实施阶段

六个阶段为:检查器、目标状态机、事故日志、调度器、状态服务、CLI 与测试。阈值边界与首次成功恢复是核心断言。

测试与验收

用 vitest 运行。状态测试驱动一串结果断言事故恰在阈值处开启、首次成功即关闭;转移测试用假时钟断言事故时长由开启与关闭时间戳之差得出。

延伸方向

多种检查类型、告警通道与响应时间分位都是检查器与事故日志外围的增量。调度器只调用检查器,因此新检查类型不改动状态机。

本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。

代码示例

export function applyCheck(state, outcome, { threshold = 3 } = {}) {
  const next = { ...state, consecutiveFailures: outcome.ok ? 0 : state.consecutiveFailures + 1 };
  if (state.status === 'up' && next.consecutiveFailures >= threshold) {
    return { next: { ...next, status: 'down' }, transition: { type: 'opened', at: outcome.at } };
  }
  if (state.status === 'down' && outcome.ok) {
    return { next: { ...next, status: 'up' }, transition: { type: 'closed', at: outcome.at } };
  }
  return { next, transition: null };
}
mkdir uptime-monitor && cd uptime-monitor
npm init -y
npm install -D vitest@^2.1.0
{
  "name": "uptime-monitor",
  "type": "module",
  "scripts": {
    "start": "node src/cli.js",
    "test": "vitest run"
  }
}
export async function checkTarget(target, { fetchImpl = fetch, timeoutMs = 5000, at = Date.now() } = {}) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  const started = Date.now();
  try {
    const res = await fetchImpl(target.url, { signal: controller.signal });
    return { ok: res.status >= 200 && res.status < 400, status: res.status, latencyMs: Date.now() - started, at: at() };
  } catch (err) {
    return { ok: false, status: 0, reason: err.name, latencyMs: Date.now() - started, at: at() };
  } finally {
    clearTimeout(timer);
  }
}
export function initialState() {
  return { status: 'up', consecutiveFailures: 0, since: null };
}

export function applyCheck(state, outcome, { threshold = 3 } = {}) {
  const next = { ...state, consecutiveFailures: outcome.ok ? 0 : state.consecutiveFailures + 1 };
  if (state.status === 'up' && next.consecutiveFailures >= threshold) {
    return { next: { ...next, status: 'down', since: outcome.at }, transition: { type: 'opened', at: outcome.at } };
  }
  if (state.status === 'down' && outcome.ok) {
    return { next: { ...next, status: 'up', since: outcome.at }, transition: { type: 'closed', at: outcome.at } };
  }
  return { next, transition: null };
}
import { readFile, writeFile } from 'node:fs/promises';

export async function appendIncident(path, incident) {
  let log = [];
  try { log = JSON.parse(await readFile(path, 'utf8')); } catch { log = []; }
  log.push(incident);
  await writeFile(path, JSON.stringify(log, null, 2));
  return log;
}

export function durations(log) {
  const open = new Map();
  const out = [];
  for (const entry of log) {
    if (entry.type === 'opened') open.set(entry.target, entry.at);
    else if (entry.type === 'closed' && open.has(entry.target)) {
      out.push({ target: entry.target, downMs: entry.at - open.get(entry.target) });
      open.delete(entry.target);
    }
  }
  return out;
}
export function createScheduler({ intervalMs, check, onOutcome, setIntervalImpl = setInterval }) {
  let timer = null;
  async function tick() {
    const outcomes = await Promise.all(check());
    for (const outcome of outcomes) onOutcome(outcome);
  }
  return {
    start() { timer = setIntervalImpl(tick, intervalMs); return timer; },
    stop() { if (timer) clearInterval(timer); },
    tick,
  };
}
import { createServer } from 'node:http';

export function createStatusServer({ getState }) {
  return createServer((req, res) => {
    if (req.url === '/status') {
      const targets = Object.entries(getState()).map(([url, s]) => ({ url, status: s.status, since: s.since }));
      const down = targets.filter((t) => t.status === 'down').length;
      res.writeHead(down ? 503 : 200, { 'content-type': 'application/json' });
      return res.end(JSON.stringify({ down, targets }));
    }
    res.writeHead(404, { 'content-type': 'application/json' });
    res.end(JSON.stringify({ error: 'not_found' }));
  });
}
import { initialState, applyCheck } from '../src/state.js';

const fail = (at) => ({ ok: false, at });
const ok = (at) => ({ ok: true, at });

test('opens at the threshold and closes on first success', () => {
  let state = initialState();
  state = applyCheck(state, fail(1)).next;
  state = applyCheck(state, fail(2)).next;
  expect(state.status).toBe('up');
  const third = applyCheck(state, fail(3));
  expect(third.next.status).toBe('down');
  expect(third.transition.type).toBe('opened');
  const recovered = applyCheck(third.next, ok(4));
  expect(recovered.transition.type).toBe('closed');
});