Kubernetes 基础体系 · 第 19/83 篇。示例基于 Kubernetes 当前稳定 API;弃用、版本偏差、云厂商差异和生产风险会明确说明。

Job 与 CronJob:并行、重试、补跑、时区、幂等和清理

Job 和 CronJob 都用于运行“最终应当完成”的任务,但它们解决的是两个不同层次的问题:

  • Job 负责让一组 Pod 完成指定次数的任务,并处理失败重试。
  • CronJob 负责按照时间表创建 Job;它本身不执行任务,也不替代 Job 的重试和完成语义。

因此,一个定时任务的完整链路是:

CronJob
  └── 按 schedule 创建 Job
        └── 创建 Pod
              └── 启动容器
                    └── 成功或失败

如果 Pod 被驱逐、节点故障、容器重启、Job 控制器重建,任务都可能再次执行。Kubernetes 能保证的是控制器层面的状态收敛,不是业务代码天然的“只执行一次”。


一、先区分 Pod、Job 和 CronJob

Pod 是执行载体

Pod 的 status.phase 描述 Pod 的总体阶段:

  • Pending
  • Running
  • Succeeded
  • Failed
  • Unknown

phase 不是任务结果的全部信息。例如:

  • Pod 处于 Running,说明任务还没有结束;
  • Pod 处于 Succeeded,通常表示所有容器都以退出码 0 结束;
  • Pod 处于 Failed,表示至少有一个容器失败,且 Pod 已经结束;
  • Pod 被驱逐时,可能表现为失败或终止相关 Condition,不能只看应用日志判断原因。

restartPolicy 决定容器在同一个 Pod 内是否重启:

restartPolicy: Never

表示容器失败后不在原 Pod 内重启,由 Job 控制器决定是否创建新的 Pod。

restartPolicy: OnFailure

表示容器失败时,kubelet 尝试在同一个 Pod 内重启容器。它通常更快,但会把“同一个 Pod 内的容器重启”和“Job 创建新 Pod”混在一起,不利于观察每次尝试。

Job 的 Pod 模板只允许:

restartPolicy: Never

或:

restartPolicy: OnFailure

不能使用 Always,因为 Job 需要知道任务何时结束。

Job 是一次性工作负载

Job 关注的是:

是否已经达到成功完成条件?

它可以创建一个或多个 Pod,并根据 Pod 结果继续创建替代 Pod。Job 完成后,Job 对象和已结束的 Pod 默认仍然保留,除非由 TTL 或其他清理机制删除。

CronJob 是 Job 创建器

CronJob 关注的是:

什么时候应该创建一个新的 Job?

CronJob 不会把同一个 Job“重置”后再次运行。每一个调度时间通常对应一个独立的 Job 对象:

CronJob
├── report-29123450
├── report-29123510
└── report-29123570

因此:

  • Job 的 backoffLimit 控制一次 Job 内部的失败重试;
  • CronJob 的 schedule 控制未来是否创建新的 Job;
  • “补跑”可能是 Job 内重试,也可能是 CronJob 对错过时间的补创建,二者不是同一个机制。

二、Job 的完成条件:completions、parallelism 和 completionMode

1. 单次任务

最简单的 Job 不设置 completions

apiVersion: batch/v1
kind: Job
metadata:
  name: one-shot
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: task
          image: busybox:1.36
          command: ["sh", "-c", "echo start; sleep 5; echo done"]

此时:

  • completions 默认是 1
  • parallelism 默认是 1
  • 一个 Pod 成功后,Job 达到完成条件。

可以用以下命令观察:

kubectl apply -f one-shot.yaml
kubectl get job one-shot
kubectl get pods -l job-name=one-shot
kubectl describe job one-shot

典型结果:

NAME       COMPLETIONS   DURATION   AGE
one-shot   1/1           6s         20s

1/1 的含义是:需要成功 1 次,已经成功 1 次。它不是 Pod 数量,也不是尝试次数。

