拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

StatefulSet与Deployment的选型对比:有状态应用的关键差异

StatefulSet与Deployment的选型对比:有状态应用的关键差异

StatefulSet 和 Deployment 的区别,是我做了快十年容器平台架构和排障之后,被问到的频率最高的一个问题。这个问题表面上是两个 Kubernetes 对象的对比,实际上背后是"有状态应用怎么上云原生"这个更大的话题。我见过把 MySQL 直接挂在 Deployment 上跑的生产集群,遇到节点宕机后,数据库连不回来,最后整个项目组花了两个通宵处理数据;也见过把所有 Web 应用都改成 StatefulSet 的团队,结果一次发布更新要等大半天,因为每个 Pod 都在排队。两种方式没有绝对的对错,关键是搞清楚你到底在管理什么样的应用。这篇文章里,我会用实际踩坑的经验和完整的例子,把这两个工作负载在设计上的差异讲清楚。

1. 先搞清楚:两类工作负载管理的 Pod,本质差在哪

1.1 Deployment:Pod 是流水线上的耗材

Deployment 面向的是"无状态应用"。它底下真正干活的是 ReplicaSet,Deployment 只负责声明"我要多少个副本",副本数、版本、Pod 模板都由它统一管理。你创建一个 Deployment,实际运行起来的 Pod 名字长这样:

web-7d8f9cbf74-abc12 web-7d8f9cbf74-def34 web-7d8f9cbf74-ghi56

前缀的web-后面跟着 ReplicaSet 的哈希和一段随机字符串。注意这个随机字符串,它是 Deployment 对 Pod 态度的直观体现:Pod 只是个临时冒出来的实例,名字不重要,身份不重要,IP 也不重要。任意一个 Pod 挂了,ReplicaSet 马上拉起一个新的,名字、IP 全变了,但只要总数维持正确就行。

一个很典型的场景:Deployment 管理的 Nginx 如果从节点 A 被重新调度到节点 B,客户端通过 Service 访问它,完全感知不到变化。因为 Service 本身是个负载均衡入口,后面一堆 Pod 都是等价的,请求打到哪个 Pod 都行。这就是无状态的核心:所有副本对请求等价,数据要么不落到本地,要么落到外置共享存储。所以 Deployment 下面挂一些共享 NFS 或云盘也是常见的,但那是所有副本共用一份数据,而不是每个副本各管各自的数据。

从这段描述你应该能感觉到,Deployment 的设计哲学是把 Pod "用完即弃"。

1.2 StatefulSet:Pod 带着编号和档案

StatefulSet 恰恰相反。它的 Pod 名字是固定的、有规律的:

mysql-0 mysql-1 mysql-2

编号从 0 开始,依次递增。每个编号不仅仅是个数字,而是这个 Pod 的"永久身份":Pod 被删除后,重新创建出来的 Pod 还叫mysql-0,不会变成别的名字;这个 Pod 挂的存储卷也是固定的,数据不会丢;集群里的其他组件可以通过固定的主机名找到它,不用关心它跑到哪台节点上。

这个设计的意义在分布式系统里极其明显。以 MySQL 主从集群来说,从节点初始化时需要知道主节点的地址;节点重启后,集群里其他成员需要知道它还是不是原来那个节点;复制关系建立起来了,不能因为一次重启就断掉。如果所有节点都像 Deployment 那样随机命名、随机调度,一旦某个节点换了身份,整个集群的拓扑就乱了。

用生活化的类比:Deployment 管理的 Pod 像是流水线上的临时工,走了一个,随便补人,工号不需要固定;StatefulSet 管理的 Pod 则是有工牌、有档案的正式员工,走了要留档,回来还是原来的工号,他手里的纸质档案也跟着他。所以处理数据库、消息队列、配置中心这类应用时,你需要的恰恰是后者这种"认身份"的能力。

2. 网络身份那点事:固定 IP 是谣言,稳定 DNS 才是真本事

2.1 为什么说 StatefulSet 不提供固定 IP

很多文章和视频在讲 StatefulSet 的时候都会说"提供稳定的网络标识",于是有人直接理解成"Pod 有固定 IP"。这个理解是错的,而且会带来两个隐患:一是排查问题的时候总按着 IP 去找,结果发现 IP 变了;二是在设计内部通信时,把 IP 硬编码进配置,节点一重启就全断。

