ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

StatefulSet原理与实战:固定身份、持久存储与滚动更新

StatefulSet原理与实战:固定身份、持久存储与滚动更新

如果让我在Kubernetes的Controller里挑一个最容易让人误解的,StatefulSet一定排前三。很多人刚把Deployment用熟,觉得它能滚动更新、能自愈、能扩缩容,已经足够强大,那StatefulSet的出现意义到底是什么?直到第一次接手数据库、消息队列这类需要"记住自己是谁"的应用,才意识到Deployment根本没法满足需求。StatefulSet是Kubernetes内置Controller之一,专门用于管理有状态工作负载,它给每个Pod分配固定的序号名字、固定的网络标识和可复用的持久卷,并按顺序完成部署和更新。这篇文章就从原理到实操把它拆开:它解决了什么问题,名字和存储是怎么绑定的,滚动更新怎么控制,以及我在实际环境中踩过的坑。

1. Deployment搞不定的场景:StatefulSet存在的理由

1.1 无状态和有状态,差别到底在哪

先看一个最简单的例子:Web API服务。请求打到哪个副本都行,某个副本挂了就重新拉起一个,换IP、换名字、换节点都无所谓,只要副本数够,服务就能继续转。这类应用叫无状态应用,Deployment处理它们非常顺手。

但数据库、分布式协调服务、消息队列这类系统就不一样。节点之间需要互相识别,每个节点通常有独立角色,重启之后不能变成"陌生人";数据必须持久化,Pod被删了,数据不能跟着丢。这类应用是有状态应用。很多人用一句话概括两者的区别:无状态应用是"临时工",有状态应用是"正式员工"。正式员工有工牌、有工位、有储物柜,你的工位不能随便换,你休息回来还得回到自己的柜子前。StatefulSet就是干这件事的:给每个Pod一个固定身份,并保证它使用的存储和数据不随Pod重建而漂移。

1.2 StatefulSet给Pod的三样东西,Deployment给不了

StatefulSet和Deployment的核心差异,可以归纳为三样东西:

  • 稳定且唯一的网络标识:每个Pod有一个永久的名字,从0开始编号,比如db-0、db-1、db-2。Pod重建后名字不变。
  • 稳定且可复用的持久化存储:通过volumeClaimTemplates自动生成独立PVC,Pod和PVC一一对应。
  • 有序的部署、缩容和更新:创建时从序号小的开始,缩容时从序号大的开始,滚动更新按反序逐个推进。

这三样东西组合在一起,才让有状态应用真正能在Kubernetes上跑起来。举个例子说明为什么Deployment做不到:Deployment创建的Pod名字后面跟着随机后缀,每次重建名字都变,副本之间的PVC也无法和特定Pod绑定。你可以写脚本创建多个Deployment来模拟稳定身份,但副本编号、顺序、缩容逻辑都要自己维护,等于重新实现了一遍StatefulSet,且很容易在细节上出错。

需要特别提一句:StatefulSet不是Deployment的替代品。两者解决的问题不同,一个是面向无状态应用的通用Controller,一个是面向有状态应用的专门Controller。如果一个应用无状态,用StatefulSet反而没有意义,因为StatefulSet带来了额外的存储、顺序等待和更复杂的滚动更新约束,这些对无状态场景都是负担。

2. 每个Pod都有名字:Headless Service与稳定网络标识

2.1 Pod的命名规则与DNS记录是怎么生成的

StatefulSet的Pod命名规则非常简单:$(StatefulSet名称)-$(序号),序号从0开始。比如一个名为db、副本数为3的StatefulSet,生成的Pod就是db-0、db-1、db-2。

这里有一个关键的"为什么":Pod的IP是可以变的,每次重建都可能分到新IP,但名字固定。对集群类应用来说,节点间通信、TLS证书里的SAN、客户端的连接白名单,几乎都以名字而不是IP作为标识依据。IP变了,应用可以通过DNS重新解析;名字变了,整个集群的成员关系就乱了。所以StatefulSet把"名字"锁定,这是最基本的一层保障。

要获得稳定且可解析的域名,StatefulSet必须配合Headless Service使用。所谓Headless Service,就是spec.clusterIP: None的Service,它不分配ClusterIP,DNS查询时会直接返回后端所有Pod的IP列表。同时,Kubernetes还会为每个Pod生成一条独立的A记录,格式是:

$(podName).$(serviceName).$(namespace).svc.cluster.local

例如db-0对应:

db-0.db-hs.default.svc.cluster.local

这条记录永远指向db-0当前的实际IP。Pod重建后,IP变化,这条DNS记录会跟着更新,但域名不变。外部应用只需要记域名,不需要关心Pod搬到哪里。