2. 多次成功完成

如果设置:

spec:
  completions: 10

Job 需要累计 10 次成功完成。默认 parallelism 仍然是 1,所以通常会串行执行 10 个 Pod。

如果设置:

spec:
  completions: 10
  parallelism: 3

Job 会尽量同时运行 3 个活动 Pod,直到累计有 10 个成功 Pod。

可以把非 Indexed Job 的基本目标写成:

SCS \geq C

其中:

  • SS 是成功完成次数;
  • CC.spec.completions

并发活动 Pod 数量通常受以下条件约束:

APA \leq P

其中:

  • AA 是当前活动 Pod 数量;
  • PP.spec.parallelism

当剩余任务数量不足 parallelism 时,控制器不会为了满足并发数而继续创建多余 Pod。因此更完整地表示为:

Amin(P,CS)A \leq \min(P, C-S)

这描述的是目标,不应被理解为严格的实时不变量。控制器通过异步监听和创建 Pod 工作,短时间内可能存在状态滞后,节点故障或控制器重启也可能导致实际 Pod 数量与期望值暂时不同。

3. parallelism 不是任务分片

下面的配置:

spec:
  completions: 10
  parallelism: 3

并不自动把数据分成 10 份,也不向容器传入“当前是第几份”。它只表示:

最终需要 10 个成功 Pod,并尽量同时运行 3 个 Pod。

如果每个 Pod 都执行完全相同的任务,结果可能是同一份数据被处理 10 次。要处理不同分片,应使用 Indexed Job 或由外部系统分配任务。


三、Indexed Job:并行分片和每个索引的完成状态

需要让每个 Pod 对应一个确定的任务分片时,可以使用:

apiVersion: batch/v1
kind: Job
metadata:
  name: indexed-example
spec:
  completions: 4
  parallelism: 2
  completionMode: Indexed
  backoffLimit: 6
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: worker
          image: busybox:1.36
          command:
            - sh
            - -c
            - |
              echo "processing index=${JOB_COMPLETION_INDEX}"
              sleep 2
              test "${JOB_COMPLETION_INDEX}" != "2"

在支持 Indexed Job 的集群中,Kubernetes 会为索引 03 分配任务。容器可以通过 JOB_COMPLETION_INDEX 环境变量获得自己的索引。

这段示例中索引 2 故意失败,因此 Job 不会顺利完成。实际业务中,索引通常用于计算分片范围,例如:

总记录数 = 1,000,000
分片数 = 10
索引 = 3

处理区间:
start = 3 × 100,000
end   = 4 × 100,000 - 1

此时任务身份应当由多个字段共同确定:

业务批次 + 分片索引
例如:
2025-03-08T00:00Z + shard-3

不能只用 Pod 名称作为业务任务身份,因为失败重试会产生新的 Pod 名称。

每索引重试

在支持每索引重试字段的较新 Kubernetes 版本中,可以使用:

spec:
  completionMode: Indexed
  completions: 10
  parallelism: 4
  backoffLimitPerIndex: 2
  maxFailedIndexes: 3

语义是:

  • 每个索引最多允许指定次数的失败;
  • 某个索引成功后,不再需要为该索引创建任务;
  • 失败索引数量超过 maxFailedIndexes 时,整个 Job 失败。

这些字段依赖集群版本和控制器能力。部署前应检查:

kubectl explain job.spec.backoffLimitPerIndex
kubectl explain job.spec.maxFailedIndexes

如果服务器不认识字段,不能仅因为客户端能生成 YAML 就认为集群支持它。


四、重试:容器重启、Pod 重建和 Job 失败不是一回事

Job 的“重试”至少包含三层含义。

第一层:容器在同一个 Pod 内重启

restartPolicy: OnFailure 时,kubelet 可能在原 Pod 内重启失败容器:

Pod A
└── Container attempt 1: exit 1
└── Container attempt 2: exit 1
└── Container attempt 3: exit 0

