问题背景
财务系统有一个每日报表任务,跑在 Kubernetes 的 CronJob 里:每天凌晨 1 点启动,从数据仓库拉取前一天的交易数据,生成 Excel 报表,写到共享存储,再给财务和业务负责人发邮件。正常情况十几分钟跑完,资源占用不大。
这个 CronJob 是两年前上线的,YAML 只写了 schedule 和容器镜像,其他字段基本没配。平时运行稳定,大家也就没再动过。到了月底和季末,数据量会涨到平时的十倍,任务耗时明显变长;今年三季度的一次月结,任务跑了六个多小时,之后开始出现“一天收到两份报表”的反馈。
故障现象
- 财务和业务负责人偶尔在同一天收到两封日报邮件,两封的汇总数字还不一样。
- 共享存储上的
latest.xlsx有时打不开,提示文件损坏;重命名成带时间戳的文件后能打开其中一份。 - 出问题的日子,
kubectl get jobs能看到同一个 CronJob 下有两个活跃 Job,Pod 创建时间相差 24 小时。 - 任务耗时在月结期间从十几分钟涨到 6–8 小时,极端情况超过 24 小时还没结束。
- 数据库里偶尔出现重复的报表记录,下游对账任务因此报主键冲突。
- 平时数据量小的日子完全不出现,只有月结、季结或上游延迟时才复现。
排查过程
第一步确认重复来源。查 kubectl get jobs -n finance,按创建时间排序,发现出问题当天有两个同前缀的 Job:前一天的 Job 还处于 Running,新一天的 Job 已经启动。两个 Pod 都在跑同一份报表逻辑,各自生成文件、各自发邮件,收件人自然收到两份。
第二步看 CronJob 配置。kubectl get cronjob daily-report -o yaml 里没有 concurrencyPolicy 字段。Kubernetes 对 CronJob 的默认值是 Allow:不管上一次有没有跑完,到点就再起一个。相比之下,Forbid 会跳过本次调度,Replace 会杀掉旧的再起新的。这个默认值加上“偶尔跑超过 24 小时”的任务,重叠几乎是必然的。
第三步看任务时长和超时配置。Job 模板里没有 activeDeadlineSeconds,任务再慢也不会被终止;CronJob 也没有 startingDeadlineSeconds,错过的调度不会被判定为过期。上游数据仓库在月结期间慢,任务就一路挂着,和新任务并行。
第四步解释文件损坏和重复记录。两个 Pod 同时写同一个 latest.xlsx,一个在写、一个在覆盖,Excel 文件结构被破坏;数据库插入没有唯一约束,两个 Job 各插一份,对账任务读到重复数据就报主键冲突。邮件发送也没有幂等键,两个 Pod 各发各的。
第五步验证并发策略。在测试环境把 concurrencyPolicy 改成 Forbid,用 sleep 模拟一个跑 30 分钟的任务,把 schedule 临时改成每分钟一次:后续调度全部被跳过,kubectl get jobs 始终只有一个活跃 Job;改成 Replace 则会看到旧 Job 被删除、新 Job 启动。行为符合预期。
第六步确认修复效果。生产上改成 Forbid,同时给 Job 加 activeDeadlineSeconds: 14400(4 小时)和 startingDeadlineSeconds: 3600,再加唯一输出路径和数据库唯一键。观察一个月,没有再出现双份邮件,月结任务即使超时也会被终止并告警,不会拖到第二天。
解决方案
- 设置并发策略:日报这类“同一份数据只应生成一次”的任务用
concurrencyPolicy: Forbid;如果业务要求“新数据必须覆盖旧任务”,可以用Replace,但要确保旧任务写入是原子操作,避免半成品。 - 加执行时限:Job 模板加
activeDeadlineSeconds(例如 4 小时),超过就终止并标记失败;CronJob 加startingDeadlineSeconds,错过的调度不再补跑,避免堆积。 - 控制重试与历史:
backoffLimit设为 1–2,避免失败后无限重试;successfulJobsHistoryLimit和failedJobsHistoryLimit设小一点,防止 Job 和 Pod 越积越多。 - 做幂等:输出文件带
report_date和本次运行 ID,先写临时文件再原子重命名;数据库对(report_date, report_type)建唯一约束,重复插入直接失败或 upsert;邮件发送用幂等键或发件记录表去重。 - 补监控:采集 Job 运行时长、活跃 Job 数、失败次数;时长超过基线(例如 1 小时)或活跃 Job 大于 1 就告警,不要等收件人发现双份。
- 月结容量评估:月结前评估数据量和下游延迟,必要时临时调大资源、提前调度或拆分任务;不要把“平时十几分钟”当成容量上限。
根因分析
直接根因是 CronJob 没有设置 concurrencyPolicy,默认的 Allow 允许同一任务在上一次未结束时再次启动;月结期间任务耗时超过 24 小时,两个 Job 并行写同一份文件、发同一封邮件,造成重复和文件损坏。
更深一层,是把 CronJob 当成了“会自己排队的 cron”。Linux 的 cron 只负责按时间触发,不关心上一次是否结束;Kubernetes 的 CronJob 默认同样不阻止重叠,而且没有默认执行时限。任务一旦有副作用(写文件、发邮件、插数据库),重叠就会直接变成重复数据。
还有一层是缺少运行身份和幂等设计。两个 Job 都认为自己是“今天的日报”,没有唯一的运行标识,也没有唯一约束兜底;文件写入不是原子的,邮件发送没有去重。并发策略是防线,幂等才是最后一道保险。
预防措施
- CronJob 模板统一配置:
concurrencyPolicy、activeDeadlineSeconds、startingDeadlineSeconds、backoffLimit、历史保留数、显式timeZone,不允许只写 schedule 就上线。 - 有副作用的定时任务必须幂等:唯一业务键、原子写、邮件去重、数据库唯一约束,四者至少落地两项。
- 监控 Job 时长、活跃数和失败率,按任务设基线;月结/季结前做容量评估和压测。
- 代码评审把“定时任务并发”列为检查项:任务是否可重入、输出是否唯一、失败重试是否安全。
- 定期清理历史 Job/Pod,避免 etcd 和 kubelet 被大量已完成对象拖慢。
总结
CronJob 只保证“到点创建 Job”,不保证“上一次已经结束”;默认的 Allow 把并发控制交给了使用方。一个跑了六小时的任务,加上一个每天准点启动的调度,重叠不是偶发,而是时间问题。把并发策略、执行时限和幂等一起补上,定时任务才从“能跑”变成“跑一次只产生一次结果”。