Kubernetes StatefulSet 实战:为什么数据库不能用 Deployment 部署
很多人第一次在 K8s 上跑 MySQL、Redis、ZooKeeper,直接抄了个 Deployment 的 YAML,结果 Pod 一重启数据就没了,或者主从复制配了半天 IP 一变就全乱套。问题不在你,而在于有状态应用根本不该用 Deployment。这篇讲清楚 StatefulSet 解决了什么、和 Deployment 差在哪,以及一个能跑起来的完整例子。
先看 Deployment 为什么不行
Deployment 管理的 Pod 是「牲畜不是宠物」------它假设每个副本完全对等、可随意替换。这带来三个对有状态应用致命的问题:
- Pod 名字是随机的 :
web-7d9f8c-x2k9、web-7d9f8c-p4m1,重启后名字全变。主从复制里从库要连主库,连的地址一直变,配置根本没法写死。 - 所有 Pod 共享同一个 PVC(或各自随机) :Deployment 的多个副本挂的是同一个存储,或者用
volumeClaimTemplates也没法保证 Pod 和存储的固定对应。数据库多实例各写各的盘,Deployment 给不了这种保证。 - 启动/停止是并行、无序的:Deployment 扩容时一把全拉起来。但很多集群软件要求「先起主节点,再起从节点」,顺序错了初始化就失败。
StatefulSet 就是为解决这三点而生的。
StatefulSet 给的三个稳定保证
1. 稳定的网络标识(Pod 名字固定且有序)。 StatefulSet 的 Pod 名字是 <statefulset名>-<序号>,从 0 开始:mysql-0、mysql-1、mysql-2。Pod 挂了重建,名字不变。配上一个 Headless Service,每个 Pod 还能拿到稳定的 DNS 域名:
mysql-0.mysql.default.svc.cluster.local
mysql-1.mysql.default.svc.cluster.local
从此从库连主库,直接写 mysql-0.mysql 就行,永远指向 0 号 Pod,不管它重建多少次。
2. 稳定的独占存储。 StatefulSet 用 volumeClaimTemplates 给每个 Pod 生成独立 的 PVC:data-mysql-0、data-mysql-1......Pod 删了再建,还是绑回原来那块盘,数据不丢。这是和 Deployment 最本质的区别。
3. 有序部署与伸缩。 默认按序号顺序创建(0→1→2),前一个 Ready 了才起下一个;缩容时逆序删除(2→1→0)。这正好满足「先主后从」的启动需求。
一个能跑的完整例子
先建 Headless Service(clusterIP: None 是关键,它让 DNS 直接解析到每个 Pod 而不是负载均衡):
yaml
apiVersion: v1
kind: Service
metadata:
name: mysql
labels:
app: mysql
spec:
clusterIP: None # Headless:不分配 VIP,DNS 直接返回各 Pod IP
selector:
app: mysql
ports:
- name: mysql
port: 3306
再写 StatefulSet:
yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
spec:
serviceName: mysql # 必须指向上面那个 Headless Service,否则拿不到稳定 DNS
replicas: 3
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
containers:
- name: mysql
image: mysql:8.0
ports:
- containerPort: 3306
name: mysql
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-secret
key: root-password
volumeMounts:
- name: data # 对应下面 volumeClaimTemplates 的 name
mountPath: /var/lib/mysql
volumeClaimTemplates: # 每个 Pod 自动生成独立 PVC
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: standard # 换成你集群实际的 StorageClass
resources:
requests:
storage: 10Gi
apply 之后观察:
bash
kubectl apply -f mysql-statefulset.yaml
# Pod 名字是有序的,而且是一个一个起来的
kubectl get pods -w
# mysql-0 0/1 ContainerCreating
# mysql-0 1/1 Running <- 0 号 Ready 后才起 1 号
# mysql-1 0/1 Pending
# ...
# 每个 Pod 有独立 PVC,名字带序号
kubectl get pvc
# data-mysql-0 Bound ...
# data-mysql-1 Bound ...
# data-mysql-2 Bound ...
验证稳定 DNS(在集群内任意 Pod 里):
bash
# 从 mysql-1 连 mysql-0,地址写死也永远有效
mysql -h mysql-0.mysql -uroot -p
三个最容易踩的坑
坑一:删了 StatefulSet,PVC 不会自动删。 这其实是保护机制------防止误删丢数据。但你重建时要注意,新 StatefulSet 会复用同名的旧 PVC,里面还是老数据。想彻底清空得手动删 PVC:
bash
kubectl delete statefulset mysql # Pod 没了,但 PVC 还在
kubectl get pvc # data-mysql-0/1/2 仍然 Bound
kubectl delete pvc data-mysql-0 data-mysql-1 data-mysql-2 # 要清数据得手动删
坑二:缩容不会删 PVC。 把 replicas 从 3 改成 1,mysql-1/mysql-2 的 Pod 被删,但 data-mysql-1/data-mysql-2 这两个 PVC 保留着。下次扩回 3,这两个 Pod 会绑回原来的盘、拿回原来的数据。这是特性不是 bug,但计费时别忘了这些「孤儿」PVC 还在占存储费。
坑三:滚动更新默认也是逆序、一个一个来。 StatefulSet 的 updateStrategy 默认是 RollingUpdate,从最大序号往小更新,一次一个。这比 Deployment 慢,但对有状态服务是安全的。如果你要金丝雀验证,可以用 partition 只更新序号 >= N 的 Pod:
yaml
spec:
updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 2 # 只更新 mysql-2,mysql-0/1 保持旧版本,验证 OK 再把 partition 调到 0
什么时候该用 StatefulSet
判断标准很简单:Pod 之间是不是需要「区分身份」。
- 需要固定名字/固定存储/固定启动顺序 → StatefulSet:数据库、消息队列(Kafka/RabbitMQ)、分布式协调(ZooKeeper/etcd)、需要 sticky 会话或分片的服务。
- 无状态、副本完全对等、随便杀随便换 → Deployment:HTTP API、前端、无状态 worker。
别为了「看起来高级」把无状态服务塞进 StatefulSet,那只会让你的滚动更新变慢、扩缩容变笨。
小结
- Deployment 的 Pod 名字随机、存储不固定、启停无序,这三点让它不适合有状态应用。
- StatefulSet 提供三个稳定保证:有序固定的 Pod 名字 + Headless Service 给的稳定 DNS 、每 Pod 独立且重建不丢的 PVC 、有序的部署与伸缩。
- 必配
serviceName指向一个clusterIP: None的 Headless Service,存储用volumeClaimTemplates而不是共享 PVC。 - 记住三个坑:删 StatefulSet 和缩容都不删 PVC (保护数据,但要手动清理);滚动更新逆序单个进行,可用
partition做金丝雀。
一句话记忆点:Pod 需要有名有姓有自己的盘,就用 StatefulSet;随便替换的用 Deployment。