Kubernetes Job 与 CronJob:完成项、失败策略和重叠调度
一个 Job 请求 4 个完成项,为什么最终会有 4 个不同名字的 Pod?失败退出码 42 为什么没有按普通 backoff 再创建更多 Pod?CronJob 每分钟调度一次,为什么有时看不到第二份 Job?
这三个问题分别落在 Job 控制器、失败策略和 CronJob 重叠策略上。本文用 Kubernetes v1.35.1 的 kind 实验验证这些分支,不把 Job 的状态简化成“有个 Pod 正在运行”。实验在 zj 的三节点 kind v0.33.0 集群 article-next 执行,客户端和服务端均为 v1.35.1(提交 8fea90b45245ef5c8ba54e7ae044d3e777c22500)。
先区分 Job、Pod 和完成项
Job 描述要完成多少工作,Pod 是执行工作的一次尝试。一个非索引 Job 通常只有一个完成项;completions 可以让同一个 Job 需要多个成功完成项,parallelism 限制同时运行的 Pod 数量。
Indexed Job 给每个完成项一个稳定索引,索引既不是 Pod 名也不是节点名。控制器创建 Pod 时把索引写进 batch.kubernetes.io/job-completion-index 注解,同时注入 JOB_COMPLETION_INDEX 环境变量;失败的索引可以重试,控制器要分别记录哪个索引已成功、哪个还在等待。
flowchart TB
A[Job 控制器读取 completions / parallelism] --> B[为待完成项创建 Pod]
B --> C[Pod 执行容器]
C --> D{退出结果}
D -->|成功| E[标记完成项]
D -->|失败| F{命中 podFailurePolicy?}
F -->|匹配 FailJob| G[Job 直接进入 Failed]
F -->|匹配 Ignore| H[忽略该 Pod]
F -->|未匹配| I[按 backoffLimit 计算重试]
I -->|未超过上限| B
I -->|超过上限| J[Job 进入 Failed]
E --> K{所有完成项成功?}
K -->|否| B
K -->|是| L[Job Complete]
图中把 Job 的完成项和 Pod 生命周期分开。Pod 失败、Pod 被删除、Job 条件变化是三类不同的事件,控制器每次观察后都重新计算下一步。
Indexed Job 怎样给四个完成项分配索引
实验创建一个 completions: 4、parallelism: 2、completionMode: Indexed 的 Job。等待 Complete 条件后,实际存在四个 Pod:
| |
从 batch.kubernetes.io/job-completion-index 读取到的索引是 0、1、2、3。parallelism: 2 只限制同时执行的数量,不改变最终需要完成的索引数量。
成功的 Job 不会从“有 Pod 在跑”直接跳到 Complete。v1.35.1 的 job_controller.go:938-1010 把收敛过程拆成固定顺序:先看上一轮留下的 SuccessCriteriaMet 或 FailureTarget,再评估失败场景,再评估成功场景,最后才把 FailureTarget 转成 Failed、把 SuccessCriteriaMet 转成 Complete。中间会检查 Pod 的 finalizer 是否都已清空、缓存里的 Pod 是否都已观察到;任一条件不满足就把 finishedCondition 置回 nil,下一次再算。
先写哪个条件由 delayTerminalCondition() 决定(job_controller.go:1099-1115)。只要 JobManagedBy 或 JobPodReplacementPolicy 任一开启,就先写 SuccessCriteriaMet,再由后续同步转成 Complete;两个都关才直接写 Complete。这两个功能门在 v1.35 都已 GA 并 LockToDefault,所以在当前版本上 SuccessCriteriaMet 是默认会出现的中间态。
实验记录的 indexed Job 因此同时带着两个条件:
| |
排查“Job 明明跑完了却还显示未完成”时,这个中间态就是答案所在:工作已经够了,但清理还没结束。只看 Complete 会漏掉这一段。
这四个 Pod 都运行了同一个镜像和命令,但控制器给每个 Pod 的索引不同。应用可以用索引切分数据分片、队列消息或文件范围。索引必须由应用正确消费;Kubernetes 不会自动保证同一索引的旧执行和新执行没有并发副作用,应用仍需实现幂等或外部锁。
| |
Job 控制器还会根据模板创建 Pod selector,使用 controller UID 关联对象。不要手动修改这些 selector;把 Pod 当作独立副本并改标签,可能让 Job 失去对执行结果的追踪。控制器怎样用队列和重试把实际 Pod 数收敛到 completions,和普通控制器的机制一致,见 Controller 调谐文章。
失败策略先于普通 backoff
普通失败如果没有特殊规则,会按 Job 的 backoffLimit 重试。实验中的 backoff-limit Job 让容器固定退出 1,设置 backoffLimit: 1,最终创建了 2 个 Pod,Job 条件为:
| |
第一个 Pod 失败后还有一次重试机会,第二个 Pod 再失败就没有更多重试。这个数量关系比“最多重试一次”更准确:backoffLimit 约束的是失败 Pod 数量,不是成功 Pod 数量。
另一个 podFailurePolicy Job 让容器退出 42:
| |
实验最终只创建了 1 个 Pod,Job 条件消息为:
| |
这个顺序由代码固定。v1.35.1 的 job_controller.go 在计算 exceedsBackoffLimit 之后,先看 getFailJobMessage 是否命中 FailJob 规则,命中就写入 JobFailureTarget、原因 PodFailurePolicy;只有这里没有结论时,才进入 BackoffLimitExceeded 和 ActiveDeadlineSeconds 的判断。所以退出码 42 的失败不会先被当成普通失败累积。
FailJob 在失败匹配后直接结束 Job,不再消耗普通的退避执行机会。Ignore 不计入 backoffLimit;FailIndex 只让指定索引失败,需要 JobBackoffLimitPerIndex 功能门才生效。消息模板、是否计入退避这两点见同版本的 pod_failure_policy.go。
策略要按失败类型选择,不要把所有失败都设成立即终止。瞬时的容器退出、镜像损坏和业务输入错误应采用不同规则;分类不清楚时,先记录容器退出码、Pod conditions 和 Job conditions,再决定是否添加策略。
CronJob 在重叠调度下会不会多建 Job
CronJob 在每次调度点创建 Job,但创建动作受 startingDeadlineSeconds、并发策略和历史保留影响。实验中 concurrencyPolicy: Forbid,Job 容器 sleep 75 秒,调度周期为每分钟。等待跨过一个调度点后,仍然只有一个 Job:
| |
这里跳过的那次创建,原因是前一个 Job 尚未结束;cron 表达式本身没有丢。Allow 会允许同时存在,Replace 会先删掉仍在运行的旧 Job 再建新的;选哪种要结合任务是否可重入、能否被取消和外部系统是否支持并发。
Forbid 也不是绝对的互斥保证。那段代码的注释承认了一个窗口:控制器依据 Status.Active 判断,如果状态更新还没被观察到,理论上可能出现两份并发执行,注释的描述是“只要调用间隔足够远,通常不会发生”。要真正的单实例,需要任务自己加锁,或者接受 Forbid 只在控制面视角成立。
sequenceDiagram
participant C as CronJob 控制器
participant J as Job
participant P as Pod
C->>C: 到达调度点
C->>C: 检查并发策略与历史 Job
alt Forbid 且旧 Job 仍活动
C->>C: 跳过此次创建
else 允许创建
C->>J: 创建 Job
J->>P: 创建完成项 Pod
P-->>J: 成功或失败
J-->>C: 更新 Job 状态
end
CronJob 的时区由 timeZone 决定,缺少该字段时按控制器本地时区解释表达式。Cron 表达式和时区错误可以表现为“任务没有在本地时间执行”,不能只看 Job 是否创建来判断调度器故障。
历史 Job 数、失败 Job 数和 TTL 影响的是你能看到多少执行记录,不参与 Forbid 判断:v1.35.1 的 cronjob_controllerv2.go:573-585 只在 Status.Active 非空时跳过创建。同一个文件里的 cleanupFinishedJobs(L682-L717)按 successfulJobsHistoryLimit 和 failedJobsHistoryLimit 分别清理,两个字段都为 nil 时什么也不做,保留全部历史。
清理只针对终态 Job:removeOldestJobs 先按启动时间排序,超出上限的部分逐个删除,运行中的 Job 不在候选里。所以 forbid-overlap 这个 Job 一直活动时,它不会被历史保留规则回收,也不影响跳过创建这件事。反过来说,把 successfulJobsHistoryLimit 设成 0 只意味着“完成的立即删掉”,不能让正在跑的 Job 消失。
控制器停摆一段时间后恢复,不会把错过的每次调度都补一遍。它算出最后一个应当触发的时刻,然后只针对那次判断是否已处理:utils.go 的 mostRecentScheduleTime 在错过次数超过 100 时把它标成 manyMissed,nextScheduleTime 据此记录一条 TooManyMissedTimes 警告,提示调整 .spec.startingDeadlineSeconds 或检查时钟偏移(utils.go:167-175、L209-L219)。阈值取 100 的理由写在注释里:一小时一次的任务停摆一个周末就超过 80 次,而时钟错误可能造出几十年量级的错过次数,逐个枚举会吃掉控制器资源。
startingDeadlineSeconds 决定同样的错过是否还算数。它不为 nil 时,scheduledTime + startingDeadlineSeconds 早于当前时间就记一条 MissSchedule 事件并跳过(cronjob_controllerv2.go:547-563)。所以停摆恢复后补不补、补几次,由这个字段和错过次数共同决定,不是“恢复就全补”或“恢复就全丢”。本次实验没覆盖这条路径,它来自源码阅读。
用状态和对象数量定位问题
排查 Job/CronJob 时,按以下顺序检查:
- 看 Job 的
status.conditions,区分Complete、FailureTarget和Failed。 - 看实际 Pod 数和 completion index,不要用 Pod 名猜测完成项。
- 读失败 Pod 的退出码,再对照
podFailurePolicy是否命中。 - 查
backoffLimit和已失败 Pod 数,判断是否还有普通重试空间。 - 查 CronJob 的并发策略、调度时区和历史限制,再确认是否真的到了调度点。
本次实验验证了 Indexed Job 的索引、podFailurePolicy 的直接失败、backoffLimit=1 的两个 Pod 结果,以及 Forbid 在跨过一个调度点时没有创建重叠 Job。没有测试 CronJob 错过多个调度窗口、Replace 策略、TTL 清理和真实外部任务的事务语义。字段语义以官方文档为准:Job 和 CronJob。
实验目录和命令见 k8sdev/article-job-cronjob。