☰
K8s有状态应用与运维实战:StatefulSet、DaemonSet、Job及资源配额详解
2026/10/20 23:54:34 网站建设 项目流程

前两篇聊完集群部署和 Pod、Deployment、Service 这些基础工作负载之后,这篇 k8s操作(三)要进入日常交付里最容易被反复折腾的内容:有状态应用怎么部署、节点级代理怎么管理、一次性任务和定时任务怎么调参,以及多团队共享集群时怎么用配额守住底线。

这四个主题放在一起,不是编辑流水账,而是因为它们在实际运维现场往往同时出现:业务库要用 StatefulSet,日志采集要用 DaemonSet,报表和迁移要用 CronJob/Job,而整个命名空间前面还要卡一道 Quota,避免有人手滑把资源打爆。适合的读者是已经能独立部署集群、对 Deployment/Service 有一定操作基础的人;如果对那部分还不熟,建议先回去翻上一篇文章再回来。

1. 为什么“有状态”会成为部署分水岭:StatefulSet关键机制

1.1 为什么 Deployment 管不住有状态应用

在 Kubernetes 里,Deployment 的设计前提是“所有副本都是同义词”:模板一填,扩容缩容随便拆,哪个 Pod 挂了直接换一个新的顶上,IP、名字都不重要。这套模型对无状态 Web 服务非常友好,可一旦换成数据库、缓存、消息队列,问题就来了。第一,Pod 重建后 IP 变了,客户端没法把旧连接接到新 Pod 上;第二,多个副本如果共用同一个卷,数据库会被锁头压死;如果各挂各的卷,副本重建后怎么知道自己该挂哪一块;第三,中间件大多有启动顺序依赖,比如主节点要先就绪,从节点才能去拉主库同步位点,Deployment 是并发滚动升级的,不会管谁先谁后。

StatefulSet 就是为解决“身份 + 独立存储 + 顺序”这三件事而生的控制器。它给每个 Pod 分配一个从 0 开始递增的序号,名字固定为“控制器名-序号”,例如 sim-db-0、sim-db-1。不管这个 Pod 被删掉还是被重新调度,只要 StatefulSet 还在,新 Pod 依然叫这个名字。光有名字还不够,存储也不能乱:StatefulSet 一般配合 volumeClaimTemplates,按每个副本独立创建 PVC,PVC 的名字同样带序号,所以 sim-db-0 重建后还能挂回原来那块卷,数据不丢。

维度DeploymentStatefulSet
Pod 名称随机前缀固定序号名
Pod 身份不关心稳定且唯一
存储一般不挂或共享卷每副本独立 PVC
启动顺序并发按序号有序
典型场景Web/API/批处理数据库/中间件

需要说清一件事:StatefulSet 并不神奇,它不会让网络性能更好,也不会自动复制数据,更不会帮你做主从切换。数据备份、读写分离、高可用这些能力都来自应用本身,StatefulSet 只是把“稳定的标识”和“稳定的存储”这两个基础条件给到了。很多人在中间件容器化时踩坑,就是因为把 StatefulSet 当成万能的,以为部署上去应用就自动集群化,结果一堆副本在报错。

1.2 没有 headless Service,名字没法变成连接地址

光有固定名字不代表能被业务访问。对无状态应用,一个普通 ClusterIP Service 就够了;但对 StatefulSet,几乎总要配一个 headless Service,也就是clusterIP: None。为什么?因为 StatefulSet 里的每个 Pod 都需要一个独立的 DNS 记录,形如:

sim-db-0.sim-db-svc.default.svc.cluster.local sim-db-1.sim-db-svc.default.svc.cluster.local

这样应用就能直接通过完整域名连到某个确定的实例。普通 Service 把流量负载均衡到 Pod 上,而 headless Service 不提供虚拟 IP,它只是把每个 Pod 的地址直接解析出来,由调用方自己决定连哪一个。

对应配置一般是:

