课程目录(第 13 章 / 共 33 章)
课程/走向生产

Job 与 CronJob:批处理与定时任务

13 章 / 共 33·14 分钟·入门JobCronJob批处理

跑一次就结束的任务该用什么资源?这一章讲清 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 的难点不在怎么写,而在「要跑几个、能不能并行、失败了怎么办」。四个字段覆盖了这四件事:

字段默认值作用
completions1需要成功结束的 Pod 总数
parallelism1同时最多运行几个 Pod
backoffLimit6失败重试的次数上限
activeDeadlineSeconds不限制整个 Job 的最长运行时间,超时后 Job 被终止

completions: 5parallelism: 2 的意思是:一共要完成 5 个 Pod,同时最多跑 2 个,完成一个就补一个。如果 parallelism 大于 completions,实际并发会被 completions 限制住。

还有一个必须显式写的字段:template.spec.restartPolicy。Job 的 Pod 只允许 NeverOnFailure

  • Never:容器失败后新建一个 Pod,旧 Pod 保留失败现场,日志好查。
  • OnFailure:在同一个 Pod 里重启容器,Pod 数量不会暴涨。

另外 ttlSecondsAfterFinished 很实用:它让 Job 完成后过一段时间自动被清理,连带删掉它的 Pod,避免集群里堆满 Completed 的 Pod。

一张图看懂 CronJob → Job → Pod

三个资源的职责是分层的:CronJob 只负责「什么时候创建」,Job 负责「跑几次、失败了怎么办」,Pod 负责「真正执行命令」。搞清这条链路,排查时就知道该看哪一层。

text
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

yaml
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"
bash
kubectl apply -f job-report.yaml
kubectl get jobs,pods -n demo
kubectl logs job/report-once -n demo

kubectl get jobsCOMPLETIONS 列从 0/1 变成 1/1STATUS 变成 Complete,就说明任务成功结束。kubectl logs job/report-once 会输出 job startjob 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 拒绝。

另外三个控制行为的字段:

字段作用
concurrencyPolicyAllow(默认,允许重叠)、Forbid(上次没跑完就跳过这次)、Replace(取消旧的,启动新的)
successfulJobsHistoryLimit保留多少个成功的 Job,默认 3
startingDeadlineSeconds错过预定时间后,最多还能补多久;超时就跳过这一次
yaml
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)"'
bash
kubectl apply -f cronjob-report.yaml
kubectl get cronjobs -n demo
kubectl get jobs -n demo -w

kubectl get cronjobsSCHEDULE*/5 * * * *SUSPENDFalseACTIVE 在有任务运行时大于 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,忽略即可):

bash
kubectl create namespace demo

第一步:观察一个 Job 的完成状态。 保存为 job-ok.yaml 并提交:

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"]
bash
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

yaml
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"]
bash
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/1STATUSFailed,Events 里最后一行是 BackoffLimitExceeded。重试间隔是 10 秒起步的指数退避(10s → 20s → 40s,最多 6 分钟),所以这一步要等一两分钟才看得全。

第三步:手动触发一次 CronJob。 保存上面的 cronjob-report.yaml 并提交,然后不等时间点,直接生成一个 Job:

bash
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,跑得快、可控,不用干等。

收尾(顺手看一眼有没有残留):

bash
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 demoSUSPENDLAST SCHEDULE 两列,再看 kubectl get events -n demo。常见原因是 schedule 写错、suspend: truestartingDeadlineSeconds 太小、控制器没在运行。
现象原因怎么确认怎么办
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=50Never 保留失败现场,或用 --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核对五字段表达式、suspendstartingDeadlineSeconds
任务在错误的时间点执行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 默认保留,历史上限只清 Jobkubectl get pods -n demo --field-selector status.phase=SucceededttlSecondsAfterFinishedsuccessfulJobsHistoryLimit

自测题

自测:为什么 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,只能是 NeverOnFailure
  • CronJob 的 schedule 是五字段标准 cron,没有秒;时区要看 timeZone 是否被集群支持,并发策略默认 Allow 意味着允许重叠。
  • kubectl create job --from=cronjob/<名字> 是验证定时任务最省事的方式。

相关章节:第 6 章 Deployment:副本、滚动更新与回滚 讲的是常驻负载;第 15 章 排障手册 里的固定排查顺序同样适用于 Job 的 Pod。

练习

  1. job-report.yamlcompletions 改成 5、parallelism 改成 2,观察 kubectl get pods 里 Pod 的出现节奏和 kubectl get jobsCOMPLETIONS 变化。
  2. 把 Job 里的命令改成 exit 1,观察 backoffLimit 生效的过程,并用 kubectl describe job 找出最终失败的原因。
  3. 写一个 schedule: "*/1 * * * *" 的 CronJob,配 concurrencyPolicy: Forbid,观察 ACTIVELAST SCHEDULE 两列在几分钟内的变化。

到这里,我们已经能描述「什么任务、跑几个副本、跑多久」了。但 Pod 最终要落在哪台节点上,是由谁决定的?下一章我们讲调度:节点选择、亲和性、污点与容忍。