你定的任务凌晨 2 点跑,今早一看下游报表是空的、邮件也没来、日志里干干净净——没有报错、没有堆栈,就是没跑。
最快的修法: 按顺序查三件事。(1) 调度器的时区跟你以为的对不对(在容器里跑 date -u)。(2) 如果是 Kubernetes,看 CronJob controller 是不是在刷 Too many missed start time (> 100)、已经拒绝再起任何任务。(3) 上一轮是不是还占着锁。然后把”静默跳过”变响:每轮打心跳,应该跑而没跑时直接告警。
你属于哪一类?
| 你能直接观察到的现象 | 最可能的原因 | 跳转 |
|---|---|---|
| 任务跑了,但比你预期的时间差了好几个小时 | 时区对不上 | 原因 1 |
| 漏跑集中在三月/十一月的某个周日 | 夏令时切换吞掉了这次 | 原因 2 |
controller event 显示 missed schedule,K8s 日志里有 Too many missed start time | 撞上漏跑次数上限 | 原因 3 |
下一轮调度点时 pg_stat_activity 里还看得到上一轮 | 跟前一轮锁冲突 | 原因 4 |
kubectl get cronjob 显示 SUSPEND: True | 调度被暂停/禁用 | 原因 5 |
常见原因
按踩坑频率排序。
1. 时区对不上
cron 写的是 0 2 * * *,你以为是本地时间,容器或调度器其实跑在 UTC。任务在 UTC 02:00 跑,对一个美东办公室来说是前一天晚上 21:00。
怎么判断:在容器里分别跑 date -u 和 date,跟你期望的时间对一下;或者看调度器日志的时间戳。注意:在 Kubernetes 里把 CRON_TZ= 或 TZ= 直接写进 .spec.schedule 是不被支持的,会被校验直接拒绝——必须用专门的 .spec.timeZone 字段(该字段自 Kubernetes v1.27 起为 stable)。
2. 夏令时切换把这次跑吞了
cron 写的是本地时间 02:30。春令时那个周日 02:30 这个时刻根本不存在,于是静默跳过。秋令时反方向同一个本地时间会出现两次,有些调度器会把任务跑两遍。
怎么判断:漏跑(或跑两次)总是踩在 DST 切换边界上——美国是三月中和十一月初,欧盟是三月底和十月底。
3. Kubernetes 撞上 100 次漏跑上限
这是最经典的 Kubernetes 静默跳过。如果 CronJob controller 挂过、集群被暂停过,或者 status.lastScheduleTime 是旧值,controller 会算自己漏了多少个调度点。一旦这个数超过 100,它就彻底放弃、不再起任务,并打出:
Cannot determine if job needs to be started: Too many missed start time (> 100). Set or decrease .spec.startingDeadlineSeconds or check clock skew.
如果没设 .spec.startingDeadlineSeconds,controller 会从上一次成功调度开始把所有漏跑都算上,所以一个被暂停几天的任务很容易就冲破 100、再也自己恢复不了。
怎么判断:kubectl describe cronjob <name> 的 events,或者 controller-manager 日志里有上面那行;kubectl get cronjob 的 LAST SCHEDULE 是个很旧的值。
修法:设一个 startingDeadlineSeconds,让 controller 只看一个有界的窗口,然后推一下调度。设成 200 秒后,controller 只统计最近 200 秒内的漏跑,而不是从头算到现在:
spec:
startingDeadlineSeconds: 200
如果 controller 已经卡死了,重建 CronJob(kubectl delete cronjob <name> 再重新 apply)来重置它的 status.lastScheduleTime。
4. 上一轮还占着锁
第 N 轮跑了 25 小时还没结束,第 N+1 轮调度器去拉的时候,数据库 advisory lock 或者文件锁还没释放。如果是非阻塞拿锁、拿到 false,N+1 直接退出。
怎么判断:在下一轮调度时间附近,pg_stat_activity 或进程列表里能看到长时间没结束的前一轮。在 Kubernetes 上配了 concurrencyPolicy: Forbid 时,controller 是按设计跳过新一轮,只在 events 里留个记录。
5. 调度被禁用或暂停了
有人在 UI 上点了暂停、或者把 suspend: true 设了没恢复。又或者上线时把 schedule 重置回了默认值。
怎么判断:kubectl get cronjob 显示 SUSPEND: True,或者 Airflow DAG 在 UI 里处于 paused(开关被关掉了)。
最短修复路径
Step 1: 全栈走 UTC
所有调度表达式统一用 UTC,并把这件事写进文档。本地时间只在 UI 层显示时转一次。
# Kubernetes CronJob
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-report
spec:
schedule: "0 9 * * *" # UTC 09:00 = PDT 02:00、PST 01:00
timeZone: "Etc/UTC" # .spec.timeZone 自 Kubernetes v1.27 起 stable
concurrencyPolicy: Forbid # 上一轮没跑完就跳过这一轮
startingDeadlineSeconds: 200 # 有界的漏跑窗口,避免撞上 >100 卡死
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
systemd timer 用 OnCalendar=*-*-* 09:00:00 UTC。再提醒一次:别把 CRON_TZ= 或 TZ= 写进 Kubernetes 的 schedule 字符串里,用 timeZone 字段。
Step 2: 每轮打心跳指标
任务开始和成功结束各打一个计数。心跳不来就是信号。
# Python prometheus_client
from prometheus_client import Counter, push_to_gateway, CollectorRegistry
registry = CollectorRegistry()
runs = Counter('cron_runs_total', 'Cron run count', ['job','phase'], registry=registry)
def heartbeat(job, phase):
runs.labels(job=job, phase=phase).inc()
push_to_gateway('pushgateway:9091', job=job, registry=registry)
heartbeat('nightly-report', 'start')
do_work()
heartbeat('nightly-report', 'success')
更轻量的方案:用 dead-man switch(死人开关)服务,比如 Healthchecks.io(截至 2026 年 6 月免费档:20 个 check、3 个月日志保留)或 Cronitor(免费档 5 个 monitor)。任务每次成功后往一个专属 URL 发一次 ping,ping 没在预期窗口内到,服务就告警你。这能抓到”任务压根没启动”这种情况——而进程内的指标抓不到。
Step 3: 对漏跑直接告警
# Prometheus 告警规则
- alert: CronMissedRun
expr: |
time() - max(cron_last_success_timestamp{job="nightly-report"}) > 90000
for: 5m
labels:
severity: page
annotations:
summary: "nightly-report 已经超过 25 小时没成功跑过了"
窗口比节奏稍大:日任务用 90000 秒(25h),小时任务用 4500 秒(75min)。
Step 4: 用显式锁防并发重叠
-- Postgres advisory lock,已被持有则立即返回 false
SELECT pg_try_advisory_lock(hashtext('nightly-report'));
-- 拿到返回 true,前一轮还在跑则返回 false
import psycopg
with psycopg.connect(DSN) as conn:
acquired, = conn.execute("SELECT pg_try_advisory_lock(hashtext(%s))", ['nightly-report']).fetchone()
if not acquired:
print("上一轮还在跑,跳过本轮")
return
try:
run_job()
finally:
conn.execute("SELECT pg_advisory_unlock(hashtext(%s))", ['nightly-report'])
给”锁冲突跳过”另起一个指标——这样静默跳过就变成一个看得见的数据点,而不是一段空白。
Step 5: 把调度状态纳入上线检查
CI/CD 加一步冒烟:上线后检查没有意外被 suspend 的 CronJob。
kubectl get cronjob -o json \
| jq -r '.items[] | select(.spec.suspend==true) | .metadata.name'
输出非空且不在白名单里就阻断上线。
怎么确认真的修好了
- 手动触发一次并确认跑完:
kubectl create job --from=cronjob/nightly-report manual-test-1(Kubernetes),然后kubectl get jobs里能看到COMPLETIONS 1/1。 - 确认两个心跳都到了:查
cron_runs_total{job="nightly-report"},确认start和success两个 phase 都涨了。 - 确认 controller 没有还卡着:
kubectl describe cronjob nightly-report应该显示一个较新的LAST SCHEDULE,且没有Too many missed start time的 event。 - 等一个真正的调度周期过去,确认 dead-man switch 的 ping 按时到了。
预防
- 代码和配置全栈走 UTC,仪表盘里再换成本地时间给人看。
- Kubernetes CronJob 一律设
startingDeadlineSeconds,这样 controller 被暂停或滞后时也不会撞上静默的> 100上限。 - 每个任务都打 start 和 success 心跳,用外部 dead-man switch 对漏脉冲告警,而不是只靠进程内的指标。
- 用
concurrencyPolicy: Forbid(Kubernetes)或显式 advisory lock 把重叠语义写明白。 - 别把任务定在本地时间 02:00-03:00——地球某地正好踩在 DST 切换边界上。
- 维护一份单一来源的 cron 清单,每月 review 一次。
常见问题
为什么应用日志里一点报错都没有?
大多数静默跳过都发生在你的代码运行之前:调度器根本没把进程拉起来。时区漂移、Kubernetes 的 > 100 漏跑上限、Forbid 并发跳过、被 suspend 的调度,这些都在调度器/controller 那一层,所以你的应用日志当然干净。先去看调度器自己的 events(kubectl describe cronjob、controller-manager 日志、Airflow scheduler 日志)。
Too many missed start time (> 100) 到底是什么意思?
Kubernetes CronJob controller 拿当前时间跟 status.lastScheduleTime 比,算自己漏了多少个调度点。一旦超过 100,它就不再尝试,并打出 Cannot determine if job needs to be started: Too many missed start time (> 100)。解法是设 startingDeadlineSeconds(把它统计的窗口收窄),如果已经卡死,就重建 CronJob 重置 lastScheduleTime。
需要设 startingDeadlineSeconds 吗?
Kubernetes 上需要。不设的话 controller 没有有界窗口,会从上一次调度起把所有漏跑都算上——这正是任务在一次暂停或 controller 重启后撞上 > 100 卡死的原因。取一个略大于任务启动延迟的值(比如 200)是个安全默认。
心跳指标和 dead-man switch 选哪个? 两个都要,它们抓的是不同的故障。进程内的 Prometheus 计数器能证明任务跑了、跑到哪一步。外部的 dead-man switch(Healthchecks.io、Cronitor)能证明任务到底有没有跑、有没有按时跑——而”调度器压根没触发”只有它抓得到。
为什么大家都说别用本地时间凌晨 2-3 点? 因为春令时那次 DST 切换会把这个区间里的一整个挂钟小时直接删掉,于是一个定在本地 02:30 的任务那天根本没有可执行的时刻,就被跳过了。用 UTC,或者把时间挪出 02:00-03:00,这个歧义就消失了。