实际上,StatefulSet 里的 Pod 每次重建,IP 都是重新分配的。它保证的不是 IP 不变,而是主机名(也就是 Pod 名)不变,以及基于主机名生成的 DNS 记录不变。这一点我建议每个用 StatefulSet 的人都先记住,能省掉后面很多排查时间。

那"稳定的网络标识"到底是怎么实现的?Kubernetes 里的 Pod 默认没有对外可达的稳定主机名,访问一个 Pod 一般是走 Service。普通 Service 用一个 ClusterIP 做负载均衡,Pod 的 IP 变化对客户端不可见。但 StatefulSet 的场景里,客户端往往需要直连某一个具体节点,这时候就需要另一种 Service。

2.2 Headless Service 与每个 Pod 独一无二的 DNS

StatefulSet 的 spec 里有个必填字段叫serviceName,它要求你提供一个 Service,通常是一个 Headless Service。所谓 Headless,就是把clusterIP设为None:

apiVersion: v1 kind: Service metadata: name: mysql-headless spec: clusterIP: None selector: app: mysql ports: - port: 3306

这个 Service 不提供统一的 ClusterIP 负载均衡入口,而是把域名直接解析到各个 Pod 的 IP。配合 StatefulSet 的命名规则,每个 Pod 会得到一条独有的 DNS 记录:

mysql-0.mysql-headless.default.svc.cluster.local mysql-1.mysql-headless.default.svc.cluster.local mysql-2.mysql-headless.default.svc.cluster.local

格式是<pod-name>.<headless-service-name>.<namespace>.svc.cluster.local。也就是说,只要通过这个域名访问,不管 Pod 换了多少个 IP,都能准确找到那个"人"。你可以在集群里随便找个 Pod 用nslookup或者dig验证,返回的不是一个 VIP,而是一串 Pod 的 A 记录。

2.3 稳定 DNS 在分布式组件里到底解决什么问题

举几个我实际部署过的例子。ZooKeeper 的配置里要写每个节点的地址,节点启动后会根据配置里的主机名互相建立会话;Etcd 集群启动参数里也是用 peer 地址互相发现;Kafka 的 broker 注册到元数据服务之后,客户端和其他 broker 要能按同一个地址找到它。这些组件如果跑在 Deployment 里,Pod 每次重启名字都变,集群的一致性和成员列表就得靠额外的服务发现机制来维护,维护成本极高。

StatefulSet 的做法等于把"谁是谁"这件事固化了下来。节点从机器 1 搬到机器 2,主机名不变,DNS 指向新 IP,其他成员重新连接时还是按老名字找,连接关系不中断。这是分布式系统最需要的一条底层能力。

注意:稳定 DNS 不是说 Pod 永远不宕机,而是说它宕机重建之后,身份不会变。这两件事有本质区别。

当然,稳定 DNS 不是没有代价。它要求你的应用开发时就支持主机名配置,而不是硬编码 IP;而且如果应用自身不做成员发现,你依然需要手写初始节点列表。StatefulSet 只是给了你地基,盖什么样的房子还得看应用自己。

3. 存储绑定:volumeClaimTemplates 才是 StatefulSet 的杀手锏

3.1 Deployment 为什么做不到"每个副本一块独立盘"

从名字就能看出来,Deployment 关心的是"部署"这个动作,它不关心每个副本的"状态"。所以 Deployment 的 Pod 模板里,存储卷一般是下面两种情况之一:所有副本共享同一个 PVC,或者每个副本挂同一个只读配置卷。

共享一个 PVC 没问题,但你要想清楚数据语义:如果三个副本同时往一个 RWO(ReadWriteOnce)卷上写数据,绝大多数存储后端会直接报错。而改成 RWX(ReadWriteMany)共享卷,又会面临并发写入的一致性问题。所以 Deployment 里跑数据库,要么单副本硬扛,要么就得在应用层面自己搞定多副本数据同步,Kubernetes 层面帮不了你。

StatefulSet 提供了volumeClaimTemplates,翻译过来是"卷声明模板":

apiVersion: apps/v1 kind: StatefulSet metadata: name: mysql spec: serviceName: mysql-headless replicas: 3 selector: matchLabels: app: mysql template: metadata: labels: app: mysql spec: containers: - name: mysql image: mysql:8.0 volumeMounts: - name: data mountPath: /var/lib/mysql volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] resources: requests: storage: 10Gi