apiVersion: v1 kind: Service metadata: name: sim-db-svc spec: clusterIP: None selector: app: sim-db ports: - port: 3306

注意:headless Service 的 selector 必须和 StatefulSet 的 Pod 标签一致;如果标签对不上,Pod 的 DNS 记录就不会生成,虽然 Pod 能跑,但别人根本找不到它。这个坑我见过好几次,症状是应用启动时 DNS 解析失败,查了半天才发现在改标签时把 Service 的 selector 漏了。

还有一个容易忽略的点:如果 StatefulSet 的serviceName写错或引用的 Service 不是 headless,集群内 DNS 不会按“Pod名.Service名”的格式生成记录。很多中间件客户端配置的“主库地址”就是靠这种固定域名,一旦域名解析不到,应用就会反复重试连接,表现起来像是网络故障,其实是基础配置没对上。

1.3 volumeClaimTemplates 和滚动升级的注意点

StatefulSet 的持久化存储不在 Pod 模板的 volumes 里硬编码,而是用 volumeClaimTemplates 来声明“存储模板”。每个副本都会根据这个模板生成一份独立的 PVC,名字规则是“模板名-控制器名-序号”。

apiVersion: apps/v1 kind: StatefulSet metadata: name: sim-db spec: serviceName: sim-db-svc replicas: 2 selector: matchLabels: app: sim-db template: metadata: labels: app: sim-db spec: containers: - name: sim-db image: sim-db:3.2 ports: - containerPort: 3306 volumeMounts: - name: data mountPath: /var/lib/sim-db volumeClaimTemplates: - metadata: name: data spec: accessModes: [ "ReadWriteOnce" ] resources: requests: storage: 10Gi

部署后会生成>apiVersion: apps/v1 kind: DaemonSet metadata: name: node-log-agent spec: selector: matchLabels: app: node-log-agent updateStrategy: type: RollingUpdate rollingUpdate: maxUnavailable: 1 template: metadata: labels: app: node-log-agent spec: hostNetwork: true tolerations: - operator: Exists containers: - name: agent image: node-log-agent:1.6 resources: requests: cpu: 100m memory: 128Mi

说明:hostNetwork: true会让容器直接使用节点网络栈,很多日志采集器依赖它来区分节点和容器。tolerations里operator: Exists表示容忍全部污点,一般只给必须覆盖每个节点的采集类组件用,业务型的 DaemonSet 别这么写。resources.requests一定要设,否则资源紧张时调度器容易把这种“看不见”的代理排到最后。

DaemonSet 的更新策略有两个选择。默认 RollingUpdate 可以控制 maxUnavailable,比如一次只让一台节点上的老版本退出,尽量减少采集链路中断。OnDelete 则意味着只有你手动删除某个 Pod,控制器才会按新模板重建,适合网络插件这类需要人工确认的敏感组件。我倾向于给采集器配置 RollingUpdate,配合maxUnavailable: 1,这样线上变更的影响面可控;网络类组件则先用 OnDelete,在测试节点上验证没问题再手动批量推进。

有个容易忽视的点:DaemonSet 的 Pod 和普通 Pod 一样占资源,也会被驱逐、被资源配额计算。别因为它是“系统组件”就不设 requests/limits,更别把它的日志目录直接放内存盘,不然节点内存一紧张,最先死的往往是它。真实环境里我见过采集器把 hostPath 日志目录写到默认盘,结果/var/log被撑满,节点组件都跟着受影响,所以最好给这类 Pod 单独留一块磁盘或做日志轮转。

3. 一次性任务和定时任务:Job 与 CronJob 的细节

3.1 Job 的重试、超时与并行

Job 的定位是“运行一次直到完成”的负载。典型场景:数据库迁移、报表生成、批量导出、影像数据清洗。这些任务跑完了就退出,不能被当成常驻进程。但奇怪的是,很多人写 Job 时习惯照抄 Deployment 的模板,结果留了不少隐患。

