首页 / 资讯中心 / 文章详情

Kubernetes控制器实战:DaemonSet与Job的调度机制与最佳实践

Kubernetes控制器实战:DaemonSet与Job的调度机制与最佳实践 ★ FEATURED ARTICLE
把一批要在集群里常驻的守护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这个“本地目录”语义是配套的。如果你用emptyDirPod重建数据就丢了。resources的requests和limits我特意拉低了日志采集器虽然是基础设施但也别把一个节点占满留足资源给业务容器。应用这份配置可以直接kubectl apply -f daemonset-demo.yaml然后观察Pod生成情况kubectl get pods -l appdemo-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设置了AlwaysPod退出后立刻被重启永远不会有“终态”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都该配防的就是任务卡死却没人发现。ttlSecondsAfterFinishedJob完成后多少秒由控制器自动清理。这个放在第4节细说但写在这里是同一份YAML里最容易一起配好的。运行命令kubectl apply -f job-demo.yaml kubectl get jobs kubectl get pods -l job-namedemo-data-clean第一个kubectl get jobs会看到COMPLETIONS: 0/1表示还没完成等Pod运行结束后再执行会看到1/1。查询Pod日志kubectl logs job/demo-data-clean注意Job完成之后它的Pod不会被自动删除而是保留在集群里直到你手动清理或TTL机制触发。这正是为了让你能查看完成任务时的日志和退出状态。如果不需要旧Pod日志我会显式删除Jobkubectl delete job demo-data-clean默认情况下删除Job会连带删除它管理的Pod。如果只是想删Job但保留Pod可以加--cascadeorphan但这种情况非常少见我几乎不用。3.3 并行模式、索引任务和CronJob的衔接parallelism和completions的组合可以产生几种不同的并行模式。最常用的一个组合是completions固定为Nparallelism设为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都会影响实际调度结果而这些影响往往是隐性的。我在一次日志采集接入中遇到过这样的情况某几个节点的磁盘是本地高可用磁盘专门留给数据库用运维给它们打了dedicateddb: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-namejob-name --show-labels这个命令很有用它可以同时看到每个Pod的标签、状态和所属的Job尤其当并行模式开了多个Pod时一眼就能看出哪些Pod对应哪次重试。配合kubectl get events按时间排序基本上大部分Job异常都能定位。这两类控制器都不复杂但它们的调度语义和生命周期模型跟Deployment差别很大。只要抓住“每节点一份随节点生灭”和“跑到成功就退出”这两个核心心智绝大部分问题都能提前规避。希望这份实战梳理能帮你在自己的集群里少踩几个坑。
阅读完成 · 觉得有帮助?
咨询建站