StatefulSet 与有状态工作负载:分布式数据库、消息队列的 K8s 治理

深入解析 Kubernetes StatefulSet:与 Deployment 的本质差异、稳定的网络身份(headless Service + hostname)、有序部署与滚动更新、volumeClaimTemplates 与 PVC 生命周期、优雅缩容、分布式有状态系统(etcd/Kafka/ES)的部署模式,以及 Operator 与 PDB 的生产治理。

大多数教程教你用 Deployment 跑无状态服务,但数据库、消息队列、缓存、协调器这些有状态组件,才是生产里最难的那部分。StatefulSet 解决了它们最痛的三件事:稳定的网络身份(名字不变)、稳定的存储绑定(PVC 不丢)、有序的部署升级。本指南从 StatefulSet 核心语义讲起,落到 etcd / Kafka / Elasticsearch 的真实部署模式与治理。


目录


1. 为什么 Deployment 不够:StatefulSet 的三板斧

1.1 Deployment 的"无状态假设"

Deployment 的三个假设,对有状态应用全部不成立:
  1. Pod 可任意替换(名字/网络身份变了无所谓)
     → 数据库节点换了 hostname,客户端连接全部失效
  2. Pod 可共享同一存储(或不用存储)
     → 每个数据库副本必须有自己的数据盘
  3. 副本间无顺序依赖
     → 主从、投票、法定人数都要求"谁先谁后"

StatefulSet 的对应机制:
  1. 稳定标识:Pod 名字固定(<name>-0, -1, ...)+ 稳定的网络身份
  2. 稳定存储:每个 Pod 绑定自己的 PVC(volumeClaimTemplates 自动生成)
  3. 有序管理:部署/升级/缩容按序号有序进行

ℹ️ 核心洞察:StatefulSet 不是"带存储的 Deployment",而是把"每个副本的身份与数据"当作一等公民对待——这正是有状态应用的生命线。


2. 稳定的网络身份:headless Service 与 hostname

2.1 稳定的 hostname 与 DNS

apiVersion: v1
kind: Service
metadata:
  name: etcd
  namespace: infra
spec:
  clusterIP: None                 # headless:不分配虚拟 IP
  selector:
    app: etcd
  publishNotReadyAddresses: true  # 未 Ready 也发布地址(成员发现需要)
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: etcd
  namespace: infra
spec:
  serviceName: etcd               # 关联 headless Service
  replicas: 3
  selector:
    matchLabels: { app: etcd }
  template:
    metadata:
      labels: { app: etcd }
    spec:
      containers:
        - name: etcd
          image: quay.io/coreos/etcd:v3.5
          command: ["etcd", "--name=$(POD_NAME)"]
          env:
            - name: POD_NAME
              valueFrom:
                fieldRef: { fieldPath: metadata.name }
生成的稳定 DNS 名:
  etcd-0.etcd.infra.svc.cluster.local
  etcd-1.etcd.infra.svc.cluster.local
  etcd-2.etcd.infra.svc.cluster.local

特点:
  - 名字在重建/滚动升级后不变(身份稳定)
  - headless Service 无 VIP,DNS 直接解析到各 Pod IP
  - etcd/Kafka 等成员发现直接靠"固定的成员清单"

2.2 有状态应用怎么用这个身份

etcd 初始化成员列表(静态成员发现):
  --initial-cluster etcd-0=http://etcd-0.etcd:2380,etcd-1=http://etcd-1.etcd:2380,...

Kafka(KRaft 模式):
  broker.id = 序号;controller 用稳定 hostname 做 Quorum

注意:
  - headless Service 的 publishNotReadyAddresses=true 很重要:
    未 Ready 的 Pod 也要能被 DNS 发现(初始化阶段成员要先互连)
  - 不要给 headless Service 配 LoadBalancer / NodePort

3. 稳定的存储:volumeClaimTemplates 与 PVC 生命周期

3.1 volumeClaimTemplates 自动生成 PVC

spec:
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: [ReadWriteOnce]
        storageClassName: fast-ssd          # 每副本独立盘
        resources:
          requests:
            storage: 100Gi
自动生成的 PVC:
  data-etcd-0   → 绑定 PV-A(专属 etcd-0)
  data-etcd-1   → 绑定 PV-B(专属 etcd-1)
  data-etcd-2   → 绑定 PV-C(专属 etcd-2)

关键语义:
  - PVC 与 Pod 解耦:Pod 删除重建,PVC 还在,数据不丢
  - Pod 从 etcd-2 缩到 etcd-1 → 只删 Pod 保留 PVC(谨慎,见第 5 节)
  - 不同节点故障:StatefulSet 不会把 etcd-0 的盘自动挪到别的节点
    (这由存储层决定,如跨节点的分布式存储)