几个细节不能漏:

  • restartPolicy 必须显式设为 Never 或 OnFailure。如果漏写,Kubernetes 会按默认值配置,Job 逻辑没法判断任务到底算成功还是失败。
  • backoffLimit 控制 Job 控制器创建新 Pod 的重试次数。例如设为 3,表示任务失败后控制器还会创建新的 Pod 再试几次,直到成功或达到上限。需要注意,容器重启次数和 Job 重建 Pod 的次数是叠加的,所以排查时不能只看restartCount一个字段。
  • activeDeadlineSeconds 是任务总执行时间上限。超过这个时间,Job 会被判定为失败并终止。很多卡死的任务,靠这个字段能从“无限挂起”变成“可感知失败”。
  • parallelism 是同时跑的 Pod 数,completions 是至少成功完成多少个才整体完成。默认都是 1,大批量任务可以适当提高。

示例:

apiVersion: batch/v1 kind: Job metadata: name: db-migrate spec: backoffLimit: 3 activeDeadlineSeconds: 3600 template: spec: restartPolicy: Never containers: - name: migrate image: migrate-tool:2.1 env: - name: DB_DSN value: "user:pass@tcp(sim-db-svc:3306)/app"

为什么推荐restartPolicy: Never?因为这样每次失败都会产生一个新 Pod,你可以在 describe Pod 时看到每一次失败的完整状态和日志,而不是同一个容器不断原地重启,状态混在一起。如果是 OnFailure,容器会在同一个 Pod 里重启,排查记录没有隔离开。特别是数据库迁移这种对“是否重复执行”敏感的任务,明确定位“第几次失败”很重要。

3.2 CronJob 的几个易错点

CronJob 等于“到点创建 Job”的调度器。它并不保证秒级精准,只要集群状态正常,一般也就几秒到十几秒的延迟。如果要严格的“整点必须跑到”,最好在应用层也做幂等和补偿,别指望平台级精确。

几个坑要单独讲:

第一,时区。CronJob 的 schedule 字段虽然看着像标准 cron,但不少默认环境使用 UTC。如果你写0 2 * * *又以为是凌晨两点,实际可能会是早上八点。新版 K8s 支持通过spec.timeZone直接指定时区;老版本环境建议在 schedule 里写CRON_TZ=Asia/Shanghai 0 2 * * *,或者干脆统一约定集群时间。

第二,并发策略。concurrencyPolicy 有三个选项:Allow 允许上一次没跑完就开新一轮;Forbid 禁止并发,错过就等下一轮;Replace 干掉旧的直接换新的。日常运维我偏爱 Forbid,尤其是数据库迁移、批量写入这类有脏数据风险的任务,并发执行可能造成重复写入。

第三,历史记录。CronJob 默认会保留 3 个成功记录和 1 个失败记录,时间一长历史 Job 对象会积累。如果不加限制,controller 会反复创建 Job,旧对象还占着命名空间配额,集群里看起来一堆资源被占用。建议显式设置 successfulJobsHistoryLimit 和 failedJobsHistoryLimit。

apiVersion: batch/v1 kind: CronJob metadata: name: daily-report spec: schedule: "0 2 * * *" timeZone: "Asia/Shanghai" concurrencyPolicy: Forbid successfulJobsHistoryLimit: 5 failedJobsHistoryLimit: 2 jobTemplate: spec: backoffLimit: 2 template: spec: restartPolicy: Never containers: - name: report image: report:4.0 command: ["python", "gen_report.py"]

注意:timeZone字段要求集群版本足够新,如果你的版本不支持,别硬上,用 CRON_TZ 或在 schedule 里换算更稳。另外,CronJob 真正执行的并不是模板本身,而是每次生成一个 Job,Job 又生成 Pod,所以“查看日志”要先找到最近一次 Job 的 Pod 名,再 kubectl logs 对应 Pod。直接kubectl logs deployment/xxx这套逻辑在 CronJob 上不适用。

