云端部署

Vercel Cron Jobs:网站定时巡检与故障通知

用 Vercel Cron Jobs 做一个免费的网站巡检:每天定时访问你的几个网站,检查状态码和页面关键字,有异常就推送到群机器人。整个项目只有一个函数和一个配置文件。

BINBHO · 图文教程

用 Vercel Cron Jobs 做一个免费的网站巡检:每天定时访问你的几个网站,检查状态码和页面关键字,有异常就推送到群机器人。整个项目只有一个函数和一个配置文件。

先看清成品

成品是 api/cron.js 加 vercel.json 里的 crons 配置。本文已用 Node.js 22 实际运行 2 个测试,全部通过:没有或错误的 CRON_SECRET 返回 401;本地模拟的正常页面、缺少关键字、HTTP 500 三种情况能正确区分,并向模拟 Webhook 发出一条包含两个异常的告警;带正确密钥时真实检查 example.com 返回 200。本文环境没有 Vercel 账号,Cron 没有在 Vercel 上实际触发。

注意:Vercel 的免费 Hobby 计划仅限个人、非商业用途(官方 Hobby 页面和公平使用准则写明),商业项目需要 Pro 计划。各计划额度以官方页面为准。

注意:官方文档写明,Hobby 计划的 Cron 每天只能运行一次,写成更频繁会导致部署失败,而且可能在指定小时内的任意时间触发;失败的调用不会重试。需要每几分钟检查一次的话,要升级计划或用专门的监控服务。

做完之后你会得到什么?

成果 说明
网站清单 sites.json,可选关键字检查
并发检查 每个网站 10 秒超时
鉴权 只接受带 CRON_SECRET 的调用
告警 有异常才推送
日志 每次结果打印到 Vercel 日志

需要准备哪些环境?

项目 要求 说明
Vercel 账号 Hobby 免费计划即可 用 GitHub 账号注册最方便
Node.js 20 或更高 本文使用 22.23.3
Vercel CLI npm i -g vercel 本文核对时最新版为 65.0.0
GitHub 仓库 可选 连接后每次推送自动部署
群机器人 Webhook 可选 不配置就只记日志

第一步:Cron 是怎么工作的?

机制 官方说明
配置位置 vercel.json 的 crons,path + schedule
时区 表达式按 UTC 计算
调用方式 Vercel 对生产部署的这个路径发 GET 请求
鉴权 设置了 CRON_SECRET 后,会带 Authorization: Bearer 密钥
重定向 不跟随
重复/漏调用 偶尔会发生,任务要能重复执行

本文的 0 1 * * * 是 UTC 1 点,也就是北京时间 9 点左右。

第二步:代码怎么写?

1. 项目结构

uptime-cron/
├── api/cron.js
├── sites.json
├── cron.test.js
├── vercel.json
└── package.json

2. sites.json

[
  { "name": "官网", "url": "https://example.com/" },
  { "name": "博客", "url": "https://example.org/", "expect": "Example Domain" }
]

3. api/cron.js

// 每天由 Vercel Cron 调用:检查网站是否可用,有异常就发通知
import sites from '../sites.json' with { type: 'json' };

export async function checkOne(site, timeoutMs = 10000) {
  const started = Date.now();
  try {
    const r = await fetch(site.url, { signal: AbortSignal.timeout(timeoutMs), redirect: 'follow' });
    const ms = Date.now() - started;
    if (!r.ok) return { ...site, ok: false, reason: `HTTP ${r.status}`, ms };
    if (site.expect && !(await r.text()).includes(site.expect)) {
      return { ...site, ok: false, reason: `页面里没有“${site.expect}”`, ms };
    }
    return { ...site, ok: true, ms };
  } catch (e) {
    return { ...site, ok: false, reason: e.name === 'TimeoutError' ? '超时' : e.message, ms: Date.now() - started };
  }
}

export async function GET(request) {
  const secret = process.env.CRON_SECRET;
  if (!secret || request.headers.get('authorization') !== `Bearer ${secret}`) {
    return new Response('Unauthorized', { status: 401 });
  }
  return Response.json(await run(sites));
}

