1. 先理清控制器的分工,别再看见工作负载就用Deployment
做 Kubernetes 运维的,日常打交道最多的控制器肯定是 Deployment。它能解决大部分无状态服务的部署、滚动升级和副本伸缩问题。但等你真要在生产环境里部署日志采集器、节点监控插件,或者跑一个数据清洗、批量计算任务时,你会发现 Deployment 完全不是这块料——它讲究的是“永远保持 N 个副本在线”,而这两类需求一个要的是“每台节点恰好有一个 Pod”,另一个要的是“跑完就要结束”。
去翻文档你会找到两个名字:DaemonSet 和 Job(以及它的兄弟 CronJob)。这篇文章我想把这两个控制器从原理到实战完整串一遍:它们分别解决了什么问题,YAML 怎么写,线上怎么排查。适合刚学完 Deployment 想进一步掌握工作负载用法的同学,也适合给生产集群里被日志采集、定时任务搞到头疼的运维一份可直接抄的作业。
1.1 Deployment、DaemonSet、Job 三者的核心差异
先看一个最本质的区别:这三个控制器对“成功”的定义完全不同。
Deployment 认为“成功”是集群里始终有指定数量的 Pod 在运行。比如你设置了 3 个副本,控制器就会保证任何时刻都有 3 个 Pod 处于 Running 状态,其中任何一个挂掉,它会立刻补一个新的,维持期望值。
DaemonSet 认为“成功”是集群里每个符合条件的节点上,恰好有一个 Pod 在运行。节点数量是动态的:新增节点时,DaemonSet 会自动在那个节点上拉起 Pod;节点被删除时,对应 Pod 也会被回收。它不关心副本数是不是 3 或 5,它只关心“每个节点都有一份”。
Job 认为“成功”是指定数量的 Pod 都完成了任务并正常退出。它会持续跟踪有多少个 Pod 以退出码 0 结束,直到达到目标数量,才把整个 Job 标记为 Completed。如果 Pod 失败了,它会根据配置决定是否重新创建一个 Pod 继续尝试。
一个通俗的类比是:Deployment 像 7x24 小时在线的客服,时刻保证有人接电话;DaemonSet 像是每层楼各安排一位安全员,楼层在,安全员就在;Job 像是外包团队,合同约定的活儿干完就撤场,干不完就重来。
1.2 业务场景与控制器匹配速查
很多人分不清某个需求到底该选哪个控制器,其实只要把需求描述成一句话,答案就出来了。我整理了一个速查逻辑:
| 需求特征 | 首选控制器 | 理由 |
|---|---|---|
| 每台节点都要跑采集器/代理 | DaemonSet | 日志、监控、网络插件天然和节点绑定 |
| 跑一个任务,结束后不再需要 | Job | 有明确终止条件,不占副本名额 |
| 周期性地跑批处理任务 | CronJob | 本质是 Job 加定时调度 |
| 无状态服务持续对外提供请求 | Deployment | 期望副本数固定,随时扩缩容 |
| 任务需要严格串行,一次只能一个 | Job(parallelism=1) | 通过并发数限制实现串行 |
这里有个容易混淆的点:日志采集这种需求,如果日志文件路径在节点上,采集器必须跟日志文件在同一台主机上,你用 Deployment 把采集器调度到任意节点,只会采集到所在节点的日志。所以当需求里出现“每个节点”“全部节点”这类关键词时,不要犹豫,直接换 DaemonSet。
2. DaemonSet 的设计原理与核心机制
理解 DaemonSet 不能只看表面 YAML,你得清楚它内部是怎么保证“每个节点一个 Pod”的,以及它和普通调度有什么本质区别。
2.1 一条 Pod 如何保证“每个节点一份”
DaemonSet 控制器在创建 Pod 时,会往 Pod 的调度信息里注入一个必需的节点亲和性条件,把 Pod 限制在目标节点集合内。这个亲和性是requiredDuringSchedulingIgnoredDuringExecution类型,意思是调度时必须满足,Pod 运行后即使节点标签变化也不会驱逐它。
具体来说,DaemonSet 会根据 Pod 模板里的nodeSelector、节点亲和性规则,以及节点上的污点容忍情况,计算出当前应该运行该 Pod 的节点列表。然后对每个节点,它都会单独构造一个 Pod 对象,绑定到该节点。这就是为什么你用kubectl get pods -o wide查看 DaemonSet 的 Pod 时,每个 Pod 的NODE列都是分散的,并且 Pod 名称里会带上节点相关的哈希。
另外 DaemonSet 控制器还会自动为 Pod 注入一些系统级容忍配置,确保守护进程能在特殊节点上跑起来。比如控制面节点默认带NoSchedule污点,如果你不在 Pod 模板里声明容忍,调度器会拒绝把 Pod 放上去。实际部署日志采集、监控采集这类组件时,通常需要显式声明对控制面污点的容忍,否则 master 节点上就一直缺一个采集器。
2.2 更新策略:OnDelete 与 RollingUpdate
DaemonSet 支持两种更新策略,这是生产环境最容易踩坑的地方。
OnDelete策略意味着当你修改 DaemonSet 的 Pod 模板后,控制器不会主动去动任何现有 Pod,只有当一个 Pod 被手动删除时,它才会用新模板重建这个 Pod。这种策略适合灰度验证:你先手动删除某个节点的采集器,让集群用新镜像顶上,观察没问题后再手动滚动删除其余节点。
RollingUpdate策略是默认且常用的方案。它像 Deployment 的滚动更新一样,逐步用新模板替换老版本 Pod。你可以在rollingUpdate.maxUnavailable里控制最多允许多少个 Pod 同时不可用,也可以设置minReadySeconds,让新 Pod 就绪后等待一段时间再继续滚动,避免“刚起来又被压垮”。
这里有个实际经验:maxUnavailable建议按节点规模设置。节点少可以设 1,节点多可以适度调大。如果你设置成 0,就意味着滚动更新期间必须保证所有 Pod 都可用,控制器只能等一个 Pod 就绪后才删下一个,大规模集群下更新速度会非常慢,甚至让人觉得“卡住了”。
2.3 什么场景适合 DaemonSet,什么场景要避开
适合 DaemonSet 的场景基本都有“节点级”这个共性。
日志采集是最典型的例子。节点上的容器日志、系统日志都在本地文件系统,采集进程必须运行在同一节点上才能读到。用 DaemonSet 让每个节点一个采集 Pod,再把宿主机的日志目录挂载进去,是最合理的架构。
节点监控指标采集也一样。监控 agent 需要读取节点 CPU、内存、磁盘、网络等指标,这些数据必须本机读取,通过宿主机网络暴露。还有网络插件组件,比如节点上的 CNI 相关 agent,以及 kube-proxy 这类集群网络组件,本质上都是 DaemonSet 形态。
但我见过一些错误用法,把尊重数据的中间件也用 DaemonSet 部署,比如想在每个节点跑一个缓存实例。这种需求要谨慎:如果数据需要多副本同步,或者需要持久化存储,节点宕机时 DaemonSet 虽然会把 Pod 迁移到其他节点(其实会重建),但数据恢复成本很高。DaemonSet 更适合“无状态或本地临时状态”的守护进程,不适合需要分布式一致性、持久化存储的服务。
3. DaemonSet 实战部署:写一份能落地的节点日志采集 YAML
光讲原理不够,我们还是直接写一份带注释的 DaemonSet 配置,把一个典型的采集任务跑起来。
3.1 一份能落地的节点日志采集 YAML 长什么样
假设场景:每个节点需要跑一个日志采集 agent,采集/var/log和容器运行目录下的日志,并且要能容忍控制面污点,同时采集后需要上报到某处。以下是一份实际可用的模板:
apiVersion: apps/v1 kind: DaemonSet metadata: name: node-logger namespace: ops labels: app: node-logger spec: selector: matchLabels: app: node-logger updateStrategy: type: RollingUpdate rollingUpdate: maxUnavailable: 1 minReadySeconds: 10 template: metadata: labels: app: node-logger spec: hostNetwork: true tolerations: - key: node-role.kubernetes.io/control-plane operator: Exists effect: NoSchedule - key: node-role.kubernetes.io/master operator: Exists effect: NoSchedule containers: - name: collector image: your-image-registry/collector:latest args: - --log-dir=/var/log securityContext: privileged: true env: - name: NODE_NAME valueFrom: fieldRef: fieldPath: spec.nodeName volumeMounts: - name: varlog mountPath: /var/log readOnly: true - name: container-logs mountPath: /var/lib/docker/containers readOnly: true resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 512Mi volumes: - name: varlog hostPath: path: /var/log - name: container-logs hostPath: path: /var/lib/docker/containers这份配置里有几个关键点,单独拿出来讲。
第一,hostNetwork: true。日志采集器通常需要直接访问宿主机的网络资源,同时很多采集器会监听端口,用 hostNetwork 可以直接把端口暴露在节点上。但这也带来端口冲突风险,同一节点上不能同时跑两个监听相同端口的 Pod。
第二,污点容忍没有用operator: Exists一股脑全部容忍。我见过很多模板为了省事写- operator: Exists,意思是容忍所有污点。这确实能确保 Pod 被调度到任何节点,但也可能让采集器跑到你不想部署的节点上。生产环境建议精确到 key 和 effect,只容忍必要的控制面污点。
第三,NODE_NAME环境变量通过 fieldRef 注入。采集器上报日志时通常需要带上节点名,这个字段的值就是当前 Pod 所在的节点名。
第四,资源限制要结合节点数算总量。比如这个模板里单个 Pod 的内存 limit 是 512Mi,如果集群有 20 个节点,光这一套 DaemonSet 满跑就是 10GiB 内存上限。在节点规格较小的集群里,这个占用比例会很明显。
3.2 部署与验证:从 apply 到 rollout status
保存文件后用标准命令部署:
kubectl apply -f node-logger-ds.yaml然后依次检查状态:
kubectl get ds -n ops node-logger kubectl get pods -n ops -o wide -l app=node-logger kubectl rollout status ds/node-logger -n opskubectl get ds会看到DESIRED、CURRENT、READY、UP-TO-DATE、AVAILABLE几列。如果DESIRED等于节点数量,而CURRENT小于它,说明有些节点的 Pod 还没创建成功,需要进一步看事件。
更细致的排查推荐kubectl describe ds/node-logger -n ops,它会列出控制器最近的调度事件。如果有节点因为污点无法调度,这里会直接给出did not match tolerations之类的提示。
更新配置时,直接修改 YAML 再kubectl apply即可。如果希望手动控制的 Pod 在下次发布时不被动更新,可以把updateStrategy.type改成OnDelete。滚动更新的过程可以用kubectl rollout status观察进度,如果卡住,通常是因为maxUnavailable设置太小或者镜像拉取失败。
3.3 别忘了处理污点和节点选择器
很多初学者会在 DaemonSet 的 Pod 模板里写nodeSelector,这完全没问题,它决定 DaemonSet 在哪些节点上运行。比如只想在 GPU 节点上采集 GPU 监控,可以写:
spec: template: spec: nodeSelector: gpu-node: "true"这样 DaemonSet 只在带gpu-node=true标签的节点上创建 Pod,其他节点不会跑。
但要注意:如果你同时用了nodeSelector和污点容忍,调度行为是两者叠加的。节点必须满足标签条件,同时 Pod 必须容忍该节点上的污点才能调度上去。实际生产环境中,GPU 节点往往带有资源类污点,比如nvidia.com/gpu:NoSchedule之类的第三方污点,你需要在容忍列表里显式加一条,否则 Pod 会一直 Pending。
4. Job 控制器的执行模型与参数设计
Job 和 DaemonSet 是两种思路完全相反的控制器。DaemonSet 关心“持续在线”,Job 关心“执行完成”。这一节先把 Job 的执行模型和关键参数讲透。
4.1 三种执行模型:非并行、固定完成数、工作队列
Kubernetes 官方文档把 Job 分成三类,我结合实战讲讲它们的区别。
非并行 Job:不设置completions,默认情况下只有一个 Pod 完成(退出码 0)后,整个 Job 就算完成。适合“执行一次就结束”的简单任务,比如数据库备份、数据导出。
固定完成数的并行 Job:同时设置completions和parallelism。比如completions: 12,parallelism: 4,意思是总共需要 12 个 Pod 全部成功才算完成,但最多同时运行 4 个。适合把一个大任务拆成多个独立小任务,比如 12 个文件分片处理,每条数据独立计算。
工作队列型 Job:不设置completions,只设置parallelism,Pod 之间通过外部队列竞争任务。每个 Pod 从队列里取任务处理,处理完一个再取下一个,所有任务消费完,Pod 成功退出后 Job 完成。适合任务数量不固定、每个任务耗时差异大的场景。这种模型下,completions没意义,因为任务数量不由控制器决定。
我在实际项目里最常用的还是固定完成数这种,因为它的语义最直观:我要跑 N 个子任务,每个子任务对应一个 Pod。
4.2 关键参数的计算与选择逻辑
Job 的参数设计直接影响任务的总耗时和失败恢复能力。
parallelism决定同时跑多少个 Pod。这个值不能拍脑袋定,要结合下游系统的承受能力。如果你处理的是外部 API,接口限流是 5 QPS,每个 Pod 处理一个请求,那parallelism最多设 5,设大了会把接口打爆。如果任务本身是 CPU 密集的本地计算,可以结合集群可用资源来定。
completions决定总成功次数。如果每个 Pod 只处理一个任务,completions就等于任务数。如果每个 Pod 通过工作队列消费多个任务,completions就不需要设置了。
backoffLimit决定失败重试的总次数,默认是 6。这个值不要设太大,失败说明大概率有问题,重试 6 次已经很多了。而且要注意,每次重试之后控制器会按指数退避的间隔创建新 Pod,不会立即连续重试。
activeDeadlineSeconds是 Job 的“绝对死刑时间线”。如果整个 Job 运行超过这个时间仍未完成,控制器会终止所有 Pod,并把 Job 标记为失败。这个参数适合保护那些可能会死循环或者被外部依赖卡住的任务,给一个最大运行时长,防止任务无限消耗资源。
4.3 为什么 restartPolicy 必须是 Never 或 OnFailure
Job 的 Pod 模板里,restartPolicy只允许写Never或OnFailure,写Always会被 API 拒绝。这个限制很多人第一次遇到会不理解。
原因是这样的:Job 的完成语义建立在 Pod 会退出这件事上。如果一个 Pod 里的容器永远会被重启,那它永远处于 Running 状态,Job 永远看不到成功或失败,completions这个目标就永远无法达成。
Never和OnFailure的行为差异也要搞清楚。Never模式下,容器一旦失败退出,Pod 进入 Failed 状态,控制器创建一个全新的 Pod 来重试。OnFailure模式下,容器失败后 kubelet 会在同一个 Pod 内重启容器,Pod 本身不进入 Failed,Job 控制器在后台持续等待这个 Pod 成功。
两种模式的选择取决于任务的可恢复性。如果容器启动时会建立临时目录、初始化状态,失败后重启容器大概率能自己清理,那OnFailure效率更高,不用重建 Pod。如果容器失败后留下的临时文件会影响重启后的行为,建议用Never,让控制器创建干净的新 Pod。
5. Job 与 CronJob 实战部署
理论讲完,进入实际操作环节。这一节我会带着你从最简 Job 开始,逐步延伸到并行任务和定时任务。
5.1 跑一个最简 Job,理解运行态
先创建一个最简单的测试 Job,用来观察运行状态和终态:
apiVersion: batch/v1 kind: Job metadata: name: demo-job spec: template: spec: restartPolicy: Never containers: - name: runner image: busybox command: ["sh", "-c", "echo start; sleep 3; echo done"] backoffLimit: 3 activeDeadlineSeconds: 60部署后立即查看状态:
kubectl apply -f demo-job.yaml kubectl get job demo-job kubectl get pods -l job-name=demo-job一开始 Job 的COMPLETIONS是 0/1,Pod 处于 Running,几秒后 Pod 变成 Completed,Job 显示COMPLETIONS是 1/1,状态为 Complete。
这里有几个细节值得记住:Job 创建的 Pod 会带上job-name=demo-job这个标签,方便你用标签筛选。Pod 退出后不会自动删除,日志还有保留价值,方便排查。如果 Job 成功结束后你想清理现场,执行kubectl delete job demo-job,它会把关联的 Pod 一并删掉。
5.2 并行任务编排示例
假设你有一个批处理场景,需要把 12 个独立文件做哈希计算,集群最多允许 4 个 Pod 同时运行。配置如下:
apiVersion: batch/v1 kind: Job metadata: name: batch-hash spec: parallelism: 4 completions: 12 backoffLimit: 2 activeDeadlineSeconds: 300 template: spec: restartPolicy: Never containers: - name: worker image: busybox command: ["sh", "-c", "echo started; sleep 10; echo finished"]部署后kubectl get job batch-hash会看到COMPLETIONS: 0/12,然后随着一批批 Pod 完成,这个数字逐步增长。
这里要说一下并行度的实际计算感受。每个 Pod 跑 10 秒,parallelism是 4,12 个任务需要分 3 批,理想耗时约 30 秒。如果parallelism改成 2,就需要 6 批,耗时翻倍到约 60 秒。所以并行度不是越高越好,它受两个约束:一是业务能否并行,二是下游系统能不能抗住。
在很多真实项目里,任务失败重跑导致的副作用比你想的严重。比如数据迁移 Job,第一次跑的时候有一半任务成功写入数据,另一半失败,Job 重试后失败的 Pod 会重新跑,但成功的那批任务已经写过了。如果你的业务逻辑没有做幂等处理或者断点续传,就会出现重复数据。
5.3 CronJob 定时任务配置与三个隐蔽坑
CronJob 本质是“定时创建 Job”的控制器,同一个 YAML 的kind改成CronJob,里面嵌套一个jobTemplate就行:
apiVersion: batch/v1 kind: CronJob metadata: name:>