Skip to main content

4. StatefulSet 控制循环

上一章讲了 Deployment + ReplicaSet 的套娃设计。这一章看 StatefulSet——它看着跟 Deployment 像,都是管 N 个副本,但设计思路完全不同。

为什么 Deployment 不适合有状态服务​

Deployment + ReplicaSet 的模型有一个核心假设:所有 Pod 是对等的——杀哪个都行,新 Pod 顶上来就能干活。这对 Nginx 这种无状态服务成立。

但 MySQL 主从集群不行:

  • mysql-0 是主,mysql-1、mysql-2 是从——身份不同
  • 主挂了,从要按序号接力——序号是身份
  • 每个 Pod 有自己的数据卷——mysql-0 的数据不能给 mysql-1 用
  • DNS 要稳定——mysql-0.mysql 必须永远指向同一个 Pod

Deployment 给不了这些——Pod 名字随机(mysql-abc-def 这种)、PVC 共享、创建无序。StatefulSet 就是为这种"有身份"的工作负载设计的。

StatefulSet 不套娃​

上一章 Deployment 隔了一层 ReplicaSet——Deployment 管 ReplicaSet,ReplicaSet 管 Pod。

StatefulSet 不一样——它直接管 Pod,没有中间层。

Deployment StatefulSet
│ │
▼ ▼
ReplicaSet-v1 (replicas: 0) Pod-0 (固定名, 绑 PVC-0)
ReplicaSet-v2 (replicas: 3) Pod-1 (固定名, 绑 PVC-1)
│ │
▼ ▼
Pod × 3 (随机名) Pod × 3 (有序号)

为什么 StatefulSet 不套 ReplicaSet?因为 ReplicaSet 的模型是"一群对等的 Pod"——selector 选中的 Pod 互相平等,杀哪个都行。但 StatefulSet 的 Pod 有身份(序号),每个 Pod 都是独立个体,不是对等的。

打个比方:ReplicaSet 像管一群一模一样的羊,少一只补一只就行。StatefulSet 像管一个球队——1 号是守门员、2 号是后卫、3 号是前锋,每个位置都不同,不能随便替换。

ControllerRevision:StatefulSet 的版本档案​

上一章讲过,Deployment 用 ReplicaSet 存历史版本——保留旧 RS,replicas 设 0,回滚时调回来。

StatefulSet 不套 RS,那它怎么做版本管理?发版时怎么记录"上一个版本长什么样"用于回滚?

ControllerRevision——一个专门存"控制器历史版本"的资源,里面装的是某个时间点的 Pod 模板快照。

StatefulSet mysql (当前 template v2)
│
│ 当 template 变化时,Controller 创建一个 ControllerRevision
▼
ControllerRevision mysql-v1 (template v1 的快照)
ControllerRevision mysql-v2 (template v2 的快照) ← 当前版本

发版时,StatefulSet Controller 干两件事:

  1. 把旧 template 存进一个新 ControllerRevision
  2. 用新 template 更新 Pod

回滚时:

  1. 找到目标 ControllerRevision
  2. 把它的 template 应用回 StatefulSet
  3. Controller 按逆序(从大到小)更新 Pod

kubectl rollout history statefulset mysql 背后就是在列 ControllerRevision。

为什么不用旧 ReplicaSet 的方式​

你可能会问——StatefulSet 为什么不也用"保留旧 ReplicaSet"的方式做版本管理?

因为 ReplicaSet 套娃的前提是"Pod 对等"——旧 RS 和新 RS 各自管一群对等的 Pod,互不干涉。但 StatefulSet 的 Pod 有身份(序号),同一个序号的 Pod 在新旧版本下是同一个"槽位"(mysql-0)。

如果用 ReplicaSet 套娃,mysql-0 到底归旧 RS 还是新 RS 管?两个 RS 的 selector 重叠,都认为 mysql-0 是自己的,会冲突。

ControllerRevision 只存模板快照,不抢 Pod 归属,避开了这个问题。

打比方:Deployment 的版本管理像"保留旧班长"——每个旧班长(ReplicaSet)还在编制里,只是不带兵了(replicas: 0)。StatefulSet 的版本管理像"档案柜"——班长只有一个,但每次换装方案都存一份档案(ControllerRevision),要回滚就翻档案。

ControllerRevision 不是 StatefulSet 专属​

它叫"Controller" Revision 而不是"StatefulSet Revision"——因为它是通用的控制器版本快照资源。DaemonSet 也用它存版本历史。任何需要版本管理但不套 ReplicaSet 的控制器,都用 ControllerRevision。

Reconcile:有序创建​

StatefulSet 的 Reconcile 逻辑跟 Deployment 完全不同:

func Reconcile(sts *appsv1.StatefulSet) error {
// 1. 拿当前所有 Pod,按序号排序
pods := listPods(sts.Spec.Selector)
sort.Slice(pods, func(i, j int) bool {
return ordinal(pods[i]) < ordinal(pods[j])
})

// 2. 按 replica 数逐个检查
for i := 0; i < int(sts.Spec.Replicas); i++ {
podName := fmt.Sprintf("%s-%d", sts.Name, i)

if i >= len(pods) {
// 该有但没有:创建
// 但要先检查前一个 Pod 是否 ready
if i > 0 && !isReady(pods[i-1]) {
return nil // 前一个没 ready,停住
}
createPod(sts, i) // 绑定 PVC-i
return nil // 创建一个就停,等下一轮
}

// 已存在,检查是否需要更新
if needUpdate(pods[i], sts.Spec.Template) {
updatePod(pods[i], sts.Spec.Template)
return nil
}
}

// 3. 缩容:从后往前删
for i := len(pods) - 1; i >= int(sts.Spec.Replicas); i-- {
if isReady(pods[i]) || i == int(sts.Spec.Replicas) {
deletePod(pods[i])
return nil
}
}

return nil
}