这里声明了一个名为data的 PVC 模板。StatefulSet 控制器会为每个编号的 Pod 自动生成对应的 PVC,名字是>spec: persistentVolumeClaimRetentionPolicy: whenDeleted: Retain whenScaled: Retain

whenScaled可以设成Delete,这样缩容时对应 PVC 会被自动清理,避免残留一堆没人用的云盘持续收费;whenDeleted控制 StatefulSet 删除时 PVC 是否跟随删除。这两个策略建议在测试环境先验证一遍再上生产,因为Delete意味着数据直接消失,配置错了代价很大。

4. 扩缩容、更新、删除:顺序性才是最有争议的行为差异

4.1 创建和缩容要排队

Deployment 扩缩容的时候,ReplicaSet 是并行创建或销毁 Pod 的,用户几乎感受不到先后顺序。StatefulSet 默认的podManagementPolicy是OrderedReady,意思非常直白:必须等序号小的 Pod 进入 Ready 状态,才会创建下一个。

所以创建一个 3 副本的 StatefulSet,你执行kubectl get pods观察,会看到mysql-0先 Running 并 Ready,然后mysql-1才开始创建;mysql-1Ready 之后,mysql-2才会出现。缩容则是反着来:先删编号最大的,一个等一个。

这种"排队"的意义在于,很多有状态应用强依赖启动顺序。比如一主两从的 MySQL 集群,主节点必须先起来,客户端和从节点才能知道往哪连;Etcd 启动时也需要有初始成员先就位。如果你确定自己的应用不依赖任何启动顺序,可以把podManagementPolicy改成Parallel,让所有 Pod 同时创建,能省下不少等待时间。我做过一个 Kafka 集群就是这样,三个 broker 之间没有严格的启动依赖,改用 Parallel 策略之后,整体拉起时间缩短了一半还多。

4.2 滚动更新策略和 Partition 灰度

Deployment 的滚动更新有maxSurge和maxUnavailable两个参数控制节奏,可以一次多起几个新副本再逐步替换旧的。StatefulSet 的默认更新策略RollingUpdate则没有这种"多副本同时切换"的灵活性,它按编号从大到小,一次只更新一个 Pod,更新完一个并确认 Ready,才继续下一个。

这种一次一个的行为对于数据类应用是必要的,因为数据库节点不能同时全部替换,总得有节点在对外服务。但也带来了代价:如果副本数多,整个更新周期会很长。我见过一个 Elasticsearch 集群 15 个节点,一次版本升级跑了接近一小时。

如果你只想灰度更新部分节点,StatefulSet 支持partition参数:

spec: updateStrategy: type: RollingUpdate rollingUpdate: partition: 2

设置partition: 2之后,控制器只会更新序号大于等于 2 的 Pod。也就是说序号 0、1 保持旧版本,序号 2 及以上的节点先升级。这在数据库这类需要"先升级从库、观察一段时间、再升级主库"的场景下非常实用。等从库验证没问题,再把partition改成 1 或 0,让剩下的节点陆续跟上。

还有OnDelete策略,更新时不自动替换 Pod,只有你手动删除某个 Pod,控制器才会按新模板重建。适合那种想完全控制变更节奏的团队,但日常用得少,我一般只在需要配合外部数据迁移工具时才用。

4.3 Pod 被删、节点宕机后会发生什么

先说主动删 Pod:你执行kubectl delete pod mysql-0,StatefulSet 控制器会立即创建一个新的mysql-0,名字不变,网络标识不变,重新挂上原来的 PVC。这在很多场景下可以当作"重启单个节点"来用,比如某个从库连接数异常,删掉让它重新初始化,挺管用的。

再说节点宕机。如果承载mysql-0的节点失联,默认情况下 kubelet 会对该节点上的 Pod 打上终止标记,但不会立刻让 Pod 离开节点,因为需要等待节点恢复以确认 Pod 是否还活着。如果节点长时间不恢复,mysql-0会一直处于 Terminating 状态,而且由于 StatefulSet 默认"同一个编号在同一时间只能有一个 Pod",新 Pod 也无法创建,整个集群的副本数就缺了。

遇到这种情况,常规操作是确认节点确实回不来之后,对这个 Pod 执行强制删除:

kubectl delete pod mysql-0 --force --grace-period=0

