前阵子帮一个朋友看生产环境,一个用Deployment部署的Redis主从架构频繁出问题:每次发布或者节点重启,主从关系就乱套,数据还没完全同步就被切走。我看了半天配置,最后告诉他把这组Redis换到StatefulSet上——问题的根不在Redis参数,而在K8s没给它“身份”。这句话基本能概括今天这篇的核心:K8s高可用实战,Deployment和StatefulSet你得分开用。
正好这是K8s系列第九篇。前面八篇我们从Pod、Service、Namespace一路讲到Ingress和存储,经常有朋友问“Deployment已经这么方便了,什么场景还要碰StatefulSet”“为什么我的Redis集群用Deployment管理就老是埋雷”。这篇就把两个控制器放到同一张桌上对比着讲,顺便给出一套可以直接抄的Redis集群StatefulSet配置。
如果说前几篇是在教“怎么把容器跑起来”,那这一篇就是教“怎么让容器在故障面前保持体面”。我会先讲高可用设计的账怎么算,再拆Deployment的进阶玩法,接着把StatefulSet的机制彻底拆开,最后落一个完整实战。适不适合你,看到选型表应该心里就有数了。
1. 先把高可用的账算明白:Deployment和StatefulSet各守什么
1.1 无状态高可用的核心套路
很多刚接触K8s的人有个误区:高可用就是多副本。一听这话我就想摇头。
无状态服务确实靠多副本解决问题:Nginx、后端API、定时任务worker,这些组件不保存自己的业务数据,或者数据都在Redis、MySQL这类独立存储里。这种情况想做高可用,只需要保证副本数大于1,把流量交给Service或Ingress负载均衡,任何一个Pod挂掉,控制器立刻重建一个,用户几乎无感知。
无状态场景下Deployment就是最趁手的工具。它底层的ReplicaSet会持续保证副本数,滚动更新时先启动新Pod、确认可用后再干掉旧Pod,天然把发布风险控制在最小。你把maxUnavailable调小,甚至能做到发布过程零中断。
所以无状态高可用的公式很简单:副本数≥2加readiness探针准确加Service流量收敛加控制器自动重建。Deployment就是公式里“自动重建”和“滚动更新”这两环的化身。
1.2 有状态应用到底缺什么
但一旦涉及有状态应用,公式就失灵了。还是拿Redis举例:三个节点组成主从结构,对外提供读写。一个副本挂了,K8s虽然能拉起新容器,但新容器是全新的——它不知道自己是master还是slave,也不知道该找谁同步数据。
如果状态信息都保存在容器内部,比如Redis的AOF文件写在Pod本地磁盘,Pod一重建就全没了。这时候就算Deployment帮你把Pod拉起来,也只是一具空壳。高可用要求“挂了能自动恢复”,而有状态应用的恢复依赖两样东西:历史数据和稳定身份。
历史数据靠持久卷解决;稳定身份靠的是Pod的“名字”。没有身份,主从关系、集群节点互相发现、故障恢复后的归属判定,全部都会乱。
1.3 一个简单的选型决策列表
我把这两类控制器的选型逻辑总结成一个决策列表,遇到问题直接对号入座:
- 应用实例之间完全不共享数据,谁处理请求都一样,用Deployment。
- 应用需要固定唯一的网络标识,比如主从识别、集群成员发现,用StatefulSet。
- 应用需要每个实例都挂持久卷且数据独立,用StatefulSet配合volumeClaimTemplates。
- 应用启动有依赖顺序,比如某些数据库节点必须按编号初始化,用StatefulSet。
- 只是API网关、前端、定时任务这类无状态负载,别为酷炫用StatefulSet,徒增复杂度。
我见过不少团队把MySQL、Redis、Kafka全部硬塞进Deployment,流量一大就出各种灵异问题。其实K8s社区早就把边界划得很清楚:Deployment是给“无状态平民”用的,StatefulSet才是给“有身份的角色”用的。
2. Deployment进阶:滚动更新、回滚、暂停编排,我把每个参数都讲透
2.1 maxSurge和maxUnavailable的计算逻辑
可能很多人在写Deployment时看到过这两个字段:
strategy: type: RollingUpdate rollingUpdate: maxSurge: 25% maxUnavailable: 25%默认值两个都是25%。这个百分比的算法不是“四舍五入”,而是向上取整,而且它们可以同时作用在一个发布过程中。我拿3副本的Deployment举例:
- 期望副本数等于3,maxSurge计算结果是向上取整等于1,意味着发布期间最多允许1个额外Pod,总数不能超过4。
- maxUnavailable计算结果也等于1,意味着发布期间允许有1个Pod处于不可用状态,也就是最少要保证2个Pod可用。
发布时控制器会新建一个ReplicaSet,先把新Pod拉起来,等它通过readiness探针变成Ready,再停掉旧Pod。整个过程会在“新RS扩容、旧RS缩容”之间来回,直到流量全部切到新版镜像。
如果我想让发布更快一点,可以把maxSurge调大到50%甚至100%,让新副本一次性多起几个。如果我想让发布更保守一点,可以把maxUnavailable设为0,再配合PodDisruptionBudget,保证任意时刻都有足额可用副本。世界上没有“最安全配置”,只有适合你业务的配置。
2.2 minReadySeconds和探针为什么不能省
滚动更新里最容易翻车的一个环节,是新Pod还没真正就绪就被判成Ready。很多服务启动后会有一段“自检”时间,接口能通不代表内部状态已经就绪。如果readinessProbe只配一个简单的TCP端口检查,结果就是新Pod在流量进来后才开始报错。
建议至少配置两个东西:
- readinessProbe:用真实业务接口或者命令做检查。Redis就用redis-cli ping,HTTP服务就用httpGet路径判断返回码。
- minReadySeconds:让Pod在变成Ready之后再“观察”一段时间才算数。比如设成30,控制器会等30秒确认稳定性后才继续滚动更新。
这两个参数一配合,发布过程的假Ready就基本被堵死了。否则你加再多副本,也是在给故障扩散批通行证。
2.3 回滚和revisionHistoryLimit
发布失败很正常,关键是能不能快速回滚。Deployment每次配置变更都会生成一个新的ReplicaSet版本,这些版本默认保留最近10个。回滚操作其实很简单:
kubectl rollout history deployment/my-app kubectl rollout undo deployment/my-app --to-revision=3这里有个细节:如果发布失败是因为镜像tag没变,只是把代码推到同一个tag里,Deployment可能不会识别为新版本,回滚也不会生效。正确做法是给每次发布换一个明确的tag,比如v1.2.3,而不是一直用latest。生产环境用latest,等于把回滚的路亲手拆了。
revisionHistoryLimit也不要盲目调大。保留太多版本会堆积一堆旧ReplicaSet,虽然不占Pod,但占etcd资源。我一般只在需要频繁回滚的窗口期临时调大,稳定后再改回10。
2.4 暂停发布:把上线的胆子练大
Deployment还有一个容易被忽略的能力——暂停发布。当你配置:
spec: paused: trueDeployment就不会继续执行滚动更新,而是停在当前状态。这很适合“先发一部分观察,再放量”的场景。
操作流程也很简单:启动发布后,立刻执行kubectl rollout pause,新RS只承担很少流量;观察监控和日志没问题,再执行kubectl rollout resume,把剩余更新继续走完。相当于把每一步发布都变成可控的分支,而不是一把梭。
我自己的习惯是:涉及核心服务的镜像升级,先pause,再resume,全程盯着监控告警。这套操作练熟了,上线恐慌症能治好一大半。
3. StatefulSet拆解:稳定标识、创建顺序、存储模板是怎么配合的
3.1 为什么必须有Headless Service
StatefulSet的第一个设计目标,是让每个Pod有“固定的名字”。普通Deployment的Pod名是随机生成的,比如my-app-xxx-abc12,重建后名字就变了。对无状态服务无所谓,但有状态应用不行——节点A要能找到节点B,主从关系一旦绑定名字就不能乱动。
StatefulSet的Pod命名规则是“StatefulSet名-序号”,第一个叫xxx-0,第二个叫xxx-1,依次递增。不管怎么重建,名字都保持不变。但光有名字不够,还要让集群内的其他Pod能通过DNS解析到这个名字,这里就需要Headless Service。
Headless Service和普通Service的差别只有一个字段:
spec: clusterIP: None设置成None,K8s就不会给它分配VIP,而是把DNS记录直接解析到每个Pod的IP。于是每个StatefulSet Pod都会获得一条类似这样的A记录:redis-0.redis-hs.default.svc.cluster.local。别的服务只要知道这个域名,就能永远定位到同一个实例。
3.2 从0开始的序号:有序创建和缩容的规则
StatefulSet默认的podManagementPolicy是OrderedReady。创建一个3副本的StatefulSet时,控制器会严格按顺序执行:
- 先创建redis-0,等待它Running且Ready。
- 再创建redis-1,等待Ready。
- 最后创建redis-2,等待Ready。
之所以这么严格,是因为很多有状态应用要求“第一个节点先就绪,后面的节点才能加入”。比如数据库集群通常要先有主节点,副本节点才能同步;Redis哨兵要先有一个可用master,后面节点才知道往哪汇报。
缩容的顺序正好反过来:要删的时候从最大的序号开始删。也就是说如果从3个缩到1个,被删的是redis-2和redis-1,redis-0始终保留。这也是故意的——主节点通常是0号,按顺序缩容不至于把主节点先干掉。
如果你用Parallel策略,控制器会忽略顺序,一次性并行创建或删除。只有你明确知道应用不需要顺序启动时才用,否则别碰。
3.3 volumeClaimTemplates:存储跟着副本走
StatefulSet最厉害的是volumeClaimTemplates。它相当于一个“PVC模板”,每创建一个Pod,就自动绑定一块属于它的PVC。
以我下面的Redis清单为例,模板里声明name: data,创建出来的PVC名字就是data-redis-0、data-redis-1、data-redis-2。这块PVC会绑定到对应Pod上。哪怕Pod被删除重建,PVC还在,数据还在,新Pod会直接挂载同一块PVC。这就解决了“有状态应用需要历史数据”的核心诉求。
这个机制很像房产证:Deployment模式下Pod像住酒店,退房后房间就重置了;StatefulSet模式下Pod像住自己的房子,就算搬走再回来,家里的东西还在。
3.4 更新策略、partition和OnDelete
StatefulSet的更新策略默认也是RollingUpdate,但顺序和Deployment完全相反:从序号最大的Pod开始,一个一个更新,更新完最大的才动下一个。为什么?还是那句话,主从结构里主节点通常编号小,先把从节点更新完,最后再动主节点,风险最小。
partition参数是很多人没搞懂的点。简单说:只有序号大于等于partition的Pod才会更新。
比如有5个节点,设置partition=3,控制器只会更新序号3和4,序号0、1、2保持旧版本。这个能力用来做金丝雀发布非常合适——先让最后两个节点用新版,跑一段时间没问题,再把partition调成0,全部更新完。
OnDelete策略则更“佛系”:改了Pod模板也不会主动重建,只有你手动删除某个Pod,控制器才会用新版配置把它重新拉起来。适合那些对更新时机极度敏感、必须由人控制节奏的应用。
StatefulSet并不是神,但它在有状态业务里把“身份、顺序、数据、更新节奏”四件事都接管了,这才是我敢在生产环境用它托管Redis和数据库类服务的原因。
4. 实战:用StatefulSet部署一个3节点Redis集群
4.1 全貌:headless Service加StatefulSet加ConfigMap
纸上谈兵没意思,下面这套配置是我在测试集群里验证过的。目标很单纯:3个Redis节点,任意节点挂掉后Pod能原地重建并找回数据,同时每个节点通过DNS拥有稳定身份,后续接哨兵也好、接运维脚本也好,都有固定入口。
完整组成三块:一个Headless Service,让redis-0/1/2有可解析的稳定域名;一个ConfigMap,放redis.conf;最后是StatefulSet,负责管Pod和PVC。
4.2 完整的YAML清单
先创建ConfigMap:
apiVersion: v1 kind: ConfigMap metadata: name: redis-config data: redis.conf: | appendonly yes save 900 1 save 300 10再创建Headless Service:
apiVersion: v1 kind: Service metadata: name: redis-hs labels: app: redis spec: clusterIP: None selector: app: redis ports: - name: redis port: 6379 targetPort: 6379然后是核心的StatefulSet:
apiVersion: apps/v1 kind: StatefulSet metadata: name: redis spec: serviceName: redis-hs replicas: 3 podManagementPolicy: OrderedReady updateStrategy: type: RollingUpdate rollingUpdate: partition: 0 selector: matchLabels: app: redis template: metadata: labels: app: redis spec: terminationGracePeriodSeconds: 30 containers: - name: redis image: redis:7.2 command: - redis-server - /etc/redis/redis.conf ports: - containerPort: 6379 readinessProbe: exec: command: - sh - -c - redis-cli ping | grep PONG initialDelaySeconds: 5 periodSeconds: 5 volumeMounts: - name: data mountPath: /data - name: config-volume mountPath: /etc/redis/redis.conf subPath: redis.conf volumes: - name: config-volume configMap: name: redis-config volumeClaimTemplates: - metadata: name: data spec: accessModes: - ReadWriteOnce storageClassName: local-path resources: requests: storage: 2Gi这里有几个关键点:
- spec.serviceName必须和前面的Headless Service名字完全一致,否则Pod拿不到DNS记录。
- volumeClaimTemplates里的storageClassName要改成你集群实际存在的存储类。我在本地测试环境用的是local-path,云上一般用云厂商的SSD类。
- readinessProbe用redis-cli ping检查,比TCP端口检查靠谱。因为Redis可能TCP虽然通着,但本身还在持久化或者恢复状态。
4.3 启动后的验证清单
应用完这套YAML,不要马上说“部署完成”。建议按下面顺序验证:
- 查看Pod名和启动顺序:
kubectl get pods -l app=redis应该看到redis-0先变成Running且1/1 READY,然后redis-1、redis-2依次跟上。如果redis-0卡在ContainerCreating,多半是PVC没绑上,用kubectl describe pod redis-0看事件。
- 验证DNS解析:
kubectl run test-pod --image=busybox --rm -it -- nslookup redis-1.redis-hs.default.svc.cluster.local能正常返回IP,说明Headless Service接入成功。
- 验证持久化。往redis-0写一个key,然后删除这个Pod:
kubectl exec redis-0 -- redis-cli set hello k8s kubectl delete pod redis-0等新的redis-0重建并变成Ready后,再查询:
kubectl exec redis-0 -- redis-cli get hello能返回k8s,说明AOF持久化和PVC绑定都生效了。这一步是整个实验的灵魂,过了就有底气把Redis托管在K8s里。
4.4 故障切换实验,观察“身份”和“数据”是否稳
持久化验证完,再做一次故障模拟。实际操作中我会验证两个层面:
一是进程级故障:执行kubectl exec redis-1 -- redis-cli debug segfault,让Redis进程崩掉。注意不要在线上环境玩这个命令,测试集群里随便。Pod会变成CrashLoopBackOff或者重启,但StatefulSet会把同名Pod拉起,PVC原样挂上,数据不丢。
二是节点级故障(可选):直接到宿主机上把某个节点关机,或者kubectl drain某台Node。Pod会被驱逐,然后调度到其他可用节点。因为StatefulSet的PVC大多绑定节点上的本地路径,如果是local-path存储类,新Pod会卡在跨节点漂移上——这也暴露一个现实问题:部分本地存储方案并不适合跨节点恢复。如果要追求节点级高可用,最好用分布式存储或者云盘类存储。
做完这两个实验,基本就能理解StatefulSet的边界在哪里:它能保证名字不变、数据不丢,但数据的可迁移性取决于底层存储实现。选存储类这件事,做得好就是一半的高可用。
5. 躲不开的坑:缩容、数据安全与环境差异
5.1 StatefulSet缩容引发的“删了又建”问题
如果你在缩容后马上又扩容,可能会遇到诡异现象:本来3节点缩到1节点,再扩回3节点,redis-1和redis-2没有像第一次部署那样全新初始化,而是把老PVC又挂回去了。原因很简单:StatefulSet缩容默认不会删除PVC,PVC还在,Pod按序号重建时自然会重新绑定。
这个机制本身是保护数据的,但也会带来困惑。比如我想彻底清掉某个节点数据,光删StatefulSet没用,还要把所有data-redis-*的PVC一起删掉,否则数据残留会反复出现。
我的建议是:凡是涉及有状态服务的“销毁重建”,先列一个清理清单——删Pod检查PVC,确认数据不再需要再删PVC,最后再删StatefulSet。顺序颠倒了,数据可能就真没了。
5.2 PVC生命周期与数据备份
StatefulSet只是保证数据在Pod崩溃时还在,不等于数据永远安全。PVC所在的存储卷损坏、整个Namespace被误删、存储节点物理故障,这些事StatefulSet全都管不了。
所以真实生产环境我坚持两条原则:
- 对Redis这类内存型数据库,K8s之外的备份机制必须存在。比如定时执行BGSAVE后把RDB文件同步到对象存储,或者直接用云厂商的备份方案。
- 对MySQL这类强一致数据库,StatefulSet只是交付载体,真正的可靠还是靠binlog备份和恢复演练。不要把“控制器”当成“灾备”。
还有个小技巧:StatefulSet销毁重建时,如果PVC用了Delete回收策略,删除PVC等于删数据。如果你暂时不确定要不要保留历史数据,先把PVC的回收策略改成Retain再操作,给恢复留一条退路。
5.3 环境差异:Rocky Linux上装K8s 1.36的几个注意点
很多朋友装了K8s之后,发现StatefulSet的PVC一直Pending,第一反应是配置写错。其实很可能是环境问题。我最近在Rocky Linux上搭K8s 1.36测试集群,就踩了几个典型坑:
- 存储类缺失。裸金属或者本地虚拟机默认没有云厂商的StorageClass,需要自己装local-path-provisioner或者手动创建PV。否则PVC一直Pending,根本到不了Pod创建那一步。
- 节点防火墙和SELinux。Rocky默认的SELinux策略可能会拦截Pod挂载目录的读写,容器启动时报PermissionDenied。要么在测试环境临时调整,要么正经配置SELinux布尔值。测试环境可以放宽,生产环境的安全策略还是要留着。
- 内核参数。K8s网络组件对内核模块和系统参数要求比较多,装完先跑一遍kubeadm init的预检,重点看交换分区是否关闭、内核模块是否加载。
这些细节不是StatefulSet本身的问题,但恰恰是它们决定了你的PVC和Pod能不能正常走完整个生命周期。环境没夯实,控制器设计得再好也白搭。
5.4 StatefulSet控制器行为的几个隐藏细节
最后补充几个我在排障过程中总结的细节,每一个都对应过一次真实教训:
- StatefulSet的serviceName如果指向一个不存在的Service,Pod会创建失败,报错信息很隐晦。先检查Service是否存在,再查别的。
- 修改StatefulSet的replicas等于触发一次扩容或缩容,但如果Pod的readinessProbe太宽松,控制器会把未就绪的Pod当成Ready,顺序启动就失效了。探针必须真实反映可用状态。
- 更新StatefulSet镜像时,如果镜像tag没变,比如总是latest,控制器不会触发更新。这和Deployment一样,但StatefulSet更坑的是,OnDelete策略下你得手动删除旧Pod才能触发重建。
- 删除Namespace会自动删除其中的StatefulSet和PVC,这是不可逆的。所以在做任何涉及Namespace删除的操作前,先把有状态服务的备份做到K8s之外。
这些坑不会写在官方文档里,但每一个都能让你在故障现场省下至少半小时。我每次迁移有状态服务到K8s,都会先按这个清单自检一遍,基本能把坑提前踩掉。
我从这套实践中最大的体会是:Deployment和StatefulSet本身没有谁更高级,只是分管的工作不同。Deployment让无状态服务跑得又轻又快,StatefulSet用一串固定序号换来数据与身份的稳定性。选错了控制器,再漂亮的YAML也只是把故障包装得晚一点出现;选对了,高可用才算是真正落地。希望这篇第九篇能帮你在下一次架构设计时,少走一点我走过的弯路。