第 4 章:K8s StatefulSet:从无状态到有状态应用管理

7 阅读7分钟

上一章我们学会了 Deployment。但 Deployment 是为无状态应用设计的:Pod 之间可以互相替换,名字随机,存储也随删随弃。这一章讲 StatefulSet,它专门管理有状态应用。

4.1 为什么 StatefulSet 存在

Deployment 管理的是无状态应用。无状态的意思是:任意一个 Pod 被删掉重建,业务都不会受影响。比如 Nginx、Tomcat、Spring Boot 这类 Web 服务,请求处理完就结束了,不依赖本地数据。

但有状态应用不一样:

  • MySQL 主从:每个实例身份固定,谁是主、谁是从必须稳定
  • ZooKeeper:节点有固定编号,启动顺序和身份绑定
  • Kafka:每个 Broker 有唯一 ID,和分区副本一一对应
  • etcd:成员之间需要固定名称和持久化数据

如果用 Deployment 跑这些应用,会遇到几个致命问题:

  1. Pod 名字随机:MySQL 主库叫 mysql-7c4d9f8b5c-2jxmt,重启后变成 mysql-7c4d9f8b5c-abc12,域名跟着变,从库找不到主库
  2. 启动顺序乱:多个副本同时启动,ZooKeeper 还没形成集群就互相报错
  3. 存储不固定:Pod 删掉后,PVC 可能被删掉,新 Pod 拿不到原来的数据

StatefulSet 就是为解决这些问题而生的。

Deployment vs StatefulSet

4.2 StatefulSet 的三条核心保证

StatefulSet 给每个 Pod 提供了三个稳定的东西:稳定网络身份、稳定存储、有序启动和伸缩。

4.2.1 稳定网络身份

StatefulSet 创建的 Pod 名字是有规律的,格式为:

<StatefulSet 名称>-<序号>

比如 StatefulSet 名为 mysql,副本数为 3,那么三个 Pod 的名字分别是:

mysql-0
mysql-1
mysql-2

这个序号叫序数索引(ordinal index)。它不会因为 Pod 重启而改变。mysql-0 被删除重建后,仍然叫 mysql-0

每个 Pod 还会有一个稳定的 DNS 域名。配合 Headless Service 使用时,格式为:

<pod >.<Service >.<namespace>.svc.cluster.local

在我们的集群中,如果在 default 命名空间创建了 Headless Service mysql-svc,那么 mysql-0 的完整域名是:

mysql-0.mysql-svc.default.svc.cluster.local

其他节点可以直接通过这个域名访问它,不用担心 IP 变化。

4.2.2 稳定存储

StatefulSet 通过 volumeClaimTemplates 为每个 Pod 单独申请一块 PVC。PVC 的名字也有规律:

StatefulSet 有序启动与 PVC

<卷模板名>-<StatefulSet >-<序号>

比如 volumeClaimTemplates 里定义了一个卷模板叫 data,那么三个 Pod 对应的 PVC 分别是:

data-mysql-0
data-mysql-1
data-mysql-2

每个 PVC 都绑定到一块独立的 PV。Pod 被删除重建后,StatefulSet 控制器会把原来的 PVC 重新挂回给对应序号的 Pod。这意味着 MySQL 的数据不会丢,Kafka 的日志不会乱。

4.2.3 有序部署和伸缩

StatefulSet 创建 Pod 时是有顺序的:

先创建 mysql-0,等它 Ready 了,再创建 mysql-1
等 mysql-1 Ready 了,再创建 mysql-2

缩容的时候也是反向的:

先删除 mysql-2,等它完全删除后,再删除 mysql-1

这个顺序很重要。ZooKeeper 需要先启动第一个节点,它才知道自己是集群的第一个成员。MySQL 主从要先起主库,再起从库。如果同时起多个,集群初始化会失败。

StatefulSet 默认启用 podManagementPolicy: OrderedReady,即按顺序管理。如果改成 Parallel,则像 Deployment 一样同时创建,适用于无状态但又有序号的特殊场景,但不常见。

4.3 典型使用场景

StatefulSet 最适合跑下面几类服务:

应用为什么需要 StatefulSet
MySQL / PostgreSQL主从身份固定,数据持久化
ZooKeeper节点编号固定,启动顺序敏感
KafkaBroker ID 固定,分区副本落点依赖 ID
etcd成员名称固定,WAL 数据持久化
MongoDB / Redis Cluster分片或主从关系需要稳定身份

有些数据库虽然有 Operator 可以更方便地部署,但底层通常也是基于 StatefulSet。理解 StatefulSet 是理解这些 Operator 的前提。

4.4 实战:部署一个单节点 MySQL StatefulSet

下面是一个最简版 StatefulSet 例子,基于我们 3 节点集群的实际 IP 环境(192.168.0.201/202/203)编写。

# statefulset-mysql.yaml
apiVersion: v1
kind: Service
metadata:
  name: mysql-svc
  labels:
    app: mysql
spec:
  ports:
  - port: 3306
    name: mysql
  clusterIP: None          # 关键:Headless Service,不分配虚拟 IP
  selector:
    app: mysql
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql
spec:
  serviceName: mysql-svc  # 必须关联 Headless Service
  replicas: 1
  selector:
    matchLabels:
      app: mysql
  template:
    metadata:
      labels:
        app: mysql
    spec:
      containers:
      - name: mysql
        image: mysql:8.0
        env:
        - name: MYSQL_ROOT_PASSWORD
          value: "root1234"
        ports:
        - containerPort: 3306
          name: mysql
        volumeMounts:
        - name: data
          mountPath: /var/lib/mysql
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: ["ReadWriteOnce"]
      storageClassName: local-path
      resources:
        requests:
          storage: 5Gi

