
一个 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`）。

<!--more-->

## 先区分 Job、Pod 和完成项

Job 描述要完成多少工作，Pod 是执行工作的一次尝试。一个非索引 Job 通常只有一个完成项；`completions` 可以让同一个 Job 需要多个成功完成项，`parallelism` 限制同时运行的 Pod 数量。

Indexed Job 给每个完成项一个稳定索引，索引既不是 Pod 名也不是节点名。控制器创建 Pod 时把索引写进 `batch.kubernetes.io/job-completion-index` 注解，同时注入 `JOB_COMPLETION_INDEX` 环境变量；失败的索引可以重试，控制器要分别记录哪个索引已成功、哪个还在等待。

```mermaid
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：

```text
indexed-0-7mtrd
indexed-1-28l46
indexed-2-4jbhp
indexed-3-ph7pc
```

从 `batch.kubernetes.io/job-completion-index` 读取到的索引是 0、1、2、3。`parallelism: 2` 只限制同时执行的数量，不改变最终需要完成的索引数量。

成功的 Job 不会从“有 Pod 在跑”直接跳到 `Complete`。v1.35.1 的 [`job_controller.go:938-1010`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/job/job_controller.go#L938-L1010) 把收敛过程拆成固定顺序：先看上一轮留下的 `SuccessCriteriaMet` 或 `FailureTarget`，再评估失败场景，再评估成功场景，最后才把 `FailureTarget` 转成 `Failed`、把 `SuccessCriteriaMet` 转成 `Complete`。中间会检查 Pod 的 finalizer 是否都已清空、缓存里的 Pod 是否都已观察到；任一条件不满足就把 `finishedCondition` 置回 nil，下一次再算。

先写哪个条件由 `delayTerminalCondition()` 决定（[`job_controller.go:1099-1115`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/job/job_controller.go#L1099-L1115)）。只要 `JobManagedBy` 或 `JobPodReplacementPolicy` 任一开启，就先写 `SuccessCriteriaMet`，再由后续同步转成 `Complete`；两个都关才直接写 `Complete`。这两个功能门在 v1.35 都已 GA 并 `LockToDefault`，所以在当前版本上 `SuccessCriteriaMet` 是默认会出现的中间态。

实验记录的 `indexed` Job 因此同时带着两个条件：

```text
SuccessCriteriaMet=True  CompletionsReached  Reached expected number of succeeded pods
Complete=True            CompletionsReached  Reached expected number of succeeded pods
```

排查“Job 明明跑完了却还显示未完成”时，这个中间态就是答案所在：工作已经够了，但清理还没结束。只看 `Complete` 会漏掉这一段。

这四个 Pod 都运行了同一个镜像和命令，但控制器给每个 Pod 的索引不同。应用可以用索引切分数据分片、队列消息或文件范围。索引必须由应用正确消费；Kubernetes 不会自动保证同一索引的旧执行和新执行没有并发副作用，应用仍需实现幂等或外部锁。

```yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: indexed
spec:
  completions: 4
  parallelism: 2
  completionMode: Indexed
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: task
          image: alpine:3.20
          command:
            - sh
            - -c
            - |
              # JOB_COMPLETION_INDEX 由 Job 控制器注入；每个索引应处理自己的那一片数据。
              echo "completion=${JOB_COMPLETION_INDEX}"
```

Job 控制器还会根据模板创建 Pod selector，使用 controller UID 关联对象。不要手动修改这些 selector；把 Pod 当作独立副本并改标签，可能让 Job 失去对执行结果的追踪。控制器怎样用队列和重试把实际 Pod 数收敛到 `completions`，和普通控制器的机制一致，见 [Controller 调谐文章](/posts/kubernetes-controller-reconcile/)。

## 失败策略先于普通 backoff

普通失败如果没有特殊规则，会按 Job 的 `backoffLimit` 重试。实验中的 `backoff-limit` Job 让容器固定退出 1，设置 `backoffLimit: 1`，最终创建了 2 个 Pod，Job 条件为：

```text
type: Failed
reason: BackoffLimitExceeded
message: Job has reached the specified backoff limit
```

第一个 Pod 失败后还有一次重试机会，第二个 Pod 再失败就没有更多重试。这个数量关系比“最多重试一次”更准确：`backoffLimit` 约束的是失败 Pod 数量，不是成功 Pod 数量。

另一个 `podFailurePolicy` Job 让容器退出 42：

```yaml
podFailurePolicy:
  rules:
    # 只匹配容器 task 以退出码 42 结束的失败。
    - action: FailJob
      onExitCodes:
        containerName: task
        operator: In
        values: [42]
```

实验最终只创建了 1 个 Pod，Job 条件消息为：

```text
Container task ... failed with exit code 42 matching FailJob rule at index 0
```

这个顺序由代码固定。v1.35.1 的 [`job_controller.go`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/job/job_controller.go#L956-L967) 在计算 `exceedsBackoffLimit` 之后，先看 `getFailJobMessage` 是否命中 `FailJob` 规则，命中就写入 `JobFailureTarget`、原因 `PodFailurePolicy`；只有这里没有结论时，才进入 `BackoffLimitExceeded` 和 `ActiveDeadlineSeconds` 的判断。所以退出码 42 的失败不会先被当成普通失败累积。

`FailJob` 在失败匹配后直接结束 Job，不再消耗普通的退避执行机会。`Ignore` 不计入 `backoffLimit`；`FailIndex` 只让指定索引失败，需要 `JobBackoffLimitPerIndex` 功能门才生效。消息模板、是否计入退避这两点见同版本的 [`pod_failure_policy.go`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/job/pod_failure_policy.go#L26-L88)。

策略要按失败类型选择，不要把所有失败都设成立即终止。瞬时的容器退出、镜像损坏和业务输入错误应采用不同规则；分类不清楚时，先记录容器退出码、Pod conditions 和 Job conditions，再决定是否添加策略。

## CronJob 在重叠调度下会不会多建 Job

CronJob 在每次调度点创建 Job，但创建动作受 `startingDeadlineSeconds`、并发策略和历史保留影响。实验中 `concurrencyPolicy: Forbid`，Job 容器 sleep 75 秒，调度周期为每分钟。等待跨过一个调度点后，仍然只有一个 Job：

```text
job_names: ["forbid-overlap-29883582"]
```

这里跳过的那次创建，原因是前一个 Job 尚未结束；cron 表达式本身没有丢。`Allow` 会允许同时存在，`Replace` 会先删掉仍在运行的旧 Job 再建新的；选哪种要结合任务是否可重入、能否被取消和外部系统是否支持并发。

`Forbid` 也不是绝对的互斥保证。那段代码的注释承认了一个窗口：控制器依据 `Status.Active` 判断，如果状态更新还没被观察到，理论上可能出现两份并发执行，注释的描述是“只要调用间隔足够远，通常不会发生”。要真正的单实例，需要任务自己加锁，或者接受 `Forbid` 只在控制面视角成立。

```mermaid
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`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/cronjob/cronjob_controllerv2.go#L573-L585) 只在 `Status.Active` 非空时跳过创建。同一个文件里的 `cleanupFinishedJobs`（[L682-L717](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/cronjob/cronjob_controllerv2.go#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`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/cronjob/utils.go#L167-L175)、[L209-L219](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/cronjob/utils.go#L209-L219)）。阈值取 100 的理由写在注释里：一小时一次的任务停摆一个周末就超过 80 次，而时钟错误可能造出几十年量级的错过次数，逐个枚举会吃掉控制器资源。

`startingDeadlineSeconds` 决定同样的错过是否还算数。它不为 nil 时，`scheduledTime + startingDeadlineSeconds` 早于当前时间就记一条 `MissSchedule` 事件并跳过（[`cronjob_controllerv2.go:547-563`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/cronjob/cronjob_controllerv2.go#L547-L563)）。所以停摆恢复后补不补、补几次，由这个字段和错过次数共同决定，不是“恢复就全补”或“恢复就全丢”。本次实验没覆盖这条路径，它来自源码阅读。

## 用状态和对象数量定位问题

排查 Job/CronJob 时，按以下顺序检查：

1. 看 Job 的 `status.conditions`，区分 `Complete`、`FailureTarget` 和 `Failed`。
2. 看实际 Pod 数和 completion index，不要用 Pod 名猜测完成项。
3. 读失败 Pod 的退出码，再对照 `podFailurePolicy` 是否命中。
4. 查 `backoffLimit` 和已失败 Pod 数，判断是否还有普通重试空间。
5. 查 CronJob 的并发策略、调度时区和历史限制，再确认是否真的到了调度点。

本次实验验证了 Indexed Job 的索引、`podFailurePolicy` 的直接失败、`backoffLimit=1` 的两个 Pod 结果，以及 `Forbid` 在跨过一个调度点时没有创建重叠 Job。没有测试 CronJob 错过多个调度窗口、`Replace` 策略、TTL 清理和真实外部任务的事务语义。字段语义以官方文档为准：[Job](https://v1-35.docs.kubernetes.io/docs/concepts/workloads/controllers/job/) 和 [CronJob](https://v1-35.docs.kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/)。

实验目录和命令见 [k8sdev/article-job-cronjob](https://github.com/jimyag/k8sdev/tree/917db9bc548a27980afe404592b862cafea22db4/article-job-cronjob)。