4. 资源配额与 LimitRange:防止一个命名空间拖垮集群

4.1 配额用途与统计口径

集群资源不是无限的。多个业务团队共用一个集群时,最大的风险不是某个 Pod 写满了 bug,而是有人不小心把“副本数调到 80”或者“内存 limit 设成 64Gi”,整台节点瞬间被占满,其他应用跟着被驱逐。资源配额就是给命名空间划一个资源使用上限,超过就不让创建。

配额统计口径有 request 和 limit 之分。调度基于 request,运行时限制基于 limit。比如请求 1Gi 内存、限制 4Gi,调度器按 1Gi 给 Pod 找位置,运行时最多允许用到 4Gi。配额如果同时配了 requests 和 limits,那么 Pod 的对应字段都要设值,否则创建会被拦截。注意这个细节,配额报错时经常提示“must specify requests”,不是真的要求你写死,只是你的 YAML 里没写 resource 段落。

再补充一点:配额是按命名空间统计的,一个 Deployment 扩容到 10 个副本,配额里就是 10 份 resources 叠加,而不是一个 Pod 的量。如果明明看着集群空闲却创建失败,多半是这个命名空间配额已经算满了。

一个常见但容易被忽视的情况:Pod 里只写了 limits 没写 requests 时,Kubernetes 默认会把 requests 当成和 limits 一样大。也就是说你设了limits.memory: 4Gi,调度时它也会占用 4Gi 的 request。这会导致配额和节点资源被迅速吃光。尽量避免这种“隐式请求”,所有工作负载都显式写清 request 和 limit。

4.2 LimitRange 如何配合配额

LimitRange 是命名空间内的资源约束模板,作用有两个:一是给每个容器补默认的 request/limit,二是限制单个容器可以申请的最大最小范围。没有它之前,如果 YAML 里不写 resources,Pod 就能以“无资源声明”的方式启动,调度器把这部分负载当成零开销,实际运行却可能占满节点。配额和 LimitRange 配合使用,才能既控总量,又控单量。

apiVersion: v1 kind: LimitRange metadata: name: default-limit spec: limits: - max: cpu: "4" memory: 4Gi min: cpu: 50m memory: 64Mi default: cpu: 500m memory: 512Mi defaultRequest: cpu: 100m memory: 128Mi type: Container

在这个配置下:

  • 如果容器没写 request,会被填成 defaultRequest;
  • 如果没写 limit,会被填成 default;
  • 如果申请量超过 max 或低于 min,直接拒绝。

于是配额、LimitRange 和部署模板会共同参与“能否创建”的决策。实际工作中我见过很多类似的情况:配额明明够,但 LimitRange 把默认 request 设得很大,单个 Pod 就把额度吃掉一大块,扩容只能扩很少的副本。调整时要三个 YAML 一起看,不能只看 Quota。

4.3 超额创建的排查路径

遇到“创建 Pod 失败并提示 exceeded quota”,我的排查顺序是:

先看配额明细。命令kubectl get resourcequota -n <ns> -o yaml会显示每个资源的使用量和上限。一般一眼就能看到是 CPU 还是内存额度用完。

再看命名空间里的现存 Pod。有可能很多旧版本 Pod 处于 Terminating 状态,但还没有完全清理,退出中的 Pod 也会占用额度。此时要么等,要么确认安全后清理残留。这里要特别提醒:强制删除有风险,如果 PVC 还在被使用,删 Pod 可以,但不要顺手把 PVC 也删了。

最后看 LimitRange 介入后的效果。把上面的默认 request/limit 改小一点,或者临时调高配额,都能让发布继续下去。但要分清是“临时救火”还是“长期不合理”,最好在事后重新统计各业务的资源画像,再调整配额值。