// 把检查逻辑单独导出,方便测试时传入自己的网站列表
export async function run(list) {
  const results = await Promise.all(list.map((s) => checkOne(s)));
  const bad = results.filter((r) => !r.ok);
  if (bad.length && process.env.ALERT_WEBHOOK_URL) {
    const text = '网站巡检异常:\n' + bad.map((b) => `${b.name} ${b.url}:${b.reason}`).join('\n');
    await fetch(process.env.ALERT_WEBHOOK_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text, msg_type: 'text', content: { text } }),
    });
  }
  console.log(JSON.stringify(results)); // 在 Vercel 日志里可以看到
  return { checked: results.length, failed: bad.length, results };
}
要点 说明
鉴权写法 与官方示例一致,密钥为空时也拒绝
AbortSignal.timeout 避免一个网站卡住整个任务
run 单独导出 测试时传入本地网站列表
只读操作 重复执行没有副作用,符合官方的幂等建议

4. vercel.json

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "crons": [
    { "path": "/api/cron", "schedule": "0 1 * * *" }
  ]
}

第三步:怎样测试和部署?

1. 本地测试

import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { GET, run } from './api/cron.js';

let alerts = [];
const srv = http.createServer((q, s) => {
  if (q.url === '/ok') return s.end('<h1>Hello 你好</h1>');
  if (q.url === '/slow') return setTimeout(() => s.end('late'), 3000);
  if (q.url === '/hook') { let d = ''; q.on('data', c => d += c); return q.on('end', () => { alerts.push(JSON.parse(d)); s.end(); }); }
  s.statusCode = 500; s.end('err');
});
await new Promise(r => srv.listen(0, r));
const base = `http://127.0.0.1:${srv.address().port}`;
process.env.CRON_SECRET = 'test-secret-1234567890';
process.env.ALERT_WEBHOOK_URL = base + '/hook';
const auth = (v) => new Request('http://x/api/cron', { headers: v ? { authorization: v } : {} });

test('没有或错误的密钥返回 401', async () => {
  assert.equal((await GET(auth())).status, 401);
  assert.equal((await GET(auth('Bearer wrong'))).status, 401);
});

test('正常、关键字缺失、500 都能识别,并发送告警', async () => {
  const list = [
    { name: 'A', url: base + '/ok', expect: '你好' },
    { name: 'B', url: base + '/ok', expect: '不存在的字' },
    { name: 'C', url: base + '/err' },
  ];
  const j = await run(list);
  assert.equal((await GET(auth('Bearer test-secret-1234567890'))).status, 200);
  assert.equal(j.failed, 2);
  assert.match(alerts[0].text, /B .*没有/);
  assert.match(alerts[0].text, /HTTP 500/);
  srv.close();
});
npm test

本文实际输出 # pass 2、# fail 0。

2. 部署

vercel
vercel env add CRON_SECRET production      # 至少 16 位随机字符
vercel env add ALERT_WEBHOOK_URL production
vercel --prod

生成随机密钥:

openssl rand -hex 24

3. 手动触发一次

curl -H "Authorization: Bearer 你的密钥" https://你的项目.vercel.app/api/cron

期望返回 {"checked":2,"failed":0,...}。不带请求头应返回 Unauthorized。

4. 查看定时任务

项目 Settings → Cron Jobs 能看到任务列表,点 View Logs 查看每次执行的日志。

出问题时该怎么调?

1. 部署失败,提示 Cron 频率

Hobby 计划写了 */5 * * * * 这类每天多次的表达式。改成每天一次。

2. Cron 执行了但一直 401

CRON_SECRET 没加到 Production 环境,或者加了之后没重新部署。

3. 某个网站总是“超时”

可能是那个网站屏蔽了来自数据中心的访问。用自己电脑 curl -I 对比,必要时调大超时。

常见问题怎么处理?

现象 原因 解决方法
时间不对 表达式按 UTC 北京时间减 8 小时
预览部署不触发 Cron 只对生产部署生效 用 vercel --prod
一直没收到告警 所有网站都正常 加一个故意错误的网址测试
告警格式报错 不同平台字段不同 只保留对应平台字段
想记录历史 函数本身不存数据 接入数据库或存储服务

项目资源

用途 地址
Cron Jobs 介绍 https://vercel.com/docs/cron-jobs
管理和保护 Cron Jobs https://vercel.com/docs/cron-jobs/manage-cron-jobs
Vercel Node.js 函数 https://vercel.com/docs/functions/runtimes/node-js
vercel env 命令 https://vercel.com/docs/cli/env
Hobby 计划 https://vercel.com/docs/plans/hobby

建议先跑 npm test,部署后用 curl 手动触发,确认能收到一次告警(可以临时放一个不存在的网址)。

确认告警正常后再换成真实网站清单,第二天到 Cron Jobs 日志里确认定时执行了。