把一批要在集群里常驻的守护Agent,和一批要做完就退的批处理任务,交给同一个控制器去管,这本身就是个容易翻车的设计。我接手过得不少集群里,见过有人拿Deployment跑日志采集,结果每个节点上塞了一堆重复副本;也见过有人把一次性数据迁移任务写成了常驻服务,跑完也不退出,白白占着内存。这两个场景的正确解法,就是Kubernetes里的DaemonSet和Job控制器。这篇文章我把两类控制器的完整实战链路写清楚,包括YAML怎么设计、调度机制怎么理解、滚动更新和重试策略怎么配,以及我在实际运维中踩过的坑。无论你是刚接触Kubernetes没多久,还是在维护生产集群,都可以直接照着这套思路落地。
1. 场景先行:哪些负载天生归DaemonSet,哪些归Job
很多教程上来就贴YAML,但我觉得先搞清楚“为什么需要这两个控制器”更重要。控制器不是越多越复杂,而是每个控制器都对应一类最匹配的调度语义。搞懂了场景,YAML拿过来就能看懂;搞不懂场景,复制十份配置也会在出问题时无从下手。
1.1 DaemonSet的三类典型画像:日志、监控、网络
DaemonSet的核心语义是“确保特定节点组上的每个节点都运行一个Pod”。注意是每个节点,不是每个Pod副本若干份。这个语义让它天然适合三类任务:
- 日志采集类:每个节点的容器日志、系统日志都要被收集,没有理由在两个节点上各跑一个、第三个节点却空着。
- 监控指标类:节点级的监控指标,比如磁盘使用率、网络流量、内核日志,必须拿到节点本地的视图,跑在任意位置的Pod根本看不到这些数据。
- 网络组件类:像节点上的网络代理、DNS辅助组件这类基础设施,必须伴随节点存在,节点起来它就起来,节点消失它就没必要存在。
这三类任务的共同点是:Pod数量与节点数量强相关,Pod的生命周期跟随节点生命周期。这种“每个节点正好一份”的编排需求,Deployment做不到,StatefulSet也不合适,只有DaemonSet的调度模型是对的。
1.2 Job的两大形态:跑到成功就退出,定时触发执行
Job的核心语义和DaemonSet正好相反——它负责那些“有明确终点”的任务。任务执行成功,Pod退出,Job完成;任务失败,控制器按策略重试,直到达到上限或者任务成功。
实际使用中有两种形态最普遍:
- 一次性批处理:数据迁移、报表生成、批量导入导出、模型训练的单次任务。这种任务做完就完了,不能像服务那样一直常驻。
- 定时批处理:每天凌晨跑一次数据清洗、每小时同步一次缓存。这种场景你写的其实是CronJob,但CronJob的底层执行单元就是Job控制器。每次触发,CronJob都会创建一个新的Job,由Job去保证这一次执行成功。
值得强调的一点:Job不是用来“让任务一直运行”的。如果你希望Pod退出后立刻被重新拉起,那是Deployment的活;如果你希望任务跑完还能被系统记作“成功完成”,这才是Job的活。
1.3 与Deployment、StatefulSet的边界为什么容易搞混
我见过很多刚上手的朋友在这四个控制器之间反复横跳。这里给一个我平时判断负载归属的口诀:
- 这个进程是否需要持续提供服务、Pod退出就要重启?是,用Deployment。
- 是否需要稳定的网络标识和独立的存储卷身份?是,用StatefulSet。
- 是否要求每个节点上正好跑一份、随节点生灭?是,用DaemonSet。
- 是否跑完就退出、不需要一直活着?是,用Job或CronJob。
四类控制器的边界不是靠“感觉”,而是靠“生命周期”和“副本分布”两个维度划分的。面向生命周期判断,这个Pod是需要“无限存活”还是“有终点”;面向副本分布判断,这个Pod分布在“任意节点多个副本”还是“每个节点单副本”。两两组合后,答案其实很清晰。
2. DaemonSet实战:从YAML到滚动更新一次讲透
搞明白了场景,下面进入正题。我拿一个日志采集器的例子来完整走一遍DaemonSet从编写到更新的流程。这个例子非常典型,也最适合用来理解DaemonSet的调度特性。
2.1 理解调度模型:为什么它不该有replicas
第一次打开DaemonSet的YAML时,很多人会下意识找spec.replicas,结果发现根本没有这个字段。这不是遗漏,而是DaemonSet的设计根本不需要它。副本数量由“匹配到的节点数量”决定,而不是你手动指定的数字。
DaemonSet的调度流程是这样运作的:控制器 watch 节点列表,每当集群里新增一个匹配调度条件的节点,它就自动为这个节点创建一个Pod;节点消失,对应Pod也会被清理。这背后的调度器会为每个Pod打上controller-revision-hash,用来追踪当前的模板版本,方便滚动更新时区分新旧Pod。
所以写DaemonSet时,你操心的事情不是“跑几个副本”,而是“让哪些节点加入匹配范围”。范围越大,Pod越多;范围越小,Pod越少。理解了这一点,下面看YAML就顺了。
2.2 手写一份日志采集DaemonSet
以下是我在模拟项目中实际用过的日志采集器DaemonSet定义,去掉了和具体业务相关的字段,保留了最核心的骨架:
apiVersion: apps/v1 kind: DaemonSet metadata: name: demo-logger namespace: default labels: app: demo-logger spec: selector: matchLabels: app: demo-logger template: metadata: labels: app: demo-logger spec: # 注意:这里故意不加 nodeSelector,目的是让全节点接入 tolerations: - operator: Exists containers: - name: logger image: busybox:1.36 command: - /bin/sh - -c - | while true; do echo "$(date) demo log from $(hostname)" >> /var/log/demo/app.log sleep 10 done volumeMounts: - name: varlog mountPath: /var/log/demo resources: requests: cpu: 50m memory: 32Mi limits: cpu: 200m memory: 128Mi terminationGracePeriodSeconds: 30 volumes: - name: varlog hostPath: path: /var/log/demo type: DirectoryOrCreate逐段解释几个关键设计:
tolerations里我写了operator: Exists,意思是容忍所有污点。做全节点采集时这是必要的,因为集群里总会有几台节点带了NoSchedule或NoExecute污点,你不容忍,Pod就永远Pending。但在生产环境我不建议无脑容忍全部,应该只容忍你确定需要的污点,否则DaemonSet会覆盖到你不想要的节点。hostPath配合terminationGracePeriodSeconds:采集任务最怕Pod被删时数据没写完,所以给了30秒优雅退出时间,和hostPath这个“本地目录”语义是配套的。如果你用emptyDir,Pod重建数据就丢了。resources的requests和limits我特意拉低了,日志采集器虽然是基础设施,但也别把一个节点占满,留足资源给业务容器。
应用这份配置可以直接kubectl apply -f daemonset-demo.yaml,然后观察Pod生成情况:
kubectl get pods -l app=demo-logger -o wide你会看到集群里每个符合条件的节点上,恰好一个Pod,名字格式是demo-logger-xxxxx,后缀是随机字符串,而不是像StatefulSet那样有稳定序号。
2.3 更新策略、灰度与回滚
DaemonSet更新和Deployment有类似的地方,但更强调“别更新太猛”。默认策略是RollingUpdate,配合maxUnavailable控制同时杀掉多少个DaemonSet Pod。日志采集组件这类基础设施,我一般配置最多允许一个节点中断:
spec: updateStrategy: type: RollingUpdate rollingUpdate: maxUnavailable: 1这样每次滚动更新时,只有一个节点的采集器会被替换,其他节点还在正常采集。更新触发后用kubectl rollout status ds/demo-logger观察进度。
如果更新后发现新版采集器有问题,回滚也很简单:
kubectl rollout undo ds/demo-logger回滚会恢复到上一个controller-revision-hash对应的模板。这里有一个经验:对于关键基础设施类的DaemonSet,我推荐先用OnDelete更新策略配合节点标签做灰度。OnDelete意味着控制器不会主动删旧Pod,只有你手动删除某个节点的Pod后,它才会用新模板重建。这样你可以先手动删掉一台测试节点上的Pod,观察新版本采集器运行一两天,确认没问题再批量删其他节点。这种方式比maxUnavailable: 0还要稳,代价是完全手工,适合更新频率很低但又怕出事的组件。
3. Job实战:批处理任务的完整生命周期
DaemonSet解决了“节点守护进程”,Job解决的是“批处理任务”。这一节我按一个真实数据处理任务的路线,从定义到运行观测再到并行扩展,完整走一遍。
3.1 restartPolicy的选择:为什么Job里不能用Always
写Job时最容易踩的第一个坑就是restartPolicy。Deployment的Pod模板里restartPolicy必须写成Always,否则校验不过;但Job的Pod模板里restartPolicy只允许Never或OnFailure。
为什么?因为Job控制器判断“任务完成”的依据,就是Pod进入终止状态。如果Pod设置了Always,Pod退出后立刻被重启,永远不会有“终态”,Job就永远无法标记为完成,整个任务会无限循环。反过来,Never和OnFailure则允许Pod以Succeeded或Failed状态结束,Job才能据此判断进度。
这里我要补充一个容易混淆的点:OnFailure时,失败后的重试发生在“同一个Pod内”,也就是Kubelet会重新拉起这个Pod的容器继续跑;而Never时,失败后是Job控制器重新创建一个全新的Pod。这两种方式对任务本身的要求不同:
OnFailure适合那种进程崩溃但环境没破坏的情况,重试代价小。Never适合任务依赖外部状态、失败后需要全新上下文的场景,但要注意每次重试都可能重新拉镜像,耗时更长。
我的习惯是能用Never就不用OnFailure,因为生产环境里Pod失败的原因太复杂,同一个Pod里反复重试容易掩盖问题,还不如让控制器创建新Pod,每次都从干净状态开始。
3.2 一个数据处理Job的完整定义与运行观测
下面是我在模拟项目里跑数据清洗用的Job定义,核心逻辑我简化成了几行命令,但字段结构和真实任务一致:
apiVersion: batch/v1 kind: Job metadata: name: demo-data-clean spec: parallelism: 1 completions: 1 backoffLimit: 4 activeDeadlineSeconds: 600 ttlSecondsAfterFinished: 3600 template: spec: restartPolicy: Never containers: - name: worker image: busybox:1.36 command: - /bin/sh - -c - | echo "start cleaning..." sleep 30 echo "cleaning done"这里每个字段都有明确的工程含义:
parallelism:允许并行运行的Pod数量。设为1,任务严格串行。completions:期望成功完成的总次数。设为1,代表整个Job只需要成功一次。backoffLimit:允许失败重试的次数。我写的4意味着前4次失败会继续重试,第5次失败则整个Job标记为Failed。activeDeadlineSeconds:整个Job从开始到结束的硬性时间上限。600秒超时后,控制器会终止所有未完成的Pod,并把Job标记为失败。这个字段我觉得每个Job都该配,防的就是任务卡死却没人发现。ttlSecondsAfterFinished:Job完成后多少秒由控制器自动清理。这个放在第4节细说,但写在这里是同一份YAML里最容易一起配好的。
运行命令:
kubectl apply -f job-demo.yaml kubectl get jobs kubectl get pods -l job-name=demo-data-clean第一个kubectl get jobs会看到COMPLETIONS: 0/1,表示还没完成;等Pod运行结束后再执行,会看到1/1。查询Pod日志:
kubectl logs job/demo-data-clean注意,Job完成之后,它的Pod不会被自动删除,而是保留在集群里,直到你手动清理或TTL机制触发。这正是为了让你能查看完成任务时的日志和退出状态。如果不需要旧Pod日志,我会显式删除Job:
kubectl delete job demo-data-clean默认情况下删除Job会连带删除它管理的Pod。如果只是想删Job但保留Pod,可以加--cascade=orphan,但这种情况非常少见,我几乎不用。
3.3 并行模式、索引任务和CronJob的衔接
parallelism和completions的组合可以产生几种不同的并行模式。最常用的一个组合是:completions固定为N,parallelism设为M,其中M可以小于N。这时Job控制器会持续创建Pod,直到累计完成N次,但同一时间最多只有M个Pod在运行。
如果任务本身不需要预先知道总数——比如消费消息队列,处理完一条算一条,直到队列耗尽——这时可以把completions留空,只设置parallelism。Job控制器就不再关心“成功几次”,而是等所有并行Pod都结束。这种模式需要你在代码里自行做好任务领取和结束判断,外部只有“最后一个Pod退出后整个Job完成”的语义。
Kubernetes 1.22之后,Indexed模式的completionMode开始普及,它给每个Pod分配一个从0到N-1的索引,环境变量里带JOB_COMPLETION_INDEX。这个特性做分片任务特别方便:比如要处理10个数据分区,就让completions: 10配合completionMode: Indexed,每个Pod通过索引知道自己该处理哪一区,天然避免了“多个Pod抢同一个任务”的分布式锁问题。
在实际项目里,这些Job很少手工创建,更多是挂在CronJob下面。CronJob的定时触发会生成一个Job实例,每个实例再按上面的并行逻辑去执行。这里有个细节:CronJob的concurrencyPolicy字段如果不设置,默认是Allow,意味着上一次任务还没跑完,下一次定时触发又会新建一个Job,很容易导致任务叠加。批处理任务我基本都会设置成Forbid或Replace,宁可错过一次执行,也不让两个批处理任务同时跑。
4. 调度、资源与清理:这两类控制器最容易翻车的地方
很多基础用法看着简单,真正生产落地时翻车的往往是调度边界、资源配比和任务堆积这些问题。这一节集中讲三类高频问题。
4.1 污点容忍、nodeSelector与“全节点”预期
DaemonSet默认会尝试调度到所有Ready节点,但“所有节点”是个动态概念。给节点打上污点、设置亲和性、限制CPU,都会影响实际调度结果,而这些影响往往是隐性的。
我在一次日志采集接入中遇到过这样的情况:某几个节点的磁盘是本地高可用磁盘,专门留给数据库用,运维给它们打了dedicated=db:NoSchedule的污点。结果DaemonSet的采集器因为没有容忍度,在这几台节点上一直Pending,日志漏采了几天没人发现。这个问题的根因正是“DaemonSet看起来是全节点的,但实际上受调度条件约束”。
所以使用DaemonSet时,一定要先明确一个预期:你这个DaemonSet到底是不是真的要覆盖全节点?如果要覆盖全节点,就要加上对应的tolerations;如果只想覆盖特定节点组,就通过nodeSelector或节点亲和性把范围收敛清楚。我建议把这两个字段写在注释里,让后来人一眼就懂这个DaemonSet的覆盖意图。
另一个容易被忽略的点是:节点加入集群和DaemonSet扩Pod之间有延迟。节点状态变成Ready后,控制器需要重新计算节点列表并创建Pod。大集群里如果同时加了好几台节点,你会看到DaemonSet Pod批量新建,此时要注意资源是否足够,避免节点扩容瞬间把集群资源打个措手不及。
4.2 重试、超时和TTL:让Job不变成僵尸
Job任务如果管理不当,会在集群里留下大量历史Pod,占用etcd的存储空间和API Server的内存。最常见的场景是CronJob每天执行1次,每次产生1个Job,三个月就是90个Job和90个已被容器污染的旧Pod。如果集群里跑着几十个CronJob,历史对象数量会迅速堆到上万,这个时候kubectl get jobs都会变慢。
根治办法是给Job配上清理机制,优先级从我实际使用效果来看是这样:
ttlSecondsAfterFinished:写完Job YAML时顺手写上,最简单有效。完成或失败后到时间自动删除,不需要额外操作。- CronJob的
historyLimit:这个字段专门控制保留多少个历史Job和多少个历史CronJob。我的生产配置通常保留2个成功记录、1个失败记录,既能排查问题,又不会让历史对象堆积。 - 自定义清理脚本:如果TTL满足不了复杂的保留策略,再用脚本按标签或时间批量删除。
超时方面,activeDeadlineSeconds是Job级的硬性保护,但很多任务在容器内部做了自己的超时控制,外部又没配Job级超时。一旦内部逻辑出了死循环,Job就永远卡在Running。我的习惯是内外都设超时,内部超时用于优雅收尾,外部超时用于兜底,两层保险缺一不可。
4.3 资源配额、PDB与实例类型适配
资源分配这块,DaemonSet和Job的思路完全相反。
DaemonSet是基础设施,它和业务容器在同一节点上抢资源。如果requests设置得太高,会挤压正常业务容器;设置得太低,日志采集高峰期可能OOM。我的经验是:requests按峰值流量的底线的1.2倍估算,limits不超过requests的3到4倍,同时配合PriorityClass让DaemonSet的优先级略高于普通业务,低于核心数据库。这样节点资源紧张时,驱逐顺序会比较合理。
Job则不太一样。批处理任务经常是重负载,跑起来CPU暴涨,结束后占用归零。如果Pod的requests和limits都写得很高,调度器会认为集群资源不够,任务排队半天;如果只写requests不写limits,任务可能无限制吃满节点内存。我建议对Job设置比较合理的requests和偏高的limits,并配合namespace级别的LimitRange兜底。这样一来,Job任务有明确的资源保障,又不会因为某个失控任务拖垮整个节点。
对于结合了抢占和缩容的生产环境,还有一个细节值得提:如果核心DaemonSet组件因为节点缩容被驱逐,集群网络或观测能力可能短暂中断,这种情况应该给关键DaemonSet配置PodDisruptionBudget,确保主动驱逐时最多只有一个Pod不可用。Job因为天然可重试,反而非常适合跑在可被抢占的节点上,失败了由backoffLimit拉起新Pod,成本低,对集群影响也小。
5. 实战排错:我踩过的坑和完整排查思路
最后分享几个我在实际运维中反复遇到的排错场景。这些场景有一个共同特点:表面现象在Pod层,根因却在控制器配置层,不联动分析很难定位。
5.1 DaemonSet Pod一直Pending,先查这三件事
遇到DaemonSet的Pod长时间Pending,我从来不去看Pod的日志,而是先看kubectl describe pod <pod-name>里的Events记录。绝大多数问题都集中在三个环节:
- 调度找不到节点。Events会显示类似“0/8 nodes are available”,后面紧跟原因。此时优先看是不是
nodeSelector或亲和性写得太严格,导致没有节点匹配。 - 污点未容忍。Events里会具体列出“untolerated taint”,这个最直观,把对应污点加到
tolerations即可。 - 资源不足。Events会显示“Insufficient cpu”或“Insufficient memory”。这种情况要结合节点实际可分配资源判断,可能是requests设得比节点容量还高,也可能节点混部导致可分配资源被占满。
还有一类不那么直观的Pending:节点上有NoExecute污点,导致Pod被反复驱逐,看起来像无限循环重启。这种要从kubectl get events --sort-by=.lastTimestamp按时间线看,光看Pod状态很难发现规律。
5.2 Job一直不结束,问题可能不在Job本身
有一次我排查一个跑了两个小时的Job,正常任务十几分钟就该结束。第一反应是看Pod状态,结果是Running,容器日志也没有明显报错。继续查发现两个Pod分别卡在等待一个外部服务的连接上,那个外部服务因为上游故障已经完全不可用。这个问题的核心不是Job配置,而是任务逻辑依赖了不健康的外部系统,但Job本身没有设置超时,于是整个任务僵住了。
从那以后我总结了一个排查顺序:
- 先看
kubectl get job输出里的ACTIVE字段,如果迟迟不为0,说明有Pod还挂着。 - 再对挂着的Pod执行
kubectl describe pod,重点看容器状态和最近事件。 - 最后检查容器日志,确认任务是否卡在等某个外部依赖。
如果任务本身逻辑正常但被外部依赖卡住,最有效的补救措施是:给Job补上activeDeadlineSeconds,让控制器强制终止;同时把任务代码里的外部调用加上超时和重试上限。前者治标,后者治本。
5.3 几个让我少走半年弯路的配置习惯
以下是我在多个集群维护中沉淀下来的习惯,不一定适用于所有团队,但很值得作为默认配置参考:
- 关键DaemonSet一律配
maxUnavailable: 1,宁可更新慢一点,也不让两个节点的采集或网络功能同时中断。 - 为日志采集类和监控类DaemonSet单独设置
PriorityClass,优先级高于普通业务。日志采集在节点资源紧张时最容易被驱逐,驱逐后又是半夜,非常被动。 - 所有Job都配
activeDeadlineSeconds,不管任务看起来多简单。总会有你想不到的情况。 - CronJob的
historyLimit一定显式设置,默认值在长期运行下会产生大量历史对象。 - DaemonSet和Job的YAML里,凡是涉及节点覆盖范围、更新策略、清理策略的字段,都用注释写明原因。配置是给团队看的,注释是给未来的自己看的。
最后再分享一个小技巧:诊断批量Job问题时,kubectl get pods -l job-name=<job-name> --show-labels这个命令很有用,它可以同时看到每个Pod的标签、状态和所属的Job,尤其当并行模式开了多个Pod时,一眼就能看出哪些Pod对应哪次重试。配合kubectl get events按时间排序,基本上大部分Job异常都能定位。
这两类控制器都不复杂,但它们的调度语义和生命周期模型跟Deployment差别很大。只要抓住“每节点一份随节点生灭”和“跑到成功就退出”这两个核心心智,绝大部分问题都能提前规避。希望这份实战梳理能帮你在自己的集群里少踩几个坑。