项目:状态页与监控,用两个免费组件做出来
状态页要回答一个问题:现在服务是好的吗,过去一天挂过多久。它不需要数据库,也不需要时序系统,一个定时任务加一份当前状态就够。这套东西拆成三块:探测端(cron Worker 或外部探针)、状态存储(KV)、展示端(静态页读 JSON)。全部落在免费额度内。
定时任务的写法见 63-workers-cron-triggers.md,日志与指标见 66-workers-observability.md。这篇给它拼成一个对外可看的页面,故障发生时的排查顺序见 104-troubleshooting-playbook.md。
1. 组件与额度
| 组件 | 用什么 | 免费额度 | 消耗 |
|---|---|---|---|
| 探测 | Cron Trigger + scheduled | 定时触发不额外计费 | 每次探测 1 次 Worker 请求 |
| 存储 | KV | 1,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 Triggers | https://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 次)为建议值,未做误报率统计。