场景:CronJob 定时备份凌晨没执行 + Job 重试耗尽(K8s v1.25) 路径:kubectl describe cronjob → kubectl get job -o yaml → kubectl get pods
上篇讲了 DaemonSet 优雅终止,核心是通过 PDB 和 preStop hook 控制 Pod 的销毁节奏。而这篇的 Job/CronJob 正好相反——它管的是 Pod 的创建节奏。
定时任务为什么没跑?——CronJob 时区/并发/退避三连坑
【坐标】→ Job 没执行 + Pod 反复失败
备份用的 CronJob(定时任务资源,cronjob-controller 负责按 schedule 创建 Job)配置每天早上 8:00 执行,但连续三天发现备份根本没在预期时间触发。
kubectl describe cronjob 一看:

Last Schedule Time 显示 08:00 UTC——按调度表达式 0 8 * * * 确实在 08:00 触发了,但那是 UTC 08:00(北京时间 16:00)。期望的北京时间 08:00(UTC 00:00)反而没反应。差 8 小时。
根因:CronJob 的 spec.timeZone 没配置,cronjob-controller 使用 kube-controller-manager 所在节点的系统时区(UTC)。cron 表达式 0 8 * * * 在 UTC 时区下就是 UTC 08:00。
同一个集群的 data 命名空间里,另一个数据清洗 Job 踩了不同的问题:
$ kubectl get jobs
NAME COMPLETIONS DURATION AGE
data-cleanup-job 0/1 15m 15m
data-cleanup-job-1 0/1 12m 12m
data-cleanup-job-2 0/1 10m 10m
Job 名字后面有自增序号——说明了和第一个问题不同,这个 Job 不是"没跑",而是跑了三次还在跑。每次启动一个新 Pod,处理到一半就退出了,然后又启动一个新的。
【分层】→ 3 层排查,从 Job 到控制器
K8s 排查不能跳层——标准流程是 Pod 层 → CRI 层 → Node 层 → 集群层。Job/CronJob 的根因在控制器行为,CRI(容器运行时)层和 Node 层在本场景中通过不影响结论(容器本身正常启动,只是退出码非 0),所以走 3 层:Job/CronJob 自身层 → Pod 层 → 控制器层。
第 1 层:Job/CronJob 资源层
$ kubectl describe cronjob backup-job
...
Schedule: 0 8 * * *
Time Zone: <none>
Last Schedule Time: Tue, 06 Jul 2026 00:00:00 UTC
Events:
Normal Scheduled 8h cronjob-controller Job created
$ kubectl get cronjob backup-job -o yaml | grep -A 2 timeZone
# timeZone 字段根本没配
CronJob 的 spec.timeZone 字段从 K8s v1.25 进入 stable。不配置时,cronjob-controller 使用 kube-controller-manager 所在节点的时区——生产环境往往部署在 UTC 时区的节点上。
$ kubectl describe job data-cleanup-job
...
Status:
Conditions:
Type Status LastProbeTime Reason
BackoffLimitExceeded True - Job has reached the specified backoff limit
Failed: 6
Start Time: Tue, 06 Jul 2026 01:00:00 UTC
Events:
Warning BackoffLimitExceeded job-controller Job has reached the specified backoff limit
backoffLimit 默认 6——Job 的 Pod 失败了 6 次,耗尽退避限额,job-controller 不再重启。
第 2 层:Pod 层
$ kubectl get pods --selector=job-name=data-cleanup-job
NAME READY STATUS RESTARTS AGE
data-cleanup-job-m9qpn 0/1 CrashLoopBackOff 3 5m
data-cleanup-job-x2rvt 0/1 Error 0 8m
data-cleanup-job-h3kws 0/1 Error 0 10m

Pod 全部是 Error 或 CrashLoopBackOff,说明应用本身有问题。但问题在于——Job 还在不断创建新 Pod,即使前面的 Pod 已经明显失败。
$ kubectl logs data-cleanup-job-h3kws
...
Error: timeout connecting to database
日志显示数据库连接超时。但这个超时是瞬时的,下一次 Pod 启动时可能恢复了——所以部分 Pod 成功运行了一会儿才失败。每次重试间隔很短,数据库连接池还没完全恢复,新的 Pod 又杀进来了。
第 3 层:控制器层
两个问题的根因都在控制器行为:
CronJob 时区判定:cronjob-controller 计算 lastScheduleTime 时,直接把 cron 表达式解释为 UTC。如果用户期望的是北京时间 08:00(0 8 * * *),实际触发是在 UTC 08:00(北京时间 16:00)。

并发策略默认为 Allow:spec.concurrencyPolicy 默认为 Allow,意味着前一个 Job 还在运行时,新的一次调度周期到达,cronjob-controller 会再创建一个新 Job。如果 Job 执行时间 > 调度间隔,Pod 会层层叠加。上面 data-cleanup 场景里,Pod 的执行时间大约 8-10 分钟,而调度间隔也是 10 分钟——刚好卡在重叠边缘。
并发策略状态机:
Allow → 总是创建新 Job(默认)
Forbid → 前一个 Job 没完成就跳过本次
Replace → 杀死前一个 Job,创建新 Job
Backoff 指数退避:job-controller 在 Pod 失败后按照指数退避重试,序列为 10s → 20s → 40s → 80s → 160s → 320s(上限 360s)。backoffLimit 默认 6——计的是失败 Pod 数,达到上限后 job-controller 标记 BackoffLimitExceeded,不再创建新 Pod。
【路径】→ 🔍 3 个命令定位 Job/CronJob 问题