3.2 PVC 重建与数据恢复

恢复流程(Pod 误删后的数据找回):
  1. Pod 删除 → PVC 仍在
  2. StatefulSet 重建同名 Pod → 自动挂回同一 PVC → 数据完好
  → 这就是"身份 + 存储绑定"的价值:删了也能原样回来

要真正"清掉数据":
  删除 PVC(kubectl delete pvc data-etcd-2)
  → 注意:这会把数据也删掉,需要明确的运维动作
  → 建议给关键 PVC 打 `retain` 回收策略

4. 有序部署与滚动更新

4.1 有序部署

创建顺序:
  etcd-0 → etcd-1 → etcd-2(依次,前一个 Ready 才部署下一个)

删除顺序:
  etcd-2 → etcd-1 → etcd-0(倒序)

为什么:
  - 分布式系统常需要"先有首个节点"再扩展
  - 缩容从序号最大的开始,避免打乱身份映射
  (如 Kafka:broker 缩容有"controller 优先级")

滚动更新:
  - 默认按序号倒序更新(从最后一个开始),逐个进行
  - 每次只更新一个,等它 Ready 再继续
  - 可通过 partition 参数做"金丝雀式"升级

4.2 升级策略参数

spec:
  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      partition: 2        # 只更新序号 >= 2 的 Pod(先升级一个做灰度)
      maxUnavailable: 1
partition 的用途:
  - partition=N:只更新序号 >= N 的 Pod
  - 先设 partition = replicas-1 → 只更新最后一个做试点
  - 验证 OK → 逐步降低 partition → 全量升级
  - 出问题 → 改回 partition 控制,或回滚镜像版本

4.3 升级的稳杀先序

升级有状态组件前必须:
  1. 有完整备份(etcd snapshot / Kafka 备份)
  2. 了解升级的滚动约束(如 ES 需要先 master 后 data)
  3. 在隔离环境先跑一遍同版本升级
  4. 准备回滚路径(镜像版本 + partition)

教训:没有备份就升级数据库,等于把生产赌在运气上。

5. 优雅缩容与终止

5.1 缩容不是"删个 Pod"那么简单

缩容 StatefulSet 的语义:
  kubectl scale sts etcd --replicas=2
  → 删除 etcd-2(序号最大)
  → PVC data-etcd-2 保留(数据仍在盘上)
  → 再次扩回 3 → 新建 etcd-2 挂回原 PVC

但分布式应用缩容本身有状态操作:
  - etcd 缩容:要先 etcdctl member remove(不然成员列表还认为有 3 个)
  - Kafka 缩容:要先迁移 partition 副本,再 graceful stop
  - ES 缩容:要先禁用该节点分配,再 remove node
→ 缩容应由 Operator / 管理脚本先做"应用层移除",再 scale sts

5.2 优雅终止与 PodDisruptionBudget

# PDB:保证维护/缩容时至少有多少个可用
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: etcd-pdb
spec:
  minAvailable: 2
  selector:
    matchLabels: { app: etcd }
PDB 的作用:
  - 节点维护(drain)时,保证 etcd 同时下线不超过 1 个
  - 防止"法定人数"跌破(3 节点最少 2 个存活)
  - 结合 terminationGracePeriodSeconds:给优雅退出留时间
    (etcd 停止前要选新 leader / Kafka 要迁移 leader)

建议:
  minAvailable = 多数(2/3、3/5)保护 Quorum
  配合 lifecycle preStop hook 做优雅退出

6. 分布式有状态系统部署模式

6.1 etcd(协调器,3-5 节点)

推荐模式:
  - headless + 静态成员发现(前述示例)
  - 3 或 5 节点(奇数是法定多数)
  - PDB minAvailable = 多数
  - 每个节点独立 fast-ssd PVC
  - 定期 snapshot 到对象存储(etcdctl snapshot save)

避坑:
  - 不要随便"动态加成员"——用静态清单更稳
  - 节点数据盘丢了 = 成员丢失 → 及时移除并补新节点

6.2 Kafka(KRaft 模式,Controller + Broker)

KRaft(去掉 ZooKeeper)的 StatefulSet 模式:
  - Controller Quorum:3 节点(controller.quorum.voters 静态清单)
  - Brokers:N 节点(每个有自己的 data PVC)
  - broker.id 用序号;listener 用稳定 hostname
  - 缩容前先 reassign partition 副本

关键:
  - 数据盘用 throughput-optimized 存储(吞吐敏感)
  - 顺序升级:先 Controller 后 Broker

6.3 Elasticsearch(Master / Data 分工)

