2.5. StatefulSet 与有状态集群

Deployment 把 Pod 当作可替换的零件,StatefulSet 则承认「每个副本都不一样」:名字、DNS、存储卷都必须跟着序号走。这一章用 etcd 三副本集群,把稳定身份、有序启停、PVC 模板和 partition 灰度一次讲透。

层级:

L2 · 核心

难度:

进阶

预计:

70 分钟

前置:

工作负载、存储与持久化、Service 与 DNS

2.5.1. 你将学到

  • StatefulSet 相对 Deployment 的三个额外保证

  • Headless Service、稳定 DNS 与 PVC 模板的对应关系

  • 扩缩容顺序、PVC 保留策略、滚动升级与 partition 灰度

  • 可运行的 etcd 三副本集群及其恢复注意事项

2.5.2. 为什么 Deployment 不够

维度

Deployment

StatefulSet

Pod 名称

随机后缀,重建即变

固定序号 name-0 … name-N-1

网络标识

不稳定,只能靠 Service

pod-0.svc.ns.svc.cluster.local 稳定 DNS

存储

共享卷或无卷

每副本独立 PVC,跟随序号

启停顺序

并行,无保证

默认按序号有序

数据库需要「我知道我是谁、我的数据在哪、我的同伴叫什么」,分别对应稳定网络标识、稳定存储、有序生命周期。

2.5.3. 最小骨架

statefulset-demo.yaml
apiVersion: v1
kind: Service
metadata:
  name: demoapp-sts
spec:
  clusterIP: None
  selector: {app: demoapp}
  ports: [{name: http, port: 80}]
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: sts-demo
spec:
  serviceName: demoapp-sts
  replicas: 2
  selector: {matchLabels: {app: demoapp}}
  template:
    metadata: {labels: {app: demoapp}}
    spec:
      containers:
        - name: demoapp
          image: ikubernetes/demoapp:v1.0
          volumeMounts: [{name: appdata, mountPath: /app/data}]
  volumeClaimTemplates:
    - metadata: {name: appdata}
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: standard
        resources: {requests: {storage: 2Gi}}

PVC 的命名规则是 <模板名>-<StatefulSet 名>-<序号>,因此会看到 appdata-sts-demo-0、appdata-sts-demo-1。删除 Pod 不会删除 PVC,重建后仍挂回原来的卷。

2.5.4. Headless Service 与 DNS

clusterIP: None 的 Service 不分配虚拟 IP,DNS 直接返回后端 Pod 的 A 记录:

记录

解析结果

demoapp-sts

所有 Ready Pod 的 IP 列表

sts-demo-0.demoapp-sts

仅 sts-demo-0 的 IP

kubectl run dns-test --rm -it --restart=Never --image=busybox:1.36 -- \
  nslookup sts-demo-0.demoapp-sts

警告

引导阶段要发布未就绪地址

默认只有 Ready 的 Pod 才进入 Endpoints,而 etcd 引导时所有成员都还没 Ready。给 Headless Service 加 publishNotReadyAddresses: true,peer 之间才能先解析到彼此,再组成集群。

2.5.5. 扩缩容与滚动升级

kubectl scale sts sts-demo --replicas=3

缩容默认从最大序号开始,并且保留 PVC。若希望随对象删除自动清理存储,声明 persistentVolumeClaimRetentionPolicy 的 whenDeleted / whenScaled,默认都是 Retain。

升级策略有两种:RollingUpdate(默认)从最大序号开始逐个删除重建,等前一个 Ready 才继续;OnDelete 只更新模板,由你手工删 Pod 触发重建。partition 是灰度开关:只有序号 大于等于 partition 的 Pod 会被更新。

# 只更新 etcd-2
kubectl patch sts etcd -p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":2}}}}'
kubectl set image sts/etcd etcd=quay.io/coreos/etcd:v3.5.16
kubectl get pod -l app=etcd -w

# 观察无误后放开其余序号
kubectl patch sts etcd -p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":0}}}}'

回滚用 kubectl rollout undo sts/etcd,StatefulSet 会从高序号往低序号逐个替换回旧版本。把 partition 保持在高位,就能让「只有部分副本升级」,这是有状态服务最实用的灰度手段。

2.5.6. 案例:三副本 etcd 集群

etcd 依赖多数派(quorum)工作:3 个成员可容忍 1 个故障,5 个成员可容忍 2 个。

etcd.yaml
apiVersion: v1
kind: Service
metadata:
  name: etcd-headless
spec:
  clusterIP: None
  publishNotReadyAddresses: true
  selector: {app: etcd}
  ports: [{name: client, port: 2379}, {name: peer, port: 2380}]
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: etcd
spec:
  serviceName: etcd-headless
  replicas: 3
  podManagementPolicy: Parallel
  selector: {matchLabels: {app: etcd}}
  template:
    metadata: {labels: {app: etcd}}
    spec:
      containers:
        - name: etcd
          image: quay.io/coreos/etcd:v3.5.15
          env: [{name: POD_NAME, valueFrom: {fieldRef: {fieldPath: metadata.name}}}]
          command:
            - /usr/local/bin/etcd
            - --name=$(POD_NAME)
            - --data-dir=/var/lib/etcd/data
            - --listen-client-urls=http://0.0.0.0:2379
            - --advertise-client-urls=http://$(POD_NAME).etcd-headless:2379
            - --listen-peer-urls=http://0.0.0.0:2380
            - --initial-advertise-peer-urls=http://$(POD_NAME).etcd-headless:2380
            - --initial-cluster=etcd-0=http://etcd-0.etcd-headless:2380,etcd-1=http://etcd-1.etcd-headless:2380,etcd-2=http://etcd-2.etcd-headless:2380
            - --initial-cluster-token=etcd-k8s-demo
          readinessProbe:
            httpGet: {path: /health, port: 2379}
            initialDelaySeconds: 5
            periodSeconds: 10
          volumeMounts: [{name: data, mountPath: /var/lib/etcd}]
  volumeClaimTemplates:
    - metadata: {name: data}
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: standard
        resources: {requests: {storage: 1Gi}}