这也是StatefulSet和普通Service搭配时最容易踩的认知误区:如果给StatefulSet配一个带ClusterIP的普通Service,Pod的独立域名是拿不到的。普通Service提供的是虚拟IP负载均衡,所有Pod共享一个入口;而StatefulSet需要的恰恰是"逐个点名"的能力。所以说,装进StatefulSet里的应用想要通过DNS互相寻找,必须有一个Headless Service在前面垫底。

2.2 客户端连接时应该用域名还是IP

这个问题在真实环境里被反复问过。我的建议很直接:能用域名就别用IP,尤其是集群内节点互相通信的场景。

举两个典型的例子:

  • 数据库集群的节点发现:每个节点启动时,通过Headless Service的DNS解析出所有节点域名,然后按名字加入集群。某个节点重建后IP变了,其他节点的成员列表不需要改,因为它们记录的是域名。
  • 分片路由场景:比如消息系统规定分区数据落在web-1上,客户端写入时直接连接web-1的域名。就算web-1被删除重建,域名照样能找到新副本,路由逻辑完全不用变。

如果你的容器端口定义了名字,Kubernetes还会自动生成SRV记录。比如端口名是db,查询:

_db._tcp.db-hs.default.svc.cluster.local

会返回所有合格Pod的主机名和端口号。对于需要动态枚举集群成员的应用,这比逐个猜域名要省事得多。

还有一个实际诉求:如果你要把某个Pod单独暴露给集群外部,不能直接用一个固定域名指向Headless Service里的单个Pod,因为Headless Service的DNS只对集群内生效,集群外无法解析。这种情况要么使用支持多主机名的Ingress或负载均衡方案,要么在应用层自己维护一份"Pod域名到对外地址"的映射表。这也是StatefulSet不适合直接对外提供服务的常见原因之一,很多人在这里绕了很久。

3. 数据不换房:volumeClaimTemplates与存储绑定

3.1 PVC命名规则和模板自动生成的逻辑

StatefulSet的spec里可以定义volumeClaimTemplates,它看起来就像一份"PVC制造模板"。每个副本创建时,StatefulSet控制器会自动用模板生成一份独立PVC,PVC的命名规则是:

$(volumeClaimTemplates中的name)-$(StatefulSet名称)-$(序号)

举例:模板叫data,StatefulSet叫db,那么自动生成的PVC就是:

data-db-0>apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: sc-local provisioner: your.provisioner.name reclaimPolicy: Retain volumeBindingMode: WaitForFirstConsumer

还有个容易被忽视的点:StatefulSet本身没有节点固定能力,节点固定是DaemonSet或者其他调度约束的事。如果应用对节点拓扑极其敏感,比如依赖本地SSD,就要在Pod模板里加上nodeSelector、nodeAffinity或者topologySpreadConstraints。否则,即使PVC配置正确,Pod也可能被调度到没有所需硬件的节点上,存储挂载会再次出问题。

4. 有序部署与滚动更新:StatefulSet的节奏控制

4.1 OrderedReady与Parallel:先启动还是大家一起上

StatefulSet的podManagementPolicy字段控制创建、删除和更新时的有序性。默认值是OrderedReady,这也是StatefulSet最有辨识度的行为:

  • 部署时按db-0、db-1、db-2的顺序逐个创建,并且当前一个Pod变成Ready后,才开始创建下一个。
  • 缩容时反序删除,先删db-2,再删db-1,最后db-0。
  • 滚动更新时也从最大序号往最小序号逐个更新。

这个设计不是为了制造仪式感,而是因为很多集群类应用的启动本身就依赖顺序。比如一个主从结构的数据库,通常要先有主节点,从节点才能成功加入集群并完成初始化。如果三个Pod同时启动,大家都不知道谁先谁后,初始化逻辑就会互相打架。

注意,判断Pod是否Ready依赖readinessProbe。如果模板里没有定义readinessProbe,Pod只要Running就会被认为Ready,OrderedReady的顺序约束实际上会在毫秒级内走完,看起来像并行创建。所以,如果你的应用真的需要严格的一前一后启动,readinessProbe必须写好,否则这个"有序"就是形式上的。

Parallel策略则完全相反:创建和缩容时并行执行,不等待Ready信号。适合那种副本之间没有依赖关系、需要快速拉起的应用。这里要提醒一句:Parallel策略下,滚动更新的节奏也会跟着改变,不会像OrderedReady那样逐个等待,实际行为在不同版本里略有差异。所以如果你正在跑正式数据库,不要轻易把podManagementPolicy改成Parallel。

4.2 partition参数与分阶段发布操作实例

StatefulSet的滚动更新还有一个非常实用的控制参数:partition。它属于updateStrategy.rollingUpdate,语义是"序号小于partition的Pod不会被更新"。换句话说,只有序号大于等于partition的Pod才会进入新版本。

