KernelRanch
返回文章列表

项目:状态页与监控,用两个免费组件做出来

2026-11-10 #Cloudflare

状态页要回答一个问题:现在服务是好的吗,过去一天挂过多久。它不需要数据库,也不需要时序系统,一个定时任务加一份当前状态就够。这套东西拆成三块:探测端(cron Worker 或外部探针)、状态存储(KV)、展示端(静态页读 JSON)。全部落在免费额度内。

定时任务的写法见 63-workers-cron-triggers.md,日志与指标见 66-workers-observability.md。这篇给它拼成一个对外可看的页面,故障发生时的排查顺序见 104-troubleshooting-playbook.md。

1. 组件与额度

组件用什么免费额度消耗
探测Cron Trigger + scheduled定时触发不额外计费每次探测 1 次 Worker 请求
存储KV1,000 写/天状态变化时才写,正常情况接近 0
展示Pages 静态页 或 Worker 返回 HTML静态请求不计 Workers 额度每次访问 1 次静态请求

每分钟探一次是 1440 次/天,占 Workers 10 万日额度的 1.4%,可以接受。探五个目标就是 7200 次,占 7%。探太密没有意义,外部用户感知不到的抖动不值得记。

2. 探测端

const TARGETS = [
  { name: "主站", url: "https://example.com/", expect: 200 },
  { name: "API", url: "https://api.example.com/health", expect: 200 },
  { name: "图床", url: "https://img.example.com/ping.txt", expect: 200 }
];

async function probe(t) {
  const started = Date.now();
  try {
    const res = await fetch(t.url, {
      method: "GET",
      signal: AbortSignal.timeout(8000),
      cf: { cacheTtl: 0 }
    });
    return {
      name: t.name,
      ok: res.status === t.expect,
      status: res.status,
      ms: Date.now() - started,
      at: new Date().toISOString()
    };
  } catch (err) {
    return { name: t.name, ok: false, status: 0, ms: Date.now() - started,
             error: String(err), at: new Date().toISOString() };
  }
}

export default {
  async scheduled(event, env, ctx) {
    const results = await Promise.all(TARGETS.map(probe));
    const prevRaw = await env.STATUS.get("current");
    const prev = prevRaw ? JSON.parse(prevRaw) : null;

    const changed = !prev || results.some((r, i) => r.ok !== prev.results[i]?.ok);
    const payload = { checkedAt: new Date().toISOString(), results };

    if (changed) {
      await env.STATUS.put("current", JSON.stringify(payload));
      await env.STATUS.put(`event:${Date.now()}`, JSON.stringify({
        at: payload.checkedAt,
        down: results.filter(r => !r.ok).map(r => r.name)
      }), { expirationTtl: 60 * 60 * 24 * 30 });
    }
  },

  async fetch(request, env) {
    const raw = await env.STATUS.get("current");
    return new Response(raw || "{}", {
      headers: {
        "content-type": "application/json; charset=utf-8",
        "cache-control": "public, max-age=30",
        "access-control-allow-origin": "*"
      }
    });
  }
};

cf: { cacheTtl: 0 } 这一行很关键。探测请求如果被 Cloudflare 缓存命中,拿到的永远是旧结果,目标挂了也检测不出来。同理,探测 URL 最好带一个随机查询参数或在响应里加 cache-control: no-store。

3. 只在变化时写

KV 免费档每天 1,000 次写。每分钟探一次、每次都写状态的话,一天 1440 次写,直接超额度,而且从第二天开始状态就不再更新——状态页会显示"一切正常",恰恰是最危险的那种假象。

所以代码里加了 changed 判断:状态没变就不写。正常运行的站点一天可能只写几次。代价是"最后一次探测时间"不新鲜,页面上的 checkedAt 只反映最近一次状态变化,不是最近一次探测。要让时间戳保持新鲜,可以另存一个 key 并用 expirationTtl 让它自然过期,或者接受 30 秒的边缘缓存把时间模糊掉。

4. 展示端

静态页拉 JSON 渲染,不需要构建:

