课程目录(第 13 章 / 共 33 章)
Job 与 CronJob:批处理与定时任务
跑一次就结束的任务该用什么资源?这一章讲清 Job 的完成与重试语义,以及 CronJob 的时间表、并发策略和清理方式。
学完这一章,你将能够
- ✓说清 Job 与 Deployment 在语义上的根本区别
- ✓会写 Job 与 CronJob 清单,理解 completions、parallelism、backoffLimit 的作用
- ✓能手动触发一次 CronJob 并排查「定时任务不执行」的问题
为什么不能拿 Deployment 跑一次性任务
假设你要跑一个数据库迁移脚本,或者生成一份日报。第一反应可能是「用 Deployment,把命令写进 command」。跑起来你会发现它永远跑不完——脚本退出后,Pod 状态变成 Completed,然后 Deployment 立刻又拉一个新的起来,如此循环。
原因在于两者的语义完全不同:
| 资源 | 期望状态 | Pod 退出后 |
|---|---|---|
| Deployment | 永远有 N 个副本在运行 | 视为异常,重新创建 Pod |
| Job | 有 N 个 Pod 成功结束 | 视为完成,不再创建 |
Deployment 的 Pod 重启策略被强制要求是 Always,它天然不适合「跑完就退出」的进程。常驻服务用 Deployment,一次性任务用 Job,按时间表重复的用 CronJob。
Job 的四个关键字段
Job 的难点不在怎么写,而在「要跑几个、能不能并行、失败了怎么办」。四个字段覆盖了这四件事:
| 字段 | 默认值 | 作用 |
|---|---|---|
completions | 1 | 需要成功结束的 Pod 总数 |
parallelism | 1 | 同时最多运行几个 Pod |
backoffLimit | 6 | 失败重试的次数上限 |
activeDeadlineSeconds | 不限制 | 整个 Job 的最长运行时间,超时后 Job 被终止 |
completions: 5 配 parallelism: 2 的意思是:一共要完成 5 个 Pod,同时最多跑 2 个,完成一个就补一个。如果 parallelism 大于 completions,实际并发会被 completions 限制住。
还有一个必须显式写的字段:template.spec.restartPolicy。Job 的 Pod 只允许 Never 或 OnFailure:
Never:容器失败后新建一个 Pod,旧 Pod 保留失败现场,日志好查。OnFailure:在同一个 Pod 里重启容器,Pod 数量不会暴涨。
另外 ttlSecondsAfterFinished 很实用:它让 Job 完成后过一段时间自动被清理,连带删掉它的 Pod,避免集群里堆满 Completed 的 Pod。
一张图看懂 CronJob → Job → Pod
三个资源的职责是分层的:CronJob 只负责「什么时候创建」,Job 负责「跑几次、失败了怎么办」,Pod 负责「真正执行命令」。搞清这条链路,排查时就知道该看哪一层。
CronJob report-hourly ← 定时器:只做「创建 Job」这件事
│ spec.schedule: "*/5 * * * *"
│ spec.concurrencyPolicy: Forbid ← 决定上一轮没跑完时怎么办
│ spec.jobTemplate: ← 每次到点就复制一份,变成 Job
▼
Job report-hourly-28934567 ← 一次执行记录:管完成数、并发、重试
│ spec.completions / parallelism / backoffLimit
│ spec.template: ← 由 Job 控制器创建 Pod
▼
Pod report-hourly-28934567-x7k2q ← 真正跑容器的东西
│ restartPolicy: Never | OnFailure
▼
Succeeded / Failed ← 结果保留在 Pod 里,等 TTL 或历史上限清理
时间轴(任务耗时 6 分钟,调度间隔 5 分钟,于是前后两轮会重叠)
09:00 09:05 09:10 09:15
│ │ │ │
[Job A ────6min────]
[Job B ────6min────]
[Job C ────6min────]
concurrencyPolicy 的三种行为(看 09:05 这一刻):
Allow Job A 还在跑 → 照常启动 Job B,两个同时跑(默认值)
Forbid Job A 还在跑 → 09:05 这一轮直接跳过,等 09:10 再判断
Replace 先终止 Job A 的 Pod,再启动 Job B(旧任务半途而废)动手:写一个 Job
apiVersion: batch/v1
kind: Job
metadata:
name: report-once
namespace: demo
spec:
backoffLimit: 3
activeDeadlineSeconds: 120
ttlSecondsAfterFinished: 600
template:
metadata:
labels:
app: report-once
spec:
restartPolicy: Never
containers:
- name: report
image: busybox:1.36
command:
- sh
- -c
- |
echo "job start: $(date)"
echo "generate report for demo"
sleep 3
echo "job done"kubectl apply -f job-report.yaml
kubectl get jobs,pods -n demo
kubectl logs job/report-once -n demokubectl get jobs 的 COMPLETIONS 列从 0/1 变成 1/1,STATUS 变成 Complete,就说明任务成功结束。kubectl logs job/report-once 会输出 job start 到 job done 三行——logs 支持直接写 job/<名字>,不用先去查 Pod 名。
任务完成后 Pod 不会自动消失,这是有意设计的:留着给你看日志。不想等 ttlSecondsAfterFinished 就手动删:kubectl delete job report-once -n demo。
需要并行处理多个分片
如果任务是「把 100 个文件转码」这类可以拆分的活,把 completions 设成 100、parallelism 设成 10 就够了。想让每个 Pod 知道自己处理第几片,可以用 completionMode: Indexed,容器里会拿到 JOB_COMPLETION_INDEX 环境变量(0 到 completions-1),按它取自己的分片即可。
CronJob:把 Job 按时间表重复
CronJob 就是「按 cron 表达式定期创建 Job」的控制器。它的 schedule 用标准 cron 格式,五个字段,没有秒:
| 位置 | 取值 | 含义 |
|---|---|---|
| 第 1 个 | 0-59 | 分钟 |
| 第 2 个 | 0-23 | 小时 |
| 第 3 个 | 1-31 | 日 |
| 第 4 个 | 1-12 | 月 |
| 第 5 个 | 0-6 | 星期,0 表示周日 |
几个常见写法:
| 表达式 | 含义 |
|---|---|
*/5 * * * * | 每 5 分钟 |
0 3 * * * | 每天 03:00 |
30 2 * * 1 | 每周一 02:30 |
0 0 1 * * | 每月 1 号 00:00 |
@yearly、@monthly、@weekly、@daily、@hourly 这些宏也可以直接用。写六个字段(多一个秒)会直接被 API Server 拒绝。
另外三个控制行为的字段:
| 字段 | 作用 |
|---|---|
concurrencyPolicy | Allow(默认,允许重叠)、Forbid(上次没跑完就跳过这次)、Replace(取消旧的,启动新的) |
successfulJobsHistoryLimit | 保留多少个成功的 Job,默认 3 |
startingDeadlineSeconds | 错过预定时间后,最多还能补多久;超时就跳过这一次 |
apiVersion: batch/v1
kind: CronJob
metadata:
name: report-hourly
namespace: demo
spec:
schedule: "*/5 * * * *"
timeZone: "Asia/Shanghai"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 60
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: OnFailure
containers:
- name: report
image: busybox:1.36
command:
- sh
- -c
- 'echo "tick $(date)"'kubectl apply -f cronjob-report.yaml
kubectl get cronjobs -n demo
kubectl get jobs -n demo -wkubectl get cronjobs 的 SCHEDULE 是 */5 * * * *,SUSPEND 是 False,ACTIVE 在有任务运行时大于 0。等最多 5 分钟,就能看到 kubectl get jobs 里冒出一个名字形如 report-hourly-28934567 的 Job(后缀是时间戳换算出来的数字)。
时区与并发策略的注意事项
这两个配置的坑最隐蔽,因为配错了任务照样「跑成功」,只是跑的时间点或次数不对。
- 时区:cron 表达式默认按
kube-controller-manager的时区解释,而控制器通常跑在 UTC 环境里,于是你写的「凌晨 3 点」实际是 UTC 03:00,在国内就是上午 11 点。spec.timeZone在 1.27 起稳定可用,写上"Asia/Shanghai"才和你的直觉一致;更旧的集群会忽略这个字段,只能自己把时间换算成 UTC。 - 并发:
Allow是默认值,任务耗时超过间隔时就会叠着跑,一起压数据库。耗时不确定的定时任务建议用Forbid;用Replace要想清楚「旧任务被中途杀掉」是否可以接受。 - 错过窗口:
startingDeadlineSeconds太小(比如 1),控制器重建、节点重启这类抖动就会让这一轮被直接跳过;不设时它只受「最多补 100 个错过的任务」限制。
动手练习:完成、失败重试与手动触发
从成功到失败重试,再到手动触发 CronJob
先建好命名空间(已存在会报 AlreadyExists,忽略即可):
kubectl create namespace demo第一步:观察一个 Job 的完成状态。 保存为 job-ok.yaml 并提交:
apiVersion: batch/v1
kind: Job
metadata:
name: job-ok
namespace: demo
spec:
backoffLimit: 2
ttlSecondsAfterFinished: 600
template:
spec:
restartPolicy: Never
containers:
- name: work
image: busybox:1.36
command: ["sh", "-c", "echo start; sleep 5; echo done"]kubectl apply -f job-ok.yaml
kubectl get job job-ok -n demo -w # COMPLETIONS 从 0/1 走到 1/1
kubectl get pods -n demo -l job-name=job-ok # STATUS: Completed
kubectl logs -n demo -l job-name=job-ok # start / done-l job-name=job-ok 是 Job 自动给 Pod 打的标签,比手写标签可靠。
第二步:故意失败,观察 backoffLimit 重试。 把命令改成 exit 1:
apiVersion: batch/v1
kind: Job
metadata:
name: job-fail
namespace: demo
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: work
image: busybox:1.36
command: ["sh", "-c", "echo trying; exit 1"]kubectl apply -f job-fail.yaml
kubectl get pods -n demo -l job-name=job-fail -w
kubectl describe job job-fail -n demo | grep -A5 Events
kubectl get job job-fail -n demo期望看到 3 个 Pod(1 次首发 + backoffLimit: 2 次重试),状态都是 Error;Job 的 COMPLETIONS 停在 0/1、STATUS 是 Failed,Events 里最后一行是 BackoffLimitExceeded。重试间隔是 10 秒起步的指数退避(10s → 20s → 40s,最多 6 分钟),所以这一步要等一两分钟才看得全。
第三步:手动触发一次 CronJob。 保存上面的 cronjob-report.yaml 并提交,然后不等时间点,直接生成一个 Job:
kubectl apply -f cronjob-report.yaml
kubectl create job --from=cronjob/report-hourly manual-run -n demo
kubectl get job manual-run -n demo # COMPLETIONS 1/1
kubectl logs job/manual-run -n demo # tick <当前时间>kubectl create job --from=cronjob/<名字> 是验证定时任务最省事的方式:它复制 jobTemplate 生成一个普通 Job,跑得快、可控,不用干等。
收尾(顺手看一眼有没有残留):
kubectl delete job job-ok job-fail manual-run -n demo
kubectl delete cronjob report-hourly -n demo
kubectl get jobs,pods -n demo常见坑与排错
三个高频问题
- 重试造成副作用重复执行:
backoffLimit默认是 6,任务失败就会重跑。如果任务是「给用户发通知」「扣款」「写外部系统」,重复执行会造成真实损失。做法是把任务设计成幂等的(用业务唯一键去重),或者缩小backoffLimit并让失败显式告警。 - 时区问题:cron 默认按
kube-controller-manager的时区解释,通常是 UTC。新版本用spec.timeZone明确指定,旧版本只能自己换算。 - CronJob 不触发:先看
kubectl get cronjobs -n demo的SUSPEND和LAST SCHEDULE两列,再看kubectl get events -n demo。常见原因是schedule写错、suspend: true、startingDeadlineSeconds太小、控制器没在运行。
| 现象 | 原因 | 怎么确认 | 怎么办 |
|---|---|---|---|
Job 一直 0/1,Pod 反复重建 | 命令退出码非 0,触发 backoffLimit 重试 | kubectl get pods -l job-name=<job> 看到多个 Error Pod;kubectl describe job 的 Events 里有 BackoffLimitExceeded | 修命令或镜像;把 backoffLimit 调小,避免无限重跑 |
Job 是 Failed,但不知道错在哪 | 失败 Pod 的日志没看 | kubectl logs -l job-name=<job> --previous --tail=50 | 用 Never 保留失败现场,或用 --previous 看上一个容器实例 |
Pod 停在 Pending | 资源不足、亲和性不满足、有污点没容忍 | kubectl describe pod <pod> 的 Events 里 0/N nodes are available | 按 第 14 章 调度入门 的顺序逐条排除 |
CronJob 的 LAST SCHEDULE 一直是 <none> | schedule 写错、suspend: true、错过窗口被跳过 | kubectl get cronjobs -n demo 三列 + kubectl get events -n demo | 核对五字段表达式、suspend、startingDeadlineSeconds |
| 任务在错误的时间点执行 | cron 按控制器所在机器的时区(多为 UTC)解释 | kubectl get cronjob report-hourly -n demo -o jsonpath='{.spec.timeZone}' 为空就是没设 | 显式写 spec.timeZone: "Asia/Shanghai" |
| 同一个任务同时跑了两份 | concurrencyPolicy: Allow(默认)且任务耗时超过间隔 | kubectl get jobs -n demo 里多个同前缀 Job 同时 Active | 改成 Forbid(跳过)或 Replace(替换) |
Completed / Error 的 Pod 越堆越多 | Job 的 Pod 默认保留,历史上限只清 Job | kubectl get pods -n demo --field-selector status.phase=Succeeded | 设 ttlSecondsAfterFinished 与 successfulJobsHistoryLimit |
自测题
自测:为什么 CronJob 的任务会重复执行?(点击展开答案)
先分清三种「重复」的来源。第一,concurrencyPolicy 默认是 Allow,如果任务耗时超过调度间隔(比如每 5 分钟一次、单次跑 6 分钟),上一轮还没结束,下一轮照常启动,看起来就是「跑了两份」。第二,backoffLimit 会让失败的容器重跑整个命令,命令里的副作用(发消息、写外部系统)就执行了两次。第三,控制器或集群重启后,落在 startingDeadlineSeconds 窗口内的错过的任务会被补跑。这三点的共同根源是:CronJob 只保证「至少一次」,不保证「恰好一次」。所以定时任务必须写成幂等的——用业务唯一键(日期 + 任务名)去重,或者让重跑不产生额外副作用。
自测:为什么 Job 的 Pod 跑完了却不自动删除?(点击展开答案)
因为「Pod 还在」本身就是 Job 的成功凭证。Job 控制器靠 Pod 的 phase 判断 completions 是否满足,你也要靠它看日志、看退出码、看失败现场;Pod 一删,这些证据就没了。所以清理被拆成两个独立的旋钮:ttlSecondsAfterFinished 决定「完成后保留多久」,successfulJobsHistoryLimit / failedJobsHistoryLimit 决定「保留几个历史 Job」。默认都不删,是为了不让你在排查时丢失线索——代价就是集群里会堆一堆 Completed。
自测:为什么 Job 的 Pod 不允许 `restartPolicy: Always`?(点击展开答案)
Job 判断完成与否,依赖「容器结束」这个事件:退出码为 0 就算这个 Pod 成功了。Always 的语义是「容器一退出就立刻重启」,于是容器永远处在运行或重启中,Pod 的 phase 几乎不会变成 Succeeded,控制器就永远等不到 completions 被满足,任务也就永远不结束。Deployment 需要 Always,因为它要的是「一直有进程在跑」;Job 要的是「跑完就停」,所以只能用 Never(失败就换新 Pod,保留现场)或 OnFailure(原地重启容器,Pod 数不膨胀)。
小结
- Deployment 管「一直运行」,Job 管「跑完结束」,CronJob 管「按时创建 Job」,三者语义不能混用。
completions定总量、parallelism定并发、backoffLimit定重试、activeDeadlineSeconds定超时,ttlSecondsAfterFinished负责自动清理。- Job 的 Pod 必须显式写
restartPolicy,只能是Never或OnFailure。 - CronJob 的
schedule是五字段标准 cron,没有秒;时区要看timeZone是否被集群支持,并发策略默认Allow意味着允许重叠。 kubectl create job --from=cronjob/<名字>是验证定时任务最省事的方式。
相关章节:第 6 章 Deployment:副本、滚动更新与回滚 讲的是常驻负载;第 15 章 排障手册 里的固定排查顺序同样适用于 Job 的 Pod。
练习
- 把
job-report.yaml的completions改成 5、parallelism改成 2,观察kubectl get pods里 Pod 的出现节奏和kubectl get jobs的COMPLETIONS变化。 - 把 Job 里的命令改成
exit 1,观察backoffLimit生效的过程,并用kubectl describe job找出最终失败的原因。 - 写一个
schedule: "*/1 * * * *"的 CronJob,配concurrencyPolicy: Forbid,观察ACTIVE、LAST SCHEDULE两列在几分钟内的变化。
到这里,我们已经能描述「什么任务、跑几个副本、跑多久」了。但 Pod 最终要落在哪台节点上,是由谁决定的?下一章我们讲调度:节点选择、亲和性、污点与容忍。