【定位】→ 三大误判
| 维度 | ❌ 误判 | ✅ 真相 |
|---|---|---|
| 时区 | "CronJob 按我设的 08:00 跑" | CronJob 默认 UTC。kube-controller-manager 在 UTC 节点上,spec.timeZone 不配永远按 UTC 跑。LastScheduleTime 显示的就是 UTC 时间 |
| 并发 | "Job 没执行完是业务代码出错了" | concurrencyPolicy=Allow(默认)让调度周期重叠时创建新 Job。Job 执行时间 > 调度间隔 → Pod 叠加 → 资源争夺 → 更频繁的失败 |
| Backoff | "Job 失败了 6 次就放弃,指数退避是 10/20/40/80/160/320s" | backoffLimit 默认 6,退避序列 10s → 20s → 40s → 80s → 160s → 320s(上限 360s)。backoffLimit 计的是失败 Pod 数,不是重试次数——Pod 创建了但没等退避结束就删除,不算 |

时区场景的真相
不是 K8s 不处理时区——K8s v1.25 起 spec.timeZone 已经是 stable。问题是:CronJob 的 controller 在计算下次调度时间时,使用 kube-controller-manager 的系统时区。很多生产集群把控制面部署在 UTC 时区的节点上。如果你的 CronJob 没配 timeZone: "Asia/Shanghai",cron 表达式 0 8 * * * 在控制面看来就是 UTC 08:00。
并发场景的真相
并发策略不是用来"提升性能"的——它是用来防止重叠的。默认 Allow 是个危险默认值。对于执行时间和调度间隔接近的 Job,Allow 等于把每一次调度都变成一个独立副本,它们在竞争同一份资源。
# 正确的并发策略选择
concurrencyPolicy: Forbid # 定时备份/数据清洗(执行时间不确定,不能重叠)
concurrencyPolicy: Allow # 事件处理(每个调度周期独立,可以并行)
concurrencyPolicy: Replace # 配置同步(只需最新一次,旧的可以杀掉)
Backoff 场景的真相
'Job 失败了 6 次就放弃'——这个说法只对了一半。backoffLimit 默认 6 次是指失败 Pod 数量的上限。但每次失败后的退避时间不是线性的:
退避序列:10s → 20s → 40s → 80s → 160s → 320s(上限360s)
↑ 每次翻倍,但不超过 360s
第 1 次失败 → 等 10s 重试 第 2 次失败 → 等 20s 重试 ... 第 6 次失败 → 等 320s 后 job-controller 检查 backoffLimit 耗尽 → 不再重试
当 activeDeadlineSeconds 也设置时,Job 的超时是"谁先到谁说了算"——backoff 还没耗尽但超时到了,Job 直接终止。反之亦然。
【标点】→ 修复 + Check-list
CronJob 时区修复

apiVersion: batch/v1
kind: CronJob
metadata:
name: backup-job
spec:
schedule: "0 8 * * *"
timeZone: "Asia/Shanghai" # ✅ 显式指定时区
jobTemplate:
spec:
template:
spec:
containers:
- name: backup
image: backup-tool:latest
restartPolicy: OnFailure
并发策略修复
spec:
schedule: "*/10 * * * *"
concurrencyPolicy: "Forbid" # ✅ 防止调度重叠
startingDeadlineSeconds: 120 # 错过调度窗口后 2 分钟内还可启动
Backoff 设置修复
spec:
jobTemplate:
spec:
backoffLimit: 3 # 最多重试 3 次(不是默认 6)
activeDeadlineSeconds: 300 # 5 分钟超时
Check-list
| 检查项 | 命令 | 判断标准 |
|---|---|---|
| □ 时区配置 | kubectl describe cronjob <name> \| grep "Time Zone" |
应有 Asia/Shanghai 或目标时区 |
| □ 并发策略 | kubectl get cronjob <name> -o yaml \| grep concurrencyPolicy |
非 Allow 以外的值需要确认是否合理 |
| □ Backoff 限制 | kubectl get job <name> -o yaml \| grep -A 5 backoffLimit |
根据业务容忍度调整(默认 6 可能太多) |
| □ Job 状态 | kubectl get job <name> -o wide |
COMPLETIONS 应与预期一致 |
| □ Job Events | kubectl describe job <name> |
无 BackoffLimitExceeded / DeadlineExceeded |
排查命令速记
下次遇到 CronJob 没跑 / Job 反复失败──按这个顺序排查:
1. kubectl describe cronjob <name> # 时区 + Events
2. kubectl get job <name> -o yaml # backoff + conditions
3. kubectl get pods --selector=job-name= # 确认 Pod 状态
4. kubectl describe job <name> # Backoff 耗尽原因
K8s 不是没有告诉你问题在哪——Events 已经说了,只是你没看。
下篇我们聊 HPA 扩缩容不灵敏——metrics 采集延迟和目标阈值设错导致的异常行为。
📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」
附:完整命令清单
# 查看 CronJob 状态和时区
kubectl describe cronjob <name>
# 查看 CronJob YAML(确认 timeZone/concurrencyPolicy)
kubectl get cronjob <name> -o yaml
# 查看 Job 状态和退避限制
kubectl get job <name> -o yaml
# 查看 Job 创建的所有 Pod
kubectl get pods --selector=job-name=<name>
# 查看 CronJob 历史执行记录
kubectl get jobs --watch
# 查看退避原因
kubectl describe job <name>
# 暂停 CronJob(排查期)
kubectl patch cronjob <name> -p '{"spec":{"suspend":true}}'
# 手动触发一次 Job
kubectl create job --from=cronjob/<name> manual-job