基于这个语义,可以很方便地做金丝雀发布。假设db目前有3个副本,全部运行旧版本:

  1. 先把partition调整为2,只允许db-2被更新。执行命令:
kubectl patch sts db --type='json' -p='[{"op":"replace","path":"/spec/updateStrategy/rollingUpdate/partition","value":2}]'
  1. 更新镜像版本:
kubectl set image sts/db db=myrepo/db-node:6.0 kubectl rollout status sts/db

此时StatefulSet只会把db-2更新到新镜像,db-0和db-1保持原样。验证新版本稳定后,再把partition调成1,让db-1加入新版本;最后partition调成0,全部更新完。

整个过程就像放水闸,每次只放一个副本进入新版本,风险被限制在很小的范围内。这也引出一个常见坑:如果你之前做过金丝雀发布,partition一直留在2,下次想全量发布时忘记改回来,就会发现只有db-2被更新,其他Pod纹丝不动。升级前一定要检查partition的当前值。

在较新的Kubernetes版本里,StatefulSet的滚动更新还支持maxUnavailable参数,允许同时有多个副本处于不可用状态,以加快更新速度。但对有状态应用,我基本不建议把它调大。数据库这种应用一次只有一个副本可以短暂不可用,已经是比较理想的状态了,为了几分钟的提速去冒两个副本同时不可用的风险,不值得。

4.3 OnDelete策略适合什么场景

StatefulSet的updateStrategy还可以设置成OnDelete。这个模式下,StatefulSet不会主动更新任何Pod,只有当你手动删除一个Pod后,它才会按照最新的模板重建这个Pod。

什么场景会用到?最常见的是Operator接管场景。当你有自己的运维控制器在管理数据库集群时,滚动升级的顺序、每次升级前是否备份、升级后是否触发成员重新加入,这些细节通常需要自定义逻辑控制。如果让StatefulSet的默认滚动更新逻辑接管,反而会和Operator的步骤打架。这时把updateStrategy改成OnDelete,StatefulSet只负责Pod重建,剩下的流程全交给Operator调度。

另一个场景是定向升级:比如副本A和副本B配置不同,你想单独升级A,并不想让B跟着动。OnDelete模式下你只需要删掉A对应的Pod,重建出来的就是新模板,完全不影响B。这个能力在灰度验证某个节点的新参数时特别有用。

5. 从零部署一个有状态服务:3副本清单与验证

5.1 准备工作和完整YAML

纸上谈兵说得再多,不如实际部署一次。下面给一套可以放到测试环境直接用的最小清单。假设你的集群里已经有一个可用的StorageClass,名字叫sc-local,如果没有,先把YAML里的storageClassName换成你集群里真实存在的名字。

apiVersion: v1 kind: Service metadata: name: db-hs spec: clusterIP: None selector: app: db ports: - port: 3306 targetPort: 3306 --- apiVersion: apps/v1 kind: StatefulSet metadata: name: db spec: serviceName: db-hs replicas: 3 selector: matchLabels: app: db template: metadata: labels: app: db spec: containers: - name: db image: myrepo/db-node:5.7 ports: - containerPort: 3306 name: db readinessProbe: tcpSocket: port: 3306 initialDelaySeconds: 5 periodSeconds: 5 volumeMounts: - name: data mountPath: /var/lib/dbdata volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] storageClassName: sc-local resources: requests: storage: 20Gi

用kubectl apply -f执行后,观察几件事。

5.2 部署后的验证与模拟故障恢复

部署完成后,我习惯用下面这组命令做一轮快速验证:

kubectl get sts kubectl get pod -l app=db -o wide kubectl get pvc -l app=db kubectl exec db-0 -- hostname

正常结果里你会看到:三个Pod名字分别是db-0、db-1、db-2;三个PVC名字分别是data-db-0、data-db-1、data-db-2;db-0里执行hostname会输出db-0。

然后推荐做几个安全演练:

  1. 删除单个Pod,观察自动恢复:
kubectl delete pod db-1

过几秒再看,db-1会以完全相同的名字重建,PVC仍然挂载data-db-1,数据路径不会变。

  1. 缩容再扩容:
kubectl scale sts db --replicas=1

观察删除顺序应该是db-2先消失,然后db-1。再扩容回3:

kubectl scale sts db --replicas=3

新Pod会按缺失序号补齐,重新挂载原来的PVC。这条验证能直观地告诉你:之前缩容删掉的Pod,数据不是丢了,而是安静地躺在PVC里等着你回来。

  1. 模拟一次滚动更新:
kubectl set image sts/db db=myrepo/db-node:6.0

