从入门到真正敢把业务放在 K8s 上跑,中间隔着一条叫“高可用”的河。很多人用 Deployment 部署完服务,觉得副本数调成 3 就万事大吉,结果一次节点宕机、一次滚动发布,业务就把用户给得罪了。真正的生产环境里,Deployment 的高级用法和 StatefulSet 的正确落地,才是决定你半夜会不会被报警电话吵醒的关键。这篇是 K8s 系列第九篇,咱们不聊基础概念,直接讲实战:Deployment 怎么才算真正配好了高可用,以及有状态应用(比如 Kafka、Redis 集群)为什么必须靠 StatefulSet 才能撑住场面。内容偏运维和架构视角,K8s 老手能拿走几段 YAML 和排障思路,正在往生产环境迁移的团队也能从中找到适合自己业务的落地路径。
1. Deployment 进阶:高可用不止是“多副本”
1.1 影响高可用的第一件事不是副本数,是“调度打散”
我接触过不少团队,Deployment 副本数写得漂漂亮亮,replicas: 3,结果一看 Pod 分布,三个实例全挤在同一台 worker 节点上。这种部署方式,等于把鸡蛋全放在一个篮子里,节点一挂,整个服务的请求量瞬间归零。K8s 的调度器确实有默认的打散策略,但它只在“同一 Deployment 的多个副本尽量不落在同一节点”这个层面做有限保证,并不会综合考虑地域、机架、故障域等维度。生产环境里只依赖默认策略,远远不够。
要真正把高可用做扎实,核心是让 Pod 在拓扑维度上打散。这里有两个手段我最常用:一个是 topologySpreadConstraints,另一个是 podAntiAffinity。前者更现代,可以按“节点”“可用区”这类 topologyKey 来控制分布;后者是传统做法,用反亲和性把同一应用的副本推开。
给你看我实际用在生产环境的一段配置:
affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchLabels: app: order-service topologyKey: kubernetes.io/hostname topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: DoNotSchedule labelSelector: matchLabels: app: order-servicemaxSkew: 1 的含义是,任意两个节点上的 Pod 数量差不能超过 1;whenUnsatisfiable 设为 DoNotSchedule 表示如果无法满足就直接不调度,这样集群资源不足时新增 Pod 会处于 Pending 状态,而不是硬塞到同一节点。我见过有人把它设成 ScheduleAnyway,结果打散效果直接被架空了,还是要根据业务容忍度来选择。
如果你在多可用区(比如 AWS 的 us-east-1a、1b、1c)部署,topologyKey 要换成“topology.kubernetes.io/zone”,ensemble 才能跨可用区容灾。单节点打散能防节点故障,跨可用区打散才能防机房级别故障,这俩都不该省。
1.2 别小看优雅关停:滚动更新和节点排水时最容易掉链子
Deployment 高可用里另一个藏着坑的地方,是 Pod 终止流程。我发现很多生产事故不是发生在流量高峰,而是发生在发布和节点维护的时候。K8s 默认删除 Pod 会先把它标记为 Terminating,然后发 SIGTERM 给主进程,等它做完收尾再强制杀掉。如果主进程不处理 SIGTERM、或者处理得太快,请求就会大量报错。
解决这个问题的关键在 Pod 里配置生命周期钩子和优雅终止时间。preStop hook 可以在 SIGTERM 之前先跑一段脚本,用来通知注册中心摘除节点、等待存量请求处理完。terminationGracePeriodSeconds 则控制 K8s 最多等多久,超过之后发 SIGKILL。
生产环境里我是这么设计的:
lifecycle: preStop: exec: command: - /bin/sh - -c - | echo "draining from service discovery..." sleep 5 curl -X POST http://localhost:8080/actuator/shutdown terminationGracePeriodSeconds: 60sleep 5 的目的是给负载均衡器一点时间,让它把新请求调度到别的副本上。注意这个值不能乱加,我见过有人直接 sleep 120,结果 Deployment 每次更新都像乌龟爬,发布延迟变成事故。合理的做法是让 sleep 时间略大于负载均衡的健康检查间隔,一般 5~10 秒足够。
还有一个细节:如果服务用的是 gRPC 长连接,K8s 默认不会主动断开存量连接,因此你还需要在 preStop 里调用一个接口让连接优雅关闭。这个坑不实际踩过,很难提前想到。
1.3 滚动升级策略:maxSurge 和 maxUnavailable 的黄金比例
Deployment 的滚动更新策略里有三个重要参数:maxSurge、maxUnavailable、minReadySeconds。很多人根本不动它们,全部用默认值,结果发布时老实例全退、新实例全起,中间两秒服务直接不可用。
maxSurge 表示允许超出期望副本数的新 Pod 数量,maxUnavailable 表示允许不可用的旧 Pod 数量。生产环境我常用的组合是:
strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0用这个组合,新 Pod 先拉起一个,等它 Ready,然后才终止一个旧 Pod,保证整个发布过程永远不会出现可用副本数为零的时刻。代价是发布期间节点资源消耗会多一份副本的量,需要在集群容量上留有余量。
maxSurge 也可以设成百分比,比如 25%。我个人的经验是,对于核心链路服务,明确用数字 1 更可控;对于副本数本来就很大的服务,比如 20 个副本,用 25% 更灵活。
minReadySeconds 同样值得设置,它让新 Pod 至少 Ready 多少秒之后才被认定为可用。推荐至少设 30 秒,避免主进程刚起来还没完成初始化就被拉入流量池。有些 Java 应用启动时间超过 1 分钟,那这个值要相应调大。配合 readinessProbe 和 minReadySeconds,才能真正做到“Ready 了才接流量”。
2. StatefulSet 为什么存在:稳定身份、稳定存储、有序操作
2.1 无状态应用和有状态应用的差别到底在哪
很多初学者对 Deployment 和 StatefulSet 的区别一脸懵。其实一句话就能讲明白:Deployment 假设应用实例长一个样,谁死了谁顶上,新 Pod 和旧 Pod 完全等价;StatefulSet 则假设每个实例有自己独立的身份和存储,一个好的给另一个好的不能随便替换。
用员工来类比:Deployment 像是临时工岗位,张三走了李四来,工作内容完全一样,工牌换不换无所谓;StatefulSet 像是核心研发岗位,每个人都有专属工位、专属电脑、专属账号,换一个人就得把工位和电脑都保住。有状态服务不能像无状态服务那样随便漂移,它需要固定的网络标识(方便客户端记住你),也需要固定的存储卷(比如数据库文件不能临时换位置)。
我最早开始用 StatefulSet 是为了在 K8s 里搭 Kafka。Kafka 每个 broker 都有 broker.id,而且数据分区分片的位置和 broker 一一对应。如果我用 Deployment 部署,Pod 每次重建 IP 都会变,broker 之间的通信地址就会失效,集群直接崩掉。后来改 StatefulSet,每个 Pod 有固定的名字和固定的网络标识,broker.id 可以按序号固定,数据盘也按序号绑定,问题瞬间解决。
2.2 “身份感”的具体体现:稳定网络标识与 Headless Service
StatefulSet 最核心的设计是给每个 Pod 一个有序的、永久的“名字”。举个例子,StatefulSet 叫 kafka,副本数配成 3,那么它的 Pod 就固定叫 kafka-0、kafka-1、kafka-2。不管它们重建多少次、漂移到哪个节点,名字永远不变。
但光有名字还不够,K8s 里 Pod 之间通信靠 DNS,所以 StatefulSet 还必须配一个 Headless Service(headless service 就是 clusterIP 为 None 的 Service)。有了它,每个 Pod 才能拿到独立的 DNS 记录,格式是:pod-name.service-name.namespace.svc.cluster.local。
这样 kafka-0 无论被调度到哪台节点,它的 DNS 地址始终是 kafka-0.kafka.default.svc.cluster.local。依赖这个地址的客户端不会因为 Pod 重启、IP 变化而断连。
YAML 里定义 Headless Service 的方式很简单:
apiVersion: v1 kind: Service metadata: name: kafka labels: app: kafka spec: clusterIP: None selector: app: kafka ports: - port: 9092 name: clientclusterIP: None 是关键。看到这一行,K8s 就不会给 Service 分配虚拟 IP,而是直接生成 Pod 级别的 DNS 记录。客户端要访问集群里的任意一个 broker,直接解析 kafka-0.kafka 这种地址就行。
2.3 “稳定存储”和 volumeClaimTemplates:数据不丢的秘密
有状态应用的另一个刚需是数据不丢。Pod 可以重建,但数据卷必须跟 Pod 绑定。StatefulSet 提供的方案是 volumeClaimTemplates——直接在 StatefulSet 里定义 PVC 模板,每创建一个 Pod,K8s 就自动给它生成一份对应的 PVC。PVC 的名字是固定的,叫pvc-name-pod-name,比如>volumeClaimTemplates: - metadata: name: data spec: accessModes: [ "ReadWriteOnce" ] resources: requests: storage: 20Gi storageClassName: managed-premium
storageClassName 我建议不要省略。如果集群里有多个 StorageClass,不写的话会用默认值,一旦默认值不是你要的那个(比如用的是本地磁盘而非云盘),数据可能落在节点本地,节点坏了数据就没了。生产环境里一定要明确指定 StorageClass,别把命运交给“默认”。
2.4 有序控制:从创建到删除,一切都讲秩序
StatefulSet 的第三大特性是有序性,体现在两个地方:部署顺序和删除顺序。
创建新 Pod 时,默认按序号从 0 到 n-1 一个一个创建。为什么不能并行创建?因为很多分布式系统要求先启动的节点成为“种子节点”,后面的节点加入时要去问它要数据(比如 Kafka 的 controller)。如果全并行启动,大家互相找不到,集群状态就乱了。
删除 Pod 时,则按从 n-1 到 0 的逆序逐个删除。这与 Kafka 缩容时的数据迁移顺序是一致的:先摘除编号最大的 broker,让它把数据迁移出去,再删 Pod,避免把有数据残留的 Pod 突然杀没。
StatefulSet 里还有一个参数叫 podManagementPolicy,默认是 OrderedReady(顺序创建),也可以设成 Parallel(并行创建)。如果是 ZooKeeper、Kafka、Elasticsearch 这类强依赖启动顺序的服务,务必用默认的 OrderedReady;如果是可以并行启动的有状态服务(部分缓存类中间件),Parallel 能显著缩短启动时间。我在实际项目里见过有人把 Kafka 的 podManagementPolicy 改成 Parallel,结果 ZooKeeper 还没准备好,Kafka 的 broker 就全起来了,整个集群折腾了一个多小时才恢复。
3. StatefulSet 全解析实战:从 Redis 集群开始
3.1 为什么要用 StatefulSet 部署 Redis Cluster
Redis 集群是高可用架构里的常客。用 StatefulSet 搭建 Redis Cluster 是一个很经典场景,也能把 StatefulSet 的特性串起来讲明白。Redis Cluster 要求每个节点有一个固定 ID,节点之间通过 gossip 协议互相通信,而且每个节点承担不同的 slot(哈希槽)。如果节点的 IP 或 DNS 每次重启都变,Redis 的 cluster 状态会迅速崩溃。用 StatefulSet 部署,每个节点有固定 DNS 名,重启后集群依然能通过 DNS 找到彼此,再配合持久化存储做数据保护,这就是 Redis 集群高可用的基础。
我建议初学者先别急着上 Operator,先用原生 StatefulSet 手工部署一个最小 Redis 集群,这能帮你把 StatefulSet 的工作原理彻底摸清。Operator 是帮你偷懒的,不是帮你补课的。
3.2 一个可以直接套用的 StatefulSet 配置(Redis 示例)
这个配置我用过多次,虽不是最精简,但每一步都有实际用途。
apiVersion: v1 kind: Service metadata: name: redis labels: app: redis spec: clusterIP: None selector: app: redis ports: - port: 6379 name: redis-client --- apiVersion: apps/v1 kind: StatefulSet metadata: name: redis spec: serviceName: redis replicas: 6 selector: matchLabels: app: redis template: metadata: labels: app: redis spec: affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: topologyKey: kubernetes.io/hostname labelSelector: matchLabels: app: redis containers: - name: redis image: redis:7.0-alpine command: - redis-server - /usr/local/etc/redis/redis.conf env: - name: POD_IP valueFrom: fieldRef: fieldPath: status.podIP ports: - containerPort: 6379 name: redis volumeMounts: - name: data mountPath: /data livenessProbe: exec: command: - redis-cli - ping initialDelaySeconds: 20 periodSeconds: 10 readinessProbe: exec: command: - redis-cli - ping initialDelaySeconds: 10 periodSeconds: 5 volumeClaimTemplates: - metadata: name: data spec: accessModes: [ "ReadWriteOnce" ] storageClassName: managed-premium resources: requests: storage: 10Gi注意几个细节。serviceName 必须指向那个 Headless Service 的名字,没有它,Pod 的身份 DNS 记录就不会生成。podAntiAffinity 我用的是 preferred(软反亲和),因为 Redis 集群 6 个实例有时候不一定能完全分散到 6 台机器,硬性要求会带来调度失败。生产环境如果节点足够,也可以换成 requiredDuringSchedulingIgnoredDuringExecution,优先保障。
startup 阶段可能遇到 Redis 卡在 loading 数据的情况,此时 livenessProbe 如果用 redis-cli ping 可能会误杀。我建议给 Redis 加上 startupProbe,或者把 livenessProbe 的 initialDelaySeconds 调大一些,保底数据加载时间。
3.3 初始化集群:先让每个 Pod 稳定,再做 cluster meet
StatefulSet 创建完,6 个 Pod 启动后,Redis 还只是 6 个独立实例,需要执行 cluster init。这一步典型做法是先确认所有 Pod 变成 Running 并 Ready:
kubectl get pod -l app=redis -o wide然后进入第一个 Pod,用 redis-cli 执行集群初始化,把每个节点加进去。
kubectl exec -it redis-0 -- redis-cli --cluster create \ redis-0.redis.default.svc.cluster.local:6379 \ redis-1.redis.default.svc.cluster.local:6379 \ redis-2.redis.default.svc.cluster.local:6379 \ redis-3.redis.default.svc.cluster.local:6379 \ redis-4.redis.default.svc.cluster.local:6379 \ redis-5.redis.default.svc.cluster.local:6379 \ --cluster-replicas 1用 DNS 名而不是 Pod IP 非常重要。如果这里你图省事写了 IP,一旦 Pod 重建、IP 变化,Redis 集群会重新变成“陌生人”。用 DNS 名,就算 Pod 被调度到别的节点,下次启动后依然能找到彼此。
这里其实有一个生产环境必须做的调整:Redis 默认会用自己的主机名拼接集群信息,进入容器后 hostname 其实就是固定的 Pod 名(比如 redis-0),所以它生成的 cluster nodes 信息里会带上 redis-0 这个主机名。只要客户端和节点都通过同一个 DNS 解析到 redis-0,就能建立稳定连接。千万别在容器里手动改 hostname,也不要让 Pod 和 Service 名对不上,否则 gossip 协议就乱了。
3.4 弹性伸缩与升级:StatefulSet 的两种常见操作
StatefulSet 支持直接kubectl scale statefulset redis --replicas=6扩缩容。扩容时注意 Redis 集群新节点不会自动加入集群,你得手工执行cluster meet;缩容前建议先手工执行cluster forget,让其他节点忘记这个即将下线的节点,再缩容,否则集群里会残留一堆“标记下线”的节点信息。
升级镜像时,StatefulSet 默认的 RollingUpdate 策略是一个一个更新。这和 Deployment 的思路不同,对有状态应用来说,同时把 6 个 Redis 全换新,数据同步负载瞬间拉满,反而容易造成集群抖动。你也可以把 strategy 改成 onDelete,意思是只有你手动删除某个 Pod,它才会按新配置重建。这个策略适合你想自己控制升级节奏的场景,比如先升级 redis-0 观察一天,再升级剩下节点。
我自己的经验是:Redis 这种集群,升级前先看redis-cli cluster info确保 cluster_state=ok,然后一次只升级一个节点,并且升级完成后等 cluster_state 重新变成 ok,再动下一个。动作慢一点,事故少一点。
3.5 存储扩容:不是简单改 PVC 大小
StatefulSet 的数据盘扩容也是高频需求。很多人在 volumeClaimTemplates 里把 storage 直接改成 20Gi,然后 apply,结果发现 PVC 大小根本没变。原因在于 PVC 一旦创建,容量是绑定死的,不能靠模板修改来“原地长胖”。
正确做法是分三步:先修改 StatefulSet 里的 volumeClaimTemplates(新 Pod 会用新大小),再单独kubectl edit pvc>kubectl patch pvc>journalctl -u kubelet -f docker ps | grep kube-apiserver kubectl get pods -n kube-system
大概率能看到两种结果:一是 etcd 容器反复重启,说明磁盘 IO 或 CPU 资源跟不上;二是 kube-apiserver 容器起不来,日志里报证书过期或端口占用。处理方式也直接:检查系统 swap 是否关闭、内存和 CPU 是否达到 kubeadm 最低要求,把 kubelet 日志里的错误清掉后kubeadm reset再来一次。
这个报错让我印象深刻,因为它是新手向生产环境迈进的第一步。反过来说,如果你连 master 启动都搞不定,后面谈 Deploymnet 高可用也连 p 都谈不了。另外提醒一点,初始化失败后千万不要直接在原环境反复 init,先 reset 清干净,否则残留的 etcd 数据会让下一次初始化更诡异。
4.2 StatefulSet 里的 Pod 被删后,PVC 绑定为什么会丢
删除 StatefulSet Pod 时有个细节:如果你执行的是kubectl delete statefulset redis,Pod 会被删除,但 PVC 大多数情况下不会被级联删除(这其实是 K8s 保护数据的行为)。但如果有人手贱执行了kubectl delete pvc>
Python数据分析三件套:NumPy、Pandas与Matplotlib实战指南
先说个我最近遇到的事。一位做区域销售的朋友,手里压着今年上半年近万条订单明细,想快速算出每个大区的回款率、环比增幅,再用图表把趋势讲清楚。他第一反应是打开表格软件,结果文件大到拖拽都卡。我告诉他,这种活计&a…
MySQL事务与锁机制:从ACID到死锁排查的实战指南
最开始接触MySQL的时候,我一直觉得"事务"是个挺玄乎的词。老看到文章里写"事务保证数据一致性",但敲了半年SQL,INSERT、UPDATE、SELECT一通操作下来,也没觉得哪里需要特别小心。直到前阵子帮朋友排查一个线上…
用集合论破解Flutter跨平台UI边界难题
看到这个标题,我知道你心里多半在嘀咕:“离散数学”和“UI边界”这两个词为什么会凑到一起去?说实话,我当年在大学听集合论的时候,也觉得它就是个考试前背背概念的东西。直到我开始用Flutter同时维护Android、iOS和鸿蒙…
macOS 搜不到 5GHz?WiFi 6E RNR 信息元素引发的兼容性故障排查
最近排查一个挺有意思的 WiFi 兼容性问题:一台 WiFi 6E 三频路由器,把 6GHz 频段打开之后,家里一台 macOS 13.2.1 的电脑突然就搜不到 5GHz 频段的 WiFi 信号了。一开始以为是路由器坏了,后来发现手机、平板、Windows 电脑全都正常…
Unity 2022安装Newtonsoft.Json全指南:解决JsonUtility短板与常见坑
打开Unity 2022的包管理器,我闭着眼都能写完那行com.unity.nuget.newtonsoft-json,但身边还是有同事每次新建项目都要为装Newtonsoft.Json折腾半小时。因为这东西说简单是真简单,可一旦卡住——要么是找不到包名、要么是装了没反应、要么是报…
上市公司对外开放程度数据包:2000-2022年面板指标与计算全解析
这份2000-2022年上市公司对外开放程度数据包,我前前后后用了差不多一个多星期才彻底吃透。最初拿到它是因为研究需要引入上市公司国际化程度的控制变量,结果发现手头现有的数据库要么是截面数据,要么区间对不上,要么指标口径完全不…