然后控制器会用新 Pod 接管这个名字和 PVC。注意,强制删除有风险:如果原 Pod 还活着,可能产生两个同名 Pod 短暂并存、同时访问同一块存储的情况。对数据库来说,两个进程写同一块数据盘是大忌,所以执行前一定要确认那个节点彻底失联,或者已经从集群中移除。这个风险我在文章开头提到的那个 MySQL 事故里就踩过,提醒各位格外小心。

5. 选型判断:到底什么业务该用 StatefulSet

5.1 三个判断标准

经过前面几轮的对比,选型其实可以收敛成三个问题:

  1. 数据丢了能重建吗?如果你的服务挂了之后,数据可以从外部源重新同步、可以重新生成、可以从备份恢复且业务能接受短暂不可用,那不一定要用 StatefulSet。
  2. 每个副本需要独立的持久化存储吗?如果每个实例都必须有一块自己的数据盘,而且实例间不能共享,那 Deployment 帮不了你,StatefulSet 的volumeClaimTemplates几乎是唯一直接支持的方式。
  3. 实例之间是否需要稳定的网络身份?分布式组件之间要互相发现、要建立固定连接,需要每个节点有稳定主机名,那 StatefulSet 就很有必要。

三个问题里只要有两个回答"是",基本就该认真考虑 StatefulSet 了。如果全是否,Deployment 完全够用。

5.2 典型场景对照表

应用场景有独立存储需求有稳定身份需求推荐工作负载备注
Web 前端 / API 网关否否Deployment无状态,随便扩缩容
批处理一次性任务否否Job / CronJob不需要常驻
MySQL 主从 / PostgreSQL 集群是是StatefulSet典型数据集群
Redis 哨兵 / 集群是是StatefulSetAOF/RDB 需要独立盘
ZooKeeper / Etcd是是StatefulSet配置中心、协调服务
Kafka / Pulsar是是StatefulSet分区数据需要独立盘
Mongo 副本集 / Elasticsearch是是StatefulSet分片副本各管各的盘
MinIO 单节点是否看架构多数建议 StatefulSet

列这么多,是想提醒你别光记住"数据库用 StatefulSet"这一句结论。缓存、队列、协调服务、对象存储这类中间件,只要满足"有独立存储 + 有稳定身份"两个条件,都应该往 StatefulSet 上靠。

5.3 从 Deployment 迁到 StatefulSet 的经验

如果你现在有一个重要应用正跑在 Deployment 上,而它其实应该有状态,怎么平滑迁移?我说几个自己用过的思路。

如果只有单副本,迁移成本很低。先把应用停掉,确保数据盘没有被占用;然后把原来的 PVC 改一下标签,装进 StatefulSet 的 selector;新建同名的 StatefulSet,让控制器"认领"这个 PVC。只要 PVC 的名字符合<templateName>-<stsName>-<ordinal>的规则且标签匹配,控制器会直接接手,不需要重新拷数据。这个方法我在多个单实例应用上验证过。

如果是多副本集群,就不能只靠改名了。稳妥的做法是新的 StatefulSet 先用一套新的 PVC 和存储类建起来,通过备份恢复或从旧集群同步数据,跑一段时间确认数据一致后,再把流量切过去。整个过程可以做成蓝绿发布,前提是两套集群之间有足够的网络和存储带宽。把迁移时间安排在业务低峰,给数据同步留足余量。

最后提醒一个迁移中容易踩的坑:StatefulSet 的selector是写在 spec 里不可变的,创建之后不能改。所以创建前务必想清楚标签怎么写,尤其别为了图省事把selector写成空匹配规则,否则控制器会把集群里一堆无关的 PVC 全认领过来,后面的问题会非常难排查。我当初就是因为这个吃了亏,在几个环境的共享集群里差点把别人的存储卷"抢"走。

最后说一个我现在一直保持的习惯:每次新建 StatefulSet,我都会在验收清单里加一项——把 PVC 的reclaimPolicy手动确认一遍,并在测试环境跑一次完整的缩容、扩容、删 StatefulSet、再重建的演练。这套流程走完,心里才有底。有状态应用出事往往不是配置写错,而是没提前验证过异常路径。有空的话,建议你也找个周末,把你的 StatefulSet 在测试环境里故意折腾一遍,这比看十篇文档都管用。

返回列表