另外,配额和 LimitRange 本身是作用于 API 准入的,和节点资源的实时压力没有关系。就算集群节点很多很闲,只要配额设置严了,Pod 一样创建不了;反过来,如果配额宽松,Pod 也照样可能调度失败。两者要分开看,别混淆。

5. 日常巡检三板斧:事件、日志、指标

5.1 事件:第一手的故障线索

Pod 卡在 Pending,起不来,又找不到原因时,第一件事应该是看事件。kubectl get events -n <ns> --sort-by=.lastTimestamp,或者直接kubectl describe pod <pod>,看最后几行 Event。

事件里常见的几个高频问题:

  • FailedScheduling:调度器因为资源不足、节点亲和、污点等原因拒绝;
  • FailedMount:卷挂载失败,存储类和访问模式可能不对;
  • ImagePullBackOff:拉镜像失败,检查镜像名、标签、仓库凭证;
  • BackOff:容器反复启动失败,要结合日志和探测参数来判断。

要注意:事件默认保留窗口很短,一般只有半小时到几小时。如果间隔太久才去查,可能什么都看不到,这就是为什么必须同时配日志采集和监控。日常巡检我习惯按命名空间扫一遍 Warning 级别事件,重点看那些一直在刷而没有自动恢复的,而不是偶发一条就紧张。

5.2 日志:看容器内部到底在干什么

事件告诉我们外部发生了什么,日志告诉我们容器内部发生了什么。基础命令就三个:kubectl logs <pod>看单容器日志;多容器加-c <container>;想看容器重启前的日志,用--previous。CrashLoopBackOff 问题时,--previous尤其重要,因为当前容器可能刚重启,日志只有启动那几行,真正导致崩溃的原因都留在了上个实例。

日志文件可能很大,优先用--tail限制行数。线上环境不建议直接裸跑不带参数,容易把终端卡住。kubectl logs适合定位单点问题,不适合做日志分析;要查某个时间段的错误聚合,需要把容器标准输出接入采集系统。我在实际定位问题时还会把事件时间、容器重启时间、日志报错时间三个时间点对齐,基本能还原出完整故障链条。

5.3 指标:判断是不是资源不足

事件和日志能看到“创建失败”和“运行报错”,但要判断节点是否压力过大,得看指标。kubectl top node、kubectl top pod -n <ns>是运维最常用的两个视图。Pod 的 CPU 和内存使用量一目了然;节点维度可以看到总量和剩余量。

如果某个 Pod 反复被驱逐或 OOM,优先看它的 requests 和 limits 设置。比如内存 limit 只给了 512Mi,实际运行值逐步涨到 600Mi,那就会被 OOMKilled,然后重启,然后又涨到 600Mi,循环往复。这时候不是调 limit 就能解决,要分析应用是不是存在内存泄漏,或者把堆参数、连接池上限调整到位。kubectl top是实时快照,也有一定延迟,连续观察一段时间更有意义。

我的习惯是:先事件判断“为什么不能创建”,再日志判断“容器里在报什么错”,再指标判断“是不是资源卡住了”。三者联动,大部分日常问题都能定位到七七八八;剩下的极少情况才需要进一步看网络抓包、存储性能这类深层次内容。CronJob 任务失败时,用kubectl get cronjob、kubectl get jobs、kubectl get pods这三级对象的名称和时间戳串起来,就能定位到具体某一次执行,然后重复 事件→日志→指标 的顺序,问题就不难暴露。

最后说点个人经验。StatefulSet、DaemonSet、Job/CronJob 和 ResourceQuota 这四样东西,单独看都能跑通,真正难的是它们在一个集群里互相影响。比如配额设了,CronJob 历史 Job 没清理,满了之后新的定时任务直接失败;DaemonSet 的采集器没设 requests,节点资源紧张时反而被驱逐,日志一断又影响排查。做成体系之后,我每次给业务方开通新命名空间,都会顺手把 ResourceQuota、LimitRange 和可观测组件一起配好,后续省下来的排查时间远比配置时那几分钟多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询