三个设计点:

  1. podManagementPolicy: Parallel:所有成员同时创建。若用默认的 OrderedReady,etcd-0 会因为等不到另外两个成员组成多数派而永远不 Ready,etcd-1 也就永远不会被创建,形成死锁。

  2. --initial-cluster 写全三个成员:配合稳定 DNS 名,任一副本重建后都能用同一份配置重新加入。

  3. 就绪探针只探本机:/health 只反映本成员状态,不会因为某个同伴故障就把全部 Pod 标记为未就绪。

storageClassName 换成 kubectl get sc 里的实际名字;quay.io 拉不动时换成内网镜像仓库中的 etcd 3.5 镜像。

kubectl apply -f etcd.yaml
kubectl get pod -l app=etcd -w

kubectl exec etcd-0 -- etcdctl \
  --endpoints=http://etcd-0.etcd-headless:2379,http://etcd-1.etcd-headless:2379,http://etcd-2.etcd-headless:2379 \
  endpoint health --cluster

kubectl exec etcd-0 -- etcdctl --endpoints=http://127.0.0.1:2379 member list -w table
kubectl exec etcd-0 -- etcdctl --endpoints=http://127.0.0.1:2379 put /demo/hello world
kubectl exec etcd-2 -- etcdctl --endpoints=http://127.0.0.1:2379 get /demo/hello
kubectl delete pod etcd-1
kubectl get pod -l app=etcd -w

etcd-1 会以同名重建并挂回 data-etcd-1,自动重新加入集群。

警告

扩缩容不等于成员变更

StatefulSet 的 replicas 只控制 Pod 数量。对 etcd 这类自带成员管理的系统,扩容必须先 etcdctl member add etcd-3 --peer-urls=http://etcd-3.etcd-headless:2380,再 kubectl scale;缩容要先 member remove 再减副本。直接改副本数会让成员列表与 Pod 列表不一致。

2.5.6.1. 备份与恢复注意事项

kubectl exec etcd-0 -- etcdctl --endpoints=http://127.0.0.1:2379 snapshot save /var/lib/etcd/snap.db

快照文件落在 PVC 上;etcd 镜像通常不带 tar,kubectl cp 可能失败,可改用临时 Pod 挂载 PVC 或上传到对象存储。

  • 删除 StatefulSet 不会删 PVC,但删除 PVC 就等于删数据;恢复时用 etcdutl snapshot restore 把快照还原到新的数据目录,并换一个新的 --initial-cluster-token,先以单成员启动验证数据,再用 member add 把其他成员逐个加回;

  • --force-new-cluster 只用于「只剩一个成员且无法恢复多数派」的灾难场景,它会让该成员成为唯一成员,之后必须手工补齐副本;

  • 副本数缩到低于多数派(3 副本只剩 1 个)时集群只读不可写,这不是故障,是 quorum 在起作用。

2.5.7. 验证清单

  • kubectl get sts etcd 显示 READY 3/3

  • Pod 名为 etcd-0、etcd-1、etcd-2,重建后序号不变

  • endpoint health --cluster 三个成员全部 healthy,member list 均为 started

  • 删除 etcd-1 后自动重建并重新加入

  • 设置 partition=2 后只有 etcd-2 被更新

2.5.8. 排错表

现象

可能原因

处理

Pod 一直 Pending

PVC 未绑定、StorageClass 不存在、反亲和过严

describe pvc、describe pod 的 Events

只创建了 etcd-0

etcd-0 未 Ready,后续序号不创建

先修 0 号;引导阶段改用 Parallel

etcd 反复重启

--initial-cluster 与 DNS 不一致、数据目录权限

看容器日志首行报错

context deadline exceeded

peer 端口不通、DNS 解析失败

nslookup、NetworkPolicy、endpoint status

滚动升级卡住

新 Pod 不 Ready,或 partition 设置过高

rollout status、检查 partition

2.5.9. 进阶挑战

  1. 把 podManagementPolicy 改成 OrderedReady,观察 etcd 引导为什么失败,再改回 Parallel。

  2. 用 partition 做一次真实灰度:只升级 etcd-2,验证读写正常后再放开其余副本,最后用 rollout undo 回滚。

  3. 用 local PV + volumeBindingMode: WaitForFirstConsumer 给每个 etcd 成员绑定本地盘,观察调度与绑定的先后关系。

2.5.10. 小结

  • StatefulSet 的核心是三个保证:稳定网络标识、稳定存储、有序生命周期;扩缩容按序号进行,PVC 默认保留,升级按序号倒序,partition 控制灰度范围。

  • etcd 这类系统要额外处理成员关系与 quorum,replicas 不等于 member;恢复的第一原则是「先确认数据还在,再谈重建」。

2.5.11. 延伸阅读