ES 在 K8s 常见两种:
  方案 A:单 StatefulSet(节点同时 master+data),适合中小规模
  方案 B:多 StatefulSet 分工(master、data、ingest 各一个 set)
    - master:3 节点(小盘,稳定性优先)
    - data:按热/温/冷分层多个 set(不同 storageClass)
    - 每个 set 的 PDB 保证法定人数

避坑:
  - 节点亲和/反亲和:master 分散到不同可用区
  - ES 缩容前要先 disable allocation

7. Operator:有状态应用的自治治理

7.1 为什么需要 Operator

StatefulSet 只保证"身份+存储+顺序"的底座,
"应用级操作"(备份、扩缩、升级、健康自愈)仍需人写脚本。

Operator 把这些操作变成"控制器":
  - 监听自定义资源(EtcdCluster / KafkaCluster)
  - 自动执行:部署、成员管理、滚动升级、备份恢复
  - 失败自愈:节点挂了 → 自动隔离/重建

生态:
  - etcd-operator / coreos/etcd-operator
  - strimzi-kafka-operator(Kafka 事实标准)
  - elastic-cloud-on-k8s(ECK,官方)
  - prometheus-operator、rook(Ceph)...

7.2 示例:Strimzi 声明式管理 Kafka

apiVersion: kafka.strimzi.io/v1beta2
kind: Kafka
metadata:
  name: orders-kafka
spec:
  kafka:
    replicas: 3
    listeners:
      - name: plain
        port: 9092
        type: internal
    storage:
      type: persistent-claim
      size: 500Gi
      class: kafka-ssd
    authorization:
      type: simple
  zookeeper:
    replicas: 3
    storage:
      type: persistent-claim
      size: 20Gi
用 Operator 后:
  改 replicas / 升级版本 → 只改 CR,Operator 全自动处理
  → 有状态应用从"手工运维"升级为"声明式自治"

8. 故障排查与生产避坑

8.1 常见故障

症状根因排查
Pod PendingPVC 未绑定 / 节点无匹配存储kubectl get pvc kubectl describe pod
成员发现失败headless publishNotReadyAddresses 未开检查 DNS nslookup
升级卡住前一个 Pod 未 Readykubectl get sts -o yaml 看 condition
缩容后数据不删PVC retain明确 PVC 生命周期
Quorum 丢失节点同时宕 > 法定人数检查 PDB / 节点亲和

8.2 避坑清单

坑一:给 StatefulSet 配了普通 Service
  → 换成 headless,否则成员发现不了稳定 hostname

坑二:缩容直接 scale sts,没做应用层移除
  → etcd member remove / Kafka reassign 先做

坑三:升级无备份
  → 有状态升级前必须快照/备份

坑四:PV 回收策略 delete
  → 误删 PVC = 数据毁灭,用 Retain 或确认后手动删

坑五:3 个副本挤在同一节点/可用区
  → 节点亲和 + 反亲和 / PodTopologySpread 分散

9. 生产最佳实践

9.1 部署前 Checklist

□ 用 headless Service(publishNotReadyAddresses 按需开启)
□ 每个副本独立 volumeClaimTemplates + 合适 storageClass
□ PDB 保护法定人数(minAvailable = 多数)
□ 升级有备份 + partition 灰度 + 回滚路径
□ 缩容走"应用层移除 → scale sts"顺序
□ 节点/可用区分散(反亲和 + topologySpread)
□ 关键组件接 Operator(Strimzi/ECK/etcd-operator)
□ 定期备份演练(快照恢复验证)
□ 监控:member 健康、存储水位、Quorum 状态

9.2 一句话原则

StatefulSet 给你"身份、存储、顺序"的底座,但"成员、备份、升级"
这些应用级操作,交给 Operator——别用裸 StatefulSet 硬扛数据库。

小结

StatefulSet 是有状态应用在 K8s 上立足的底座:headless Service 给稳定的网络身份,volumeClaimTemplates 给每副本专属存储,有序滚动给安全的升级路径。但底座之上还有大量应用级责任——成员管理、备份恢复、法定人数、优雅缩容——这些正是 Operator 存在的意义。落地记住四件事:身份用 headless、存储用 volumeClaimTemplates、法定人数用 PDB 保护、应用级操作交给 Operator。当你用 Operator 声明式地管理 etcd / Kafka / ES 时,有状态服务才算真正融入了云原生的"声明式自治"哲学。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「云原生」更多文章

  1. Kubernetes 备份容灾与数据保护:Velero、etcd 与恢复演练实战
  2. Kubernetes 渐进式交付:Argo Rollouts、金丝雀/蓝绿与流量治理实战
  3. Kubernetes 集群排障与诊断:从 Pod 症状到节点/集群根因的实战手册