这种情况下,Pod 最终可能直接进入 Succeeded,Job 不一定创建新的 Pod。

容器日志若需要查看上一次实例,应使用:

kubectl logs pod-name -c task --previous

第二层:Job 创建新的 Pod

当 Pod 以失败状态结束,Job 控制器可以创建替代 Pod:

Pod A: Failed
  └── Job 创建 Pod B
Pod B: Failed
  └── Job 创建 Pod C
Pod C: Succeeded

使用:

restartPolicy: Never

时,每个 Pod 通常就是一个可见的尝试,便于诊断和审计。

第三层:Job 达到失败阈值

backoffLimit 控制 Job 允许的失败重试数量,默认值是 6。当失败次数达到阈值,Job 会被标记为失败,不再继续创建正常重试 Pod。

spec:
  backoffLimit: 3

表示失败达到相应阈值后,Job 进入失败状态。这里的“失败次数”不能简单等同于 Pod 数量,因为 OnFailure 下同一个 Pod 内也可能发生容器重启,Job 控制器还会结合 Pod 状态和容器状态进行失败判断。

重试间隔通常采用逐步增加的退避,常见实现表现为:

约 10 秒
约 20 秒
约 40 秒
...
上限约 6 分钟

这是控制器实现中的退避行为,不应把它当作业务级精确计时器。节点资源、API Server 延迟、调度延迟和镜像拉取时间都会增加实际间隔。

失败重试的完整状态路径

stateDiagram-v2
    [*] --> Pending: Job 创建
    Pending --> Running: Pod 被调度并启动
    Running --> Succeeded: 任务退出码为 0
    Running --> Failed: Pod 失败
    Failed --> Running: 未超过 backoffLimit,创建新 Pod
    Failed --> JobFailed: 达到失败阈值
    Succeeded --> JobComplete: 达到 completions
    Succeeded --> Running: 尚未达到 completions
    JobComplete --> [*]
    JobFailed --> [*]

图中有两个容易混淆的点:

  1. 一个失败 Pod 不必然意味着整个 Job 失败;
  2. 一个成功 Pod 也不必然意味着 Job 完成,除非已经达到 completions

可以用以下命令查看 Job 的 Condition:

kubectl get job my-job -o yaml
kubectl describe job my-job

重点观察:

  • status.succeeded
  • status.failed
  • status.active
  • status.conditions
  • Events 中的调度、拉镜像、配额和节点错误

五、重试并不等于安全:Job 通常是至少一次执行

Kubernetes 控制器是异步系统。考虑下面的故障路径:

1. Pod 执行业务写入
2. 业务写入已经提交
3. Pod 在上报成功状态前崩溃
4. Job 控制器看到 Pod 未成功
5. 创建新的 Pod
6. 业务再次写入

从 Kubernetes 角度看,第二次执行是合理的,因为第一次执行的最终状态不可确认。从业务角度看,就可能产生重复扣款、重复发货、重复发送消息或重复导入数据。

因此,任务代码必须按“可能重复执行”设计。

幂等的定义

如果同一个业务操作执行一次和执行多次,最终可观察结果相同,则称该操作具有幂等性:

f(f(x))=f(x)f(f(x)) = f(x)

这里的 ff 是业务操作,xx 是系统状态。

但“函数幂等”不能简单等同于“数据库插入不会重复”。例如:

INSERT INTO payment_log(order_id, amount)
VALUES ('order-123', 100);

如果没有唯一约束,重复执行会产生两行记录,因此不是幂等操作。

一种常见实现是使用业务幂等键:

