CronJob 本身不跑你的任务:它创建一个 Job,Job 再创建 Pod。Pod 跑完之后,Job 和 Pod 都不会自动消失。Pod 是故意留着的,好让你事后还能读到它的日志;Job 留着,是为了让你还能看到这次运行的状态。

这份默认对一次运行是对的,对一万次运行就是错的。每一个已完成的 Job 都是 etcd 里的一个对象、每次列表响应里的一行,以及所有关心 Job 的控制器都会收到的一次 watch 事件。给这堆东西设边界的有两套彼此独立的机制:CronJob 控制器按数量裁的历史上限,和 TTL 控制器按时间裁的 TTL。

已完成的 Job 并不免费

已完成的 Job 会留住它的 Pod,而 Pod 只要还在,容器日志就还在。于是保留策略成了你和“命名空间堆满墓碑”之间唯一的东西。按小时调度跑三十天就是 720 次运行:720 个 Job 加 720 个 Pod,每个都有自己的名字、状态、owner reference,以及你每次 kubectl get jobs 输出里的一行。已完成的 Job 也不是完全静止的:它的 status conditions 一直留在 API 里,被每个 watcher 反复读取。单个对象的开销微不足道,但它是随时间线性增长的,所以它长期保持隐形,直到某天突然变成问题。

可观测性上的代价比资源代价来得更早。命名空间里一旦有几百个已完成的 Job,kubectl get jobs 一屏就放不下,真正的失败混在一堆成功里看不出来。API server 的内存压力确实存在,但它是渐进的;你排查问题的能力下降却是立刻发生的,也正是这一点逼着人去配清理策略。这笔账是悄悄复利的:按小时的任务一年下来堆满墓碑,而每分钟一次的任务一周就追平。

两套机制,两个控制器

CronJob 控制器按数量裁。successfulJobsHistoryLimit 默认是 3,failedJobsHistoryLimit 默认是 1,把任意一个设成 0 就表示这一类一个都不留。裁剪按类别分别进行,并且从最旧的开始,所以一个长期失败的 Job 只会留下最近一次失败。这两个上限 API 都不强制;不设策略本身也是一种策略,只不过是一种会持续泄漏的策略。

TTL 控制器按时间裁。ttlSecondsAfterFinished 写在 Job 上,控制器会在 Job 完成之后的那个秒数把它删掉,并级联删掉它的 Pod。设成 0 表示完成即刻可删;不设则 TTL 控制器永远不会碰它。这个特性从 v1.23 起稳定,而且它读的是 Job 而不是 CronJob——这一点下面会用到。两套机制之间没有任何协调:谁先触发谁生效,所以一个比历史窗口更短的 TTL 会悄无声息地盖掉你配的数量。

owner reference 决定谁来清理

由 CronJob 创建的 Job 带着指向该 CronJob 的 owner reference,所以删除 CronJob 会级联到它的 Job 以及这些 Job 的 Pod。级联删除正是删掉一个 CronJob 看起来就万事大吉的原因:Job 和 Pod 一起消失,历史上限根本没参与进来。但按数量裁剪这件事是 CronJob 控制器做的,而它必须知道 CronJob 存在,才知道上限是多少。因此 kubectl delete cronjob --cascade=orphan 会留下 owner reference 悬空的 Job,且没有任何东西再来裁它们;而 TTL 依然生效,因为它是逐个 Job 单独判定的。

对于你直接创建、而非经由 CronJob 创建的 Job,官方文档写明的默认删除策略是 orphanDependents:删掉 Job 可能把它的 Pod 留在原地。控制面最终会在这些 Pod 失败或完成后把它们回收,但文档明确警告残留的 Pod 可能拖慢甚至拖垮集群,并因此建议把 ttlSecondsAfterFinished 设上。清理策略是一道保留决策,不是洁癖偏好。

坏掉的样子

为了“保持命名空间干净”把两个历史上限都设成 0,会让每个 Job 在完成的那一刻被删掉,Pod 和它的日志随之消失。你真正需要看的那次失败就此不见,而且无法恢复。留几条成功记录,再留足够覆盖一个周末的失败记录;读不到的证据不叫证据,被删掉的日志也不叫日志。

TTL 只从“完成”那一刻起算,所以一个永远跑不完的 Job 永远不会过期。卡在 ImagePullBackOff 的 Pod 会让它的 Job 一直停在 active 状态:既不是成功,也不是失败,于是两类历史上限都不统计它。给 job template 加上 activeDeadlineSeconds,让卡死的运行最终失败,再交给 TTL 去回收。

快速验证

把配置和实际堆积量对照着看:

kubectl get cronjob hourly-rollup -o jsonpath='{.spec.successfulJobsHistoryLimit} {.spec.failedJobsHistoryLimit} {.spec.jobTemplate.spec.ttlSecondsAfterFinished}{"\n"}'
kubectl get jobs --sort-by=.metadata.creationTimestamp --no-headers | wc -l
kubectl get pods --field-selector=status.phase=Succeeded --no-headers | wc -l
kubectl get jobs -o jsonpath='{range .items[*]}{.metadata.name} ttl={.spec.ttlSecondsAfterFinished} owner={.metadata.ownerReferences[0].name}{"\n"}{end}'

第一行两个值都为空,说明没有任何东西在限制保留量。Job 数量稳定在两个上限之和附近,说明 CronJob 控制器正在裁剪;数量随时间持续上涨,说明它没有。最后一条命令里,ttl 为空且 owner 也为空的 Job 就是孤儿,两个控制器都不会再去删它。

来源

  • Kubernetes Job,“Clean up finished jobs automatically” 与已完成 Job 的 TTL 机制,已核验 2026-09-16。
  • Kubernetes CronJob,默认历史上限与 job template,已核验 2026-09-16。
  • Kubernetes 垃圾回收,owner reference 与级联删除,已核验 2026-09-16。