在OrderedReady策略下,更新顺序应该是db-2、db-1、db-0,整个过程可观测。

这些演练加起来也就是几分钟时间,但对理解StatefulSet的行为模式帮助非常大。我见过不少同行直接把数据库扔进StatefulSet,却没做过任何一次故障恢复演练,结果真正出事时连"为什么Pod重建后数据还在"都说不上来。

6. 生产环境踩坑记录:StatefulSet最常见的三个问题

6.1 Pod一直Pending,问题其实在StorageClass

StatefulSet创建后,Pod一直Pending,排查时要先看Pod状态和事件,但更大的概率是PVC出了问题。

排查链路是这样的:

  1. 看PVC状态:
kubectl get pvc -l app=db

如果某个PVC的STATUS一直是Pending,说明存储层没就绪。

  1. 看PVC详情:
kubectl describe pvc>
  • 检查StorageClass本身:
  • kubectl get storageclass kubectl describe storageclass sc-local

    很多刚接触StatefulSet的人习惯性地检查Pod配置、检查Service,却忽略存储是独立的一层。StatefulSet的Pod创建依赖PVC创建成功,而PVC创建成功依赖StorageClass和底层存储可用。这一步断掉,后面全是Pending。

    另一个隐蔽情况在WaitForFirstConsumer模式下。如果PVC一直不绑定,但StorageClass看起来正常,多半是Pod在等待调度完成后才触发卷创建。此时要看Pod为什么还没被调度,而不是盯着PVC找原因。

    6.2 卡在Terminating和强制删除的连锁反应

    StatefulSet的Pod有时会长时间卡在Terminating。原因多种多样:kubelet失联、volume detach没完成、finalizer一直没清掉等。先别急着强删,动手前把状态看清楚。

    排查时看三样东西:

    kubectl describe pod db-1 kubectl get pod db-1 -o yaml | grep -A 20 finalizers kubectl get node

    如果发现是volume detach的问题,强行删除Pod往往解决不了根因,因为底层存储还在等待卸载。某同学的测试集群里就出过这种事:一个数据库Pod卡在Terminating,他直接执行了强制删除,结果PVC和底层卷在detach流程里又卡了几小时,后续的新Pod一直Pending。等于一次小故障演练,最后虽然数据没丢,但整个过程非常惊险。

    我的原则是:能等就等,能查根因就查根因。只有在确认Pod永远不会被kubelet清理、且数据有备份的前提下,才考虑:

    kubectl delete pod db-1 --grace-period=0 --force

    强删之后还要马上检查PVC状态,确认卷没有被异常锁定。记住,强删是最后的兜底手段,不是第一反应。

    6.3 滚动更新迟迟不动:readinessProbe和partition的联合坑

    滚动更新不动,是StatefulSet实战里最让人恼火的问题之一。

    第一种原因:readinessProbe缺失或配置不合理。如果没有readinessProbe,Pod会被快速标记为Ready,更新推进很快,看起来"一切正常",但应用可能还没真正就绪,后面副本就被替换掉了。如果probe配置得太严格,比如数据库还在恢复数据,TCP端口还没起来,滚更新就会卡住等待就绪。这个坑很难通过看YAML发现,必须结合应用实际启动时间来调。

    第二种原因:partition残留。这是最容易被忽略的。前期做金丝雀发布时把partition设置为2,后来又忘了归零。下一次全量发布时,shell里翻来覆去检查镜像版本,却发现只有db-2被更新了,db-0和db-1完全不动。解决起来也简单:

    kubectl get sts db -o yaml | grep -A 5 rollingUpdate

    看到partition不是0时,先patch回0,再继续发布。这个操作我已经在自己的检查清单里加了一条"发布前确认partition"。养成这个习惯之后,几乎不会在StatefulSet更新上浪费时间。

    如果滚动更新卡在某个Pod上,别傻等。先看当前Pod镜像版本:

    kubectl get pod -l app=db -o jsonpath='{range .items[*]}{.metadata.name} {.spec.containers[0].image}{"\n"}{end}'

    再对比StatefulSet里的期望镜像版本。版本不一致,说明还有副本没被更新;版本都一致,说明卡在就绪等待阶段,就去查readinessProbe。这套排查思路走下来,大部分问题都能在几分钟内定位。

    最后再分享一个习惯:我在有状态应用的发布流程里,永远保留一份"可回滚的镜像版本号"。StatefulSet的回滚本质上就是再来一次滚动更新,把镜像指回旧版本。但如果partition还卡在某个值,回滚也会被截断。所以无论是回滚还是发布,第一步永远是确认partition,第二步才是改镜像。这个顺序反了,后面全是坑。

    RELATED READING

    延伸阅读

    更多一线实战笔记与深度复盘,助您持续精进