用Kubernetes跑服务跑久了,你会发现工作负载并不只有“常驻”这一种形态。很多场景需要的是跑一次就结束的任务:数据迁移、批量回刷、报表生成,或者每天固定时间跑一次的定时任务。Kubernetes专门为这两类需求设计了两个控制器:Job,负责运行一次性任务;CronJob,负责按时间表定时触发Job。这篇文章不会照搬官方文档,我会把这两个资源从原理、YAML写法、实操命令到常见坑一次性讲清楚,零基础也完全能跟着动手跑一遍。1. 为什么需要 Job 和 CronJob?1.1 工作负载的三种“生命周期”在Kubernetes里,不同的控制器对应不同的任务脾气。Deployment、StatefulSet这种是“长期服役型”,它们的目标是保持Pod永远在跑,就算容器挂了也要立刻拉起来,用户随时都能访问到服务。而Job和CronJob则属于“跑完就走型”和“到点就干型”。用一个超市的比喻来看:Deployment像是便利店的全职店员,开门就在,打烊才走;Job像是临时工,接到一个任务(例如盘点库存),干完活就下班,不会一直耗在店里;CronJob像是店长设置好的闹钟,每到固定时间就自动喊一个临时工来干活,比如每天凌晨三点补货、每周一早上清算账目。这个“生命周期”差异是最核心的认知。很多刚接触Kubernetes的人会把一次性任务也塞进Deployment,然后部署一个永远空转的容器来“占坑”,靠外部脚本去触发里面的业务逻辑。这样做不是不行,但非常别扭:空转容器浪费资源,任务结束了系统也不知道,日志和状态都得自己想办法收集,更不用说并发控制、失败重试这些能力全部要自己写。1.2 什么样的任务适合交给 JobJob天生适合那些“进程退出即代表任务完成”的工作。常见的例子包括:数据库迁移、历史数据回刷、字段补录;批处理计算,比如按天汇总埋点数据;生成报表、导出数据文件;镜像构建、依赖打包等CI/CD场景的收尾任务;一次性初始化,比如创建数据库表结构、预置数据字典。这类任务有一个共同特征:业务代码执行完,容器进程会正常退出,而且退出码是0。Job控制器会盯着这个结果,把任务的完成情况记录在状态里。如果一个任务跑完容器却不会自动退出,那它就不适合直接交给Job来处理,除非你在业务代码里显式增加了退出逻辑。同样的,CronJob则是“定时触发”的Job,只要把任务包进Job模板里,剩下的时间调度就交给它了。2. Job 的核心模型:这些参数决定了任务怎么跑2.1 先写一个最小的 Job看一段最基础的Job定义:apiVersion: batch/v1 kind: Job metadata: name: first-job spec: template: spec: containers: - name: task image: busybox:1.36 command: ["/bin/sh", "-c", "echo job is done && sleep 3"] restartPolicy: Never逐行解释一下。apiVersion用的是batch/v1,这是Job和CronJob的API组;kind就是Job;spec.template里写的其实是Pod模板,结构和Deployment里的Pod模板一模一样。但注意,这里没有replicas字段,因为Job不直接声明“我要跑几个副本”,而是通过completions和parallelism来管理,这个下一节会说。还有一个必须注意的点:spec.template.spec.restartPolicy在Job里只能设成Never或者OnFailure,绝对不能设成Always。原因很简单:如果容器退出后总是被重新拉起来,那任务永远没有结束的一天,Job也就失去了“跑完即止”的意义。你如果写成Always,API Server会直接拒绝创建。创建这条Job只需要一条命令:kubectl apply -f first-job.yaml然后观察:kubectl get job kubectl get pods -l job-name=first-job你会发现Pod很快从ContainerCreating变成Running,又变成Completed。整个生命周期里,没有哪个ReplicaSet在背后“保活”,因为Job根本不关心保活,它只关心“有没有成功跑完”。2.2 completions 和 parallelism:跑几个、怎么跑这是Job最重要的两个参数,绝大多数业务场景都是在跟这两个字段较劲。先看默认情况:如果不写completions和parallelism,它们的默认值都是1,表示“只创建1个Pod,成功1次就结束”。这适合临时任务。改成串行执行多个任务:spec: completions: 3 parallelism: 1意思是“总共要成功完成3次,但同一时间只跑1个Pod”。第一个Pod成功退出后,控制器创建第二个,再成功退出,创建第三个,直到COMPLETIONS列变成3/3。这种模式适合有先后依赖关系的批处理,比如先删旧数据、再灌新数据、最后做校验。再看固定并行度:spec: completions: 10 parallelism: 3意思是“一共要成功10次,但允许同时最多跑3个Pod”。控制器会动态创建Pod:刚开始一次性创建3个,其中某个成功退出后,马上再创建一个补齐,始终维持有3个在跑,直到成功总数达到10。适合处理一批相互独立的任务,比如把100个文件分片上传。还有一种组合是parallelism大于completions,比如completions: 3、parallelism: 5,控制器会一口气创建5个Pod,多出来的2个可能处于等待状态,实际上意义不大。我建议保持parallelism小于等于completions,否则不仅浪费资源,还容易让人对“进度”产生误解。这里有一个很有用的设计:当配置了completions大于1时,Kubernetes会给每个Pod注入一个JOB_COMPLETION_INDEX环境变量,从0开始递增,标识这个Pod负责第几个完成位置。echo "我是第 ${JOB_COMPLETION_INDEX} 个分片"如果你的任务需要分片处理(比如10个分片的数据),这个变量可以直接帮你区分每个Pod应该处理哪一段数据,非常顺手。2.3 backoffLimit 和 activeDeadlineSeconds:失败之后怎么办任务不可能永远一帆风顺。镜像拉不下来、磁盘写满、代码抛出异常,Pod都会失败。Job对待失败的态度不是无限重试,而是受制于两个参数。backoffLimit:默认值是6,表示最多允许“失败的Pod个数”为6。注意这里不是“重试6次”。如果你设置parallelism: 5并且5个Pod同时全部失败,那一次就消耗掉5个额度,控制器只够再重试1个Pod。超过额度后,Job会被标记为失败,整体状态显示Failed。activeDeadlineSeconds:给整个Job设定一个运行时间上限。比如你预期任务30分钟能跑完,但害怕出现“僵尸进程卡住不退出”的情况,就设置activeDeadlineSeconds: 3600,一旦整个Job从开始到当前时间超过3600秒还没完成,控制器会直接终止所有关联Pod,并把Job标记为失败。这里还要强调一个很容易被忽略的编程要求:业务逻辑必须幂等。因为Job重试会重新创建Pod,完全可能把同一段逻辑执行两遍。比如一个“给用户充值”的任务,如果在执行中途失败,重试时又把相同请求发送一次,用户就会收到两份钱。解决思路有几种:数据库里加唯一索引,重复提交直接跳过;业务代码里先查再写,状态已经是目标值就说明之前做过;利用JOB_COMPLETION_INDEX做分片锁,同一个分片不会同时被两个Pod处理。2.4 任务完成后怎么自动清理Job完成后,对应的Pod不会自动消失,会以Completed状态留在集群里。这是为了让你有时间看日志。但如果任务频繁执行,一堆Completed Pod堆在那里也会占用资源。从Kubernetes 1.23开始,可以在Job的spec里加上:spec: ttlSecondsAfterFinished: 100意思是Job完成后100秒,控制器自动把这个Job以及它管理的Pod整个清掉。实际操作中我一般设成300到600秒,既留出观察日志的时间,又不会让历史任务堆积成灾。3. 亲手运行一个 Job:从创建到日志到清理3.1 准备一个练习环境学习Job不需要特别复杂的集群,Minikube、Kind这种单节点集群完全够用。只要有kubectl能连上集群就行。建议先建一个独立的namespace,避免练习的Pod和正式环境的资源混在一起:kubectl create ns job-demo后续所有命令都带着-n job-demo,或者先把上下文切过去:kubectl config set-context --current --namespace=job-demo3.2 创建带索引的批处理任务我们创建一个能体现completions和索引特性的Job。它的功能非常简单:跑3个Pod,每个Pod输出自己是第几个分片,然后睡10秒模拟业务处理。apiVersion: batch/v1 kind: Job metadata: name: batch-hello spec: completions: 3 parallelism: 1 template: spec: restartPolicy: Never containers: - name: echo image: busybox:1.36 command: - /bin/sh - -c - | echo "Task ${JOB_COMPLETION_INDEX} started" sleep 10 echo "Task ${JOB_COMPLETION_INDEX} done"这里我把parallelism设为1,目的是看到严格串行的效果:第一个Pod跑完,第二个才会创建。保存为batch-hello.yaml后执行:kubectl apply -f batch-hello.yaml3.3 用三个命令观察完整生命周期开一个终端窗口,实时查看Job状态:kubectl get job -w你会看到COMPLETIONS从0/3变成1/3、2/3、最后3/3。这就是控制器在“数数”:每多一个成功Pod,计数就加一。再看Pod列表:kubectl get pods -l job-name=batch-hello刚开始可能只有一个Pod,名字是batch-hello-xxxxx。等它Completed之后,才会出现第二个。串行效果一目了然。等Job显示3/3之后,查看某个Pod的日志:kubectl logs pod/batch-hello-xxxxx你应该能看到类似下面这样的输出:Task 0 started Task 0 done通过kubectl describe job batch-hello可以看到更详细的信息,包括事件的完整时间线:什么时候创建Pod、Pod什么时候成功退出、Job什么时候判定完成。排障时这个命令非常有用。3.4 清理:先看日志再删Job看完日志,任务就可以删掉了:kubectl delete job batch-hello删除Job会把它下面管理的Pod一起清掉。所以我的习惯是:先收集所有需要留存的日志,再去删Job。如果Job设置了ttlSecondsAfterFinished,甚至不需要手动删。实际操作中还有一个细节:如果某个Pod失败了,你想手动删除它,Job控制器会认为“这个位置缺人”,立刻创建一个新Pod顶上来。这是Job的自我修复逻辑,不是故障。4. 进阶操作:多个 Pod 并发处理任务队列4.1 固定任务数并行和工作队列模式上面例子是串行,但很多业务场景需要并行:一大批数据要按分片处理,10个Pod同时开跑,速度能快好几倍。Job支持两种主流的并行风格:固定任务数并行:预先知道总共要完成多少份工作,设置completions: N、parallelism: M;工作队列模式:任务总量不确定,Pod自己去一个外部队列领活,领完就退出,不设置completions。零基础阶段,先把固定任务数并行吃透就够了。工作队列模式往往需要依赖消息中间件,等真正到了生产环境再学也不迟。4.2 一个能落地的分片并行案例假设业务表里有10万条用户数据,需要为每条数据补算一个“会员等级”。如果把所有数据都放在一个Pod里串行处理,可能要跑两三个小时;拆成多个分片并行,时间能缩短到十几分钟。在Job里可以这样设计:completions: 20:把10万条数据拆成20个分片,每个分片5000条;parallelism: 4:同时最多4个Pod跑,防止数据库连接被压垮;每个Pod通过JOB_COMPLETION_INDEX知道自己负责第几个分片;业务SQL里用取模方式定位分片,例如:UPDATE user SET vip_level = 'gold' WHERE user_id % 20 = ${JOB_COMPLETION_INDEX} AND vip_level != 'gold';这个SQL本身就幂等,因为WHERE条件限制了只会处理自己分片内的数据,而且即使重复执行,vip_level已经是gold的数据也不会被再次更新。这里我想提醒一个容易踩的坑:并发度不是越大越好。如果数据库连接池上限是100,每个Pod要占20个连接,那parallelism最多只能设5,设成10大概率会把数据库连接池打爆。合理并行度可以按这个公式估算:合理并行度 = min(集群可用Pod配额, 数据库连接池上限 / 每个Pod连接数, 业务可容忍的峰值压力)保守起见,刚开始先设小一点,看监控曲线再逐步上调。生产环境千万不要一上来就parallelism: 50。4.3 索引失败重试的一个小细节固定任务数并行模式下,如果某个Pod失败,新创建的Pod会继承原来那个Pod的JOB_COMPLETION_INDEX,相当于“同一分片换个人继续干”。这种设计保证了分片不会丢失,也让进度统计变得可靠。遇到失败时,你可以通过Pod的标签筛选出失败记录:kubectl get pods -l job-name=xxx --field-selector=status.phase=Failed观察失败Pod的日志,多半能找到原因。排查完再删掉失败Pod,让控制器重新创建一个补齐分片。5. CronJob:定时任务的调度者5.1 CronJob 和 Job 的关系CronJob其实是一个“定时触发器包着Job模板”的复合体。每到设定的时间点,CronJob控制器会基于jobTemplate创建一个真实的Job,然后Job再去创建Pod。所以YAML结构上是典型的俄罗斯套娃:CronJob里套一个Job的spec,Job的spec里再套一个Pod模板。apiVersion: batch/v1 kind: CronJob metadata: name: cron-job spec: schedule: "0 2 * * *" jobTemplate: spec: template: spec: restartPolicy: OnFailure containers: - name: task image: busybox:1.36 command: ["/bin/sh", "-c", "echo timer works"]CronJob创建的Job,名字会自动带上时间戳,类似cron-job-1721234567,查日志时一眼就能认出是哪个周期的任务。5.2 Cron 表达式的5个字段Linux用户看到这个应该不陌生,CronJob的表达式和系统crontab基本一致,一共5个字段:分钟 小时 日期 月份 星期常用的几组写法:表达式含义0 2 * * *每天凌晨2点*/15 9 * * 1-5工作日每天9点起每15分钟一次0 0 1 * *每月1号零点30 20 * * 0每周日晚上8点半*/5 * * * *每5分钟一次(调试时好用)调试CronJob时,我习惯先用*/5 * * * *快速验证逻辑,确认没问题再改成正式的调度周期。等任务真正跑起来,再改成每天、每周这种低频周期。5.3 时区问题:最容易踩的坑CronJob的调度时间是按照kube-controller-manager所在节点的时区计算的,而大部分集群默认是UTC。在中国大陆,你写0 2 * * *,实际跑的是UTC凌晨2点,也就是北京时间早上10点。这个问题上线的团队基本都踩过。解决办法有三种:第一种,手动把cron表达式换算成UTC时间。北京时间凌晨2点,UTC是前一天傍晚6点:schedule: "0 18 * * *"第二种,如果集群版本较新,可以直接在CronJob里写timeZone字段:spec: schedule: "0 2 * * *" timeZone: "Asia/Shanghai"这个字段在较新的Kubernetes版本中支持,用之前先确认一下集群版本。好处是直观,缺点是如果集群版本太老,YAML可能无法通过校验。第三种,在容器内部设置TZ环境变量:env: - name: TZ value: "Asia/Shanghai"但这只能保证容器内日志时间显示正确,改变不了调度器的触发时间。经常有人在这里产生误解:明明容器里时间是北京时间了,为什么任务还是提前了8小时跑?答案就是时区问题出在调度层,不在容器层。5.4 并发策略、宽限期与历史保留CronJob有四个容易被忽略的参数,生产环境几乎必设。第一个是concurrencyPolicy,三个值:Allow:允许并发。上次任务没跑完,这次到点照常新起一个Job;Forbid:禁止并发。上次没跑完,这次直接跳过;Replace:上次没跑完,把上次的取消掉,开启这次的。默认是Allow。如果你的任务要求互斥(比如数据库锁表备份),一定要设成Forbid,否则两个任务同时操作同一批数据,后果只能自己扛。第二个是startingDeadlineSeconds,指错过调度后最多追补多长时间。比如集群短暂不可用,错过了凌晨2点的任务,如果设置了600,控制器会在2点过后的600秒内尝试补执行;如果超过了这个窗口,这次调度就算彻底错过,并且被记录为失败。不设置这个字段,一旦控制器长时间停机,可能会一次性把成百上千次错过的调度全部补执行,直接把下游系统压垮。第三个是历史记录保留数:spec: successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 3默认值分别是3和1。设太小不利于排查问题,设太大资源消耗也大。我通常都设3到5,够用且不占地。第四个是suspend,用来暂停调度。比如某天要停机维护,又不想删除CronJob定义,可以把suspend设成true,恢复时再改回false。注意,suspend只影响之后新的调度,不会清理历史Job。5.5 一个完整的日志清理 CronJob下面这个例子综合了上面所有要点:每天凌晨2点(北京时间)清理临时目录,同一时间段只允许一个任务,任务本身最多运行10分钟,完成后保留最近3次成功记录:apiVersion: batch/v1 kind: CronJob metadata: name: daily-tmp-clean spec: schedule: "0 18 * * *" timeZone: "Asia/Shanghai" concurrencyPolicy: Forbid startingDeadlineSeconds: 600 successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 3 jobTemplate: spec: backoffLimit: 2 activeDeadlineSeconds: 600 template: spec: restartPolicy: OnFailure containers: - name: cleaner image: busybox:1.36 command: - /bin/sh - -c - | echo "cleaning tmp files at $(date)" find /tmp -type f -mtime +7 -delete echo "clean done"这个例子可以原样复制到测试集群里跑,把timeZone字段去掉也能兼容老版本。6. 实战中我踩过的坑与排查技巧6.1 Job侧问题速查表现象可能原因排查顺序Pod一直Pending节点资源不足、镜像拉取受限看kubectl describe pod事件,确认是不是Insufficient cpu或ImagePullBackOffPod一直Running但不结束应用没有正常退出,进程在前台挂着进Pod看进程树,确认是否有子进程没回收;检查业务代码退出逻辑Job失败后疯狂重试backoffLimit设太大,或者代码每次启动就崩溃kubectl describe job看失败原因,先修代码再调参数多个Pod数据重复处理并行任务缺少分片约束或幂等检查JOB_COMPLETION_INDEX使用情况,确认SQL/脚本按取模分片Job完成但Pod还在这是正常行为,用来保留日志设置ttlSecondsAfterFinished自动清理,或者手动删除Job节点重启后Pod被驱逐节点压力大,Pod里业务长时间占用太多内存看Evicted事件,给容器加resources.limits,必要时用activeDeadlineSeconds限制总时长6.2 CronJob侧问题排查清单kubectl get cronjob显示ACTIVE,但对应Job没出来:检查suspend是不是true,或者concurrencyPolicy: Forbid且上一轮任务还没结束。任务比预期早了或晚了8小时:八成是UTC时区问题,按上一节说的方法换算,或者用timeZone。某个任务被跳过但找不到失败记录:看看是不是因为上一轮运行超时且并发策略是Forbid,控制器直接丢弃了本次触发。历史Job堆积如山:successfulJobsHistoryLimit没设置,或者设置值太大,老Job没人清理。集群停机后一恢复,瞬间创建大量Job:典型表现是没有startingDeadlineSeconds,积压的错过的调度被一次性触发。生产环境务必要设这个字段。6.3 有用的排查命令看到任务状态不对劲,我一般按这个顺序排查:# 1. 看整体状态 kubectl get jobs -n 命名空间 kubectl get pods -n 命名空间 | grep job名 # 2. 看事件 kubectl describe job 任务名 -n 命名空间 # 3. 看具体Pod日志 kubectl logs -l job-name=任务名 -n 命名空间 --tail=200 # 4. 如果是CronJob,先看它自己状态 kubectl describe cronjob 任务名 -n 命名空间 # 5. 查看集群级别事件,很多问题能直接看到原因 kubectl get events --sort-by=.metadata.creationTimestamp我个人还有个习惯:给每个Job和CronJob都加上明显的标签,比如team=data-backfill、purpose=monthly-report。等任务堆多了以后,用kubectl get job -l purpose=monthly-report筛选起来非常舒服。6.4 走完一次任务后,我自己会坚持的两个习惯第一,任何Job任务,尤其是CronJob里包着的任务,我一定会保证业务代码具备幂等性。这不是洁癖,而是Job重试、Pod重建、并发策略这些机制决定了“跑两遍”在Kubernetes里是常态,如果不提前防住,故障时只会火上浇油。第二,凡是需要留存结果或日志的任务,我不会只依赖Pod的stdout。Pod一旦被清理,日志就没了。更稳妥的方案是:业务把关键结果写入对象存储或数据库,Pod层面的stdout只作为排查辅助。这两个习惯帮我撑过了好几个凌晨的告警走廊。Job和CronJob本身不复杂,复杂的是怎么让任务在“可能被重复执行、可能被跳过、可能跑一半断了”的现实里依然不出错。把这些边界想清楚,比背一百个YAML字段都管用。
用Kubernetes跑服务跑久了,你会发现工作负载并不只有“常驻”这一种形态。很多场景需要的是跑一次就结束的任务:数据迁移、批量回刷、报表生成,或者每天固定时间跑一次的定时任务。Kubernetes专门为这两类需求设计了两个控制器:Job,负责运行一次性任务;CronJob,负责按时间表定时触发Job。这篇文章不会照搬官方文档,我会把这两个资源从原理、YAML写法、实操命令到常见坑一次性讲清楚,零基础也完全能跟着动手跑一遍。1. 为什么需要 Job 和 CronJob?1.1 工作负载的三种“生命周期”在Kubernetes里,不同的控制器对应不同的任务脾气。Deployment、StatefulSet这种是“长期服役型”,它们的目标是保持Pod永远在跑,就算容器挂了也要立刻拉起来,用户随时都能访问到服务。而Job和CronJob则属于“跑完就走型”和“到点就干型”。用一个超市的比喻来看:Deployment像是便利店的全职店员,开门就在,打烊才走;Job像是临时工,接到一个任务(例如盘点库存),干完活就下班,不会一直耗在店里;CronJob像是店长设置好的闹钟,每到固定时间就自动喊一个临时工来干活,比如每天凌晨三点补货、每周一早上清算账目。这个“生命周期”差异是最核心的认知。很多刚接触Kubernetes的人会把一次性任务也塞进Deployment,然后部署一个永远空转的容器来“占坑”,靠外部脚本去触发里面的业务逻辑。这样做不是不行,但非常别扭:空转容器浪费资源,任务结束了系统也不知道,日志和状态都得自己想办法收集,更不用说并发控制、失败重试这些能力全部要自己写。1.2 什么样的任务适合交给 JobJob天生适合那些“进程退出即代表任务完成”的工作。常见的例子包括:数据库迁移、历史数据回刷、字段补录;批处理计算,比如按天汇总埋点数据;生成报表、导出数据文件;镜像构建、依赖打包等CI/CD场景的收尾任务;一次性初始化,比如创建数据库表结构、预置数据字典。这类任务有一个共同特征:业务代码执行完,容器进程会正常退出,而且退出码是0。Job控制器会盯着这个结果,把任务的完成情况记录在状态里。如果一个任务跑完容器却不会自动退出,那它就不适合直接交给Job来处理,除非你在业务代码里显式增加了退出逻辑。同样的,CronJob则是“定时触发”的Job,只要把任务包进Job模板里,剩下的时间调度就交给它了。2. Job 的核心模型:这些参数决定了任务怎么跑2.1 先写一个最小的 Job看一段最基础的Job定义:apiVersion: batch/v1 kind: Job metadata: name: first-job spec: template: spec: containers: - name: task image: busybox:1.36 command: ["/bin/sh", "-c", "echo job is done && sleep 3"] restartPolicy: Never逐行解释一下。apiVersion用的是batch/v1,这是Job和CronJob的API组;kind就是Job;spec.template里写的其实是Pod模板,结构和Deployment里的Pod模板一模一样。但注意,这里没有replicas字段,因为Job不直接声明“我要跑几个副本”,而是通过completions和parallelism来管理,这个下一节会说。还有一个必须注意的点:spec.template.spec.restartPolicy在Job里只能设成Never或者OnFailure,绝对不能设成Always。原因很简单:如果容器退出后总是被重新拉起来,那任务永远没有结束的一天,Job也就失去了“跑完即止”的意义。你如果写成Always,API Server会直接拒绝创建。创建这条Job只需要一条命令:kubectl apply -f first-job.yaml然后观察:kubectl get job kubectl get pods -l job-name=first-job你会发现Pod很快从ContainerCreating变成Running,又变成Completed。整个生命周期里,没有哪个ReplicaSet在背后“保活”,因为Job根本不关心保活,它只关心“有没有成功跑完”。2.2 completions 和 parallelism:跑几个、怎么跑这是Job最重要的两个参数,绝大多数业务场景都是在跟这两个字段较劲。先看默认情况:如果不写completions和parallelism,它们的默认值都是1,表示“只创建1个Pod,成功1次就结束”。这适合临时任务。改成串行执行多个任务:spec: completions: 3 parallelism: 1意思是“总共要成功完成3次,但同一时间只跑1个Pod”。第一个Pod成功退出后,控制器创建第二个,再成功退出,创建第三个,直到COMPLETIONS列变成3/3。这种模式适合有先后依赖关系的批处理,比如先删旧数据、再灌新数据、最后做校验。再看固定并行度:spec: completions: 10 parallelism: 3意思是“一共要成功10次,但允许同时最多跑3个Pod”。控制器会动态创建Pod:刚开始一次性创建3个,其中某个成功退出后,马上再创建一个补齐,始终维持有3个在跑,直到成功总数达到10。适合处理一批相互独立的任务,比如把100个文件分片上传。还有一种组合是parallelism大于completions,比如completions: 3、parallelism: 5,控制器会一口气创建5个Pod,多出来的2个可能处于等待状态,实际上意义不大。我建议保持parallelism小于等于completions,否则不仅浪费资源,还容易让人对“进度”产生误解。这里有一个很有用的设计:当配置了completions大于1时,Kubernetes会给每个Pod注入一个JOB_COMPLETION_INDEX环境变量,从0开始递增,标识这个Pod负责第几个完成位置。echo "我是第 ${JOB_COMPLETION_INDEX} 个分片"如果你的任务需要分片处理(比如10个分片的数据),这个变量可以直接帮你区分每个Pod应该处理哪一段数据,非常顺手。2.3 backoffLimit 和 activeDeadlineSeconds:失败之后怎么办任务不可能永远一帆风顺。镜像拉不下来、磁盘写满、代码抛出异常,Pod都会失败。Job对待失败的态度不是无限重试,而是受制于两个参数。backoffLimit:默认值是6,表示最多允许“失败的Pod个数”为6。注意这里不是“重试6次”。如果你设置parallelism: 5并且5个Pod同时全部失败,那一次就消耗掉5个额度,控制器只够再重试1个Pod。超过额度后,Job会被标记为失败,整体状态显示Failed。activeDeadlineSeconds:给整个Job设定一个运行时间上限。比如你预期任务30分钟能跑完,但害怕出现“僵尸进程卡住不退出”的情况,就设置activeDeadlineSeconds: 3600,一旦整个Job从开始到当前时间超过3600秒还没完成,控制器会直接终止所有关联Pod,并把Job标记为失败。这里还要强调一个很容易被忽略的编程要求:业务逻辑必须幂等。因为Job重试会重新创建Pod,完全可能把同一段逻辑执行两遍。比如一个“给用户充值”的任务,如果在执行中途失败,重试时又把相同请求发送一次,用户就会收到两份钱。解决思路有几种:数据库里加唯一索引,重复提交直接跳过;业务代码里先查再写,状态已经是目标值就说明之前做过;利用JOB_COMPLETION_INDEX做分片锁,同一个分片不会同时被两个Pod处理。2.4 任务完成后怎么自动清理Job完成后,对应的Pod不会自动消失,会以Completed状态留在集群里。这是为了让你有时间看日志。但如果任务频繁执行,一堆Completed Pod堆在那里也会占用资源。从Kubernetes 1.23开始,可以在Job的spec里加上:spec: ttlSecondsAfterFinished: 100意思是Job完成后100秒,控制器自动把这个Job以及它管理的Pod整个清掉。实际操作中我一般设成300到600秒,既留出观察日志的时间,又不会让历史任务堆积成灾。3. 亲手运行一个 Job:从创建到日志到清理3.1 准备一个练习环境学习Job不需要特别复杂的集群,Minikube、Kind这种单节点集群完全够用。只要有kubectl能连上集群就行。建议先建一个独立的namespace,避免练习的Pod和正式环境的资源混在一起:kubectl create ns job-demo后续所有命令都带着-n job-demo,或者先把上下文切过去:kubectl config set-context --current --namespace=job-demo3.2 创建带索引的批处理任务我们创建一个能体现completions和索引特性的Job。它的功能非常简单:跑3个Pod,每个Pod输出自己是第几个分片,然后睡10秒模拟业务处理。apiVersion: batch/v1 kind: Job metadata: name: batch-hello spec: completions: 3 parallelism: 1 template: spec: restartPolicy: Never containers: - name: echo image: busybox:1.36 command: - /bin/sh - -c - | echo "Task ${JOB_COMPLETION_INDEX} started" sleep 10 echo "Task ${JOB_COMPLETION_INDEX} done"这里我把parallelism设为1,目的是看到严格串行的效果:第一个Pod跑完,第二个才会创建。保存为batch-hello.yaml后执行:kubectl apply -f batch-hello.yaml3.3 用三个命令观察完整生命周期开一个终端窗口,实时查看Job状态:kubectl get job -w你会看到COMPLETIONS从0/3变成1/3、2/3、最后3/3。这就是控制器在“数数”:每多一个成功Pod,计数就加一。再看Pod列表:kubectl get pods -l job-name=batch-hello刚开始可能只有一个Pod,名字是batch-hello-xxxxx。等它Completed之后,才会出现第二个。串行效果一目了然。等Job显示3/3之后,查看某个Pod的日志:kubectl logs pod/batch-hello-xxxxx你应该能看到类似下面这样的输出:Task 0 started Task 0 done通过kubectl describe job batch-hello可以看到更详细的信息,包括事件的完整时间线:什么时候创建Pod、Pod什么时候成功退出、Job什么时候判定完成。排障时这个命令非常有用。3.4 清理:先看日志再删Job看完日志,任务就可以删掉了:kubectl delete job batch-hello删除Job会把它下面管理的Pod一起清掉。所以我的习惯是:先收集所有需要留存的日志,再去删Job。如果Job设置了ttlSecondsAfterFinished,甚至不需要手动删。实际操作中还有一个细节:如果某个Pod失败了,你想手动删除它,Job控制器会认为“这个位置缺人”,立刻创建一个新Pod顶上来。这是Job的自我修复逻辑,不是故障。4. 进阶操作:多个 Pod 并发处理任务队列4.1 固定任务数并行和工作队列模式上面例子是串行,但很多业务场景需要并行:一大批数据要按分片处理,10个Pod同时开跑,速度能快好几倍。Job支持两种主流的并行风格:固定任务数并行:预先知道总共要完成多少份工作,设置completions: N、parallelism: M;工作队列模式:任务总量不确定,Pod自己去一个外部队列领活,领完就退出,不设置completions。零基础阶段,先把固定任务数并行吃透就够了。工作队列模式往往需要依赖消息中间件,等真正到了生产环境再学也不迟。4.2 一个能落地的分片并行案例假设业务表里有10万条用户数据,需要为每条数据补算一个“会员等级”。如果把所有数据都放在一个Pod里串行处理,可能要跑两三个小时;拆成多个分片并行,时间能缩短到十几分钟。在Job里可以这样设计:completions: 20:把10万条数据拆成20个分片,每个分片5000条;parallelism: 4:同时最多4个Pod跑,防止数据库连接被压垮;每个Pod通过JOB_COMPLETION_INDEX知道自己负责第几个分片;业务SQL里用取模方式定位分片,例如:UPDATE user SET vip_level = 'gold' WHERE user_id % 20 = ${JOB_COMPLETION_INDEX} AND vip_level != 'gold';这个SQL本身就幂等,因为WHERE条件限制了只会处理自己分片内的数据,而且即使重复执行,vip_level已经是gold的数据也不会被再次更新。这里我想提醒一个容易踩的坑:并发度不是越大越好。如果数据库连接池上限是100,每个Pod要占20个连接,那parallelism最多只能设5,设成10大概率会把数据库连接池打爆。合理并行度可以按这个公式估算:合理并行度 = min(集群可用Pod配额, 数据库连接池上限 / 每个Pod连接数, 业务可容忍的峰值压力)保守起见,刚开始先设小一点,看监控曲线再逐步上调。生产环境千万不要一上来就parallelism: 50。4.3 索引失败重试的一个小细节固定任务数并行模式下,如果某个Pod失败,新创建的Pod会继承原来那个Pod的JOB_COMPLETION_INDEX,相当于“同一分片换个人继续干”。这种设计保证了分片不会丢失,也让进度统计变得可靠。遇到失败时,你可以通过Pod的标签筛选出失败记录:kubectl get pods -l job-name=xxx --field-selector=status.phase=Failed观察失败Pod的日志,多半能找到原因。排查完再删掉失败Pod,让控制器重新创建一个补齐分片。5. CronJob:定时任务的调度者5.1 CronJob 和 Job 的关系CronJob其实是一个“定时触发器包着Job模板”的复合体。每到设定的时间点,CronJob控制器会基于jobTemplate创建一个真实的Job,然后Job再去创建Pod。所以YAML结构上是典型的俄罗斯套娃:CronJob里套一个Job的spec,Job的spec里再套一个Pod模板。apiVersion: batch/v1 kind: CronJob metadata: name: cron-job spec: schedule: "0 2 * * *" jobTemplate: spec: template: spec: restartPolicy: OnFailure containers: - name: task image: busybox:1.36 command: ["/bin/sh", "-c", "echo timer works"]CronJob创建的Job,名字会自动带上时间戳,类似cron-job-1721234567,查日志时一眼就能认出是哪个周期的任务。5.2 Cron 表达式的5个字段Linux用户看到这个应该不陌生,CronJob的表达式和系统crontab基本一致,一共5个字段:分钟 小时 日期 月份 星期常用的几组写法:表达式含义0 2 * * *每天凌晨2点*/15 9 * * 1-5工作日每天9点起每15分钟一次0 0 1 * *每月1号零点30 20 * * 0每周日晚上8点半*/5 * * * *每5分钟一次(调试时好用)调试CronJob时,我习惯先用*/5 * * * *快速验证逻辑,确认没问题再改成正式的调度周期。等任务真正跑起来,再改成每天、每周这种低频周期。5.3 时区问题:最容易踩的坑CronJob的调度时间是按照kube-controller-manager所在节点的时区计算的,而大部分集群默认是UTC。在中国大陆,你写0 2 * * *,实际跑的是UTC凌晨2点,也就是北京时间早上10点。这个问题上线的团队基本都踩过。解决办法有三种:第一种,手动把cron表达式换算成UTC时间。北京时间凌晨2点,UTC是前一天傍晚6点:schedule: "0 18 * * *"第二种,如果集群版本较新,可以直接在CronJob里写timeZone字段:spec: schedule: "0 2 * * *" timeZone: "Asia/Shanghai"这个字段在较新的Kubernetes版本中支持,用之前先确认一下集群版本。好处是直观,缺点是如果集群版本太老,YAML可能无法通过校验。第三种,在容器内部设置TZ环境变量:env: - name: TZ value: "Asia/Shanghai"但这只能保证容器内日志时间显示正确,改变不了调度器的触发时间。经常有人在这里产生误解:明明容器里时间是北京时间了,为什么任务还是提前了8小时跑?答案就是时区问题出在调度层,不在容器层。5.4 并发策略、宽限期与历史保留CronJob有四个容易被忽略的参数,生产环境几乎必设。第一个是concurrencyPolicy,三个值:Allow:允许并发。上次任务没跑完,这次到点照常新起一个Job;Forbid:禁止并发。上次没跑完,这次直接跳过;Replace:上次没跑完,把上次的取消掉,开启这次的。默认是Allow。如果你的任务要求互斥(比如数据库锁表备份),一定要设成Forbid,否则两个任务同时操作同一批数据,后果只能自己扛。第二个是startingDeadlineSeconds,指错过调度后最多追补多长时间。比如集群短暂不可用,错过了凌晨2点的任务,如果设置了600,控制器会在2点过后的600秒内尝试补执行;如果超过了这个窗口,这次调度就算彻底错过,并且被记录为失败。不设置这个字段,一旦控制器长时间停机,可能会一次性把成百上千次错过的调度全部补执行,直接把下游系统压垮。第三个是历史记录保留数:spec: successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 3默认值分别是3和1。设太小不利于排查问题,设太大资源消耗也大。我通常都设3到5,够用且不占地。第四个是suspend,用来暂停调度。比如某天要停机维护,又不想删除CronJob定义,可以把suspend设成true,恢复时再改回false。注意,suspend只影响之后新的调度,不会清理历史Job。5.5 一个完整的日志清理 CronJob下面这个例子综合了上面所有要点:每天凌晨2点(北京时间)清理临时目录,同一时间段只允许一个任务,任务本身最多运行10分钟,完成后保留最近3次成功记录:apiVersion: batch/v1 kind: CronJob metadata: name: daily-tmp-clean spec: schedule: "0 18 * * *" timeZone: "Asia/Shanghai" concurrencyPolicy: Forbid startingDeadlineSeconds: 600 successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 3 jobTemplate: spec: backoffLimit: 2 activeDeadlineSeconds: 600 template: spec: restartPolicy: OnFailure containers: - name: cleaner image: busybox:1.36 command: - /bin/sh - -c - | echo "cleaning tmp files at $(date)" find /tmp -type f -mtime +7 -delete echo "clean done"这个例子可以原样复制到测试集群里跑,把timeZone字段去掉也能兼容老版本。6. 实战中我踩过的坑与排查技巧6.1 Job侧问题速查表现象可能原因排查顺序Pod一直Pending节点资源不足、镜像拉取受限看kubectl describe pod事件,确认是不是Insufficient cpu或ImagePullBackOffPod一直Running但不结束应用没有正常退出,进程在前台挂着进Pod看进程树,确认是否有子进程没回收;检查业务代码退出逻辑Job失败后疯狂重试backoffLimit设太大,或者代码每次启动就崩溃kubectl describe job看失败原因,先修代码再调参数多个Pod数据重复处理并行任务缺少分片约束或幂等检查JOB_COMPLETION_INDEX使用情况,确认SQL/脚本按取模分片Job完成但Pod还在这是正常行为,用来保留日志设置ttlSecondsAfterFinished自动清理,或者手动删除Job节点重启后Pod被驱逐节点压力大,Pod里业务长时间占用太多内存看Evicted事件,给容器加resources.limits,必要时用activeDeadlineSeconds限制总时长6.2 CronJob侧问题排查清单kubectl get cronjob显示ACTIVE,但对应Job没出来:检查suspend是不是true,或者concurrencyPolicy: Forbid且上一轮任务还没结束。任务比预期早了或晚了8小时:八成是UTC时区问题,按上一节说的方法换算,或者用timeZone。某个任务被跳过但找不到失败记录:看看是不是因为上一轮运行超时且并发策略是Forbid,控制器直接丢弃了本次触发。历史Job堆积如山:successfulJobsHistoryLimit没设置,或者设置值太大,老Job没人清理。集群停机后一恢复,瞬间创建大量Job:典型表现是没有startingDeadlineSeconds,积压的错过的调度被一次性触发。生产环境务必要设这个字段。6.3 有用的排查命令看到任务状态不对劲,我一般按这个顺序排查:# 1. 看整体状态 kubectl get jobs -n 命名空间 kubectl get pods -n 命名空间 | grep job名 # 2. 看事件 kubectl describe job 任务名 -n 命名空间 # 3. 看具体Pod日志 kubectl logs -l job-name=任务名 -n 命名空间 --tail=200 # 4. 如果是CronJob,先看它自己状态 kubectl describe cronjob 任务名 -n 命名空间 # 5. 查看集群级别事件,很多问题能直接看到原因 kubectl get events --sort-by=.metadata.creationTimestamp我个人还有个习惯:给每个Job和CronJob都加上明显的标签,比如team=data-backfill、purpose=monthly-report。等任务堆多了以后,用kubectl get job -l purpose=monthly-report筛选起来非常舒服。6.4 走完一次任务后,我自己会坚持的两个习惯第一,任何Job任务,尤其是CronJob里包着的任务,我一定会保证业务代码具备幂等性。这不是洁癖,而是Job重试、Pod重建、并发策略这些机制决定了“跑两遍”在Kubernetes里是常态,如果不提前防住,故障时只会火上浇油。第二,凡是需要留存结果或日志的任务,我不会只依赖Pod的stdout。Pod一旦被清理,日志就没了。更稳妥的方案是:业务把关键结果写入对象存储或数据库,Pod层面的stdout只作为排查辅助。这两个习惯帮我撑过了好几个凌晨的告警走廊。Job和CronJob本身不复杂,复杂的是怎么让任务在“可能被重复执行、可能被跳过、可能跑一半断了”的现实里依然不出错。把这些边界想清楚,比背一百个YAML字段都管用。