ARTICLE DETAIL

资讯详情

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

StatefulSet实战指南:从Deployment局限到有状态应用部署

StatefulSet实战指南:从Deployment局限到有状态应用部署 前阵子帮一个朋友排查线上故障情况很典型他用Deployment跑MySQL某个worker节点宕机后Kubernetes自动把Pod调度到了新节点Pod起来了但整个主从集群直接瘫痪。原因不难猜——Deployment重建Pod时用的是随机后缀旧Pod的身份彻底消失从节点连不上原来的主节点加上数据目录和Pod的关系也乱了。这件事之后他一直对Kubernetes的控制器体系有疑虑其实问题不在Kubernetes本身而是他选错了控制器。StatefulSet就是专门为这类有状态应用设计的控制器。这篇文章我会把StatefulSet的底层设计逻辑、部署步骤、发布扩缩容的注意事项以及我在生产环境里踩过的坑完整讲一遍既适合刚入门的人照着做也适合准备Kubernetes面试的朋友把Deployment vs StatefulSet这个问题真正讲透。1. 有状态应用上K8s的第一道坎为什么Deployment搞不定1.1 Deployment的牲畜哲学Kubernetes社区有一个经典比喻用Deployment管理的Pod是牲畜cattle不是宠物pet。牲畜生来就是群体管理——生病了治不好直接淘汰买一头新的补上数量就行宠物则每只都有名字、有自己的习性丢了会很麻烦。Deployment明显属于前者。它关心的核心指标只有一个副本数是否满足期望值。Pod的名字、IP、启动顺序、存储归属这些细节它一概不负责。这种设计让Deployment在处理Nginx这类无状态服务时效率极高。应用本身不保存数据所有配置从一个ConfigMap里读流量通过Service做负载均衡请求打到哪个副本都无所谓。节点故障时kubelet上报NotReadyDeployment控制器会在其他节点上按PodTemplate重新拉起一个Pod整个过程对客户端几乎无感知。但问题也出在这套一视同仁的假设上。Deployment创建的每个Pod都是同一个模板的产物名字是xxx-abc12这种随机串IP是动态分配的谁也无法保证某个具体Pod会落在哪个节点上。这套机制对无状态应用是优点对数据库、消息队列这类应用就是致命伤。1.2 有状态应用真正担心的三件事把MySQL、Redis Cluster、ZooKeeper这类应用塞进Deployment问题会立刻暴露。我总结下来有状态应用在Kubernetes里担心的核心事情就三件第一网络身份必须稳定。主从复制的场景里从节点要用主节点的主机名或固定IP去建立连接如果主节点每次重建都换一个随机名字连接配置就是一场灾难。你可以用Service把流量打到主节点但前提是得有办法区分哪一个是主节点而这个区分能力恰恰是Deployment不提供的。第二数据必须跟某个实例绑定。主节点宕机后重新调度新Pod必须挂载原来那块数据盘里面不能是新初始化的空目录。Deployment的Pod模板是共享的它不知道也不关心这个副本曾经属于谁存储挂载完全靠手工指定同名PVC去碰运气。第三启动和停止要有顺序。一个三节点的ZooKeeper集群通常需要前一个节点真正启动完成、加入集群再启动下一个才能形成法定人数Deployment的一批全起策略在部分场景下会直接导致集群初始化失败。这也是StatefulSet存在的全部理由。1.3 面试视角StatefulSet和Deployment的本质差异我把这两者的差异整理成一张表面试或者做方案选型时可以直接拿来用维度DeploymentStatefulSetPod命名随机后缀xxx-abc12固定序号xxx-0, xxx-1网络身份不稳定重建即变稳定配合无头服务可持久访问存储所有副本共享模板卷每个副本独立PVC数据隔离启动顺序并行启动OrderedReady按序号依次启动更新方式无序遍历逆序逐个更新适用场景无状态服务数据库、消息队列、协调服务等记住一句话Deployment保证的是有足够多的副本在跑StatefulSet保证的是每个副本以确定的身份和数据状态在跑。这两者的目标函数从根上就不同理解了这一点后续所有细节都会顺理成章。2. StatefulSet的三张王牌稳定身份、有序编排、持久化绑定2.1 稳定网络标识与无头服务StatefulSet最直观的特征就是Pod名字从随机串变成了有规律序号比如web-0、web-1、web-2。序号是持久化的Pod被删除重建后序号不会变。这个看似简单的变化是StatefulSet所有能力的基石。但光有名字还不够。为了让其他组件能够稳定访问这些有名字的PodStatefulSet要求必须配合一个无头服务Headless Service。无头服务的特点是spec.clusterIP: None它不创建ClusterIP也不做负载均衡只负责把服务的DNS解析到后端所有Pod的IP上。这样每个Pod就有了一个稳定的FQDN例如web-0.web-svc.default.svc.cluster.local无论Pod在哪个节点、IP是多少这个域名始终指向序号为0的那个Pod。这里我要多说一句为什么不能用普通Service普通Service会隐形一层负载均衡客户端访问服务名时会被随机分发到某个Pod此时web-0这个身份就失去了意义。比如主从架构中客户端要找的是确切的主节点而不是随便一个能用的节点。无头服务正好把每个Pod的身份完整暴露给DNS让应用自己去选这是StatefulSet能跑起来的前提。2.2 有序创建、删除与更新StatefulSet的第二个关键机制是有序性。默认的podManagementPolicy是OrderedReady创建时严格按0、1、2的顺序来只有第n个Pod运行并且Ready控制器才会创建第n1个。删除和缩容则反过来先删序号最大的。这个机制对有启动依赖的应用是救命的。比如Etcd集群第一个成员要先起来并对外提供协调服务第二个成员才能加入如果三个节点同时启动、互相等待很可能出现集群初始化不稳定。StatefulSet用排队过闸的方式把不确定性彻底拿掉了。不过注意OrderedReady也意味着部署和扩缩容会比Deployment慢若干个Pod需要串行启动整体耗时依赖每个Pod就绪的时间。如果你的应用对顺序无感可以设置podManagementPolicy: Parallel让所有Pod并行启动省时间。2.3 volumeClaimTemplatesPVC跟着Pod走的秘密Deployment的Pod如果声明了存储卷所有副本共用同一个模板也就是所有Pod挂的是同一块卷这在无状态场景下其实很少这么用。有状态应用需要的是每个Pod一块独立的存储StatefulSet用volumeClaimTemplates解决。volumeClaimTemplates的写法跟PVC的spec一模一样但它是一个量产模具控制器为每个Pod自动生成一个PVC命名规则是volumeClaimTemplate名称-StatefulSet名称-序号。例如>kubectl get storageclass输出里会看到类似nfs-client、openebs-hostpath或者云厂商的alicloud-disk、ebs-gp2等。我的测试环境用的是nfs-client它由NFS Subdir External Provisioner提供会在NFS服务器上为每个PV创建独立目录部署起来快适合验证StatefulSet的机制。生产环境建议用云厂商的块存储云盘或兼具性能与可靠性的分布式存储毕竟数据库这类应用的IOPS和延迟对底层存储的敏感度非常高。3.2 清单文件怎么拆Headless Service与StatefulSet下面是一个MySQL主从的简化清单拆成两个资源一个无头服务负责暴露稳定DNS一个StatefulSet负责创建和管理Pod。--- apiVersion: v1 kind: Service metadata: name: mysql-svc spec: clusterIP: None selector: app: mysql ports: - port: 3306 targetPort: mysql --- apiVersion: apps/v1 kind: StatefulSet metadata: name: mysql spec: serviceName: mysql-svc replicas: 2 selector: matchLabels: app: mysql template: metadata: labels: app: mysql spec: initContainers: - name: init image: busybox:1.36 env: - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name command: - sh - -c - | ordinal${POD_NAME##*-} if [ $ordinal -eq 0 ]; then echo primary /etc/role/role else echo replica /etc/role/role fi volumeMounts: - name: role mountPath: /etc/role containers: - name: mysql image: mysql:8.0 env: - name: MYSQL_ROOT_PASSWORD value: change-me - name: MYSQL_REPLICATION_USER value: repl - name: MYSQL_REPLICATION_PASSWORD value: repl-pass ports: - containerPort: 3306 name: mysql volumeMounts: - name: data mountPath: /var/lib/mysql - name: role mountPath: /etc/role volumeClaimTemplates: - metadata: name: data spec: accessModes: [ReadWriteOnce] storageClassName: nfs-client resources: requests: storage: 20Gi这里有几个关键点要解释。spec.serviceName必须填无头服务的名字StatefulSet靠它来生成Pod的DNS记录。volumeClaimTemplates里的storageClassName要改成你集群里真实可用的StorageClass。initContainers里的逻辑是读取Pod名称的最后一段数字判断自己是主节点还是从节点然后把角色写入共享空卷roleMySQL容器启动时读这个文件来决定复用配置文件。要注意这个例子只是为了让StatefulSet的机制清晰可见真正生产要跑MySQL主从还需要写entrypoint脚本来根据角色执行CHANGE MASTER TO或者用Orchestrator/MySQL Operator来做自动故障转移。StatefulSet负责的是让每个副本有稳定身份和独立存储应用层面的主从逻辑依然要自己实现。3.3 用序号做差异化配置initContainer的小技巧上一节的例子里用到了一个非常实用的技巧通过Downward API把Pod名字注入环境变量再在initContainer里解析序号。ordinal${POD_NAME##*-}${POD_NAME##*-}是Shell参数扩展表示从左边开始删除直到最后一个-之前的内容留下的就是序号。序号为0的Pod当主节点其余当从节点这样就用一个模板定义出了角色不同的副本。这就是每个副本可以有不同的配置的实现方式——不是写死在YAML里而是根据序号的确定性在启动时计算出来。这个思路可以扩展到很多场景按序号分片的Redis集群、按序号分配不同端口的游戏服务器、按序号决定优先级的工作节点。只要问题能抽象成序号决定行为StatefulSet就能优雅解决。3.4 部署验证从Pod顺序到PVC绑定执行部署后按下面的顺序验证kubectl apply -f mysql.yaml kubectl get pod -l appmysql -w你会观察到mysql-0先进入ContainerCreating等它变成Running且1/1后mysql-1才会开始创建。这就是OrderedReady策略的直接体现。kubectl get pvc输出里应该有>kubectl run -it --rm test --imagebusybox:1.36 --restartNever -- nslookup mysql-0.mysql-svc.default.svc.cluster.local能解析出实际Pod IP就说明无头服务工作正常。这套验证流程我每次部署StatefulSet都会走一遍顺序、存储、DNS三者都通了后面才敢继续做业务配置。4. 发布与扩缩容StatefulSet的运维操作细节4.1 RollingUpdate与OnDelete两种更新策略怎么选StatefulSet默认更新策略是RollingUpdate但它和Deployment的滚动更新有一个关键区别更新顺序是逆序的先更新序号最大的Pod且要等当前Pod更新完并Ready才继续更新下一个。这符合有状态应用对稳定性的预期——先更新从节点观察没有问题最后才轮到主节点。另一种策略是OnDelete修改Pod模板后不会自动更新只有手动删除某个Pod控制器才按新模板重建它。这种策略适合数据库大版本升级这类需要完全人工掌控节奏的场景。比如MySQL从5.7升到8.0你可能想手动指定某个从节点先升级验证兼容性之后再逐个推进OnDelete给的就是这种自由度。4.2 partition参数从灰度到金丝雀发布RollingUpdate策略里还有一个被很多人忽略的参数partition它的含义是只更新序号大于等于partition值的Pod。比如当前副本是3你想先升级mysql-1和mysql-2这两个从节点不动主节点mysql-0。在spec.updateStrategy.rollingUpdate.partition里设为1然后更新镜像版本。控制器只会把序号大于等于1的Pod升级mysql-0保持旧版本。观察一段时间业务无异常后再把partition改成0主节点才会被更新。这个机制我愿称之为天然的金丝雀发布方案。对有状态应用来说灰度发布比全量发布安全太多。数据库这类组件一旦更新出错影响的是整个集群的读写链路能先在一个副本上验证再全量是必须的。4.3 扩缩容时容易被忽略的三个点第一缩容是逆序删Pod但PVC默认保留。kubectl scale statefulset mysql --replicas1之后>persistentVolumeClaimRetentionPolicy: whenScaled: Retain whenDeleted: DeletewhenScaled控制缩容时是否删除PVCwhenDeleted控制删除整个StatefulSet时是否删除PVC。默认行为都是Retain也就是留着数据。设置成Delete要非常谨慎——PVC被删PV的回收策略如果是Delete底层存储上的数据就真的没了。第三频繁扩缩容的场景不要用StatefulSet。每次扩容都是串行启动每加一个副本都可能是几分钟遇到需要快速弹性的流量高峰会很难受。这时候要想一想业务到底有没有状态如果有能不能接受副本数不经常变化有状态应用本来就该以稳定为主弹性是次要诉求。5. 生产环境踩坑实录三次故障的完整排查链路5.1 故障一Pod一直PendingPVC卡在Bound不上现象是kubectl get pod看到StatefulSet的Pod长期处于Pendingdescribe之后显示FailedScheduling但节点资源明明充足。排查链路是这样的先kubectl get pvc发现PVC状态是Pending而不是Bound说明问题出在存储供给环节。再kubectl describe pvc事件里往往会直接写明等待的StorageClass。如果StorageClass不存在或者provisioner对应的控制器Pod没有正常运行PVC就永远无法完成绑定。这里还有一个小坑动态供给的StorageClass有两种卷绑定模式Immediate和WaitForFirstConsumer。Immediate模式下PVC创建后立刻尝试绑定PV如果当时没有合适的存储会一直PendingWaitForFirstConsumer则等Pod调度完成后才开始绑定能考虑Pod的节点拓扑。云盘类存储通常有可用区限制Pod调度到的节点如果和PV不在同一个可用区挂载也会失败。遇到这种问题检查StorageClass的volumeBindingMode必要时改成WaitForFirstConsumer。5.2 故障二滚动更新卡死如何安全回滚有一次我把StatefulSet的镜像tag从8.0改成8.0.36结果kubectl rollout status statefulset/mysql一直卡在waiting for update。原因是一个Pod的启动探针连续失败新版本一直没有Ready控制器就停止推进后续副本的更新。这个时候先别急用kubectl describe pod mysql-1看事件确认是镜像拉取失败、启动命令报错还是探针配置太苛刻。如果只是探针超时设置得太短调整initialDelaySeconds和periodSeconds重新apply即可更新会自动继续。如果确定是版本本身有问题需要回滚kubectl rollout undo statefulset/mysqlStatefulSet支持回滚到上一个版本方式也是逆序逐个重建Pod。回滚完成后务必检查kubectl get pod和数据库的实际读写状态确认主从同步正常。这里我要提醒一句所有对StatefulSet的更新操作尤其是数据库类应用都建议先查kubectl rollout history statefulset/mysql确认当前版本和上一次版本再动手。额外留一份数据库备份比什么后手都踏实。5.3 故障三误删StatefulSet后数据差点被清空这是我最想强调的一个坑。有同事在清理资源的时候执行了kubectl delete statefulset mysql然后一脸惊恐地来找我说数据库数据没了。实际上数据大概率还在——删除StatefulSet默认不会删除PVC和PV所以PVC、PV和底层存储的数据都还保留着。重新kubectl apply -f mysql.yaml之后新Pod会按顺序重新绑定到原来的PVC上数据自然回来。真正危险的操作是后面那句kubectl delete pvc>
返回列表