# Cron Doctor

> 在 cron 表达式上线前进行诊断与验证。捕获五种静默死亡陷阱：永远不会触发的不可达日期、触发过于频繁的 OR 语义、午夜流量尖峰、不均匀步长的执行漂移、以及闰年 2 月 29 日。cron 表达式、crontab、调度、定时任务、调试、kubernetes、验证、cron-doctor

- Skill: `kscz0000/cron-doctor` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add kscz0000/cron-doctor`
- Raw SKILL.md: https://api.skillmd.com/api/skills/kscz0000/cron-doctor/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- License: MIT
- Author: kscz0000 (https://skillmd.com/u/kscz0000)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/kscz0000/cron-doctor

---


# cron-doctor

## 概述

Cron 看似简单，实则极易出错。其失败模式是**静默的** —— 一个语法合法的表达式，实际上从未触发，或者触发的频率远超预期。`0 0 30 2 *` 解析没问题，但永远空转（2 月没有 30 日）。`0 0 1,15 * 1` 看起来像"如果是周一则在 1 号和 15 号触发"，但实际含义是"1 号、15 号，**或**每个周一" —— 每月约触发 6 次，而非约 2 次。

本技能教会智能体在上线前捕获这些问题。它自带一个零依赖的验证引擎（`scripts/cron-engine.js`，无需安装），用于解析、描述、深度验证并计算下次触发时间。

## 何时使用本技能

- 当用户编写、编辑、审查或部署 cron 表达式时 —— 无论是在 crontab、Kubernetes `CronJob`、GitHub Actions `schedule`、Airflow DAG、Celery beat 调度、systemd 定时器，还是其他任何定时任务中。
- 当调试一个"没触发"或"在错误的时间触发"的作业时。
- 当用户问"这个 cron 表达式是什么意思？""下次什么时候跑？""一年跑多少次？"时。
- 当审查包含 `schedule` 字段的 CI/CD 流水线或基础设施配置时。
- 当用户粘贴一个 5 字段的 cron 表达式并请求健康检查时。

## 工作原理

### 步骤 1：解析表达式

按空白拆分为 5 个字段：分钟、小时、日期、月份、星期。确认取值范围合法：

| 字段 | 位置 | 范围 | 备注 |
|-------|----------|-------|-------|
| minute（分钟） | 1 | 0–59 | |
| hour（小时） | 2 | 0–23 | |
| day-of-month（日期） | 3 | 1–31 | |
| month（月份） | 4 | 1–12 | 接受名称（JAN–DEC） |
| day-of-week（星期） | 5 | 0–7 | 0 和 7 都代表周日；接受名称（SUN–SAT） |

### 步骤 2：用通俗语言描述它

说明用户*以为*它做什么，与它*实际*做什么。对于日期 + 星期字段，要明确指出 OR 与 AND 语义（见死亡陷阱 #2）。

### 步骤 3：运行陷阱检查清单

检查下面的五个死亡陷阱，并标记出命中的项。

### 步骤 4：计算下次触发时间与年度触发次数

具体计算出接下来 5 次触发时间作为具体日期，便于用户验证调度行为是否符合预期。估算年度触发次数 —— 一个一年触发 365 次的计划与一年触发 12 次的计划，在成本与负载上相差约 30 倍。

## 五种 Cron 死亡陷阱

这些都是能通过 `crontab -l` 验证、却在生产环境翻车的 bug。

### 1. 不可达日期 —— "永不触发"的 bug

```
0 0 30 2 *
```

**语法合法。永不触发。** 2 月没有 30 日。这条调度是一个沉默空转的死作业。在任何只有 30 天的月份里指定 31 日也同样如此：`0 0 31 4 *`、`0 0 31 6 *`、`0 0 31 9 *`、`0 0 31 11 *`。

**修复方法：** 使用 `0 0 28-31 * *` 并在脚本里判断月末，或在调度器支持时使用 `L`（最后一天）语法。

### 2. OR 语义 —— "触发太频繁"的 bug

```
0 0 1,15 * 1
```

**并不意味着** "如果是周一，则在 1 号和 15 号的零点触发"。
**实际含义是** "1 号、15 号，**或**每个周一的零点触发"。每月约 6 次，而非约 2 次。

这是 cron 中被误解最深的一条规则。当**日期**和**星期****都被**限制（都不是 `*`）时，cron 使用 OR 逻辑，而非 AND。

**修复方法：** 如果你需要"仅当周一时在 1 号和 15 号触发"，改为每天运行并在脚本内判断：

```bash
0 0 * * 1 [ "$(date +%d)" = "01" -o "$(date +%d)" = "15" ] && your-command
```

### 3. 午夜流量尖峰 —— "万事齐发"的 bug

```
0 0 * * *
```

所有调度在 `0 0` 的作业会同时争抢资源。数据库备份、日志轮转、证书续期、报表生成 —— 全部同时触发。这会导致负载尖峰、连接池耗尽以及级联超时。

**修复方法：** 把作业分散到不同时段。改用 `17 2 * * *` 或 `43 3 * * *` 代替 `0 0`。错峰（jitter）是你的好朋友。

### 4. 不均匀步长 —— "漂移"的 bug

```
*/7 * * * *
```

**并不意味着** "每 7 分钟均匀执行"。它的实际含义是"每 7 分钟执行一次，从 0 开始，60 时归零"。所以触发序列为：0、7、14、21、28、35、42、49、56 —— 然后回到 0（间隔 4 分钟）。间隔序列漂移为：7,7,7,7,7,7,7,7,**4**。

**修复方法：** 60 不能被 7 整除。使用能整除 60 的步长：`*/5`、`*/10`、`*/15`、`*/20`、`*/30`。如果确实需要每 7 分钟一次，使用带 `sleep 420` 的循环。

### 5. 闰年 2 月 29 日 —— "年度惊喜"

```
0 0 29 2 *
```

仅在闰年触发 —— 2024 / 2028 / 2032 年的 2 月 29 日…… 如果有人期望这是"2 月底"，那么 4 年中会有 3 年感到困惑。

**修复方法：** 使用 `0 0 28 2 *`，如需要 29 号的情况在脚本内处理。

## 使用验证脚本

本技能附带一个零依赖引擎，位于 `scripts/cron-engine.js`（Node.js，无需 `npm install`）。你可以以编程方式或从 CLI 使用它：

```javascript
// 编程式 —— Node.js，零依赖
const { describe, validate, nextRuns, formatNextRuns } = require('./scripts/cron-engine.js');