CREATE TABLE payment_log (
    idempotency_key TEXT PRIMARY KEY,
    order_id        TEXT NOT NULL,
    amount          NUMERIC NOT NULL,
    created_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

业务处理时:

INSERT INTO payment_log(idempotency_key, order_id, amount)
VALUES ('order-123:charge:20250308', 'order-123', 100)
ON CONFLICT (idempotency_key) DO NOTHING;

关键不在 SQL 语法本身,而在于幂等键必须稳定:

  • 不能使用 Pod 名称;
  • 不能使用随机 UUID;
  • 不能每次重试都生成新键;
  • 应使用业务批次、对象 ID、分片索引、逻辑时间等确定性字段。

例如 Indexed Job 的幂等键可以是:

daily-report:2025-03-08:shard-3

幂等与事务边界

以下流程仍然可能重复:

1. 数据库事务提交
2. 调用外部支付接口
3. 在第 2 步后进程崩溃
4. 重试

应尽量使用:

  • 数据库唯一键;
  • 数据库事务;
  • 外部 API 的幂等键;
  • Outbox 或 Inbox 模式;
  • 可查询结果的状态机;
  • 对账和补偿流程。

Kubernetes 只能负责重新运行 Pod,不能替业务确认跨系统操作是否已经完成。


六、CronJob 的调度模型

一个 CronJob 示例:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: daily-report
spec:
  schedule: "0 2 * * *"
  timeZone: "Asia/Shanghai"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 1800
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      backoffLimit: 2
      ttlSecondsAfterFinished: 86400
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: report
              image: example/report:1.0
              args: ["--date", "previous-day"]

字段的关系是:

CronJob.spec.schedule
        │
        ▼
CronJob 控制器计算到期时间
        │
        ▼
创建一个 Job
        │
        ▼
Job 使用 jobTemplate.spec
        │
        ▼
Job 创建 Pod

jobTemplate 是创建出来的 Job 模板。修改 CronJob 的模板不会修改已经创建的 Job,也不会重启正在运行的 Job。


七、Cron 表达式和时区

1. 五字段 Cron 表达式

Kubernetes CronJob 的常见格式是:

分钟 小时 日期 月份 星期

例如:

0 2 * * *

表示每天的 02:00。

几个例子:

*/5 * * * *      每 5 分钟
0 * * * *        每小时整点
30 9 * * 1-5     工作日 09:30
0 0 1 * *        每月 1 日零点

CronJob 不适合表达复杂日历规则,例如:

  • 最后一个工作日;
  • 某个节假日前一天;
  • 每月倒数第二天;
  • 依赖业务数据库状态的时间条件。

这些规则应由任务代码、外部调度系统或明确的补跑机制处理。

2. timeZone

在支持该字段的 Kubernetes 版本中:

spec:
  schedule: "0 2 * * *"
  timeZone: "Asia/Shanghai"

表示按照上海时区解释 02:00,而不是按照控制器所在节点的本地时区解释。

时区名称应使用 IANA 时区数据库名称,例如:

UTC
Asia/Shanghai
Europe/Berlin
America/New_York

不要把以下写法放入 schedule

CRON_TZ=Asia/Shanghai 0 2 * * *
TZ=Asia/Shanghai 0 2 * * *

时区应通过 .spec.timeZone 指定。Kubernetes 会校验时区;如果时区无效,CronJob 会产生相应状态或事件,而不是可靠地按预期调度。

如果集群版本不支持 timeZone,客户端可能仍能保存 YAML 之外的结构差异,但服务器行为不会自动获得该能力。部署前检查:

kubectl explain cronjob.spec.timeZone

3. 夏令时边界

在使用夏令时的地区,一天可能有:

  • 不存在的本地时间;
  • 重复出现的本地时间。

例如某些地区在切换到夏令时的夜间会跳过一个小时,而切回冬令时会重复一个小时。CronJob 控制器对这类边界不能提供业务意义上的“绝不漏、绝不重”。

对于财务结算、账单、日终快照等任务,通常更容易验证的设计是:

  • 使用 UTC
  • 在业务中明确逻辑结算日;
  • 用业务批次键做幂等;
  • 对缺失批次进行显式扫描和补偿。

八、CronJob 不是精确计时器:调度、错过和补跑

CronJob 控制器周期性检查 CronJob,而不是在每个时间点由一个硬实时定时器精准触发。因此,下面因素都可能影响创建时间:

  • 控制器调谐周期;
  • API Server 延迟;
  • 控制器重启或故障转移;
  • 集群压力;
  • 时钟和时间数据库问题;
  • 前一个 Job 的并发策略。

startingDeadlineSeconds

该字段限制一个错过的调度时间可以延迟多久仍然允许创建 Job:

spec:
  startingDeadlineSeconds: 1800

表示错过某次计划时间后,超过 1800 秒就不再为这次计划创建 Job。

它不是“保证在 1800 秒内启动”,而是一个可接受迟到窗口:

当前时间计划时间startingDeadlineSeconds\text{当前时间} - \text{计划时间} \leq \text{startingDeadlineSeconds}

如果控制器恢复时发现某次计划时间仍在窗口内,可能创建对应 Job;如果已经超过窗口,则跳过。

没有设置该字段时,错过调度的处理窗口更宽,但这不应被理解为无限可靠的补跑队列。控制器对错过次数有保护限制;当错过数量过大时会停止逐个补建,并在事件中报告相关问题。高频 CronJob 长时间停机后,不能假设 Kubernetes 会无限创建历史 Job。

补跑的两种含义

“补跑”至少有两种不同操作。

CronJob 自动处理错过的调度

这是控制器根据 startingDeadlineSeconds 和调度历史做出的行为,受:

  • 控制器运行状态;
  • 错过时间;
  • 并发策略;
  • 时区;
  • 控制器实现和版本

影响。

它不适合作为财务或数据管道的唯一补偿机制。

人工创建一个新的 Job

可以从 CronJob 模板创建一次性 Job:

kubectl create job daily-report-rerun-20250308 \
  --from=cronjob/daily-report

这条命令的含义是:

  1. 读取 daily-report 的 Job 模板;
  2. 创建名为 daily-report-rerun-20250308 的独立 Job;
  3. 该 Job 不会改变 CronJob 的调度状态;
  4. 该 Job 完成后不会自动变回 CronJob 的一部分。

如果任务需要处理特定日期,应显式传入业务日期,而不是让容器猜测当前日期:

kubectl create job daily-report-rerun-20250308 \
  --from=cronjob/daily-report \
  --dry-run=client -o yaml

先检查生成的 YAML,再修改参数并应用,是比直接人工改线上对象更容易审计的方式。


九、并发策略:Allow、Forbid 和 Replace

CronJob 的 .spec.concurrencyPolicy 控制同一个 CronJob 创建的 Job 之间是否允许重叠。

Allow

spec:
  concurrencyPolicy: Allow

默认策略。新的 Job 按计划创建,即使旧 Job 仍在运行。

若每个 Job 运行 40 分钟,而调度周期为 10 分钟,则理论上可能同时存在约 4 个活动 Job。更一般地,若任务运行时长为 DD,调度周期为 PP,在长期稳定状态下,重叠数量大约受:

NDPN \approx \left\lceil \frac{D}{P} \right\rceil

影响,但实际数量还受启动延迟、失败重试和控制器行为影响。

Allow 适合任务天然可并行且共享资源不会冲突的情况。

Forbid

spec:
  concurrencyPolicy: Forbid

如果上一次 Job 仍在运行,新的计划时间到达时不会启动新的 Job。

这并不意味着“等旧任务完成后自动补跑一次”。该次计划可能被跳过,尤其在任务持续时间较长时。

例如:

计划:02:00、03:00、04:00
任务:02:00 启动,运行到 03:40

03:00 到达:
  旧 Job 仍在运行
  新 Job 不创建

04:00 到达:
  如果旧 Job 已结束,则创建 04:00 对应的 Job

03:00 这一轮不是排队等待,而是被并发策略抑制。是否仍能在控制器视为“错过调度”时补建,还受调度检查和截止时间影响,不能用它实现严格的补跑队列。

Replace

spec:
  concurrencyPolicy: Replace

新的调度到达时,CronJob 控制器会尝试删除旧 Job,再创建新的 Job。

这不是强制终止的瞬时替换:

  1. 删除请求先发送到 API Server;
  2. Job 和 Pod 进入终止流程;
  3. 容器可能需要执行 preStop 或等待优雅终止;
  4. 新 Job 可能已经开始;
  5. 旧任务在短时间内仍可能产生副作用。

因此 Replace 不能替代业务取消协议。若任务会写数据库、上传文件或调用外部接口,必须处理“旧任务正在退出、新任务已经开始”的交叠窗口。


十、终止、Eviction 和优先级会如何影响 Job

Job 的 Pod 不是特殊的免故障 Pod。

节点故障和 Eviction

Pod 可能因为以下原因结束:

  • 节点宕机;
  • 资源压力;
  • kubelet 驱逐;
  • 人工删除;
  • 节点排空;
  • 抢占;
  • 容器自身失败。

一个被删除或驱逐的 Pod 可能导致 Job 创建替代 Pod,但这不表示原来的业务副作用已经回滚。若 Pod 在外部系统操作完成后才被驱逐,替代 Pod 仍可能重复操作。

可以检查:

kubectl describe pod pod-name
kubectl get pod pod-name -o jsonpath='{.status.reason}{"\n"}'
kubectl get events --sort-by=.lastTimestamp

不要只根据 Pod 的 phase=Failed 断言“业务没有执行”。Pod 终止和业务事务提交是两个不同系统的事实。

Priority 与 Preemption

Job Pod 可以设置优先级:

spec:
  template:
    spec:
      priorityClassName: batch-low

高优先级 Pod 可能抢占低优先级 Pod 的资源。对 Job 而言,抢占会带来两个后果:

  1. 被抢占的 Pod 可能失败,Job 随后创建替代 Pod;
  2. 批处理任务可能反复等待资源,直到达到失败阈值或长期无法调度。

优先级不会改变 Job 的完成定义,也不会让 Job 获得“至少执行一次”的额外保证。为关键批处理任务设置高优先级前,应确认它不会挤压在线服务;为低优先级任务设置抢占风险后,应让任务具备可重试、可恢复和幂等能力。


十一、清理:Job 对象、Pod 和历史记录是不同层次

任务完成后,至少有三类对象需要考虑:

CronJob
  └── Job
        └── Pod

1. CronJob 历史保留

CronJob 可以限制成功和失败 Job 的历史数量:

spec:
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5

含义是:

  • 保留最近若干个成功 Job;
  • 保留最近若干个失败 Job;
  • 旧 Job 会被 CronJob 控制器删除;
  • 设置为 0 表示不保留对应类型的历史 Job。

历史数量不是严格的实时上限。控制器是异步清理的,故障和调谐延迟期间可能暂时超出配置值。

2. TTL 控制器

Job 可以设置:

spec:
  ttlSecondsAfterFinished: 86400

表示 Job 完成或失败后,至少等待约 86400 秒,再由 TTL 控制器清理 Job。Job 删除时,其从属 Pod 通常会随级联删除。

TTL 更适合表达“每个 Job 完成后保留多久”,CronJob 历史限制更适合表达“保留多少个最近 Job”。二者可以同时使用,但实际删除时间取决于两个清理机制中更早触发者。

例如:

successfulJobsHistoryLimit: 3
ttlSecondsAfterFinished: 86400

可能出现:

  • 任务完成后不到一天,因为已经超过历史数量限制而被删除;
  • 任务一直没有超过历史数量限制,但一天后被 TTL 删除。

3. 为什么不要立即删除所有对象

清理过于激进会损失:

  • 失败原因;
  • Pod 终止原因;
  • 容器日志;
  • Job Condition;
  • 调度和资源事件。

生产环境通常应先确保日志、指标和审计信息已经发送到外部系统,再缩短对象保留时间。否则“清理成功”可能换来“无法解释为什么失败”。

4. 手工清理

查看对象:

kubectl get cronjob
kubectl get job --sort-by=.metadata.creationTimestamp
kubectl get pods -l job-name=some-job

删除 CronJob:

kubectl delete cronjob daily-report

删除 CronJob 通常不会自动替你决定如何处理已经创建的 Job。需要根据删除传播策略和现场验证结果确认残留对象:

kubectl get jobs
kubectl get pods

删除单个 Job:

kubectl delete job daily-report-rerun-20250308

不要在尚未确认任务是否仍有外部副作用时批量删除所有失败 Job。失败 Job 可能是唯一的诊断证据,也可能正等待人工补跑。


十二、一个完整的生产型模板

下面的模板展示了几个核心字段,但镜像和业务命令需要替换为实际任务。

apiVersion: batch/v1
kind: CronJob
metadata:
  name: shard-report
  labels:
    app.kubernetes.io/name: shard-report
spec:
  schedule: "15 2 * * *"
  timeZone: "UTC"

  # 上一次尚未结束时,不启动同一 CronJob 的下一次任务
  concurrencyPolicy: Forbid

  # 控制器恢复后,最多允许迟到 30 分钟的调度被处理
  startingDeadlineSeconds: 1800

  # 仅保留有限历史,避免对象无限增长
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5

  jobTemplate:
    metadata:
      labels:
        app.kubernetes.io/name: shard-report
    spec:
      completions: 16
      parallelism: 4
      completionMode: Indexed

      # 每个索引最多允许的失败次数
      backoffLimitPerIndex: 2

      # 失败索引达到该数量时,整个 Job 失败
      maxFailedIndexes: 4

      # Job 完成一天后允许 TTL 控制器清理
      ttlSecondsAfterFinished: 86400

      template:
        metadata:
          labels:
            app.kubernetes.io/name: shard-report
        spec:
          restartPolicy: Never
          containers:
            - name: worker
              image: example/report-worker:1.0.0
              env:
                - name: JOB_KIND
                  value: "daily"
              args:
                - "--index-env"
                - "JOB_COMPLETION_INDEX"
                - "--idempotency-prefix"
                - "daily-report"

部署前检查字段:

kubectl apply --dry-run=server -f shard-report.yaml

如果集群支持,可以检查实际对象:

kubectl get cronjob shard-report -o yaml
kubectl get jobs -l app.kubernetes.io/name=shard-report

这个模板仍然没有解决几个业务问题:

  • --index-env 是否真的被应用解析;
  • 每个索引如何计算数据范围;
  • 失败后如何恢复中间状态;
  • 多次执行如何保证幂等;
  • 任务是否允许跨逻辑日期运行;
  • 任务被 Replace 或节点驱逐时如何停止外部操作。

这些必须由任务自身和业务系统定义。


十三、常见误解和对应验证方法

误解一:parallelism: 10 表示有 10 个不同任务

错误。它只表示最多同时运行 10 个活动 Pod。是否处理不同数据,取决于应用是否使用 Indexed Job、分片配置或外部分配器。

验证:

kubectl get job name -o jsonpath='{.spec.completions}{" "}{.spec.parallelism}{"\n"}'

误解二:Job 成功一次就不会再执行

错误。如果 completions 大于 1,还需要更多成功完成。如果 Pod 成功状态在控制器获知前发生故障,业务也可能被重试。

验证:

kubectl get job name -o wide
kubectl get pods -l job-name=name

误解三:Forbid 会把错过的任务排队

错误。Forbid 的核心语义是阻止重叠运行,不是建立可靠的等待队列。计划轮次可能被跳过,补建还受截止时间和控制器调谐影响。

误解四:Replace 能保证旧任务完全停止后才启动新任务

错误。删除是异步终止过程。必须让任务对终止信号敏感,并让外部写操作幂等。

误解五:Job 失败说明业务完全没有产生结果

错误。Pod 可能在业务提交后、状态上报前失败。应在业务数据库、消息系统或外部 API 中查询幂等键,而不是只看 Job Condition。

误解六:设置了 timeZone 就能解决日期边界

不完全正确。它只规定 Cron 表达式如何解释本地时间。业务“结算日”“账单日”“上一自然日”的定义仍需在应用中明确,尤其是 UTC 与本地时区跨日时。


十四、诊断一条失败的 CronJob 链路

建议沿着对象关系逐层定位:

# 1. 查看 CronJob 的调度字段和状态
kubectl describe cronjob shard-report

# 2. 查看由 CronJob 创建的 Job
kubectl get jobs --sort-by=.metadata.creationTimestamp

# 3. 查看某个 Job 的 Condition、失败计数和事件
kubectl describe job job-name

# 4. 查看该 Job 的 Pod
kubectl get pods -l job-name=job-name -o wide

# 5. 查看 Pod 事件和终止原因
kubectl describe pod pod-name

# 6. 查看容器日志,包括上一次容器实例
kubectl logs pod-name -c worker
kubectl logs pod-name -c worker --previous

常见故障应区分:

现象 可能层次
没有 Job 被创建 CronJob 被暂停、schedule 或时区无效、错过截止时间、并发策略、控制器问题
Job 有但没有 Pod 配额、Admission、控制器、对象模板或 API 错误
Pod 一直 Pending 资源不足、节点选择器、污点、亲和性、优先级或 PVC
Pod 很快 Failed 应用退出码、参数、权限、Secret、网络或外部依赖
Job 有成功 Pod 但仍未完成 completions 未达到,或 Indexed Job 仍有未完成索引
任务重复写入 重试、驱逐、控制器确认延迟或 CronJob 并发重叠,业务缺少幂等

检查 CronJob 是否被暂停:

kubectl get cronjob shard-report \
  -o jsonpath='{.spec.suspend}{"\n"}'

spec.suspend: true 会阻止新 Job 创建,但不会删除或暂停已经运行的 Job。恢复时,之前错过的调度是否补建仍受截止时间和控制器规则影响,不能把暂停当作可靠的暂停队列。


十五、设计时应先回答的几个确定问题

一个 Job 或 CronJob 能否安全运行,不取决于 YAML 是否短小,而取决于以下问题是否有明确答案:

  1. 完成条件是什么?
    是一个批次成功,还是所有分片都成功?

  2. 失败能否重试?
    网络超时可以重试,数据格式错误通常不应无限重试。

  3. 同一任务执行两次会怎样?
    是否有稳定幂等键、唯一约束或业务状态机?

  4. 任务之间能否重叠?
    如果不能,Forbid 是否会造成业务漏跑?如果会,是否需要独立的任务队列表?

  5. 错过调度如何处理?
    是允许跳过、只补最近一次,还是必须扫描所有缺失批次?

  6. 时间按哪个时区定义?
    调度时区、数据分区时区和业务结算时区是否一致?

  7. 失败对象保留多久?
    是否能在 TTL 和历史限制触发前把日志、指标和失败批次保存到外部系统?

  8. 节点驱逐和抢占后能否恢复?
    Job 可能重新创建 Pod,但应用必须能够从已提交的状态继续执行。

Job 提供的是面向完成状态的控制循环,CronJob 提供的是面向时间的 Job 创建规则。把二者组合起来时,最重要的边界是:调度、执行、重试、补跑、幂等和清理分别由不同机制负责,任何一个机制都不会自动替另一个机制完成业务保证。


系列导航与关联阅读

官方资料

本文依据 Kubernetes、CNCF 与相关项目官方文档重新梳理;正文和生产清单由 WR BLOG 编写。