关键字段解读:

字段含义
clusterIP: None把 Service 变成 Headless,DNS 直接解析到 Pod IP
serviceNameStatefulSet 必须关联一个 Headless Service
volumeClaimTemplates为每个 Pod 按模板创建独立 PVC
accessModes: ReadWriteOnce单个节点读写,适合单节点数据库

创建并验证:

$ kubectl apply -f statefulset-mysql.yaml
service/mysql-svc created
statefulset.apps/mysql created

$ kubectl get statefulset,pod,pvc
NAME                     READY   AGE
statefulset.apps/mysql   1/1     2m

NAME          READY   STATUS    AGE
pod/mysql-0   1/1     Running   2m

NAME                                 STATUS   VOLUME
persistentvolumeclaim/data-mysql-0   Bound    pvc-6f3e2d8b...

进入 Pod 写入数据,然后删除 Pod 验证数据持久化:

$ kubectl exec -it mysql-0 -- mysql -uroot -proot1234 -e "CREATE DATABASE testdb;"

$ kubectl delete pod mysql-0
pod "mysql-0" deleted

$ kubectl get pod -w
NAME      READY   STATUS    AGE
mysql-0   1/1     Running   30s

$ kubectl exec -it mysql-0 -- mysql -uroot -proot1234 -e "SHOW DATABASES;"
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
| testdb             |
+--------------------+

testdb 还在。这就是 StatefulSet 加 PVC 的威力。

4.5 多节点 ZooKeeper 示例

ZooKeeper 是一个典型依赖 StatefulSet 的应用。三个节点需要固定编号、固定启动顺序和固定访问地址。

# zk.yaml
apiVersion: v1
kind: Service
metadata:
  name: zk-svc
  labels:
    app: zk
spec:
  ports:
  - port: 2181
    name: client
  - port: 2888
    name: server
  - port: 3888
    name: leader-election
  clusterIP: None
  selector:
    app: zk
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: zk
spec:
  serviceName: zk-svc
  replicas: 3
  selector:
    matchLabels:
      app: zk
  template:
    metadata:
      labels:
        app: zk
    spec:
      containers:
      - name: zk
        image: zookeeper:3.8
        ports:
        - containerPort: 2181
          name: client
        - containerPort: 2888
          name: server
        - containerPort: 3888
          name: leader-election
        env:
        - name: ZOO_MY_ID
          valueFrom:
            fieldRef:
              fieldPath: metadata.name   # 利用 Pod 名 my-id-0 设置 ZooKeeper ID
        - name: ZOO_SERVERS
          value: "server.0=zk-0.zk-svc:2888:3888;2181 server.1=zk-1.zk-svc:2888:3888;2181 server.2=zk-2.zk-svc:2888:3888;2181"
        volumeMounts:
        - name: data
          mountPath: /data
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: ["ReadWriteOnce"]
      storageClassName: local-path
      resources:
        requests:
          storage: 5Gi

注意:这个例子用了 fieldRef 把 Pod 名字注入环境变量。真实生产环境推荐用 ZooKeeper Operator 或 zk-my-id 等辅助方案生成正确的 ID,因为 ZooKeeper 的 myid 必须是数字。

部署后会按顺序创建 zk-0zk-1zk-2,每个 Pod 都有稳定的域名 zk-0.zk-svczk-1.zk-svczk-2.zk-svc

4.6 有状态应用扩容与缩容

扩容时:

$ kubectl scale statefulset mysql --replicas=3

执行后,K8s 会先创建 mysql-1,等它 Ready 后,再创建 mysql-2。每个新 Pod 都会对应创建一个新的 PVC,名字分别是 data-mysql-1data-mysql-2

缩容时:

$ kubectl scale statefulset mysql --replicas=1

K8s 会先删除 mysql-2,再删除 mysql-1。注意,PVC 不会被自动删除。这是为了防止误删导致数据丢失。如果你确认要清理,需要手动删除 PVC 和 PV:

$ kubectl delete pvc data-mysql-2 data-mysql-1

很多初学者缩容后发现磁盘还占着空间,就是这个原因。StatefulSet 对数据保护非常保守。

4.7 常见问题与排错

现象原因排查
Pod 卡在 PendingPVC 没绑定kubectl get pvc 看是否 Bound
域名解析失败没配 Headless Service 或 serviceName 写错kubectl get svc 检查 clusterIP
多节点启动互相连不上启动顺序或地址配置错看日志里 ordinal 和 DNS 是否对应
扩容只到一半前面 Pod 没 Readykubectl describe pod 看 Readiness
数据没保留PVC 被误删,或 Pod 序号变了StatefulSet 删了重建序号才保留

4.8 本章小结

  • StatefulSet 解决 Deployment 无法管理的有状态应用问题
  • 三个核心保证:稳定网络身份、稳定存储、有序启动和伸缩
  • 必须配合 Headless Service 使用 serviceName
  • volumeClaimTemplates 为每个 Pod 创建独立 PVC,且持久化
  • 扩容/缩容按顺序进行,PVC 不会自动删除,保护数据安全

4.9 课后练习

  1. 用本章 YAML 创建 MySQL StatefulSet,写入数据后删除 Pod,验证数据是否保留。
  2. 把 MySQL 从 1 副本扩到 3 副本,观察 Pod 创建顺序和 PVC 名称。
  3. nslookupdig 在集群内测试 mysql-0.mysql-svc.default.svc.cluster.local 能否解析。
  4. 尝试部署 ZooKeeper 示例,理解固定编号和启动顺序在集群类应用中的作用。