// 解析 + 描述 -> 返回 { text, error, parsed }
const d = describe('0 0 30 2 *');
console.log(d.text);   // "At 00:00, on day-of-month 30 in in FEB"

// 深度验证 -> 捕获各种陷阱
const result = validate('0 0 30 2 *');
console.log(result.valid);              // true（语法合法）
console.log(result.observations);       // 包含"永不触发"洞察
console.log(result.suggestions);        // 如 "Midnight is a common spike..."

// 下 5 次触发时间 -> 返回 Date[]
const runs = nextRuns('0 9 * * 1-5', new Date(), 5);
console.log(formatNextRuns(runs, new Date())); // [{ date, relative, formatted }, ...]
```

```bash
# CLI（通过内置包装器）
node scripts/cli.js describe "*/5 * * * *"
node scripts/cli.js validate "0 0 30 2 *"
node scripts/cli.js next "0 9 * * 1-5" 5
```

## 常用 cron 预设

| 表达式 | 描述 | 用途 |
|-----------|-------------|----------|
| `*/5 * * * *` | 每 5 分钟 | 健康检查、轮询 |
| `0 * * * *` | 每小时 | 按小时聚合 |
| `0 */2 * * *` | 每 2 小时 | 中频同步 |
| `0 9 * * 1-5` | 周一至周五 9 点 | 工作时段任务 |
| `0 2 * * *` | 每天凌晨 2 点 | 非高峰批处理（避开午夜） |
| `0 0 * * 0` | 周日零点 | 每周维护 |
| `0 0 1 * *` | 每月 1 号零点 | 月度报表 |
| `0 0 1 1 *` | 1 月 1 日零点 | 年度任务 |

## 最佳实践

- ✅ 始终给出通俗语言描述 AND 运行陷阱检查清单。
- ✅ 将午夜作业错峰以避免流量尖峰。
- ✅ 优先选择能整除 60 的步长（`*/5`、`*/15`、`*/30`）。
- ✅ 在每条 crontab 上方添加注释解释意图。
- ✅ 在支持的调度器上设置显式时区（`CRON_TZ`）。
- ❌ 不要迷信 `crontab -l` 验证 —— 它只检查语法，不检查语义。
- ❌ 不要在未确认 OR 逻辑的情况下同时限制日期与星期。
- ❌ 不要把所有作业都排在 `0 0`。

## 常见陷阱

- **问题：** "我的 cron 作业没有运行。"
  **解决方案：** 检查是否有不可达日期（陷阱 #1），并确认守护进程正在运行（`service cron status` / `systemctl status crond`）。确认文件以换行符结尾，并具有正确的属主。

- **问题：** "我的作业触发次数远超预期。"
  **解决方案：** 你遇到了 OR 语义（陷阱 #2）。如果日期与星期都被设置，cron 会将它们按 OR 处理。将其中一个改为 `*`，或在脚本内加判断。

- **问题：** "间隔不均匀 —— 有时 7 分钟，有时 4 分钟。"
  **解决方案：** 步长值不能整除 60（陷阱 #4）。使用 60 的因数。

- **问题：** "我的作业在本地能跑，但在集群里不行。"
  **解决方案：** 时区不匹配。Kubernetes `CronJob` 和 GitHub Actions 默认使用 UTC。确认 `timeZone` / `TZ` 已按预期设置。

## 局限性

- 本技能针对标准的 5 字段 cron，涵盖 Vixie cron、systemd timer、Kubernetes `CronJob`、GitHub Actions `schedule` 以及大多数类库的实现。它**不**验证 Quartz 的 6/7 字段（带秒/年份）表达式，也不验证非标准的 `@reboot` / `L` / `#` 扩展，除非另有说明。
- 年度触发次数估算以非闰年作为参考；2 月 29 日的调度（陷阱 #5）会被显式标记。
- 本技能不能取代环境特定的验证、测试或专家审查。如果缺少必要的输入、权限或安全边界，请停下来澄清。

## 相关技能

- `docker-expert` —— 当 cron 作业运行在容器内，问题出在容器/入口点而非调度时。
- `kubernetes-deployment` —— 当验证 `CronJob` 清单的 `spec.schedule` 字段与其他资源配置时。

## 安全与安全说明

本技能是只读的，`risk: safe`。验证脚本不执行任何文件写入、网络调用或变更 —— 仅解析与计算。可以无条件安全地对任何 cron 表达式运行。