关键特征:

严格有序——创建时从 0 开始,mysql-0 ready 了才建 mysql-1,mysql-1 ready 了才建 mysql-2。缩容时反过来,先删 mysql-2,再删 mysql-1。

打比方:搭积木塔——必须先放底层,稳了再放第二层,再放第三层。拆的时候从顶往下拆。不能跳着来。

一个一个来——每轮 Reconcile 只处理一个 Pod,然后 return,等下一轮。这跟 Deployment 不同,Deployment 一次性算完所有差异。

为什么必须有序​

这是为有状态服务设计的——比如 MySQL 主从,mysql-0 是主,mysql-1 要从 mysql-0 同步数据。mysql-0 都没 ready,mysql-1 不知道从哪同步。跳着建等于让从库在没有主库的情况下启动,行为未定义。

Pod 的固定身份​

StatefulSet 的 Pod 名字是算出来的,不是随机的:

StatefulSet name: mysql
Pod 0: mysql-0
Pod 1: mysql-1
Pod 2: mysql-2

这个名字有两层意义:

DNS:配合 Headless Service(下一章会讲),每个 Pod 有独立 DNS 记录 mysql-0.mysql.default.svc.cluster.local。Pod 重建后名字不变,DNS 不变,下游服务不用改配置。

PVC 绑定:StatefulSet 用 volumeClaimTemplates,每个序号自动绑一个 PVC:

volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi

生成的 PVC:

data-mysql-0 → 绑给 mysql-0
data-mysql-1 → 绑给 mysql-1
data-mysql-2 → 绑给 mysql-2

mysql-0 被删重建,新的 mysql-0 还是绑 data-mysql-0 这个 PVC——数据还在。

这跟 Deployment 完全不同——Deployment 的 Pod 重建后 PVC 就丢了(除非用共享 PVC,但共享 PVC 不能做"每个 Pod 独占")。

滚动更新:逆序​

StatefulSet 滚动更新从最大序号开始:

更新前:mysql-0(v1), mysql-1(v1), mysql-2(v1)

第 1 步:mysql-0(v1), mysql-1(v1), mysql-2(v2) ← 先更新 2
第 2 步:mysql-0(v1), mysql-1(v2), mysql-2(v2) ← 再更新 1
第 3 步:mysql-0(v2), mysql-1(v2), mysql-2(v2) ← 最后更新 0

为什么逆序?因为 StatefulSet 通常做主从——序号小的是主,序号大的是从。更新时先动从(从挂了不影响服务),最后动主(主切换有短暂影响)。

而且每更新一个,要等它 ready 才更新下一个——保证滚动过程中始终有 N-1 个 Pod 可用。

OnDelete vs RollingUpdate​

StatefulSet 有两种更新策略:

策略行为用途
RollingUpdate自动逆序滚动默认,适合大部分场景
OnDelete手动删一个才更新一个谨慎场景,比如主从手动控制切换时机

OnDelete 像手动挡——你删 mysql-2,Controller 才更新它;不删不动。适合不能自动滚动的场景(比如主库要先手动切主再更新)。

跟 Deployment 对比​

维度DeploymentStatefulSet
中间层ReplicaSet无
Pod 名随机后缀有序号
创建顺序并发严格顺序
滚动方向先扩新后缩旧逆序逐个
版本历史旧 ReplicaSetControllerRevision
存储共享或无每 Pod 独占
适用无状态有状态

两个 Controller 的设计差异,本质来自"无状态 vs 有状态"的语义差异。K8S 宁可两套资源,也不强行统一——如果硬用同一套设计,要么 Deployment 的套娃在 StatefulSet 场景下变累赘,要么 StatefulSet 的身份管理在 Deployment 场景下变复杂。

前置阅读​

思考题​

问题 1:StatefulSet 缩容时从最大序号开始删。扩容时为什么不能跳着建——比如已经有 0、1,直接建 2、3?

问题 2:StatefulSet 为什么不也用"保留旧 ReplicaSet"的方式做版本管理,而要搞一个 ControllerRevision?

问题 3:StatefulSet 滚动更新为什么是逆序(从大到小),不是正序?


解答​

问题 1:StatefulSet 的语义是"序号小的先 ready 才能建序号大的"。这是为有状态服务设计的——比如 MySQL 主从,mysql-0 是主,mysql-1 要从 mysql-0 同步数据。mysql-0 都没 ready,mysql-1 不知道从哪同步。跳着建等于让从库在没有主库的情况下启动,行为未定义。

问题 2:ReplicaSet 套娃的前提是"Pod 对等"——旧 RS 和新 RS 各自管一群对等的 Pod,互不干涉。但 StatefulSet 的 Pod 有身份(序号),同一个序号的 Pod 在新旧版本下是同一个"槽位"(mysql-0)。如果用 ReplicaSet 套娃,mysql-0 到底归旧 RS 还是新 RS 管?两个 RS selector 重叠,都认为 mysql-0 是自己的,会冲突。ControllerRevision 只存模板快照,不抢 Pod 归属,避开了这个问题。

问题 3:因为序号小的是主,序号大的是从。更新时先动从(从挂了不影响服务),最后动主(主切换有短暂影响)。如果正序先动主,主切换 + 从还在旧版本,可能导致数据不一致或服务中断。逆序保证滚动过程中始终有 N-1 个 Pod 可用,且主最后动。

下一章​

下一章看另一个内置控制循环——Service 控制循环,看它怎么把一组变化中的 Pod 包装成稳定访问入口。