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自动生成独立PVCPod和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它不分配ClusterIPDNS查询时会直接返回后端所有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的普通ServicePod的独立域名是拿不到的。普通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控制器会自动用模板生成一份独立PVCPVC的命名规则是$(volumeClaimTemplates中的name)-$(StatefulSet名称)-$(序号)举例模板叫dataStatefulSet叫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。如果模板里没有定义readinessProbePod只要Running就会被认为ReadyOrderedReady的顺序约束实际上会在毫秒级内走完看起来像并行创建。所以如果你的应用真的需要严格的一前一后启动readinessProbe必须写好否则这个有序就是形式上的。Parallel策略则完全相反创建和缩容时并行执行不等待Ready信号。适合那种副本之间没有依赖关系、需要快速拉起的应用。这里要提醒一句Parallel策略下滚动更新的节奏也会跟着改变不会像OrderedReady那样逐个等待实际行为在不同版本里略有差异。所以如果你正在跑正式数据库不要轻易把podManagementPolicy改成Parallel。4.2 partition参数与分阶段发布操作实例StatefulSet的滚动更新还有一个非常实用的控制参数partition。它属于updateStrategy.rollingUpdate语义是序号小于partition的Pod不会被更新。换句话说只有序号大于等于partition的Pod才会进入新版本。基于这个语义可以很方便地做金丝雀发布。假设db目前有3个副本全部运行旧版本先把partition调整为2只允许db-2被更新。执行命令kubectl patch sts db --typejson -p[{op:replace,path:/spec/updateStrategy/rollingUpdate/partition,value:2}]更新镜像版本kubectl set image sts/db dbmyrepo/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改成OnDeleteStatefulSet只负责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 appdb -o wide kubectl get pvc -l appdb kubectl exec db-0 -- hostname正常结果里你会看到三个Pod名字分别是db-0、db-1、db-2三个PVC名字分别是data-db-0、data-db-1、data-db-2db-0里执行hostname会输出db-0。然后推荐做几个安全演练删除单个Pod观察自动恢复kubectl delete pod db-1过几秒再看db-1会以完全相同的名字重建PVC仍然挂载data-db-1数据路径不会变。缩容再扩容kubectl scale sts db --replicas1观察删除顺序应该是db-2先消失然后db-1。再扩容回3kubectl scale sts db --replicas3新Pod会按缺失序号补齐重新挂载原来的PVC。这条验证能直观地告诉你之前缩容删掉的Pod数据不是丢了而是安静地躺在PVC里等着你回来。模拟一次滚动更新kubectl set image sts/db dbmyrepo/db-node:6.0在OrderedReady策略下更新顺序应该是db-2、db-1、db-0整个过程可观测。这些演练加起来也就是几分钟时间但对理解StatefulSet的行为模式帮助非常大。我见过不少同行直接把数据库扔进StatefulSet却没做过任何一次故障恢复演练结果真正出事时连为什么Pod重建后数据还在都说不上来。6. 生产环境踩坑记录StatefulSet最常见的三个问题6.1 Pod一直Pending问题其实在StorageClassStatefulSet创建后Pod一直Pending排查时要先看Pod状态和事件但更大的概率是PVC出了问题。排查链路是这样的看PVC状态kubectl get pvc -l appdb如果某个PVC的STATUS一直是Pending说明存储层没就绪。看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-period0 --force强删之后还要马上检查PVC状态确认卷没有被异常锁定。记住强删是最后的兜底手段不是第一反应。6.3 滚动更新迟迟不动readinessProbe和partition的联合坑滚动更新不动是StatefulSet实战里最让人恼火的问题之一。第一种原因readinessProbe缺失或配置不合理。如果没有readinessProbePod会被快速标记为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 appdb -o jsonpath{range .items[*]}{.metadata.name} {.spec.containers[0].image}{\n}{end}再对比StatefulSet里的期望镜像版本。版本不一致说明还有副本没被更新版本都一致说明卡在就绪等待阶段就去查readinessProbe。这套排查思路走下来大部分问题都能在几分钟内定位。最后再分享一个习惯我在有状态应用的发布流程里永远保留一份可回滚的镜像版本号。StatefulSet的回滚本质上就是再来一次滚动更新把镜像指回旧版本。但如果partition还卡在某个值回滚也会被截断。所以无论是回滚还是发布第一步永远是确认partition第二步才是改镜像。这个顺序反了后面全是坑。