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 干两件事:
- 把旧 template 存进一个新 ControllerRevision
- 用新 template 更新 Pod
回滚时:
- 找到目标 ControllerRevision
- 把它的 template 应用回 StatefulSet
- 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 对比
| 维度 | Deployment | StatefulSet |
|---|---|---|
| 中间层 | ReplicaSet | 无 |
| Pod 名 | 随机后缀 | 有序号 |
| 创建顺序 | 并发 | 严格顺序 |
| 滚动方向 | 先扩新后缩旧 | 逆序逐个 |
| 版本历史 | 旧 ReplicaSet | ControllerRevision |
| 存储 | 共享或无 | 每 Pod 独占 |
| 适用 | 无状态 | 有状态 |
两个 Controller 的设计差异,本质来自"无状态 vs 有状态"的语义差异。K8S 宁可两套资源,也不强行统一——如果硬用同一套设计,要么 Deployment 的套娃在 StatefulSet 场景下变累赘,要么 StatefulSet 的身份管理在 Deployment 场景下变复杂。
前置阅读
- Deployment控制循环 — 本章前置,对比着看更容易理解 StatefulSet 为什么走不同路线
- 工作负载管理 — Starter 第 5 章,StatefulSet 的使用层面介绍
- 存储管理 — Starter 第 7 章,StatefulSet 的 PVC 机制依赖存储基础
- Pod:最小的调度单元 — Starter 第 4 章,StatefulSet 管的就是 Pod
思考题
问题 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 包装成稳定访问入口。