const res = await fetch("https://status-api.example.com/", { cache: "no-store" });
const data = await res.json();
const allOk = data.results.every(r => r.ok);
document.getElementById("banner").textContent = allOk ? "所有服务正常" : "存在异常";
document.getElementById("banner").className = allOk ? "ok" : "bad";
document.getElementById("at").textContent = data.checkedAt;

页面本身放 Pages 或 Worker 静态资源都行,域名用 status.example.com。状态页要能在主站挂掉时照常访问,所以不要跟主站共用同一个 Worker 或同一个上游依赖。

5. 误报抑制

单次失败就标红,会被网络抖动和一次部署重启刷出一堆假告警。两个简单办法:

连续失败才算故障。在 KV 里存一个失败计数器,连续 3 次失败才把 ok 置为 false,中间只要成功一次就清零。代价是故障发现晚 2 分钟。

区分错误类型。超时和 5xx 算可疑,401/403 通常是自己配置问题不是服务挂了,4xx 大概率是探测 URL 写错了。把状态码分类处理,比一刀切 ok = res.ok 准确得多。

6. 探测点放在哪里

从 Worker 里探测,测的是"边缘到目标"这条路径,不是用户到目标。目标站也在这个账号里时,请求可能走 Cloudflare 内部路由,根本不经过公网,结果会比真实用户体验好很多。这就是"监控显示一切正常,用户却说打不开"的常见来源。

补一个外部视角很便宜。GitHub Actions 的定时任务、免费的第三方探针、甚至家里一台常开的小机器,任意一个每周跑几次,把结果推到同一个 KV key 里。两个来源不一致时,差异本身就是线索:内部正常外部异常,多半是 DNS、证书或边缘规则;两边都异常,才是源站。

探测频率按目标的重要性定,不按"越密越好"。核心接口一分钟一次,边缘服务五分钟一次,静态页十分钟一次。频率提高一倍,额度消耗翻倍,但故障发现时间只缩短一半分钟——对个人站来说这笔买卖不划算。

7. 常见问题

状态页本身挂了怎么办?这是状态页的经典悖论。把页面放 Pages、JSON 放独立的 Worker、探测放在另一个账号或外部服务,三者不共享同一个故障域。真要严谨,就再加一个外部探针(比如 GitHub Actions 定时任务)交叉验证。

要不要记历史可用率?记。在 event: 前缀下存事件,30 天过期,页面上按时段渲染成一条色带。这比"当前正常"有用得多,用户投诉时能直接看到当时的状态。

告警怎么发?免费档没有内置告警通道。定时任务里判断状态变化后调用一个 webhook(Telegram Bot、飞书机器人、邮件 API)即可,一天几条消息的成本可以忽略。

小结

状态页的最小实现是三个东西:一个每分钟跑的 scheduled 函数、一份 KV 里的当前状态、一个读 JSON 的静态页。总成本在免费额度内可以忽略,前提是把"只在状态变化时写入"这条做对。

比技术实现更重要的是故障域隔离与误报抑制。状态页要能在被监控对象挂掉时仍然可访问,单次失败不要立刻标红。额度与告警的联动见 102-cost-control-quotas.md,全系列索引见 106-roadmap-index.md。

数据来源与检索时间

检索时间 2026-11-10。

来源URL对应数据
Cron Triggershttps://developers.cloudflare.com/workers/configuration/cron-triggers/scheduled 处理器、最小 1 分钟粒度、时区为 UTC
KV 限额https://developers.cloudflare.com/kv/platform/limits/1,000 写/天、expirationTtl 最短 60 秒
Workers 缓存https://developers.cloudflare.com/workers/runtime-apis/request/cf.cacheTtl 请求选项

核验说明

  • 文档核对:scheduled 签名、cron 最小粒度、KV 写入额度与 expirationTtl 来自上述页面。
  • 未做:这篇没有部署。本次会话的部署额度已用于 97 与 100 两个 Worker,cron 触发与 KV 写入行为未在账号内实测。文中的额度推算(1440 次/天、占 1.4%)是按算术得出。
  • "单次失败不判故障"是工程惯例,具体阈值(连续 3 次)为建议值,未做误报率统计。