K8s StatefulSet:有状态应用的有序部署、稳定网络标识、持久化存储
K8s StatefulSet:有状态应用的有序部署、稳定网络标识、持久化存储
适读人群:需要在K8s上部署有状态应用的Java工程师 | 阅读时长:约22分钟 | 适用版本:K8s 1.24+
开篇故事
我们的消息中间件团队有次把RabbitMQ集群从物理机迁移到K8s,用的是Deployment而不是StatefulSet。刚开始一切正常,但维护了几个月后,问题来了。
某次升级RabbitMQ版本,Deployment做了滚动更新,旧Pod随机被删除,新Pod随机分配了不同的IP和主机名。RabbitMQ节点发现自己的集群伙伴突然消失了(IP变了),集群产生了脑裂,导致一部分消息被确认消费但实际上没有持久化。这个问题发现的时候,已经有将近3000条消息"蒸发"了。
痛苦的代价让我们深刻认识到:有状态应用必须用StatefulSet,不能偷懒。StatefulSet提供了三个关键特性:稳定的网络标识(Pod名称不变)、有序的部署和扩缩容、持久化存储的稳定绑定。这三点缺一不可。
一、核心问题分析
有状态应用的三个核心需求
稳定的网络标识:MySQL集群的节点要互相知道对方的地址;ZooKeeper的节点要维持固定的ID和地址;RabbitMQ的节点名称一旦变更就会产生集群问题。这些有状态应用要求Pod的主机名和DNS名称在整个生命周期内保持不变。
有序的生命周期管理:MySQL的主从搭建需要先启动主节点,再启动从节点;ElasticSearch集群需要所有节点都健康才能安全下线某个节点;这些都需要有序的启动和关闭顺序。
稳定的持久化存储:每个节点有自己独立的数据目录,不能共享(MySQL的数据文件、RabbitMQ的消息存储)。而且当Pod重启或迁移到其他Node时,必须能重新挂载同一块持久化存储。
Deployment无法提供这三点,StatefulSet专门为此设计。
二、原理深度解析
StatefulSet的Pod命名规则
StatefulSet的Pod命名是<StatefulSet名>-<序号>,序号从0开始递增。这个名称在Pod重启、迁移时保持不变,是StatefulSet最核心的保证。
Headless Service:StatefulSet通常配合Headless Service(clusterIP: None)使用。Headless Service不提供负载均衡,而是为每个Pod生成独立的DNS A记录,格式为<pod-name>.<service-name>.<namespace>.svc.cluster.local。有状态应用可以通过这个稳定的DNS名称互相通信。
三、完整配置实现
MySQL主从集群的StatefulSet配置
# mysql-statefulset.yaml
---
# Headless Service:为每个Pod提供稳定的DNS
apiVersion: v1
kind: Service
metadata:
name: mysql-headless
namespace: production
labels:
app: mysql
spec:
clusterIP: None # 关键:Headless Service
selector:
app: mysql
ports:
- name: mysql
port: 3306
---
# 对外暴露的读写Service(只指向主节点)
apiVersion: v1
kind: Service
metadata:
name: mysql-primary
namespace: production
spec:
selector:
app: mysql
role: primary # 只选主节点的Pod
ports:
- port: 3306
targetPort: 3306
---
# 对外暴露的只读Service(指向从节点)
apiVersion: v1
kind: Service
metadata:
name: mysql-readonly
namespace: production
spec:
selector:
app: mysql
role: replica
ports:
- port: 3306
targetPort: 3306
---
# StatefulSet配置
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
namespace: production
spec:
serviceName: mysql-headless # 关联Headless Service
replicas: 3 # 1主2从
selector:
matchLabels:
app: mysql
# 更新策略:有序滚动更新
updateStrategy:
type: RollingUpdate
rollingUpdate:
# 从最高序号开始更新
# 设置partition=1表示只更新序号>=1的Pod(从节点)
# 主节点(序号0)需要手动操作
partition: 0
# Pod管理策略
# OrderedReady:默认,等前一个Pod就绪才启动下一个
# Parallel:并行启动(适合不需要有序启动的场景)
podManagementPolicy: OrderedReady
template:
metadata:
labels:
app: mysql
spec:
terminationGracePeriodSeconds: 60
# Init Container: 根据Pod序号决定是主节点还是从节点
initContainers:
- name: init-mysql
image: mysql:8.0.36
command:
- bash
- -c
- |
set -ex
# 从Pod名称提取序号
# mysql-0是主节点,mysql-1、mysql-2是从节点
[[ $HOSTNAME =~ -([0-9]+)$ ]] || exit 1
ORDINAL=${BASH_REMATCH[1]}
echo "[mysqld]" > /mnt/conf.d/server-id.cnf
# 每个节点的server-id必须唯一,用序号+100
echo "server-id=$((100 + $ORDINAL))" >> /mnt/conf.d/server-id.cnf
if [[ $ORDINAL -eq 0 ]]; then
# 主节点配置
echo "主节点配置"
cp /mnt/config-map/primary.cnf /mnt/conf.d/
else
# 从节点配置
echo "从节点配置"
cp /mnt/config-map/replica.cnf /mnt/conf.d/
fi
volumeMounts:
- name: conf
mountPath: /mnt/conf.d
- name: config-map
mountPath: /mnt/config-map
# 从节点:从主节点或上一个从节点克隆数据
- name: clone-mysql
image: gcr.io/google-samples/xtrabackup:1.0
command:
- bash
- -c
- |
set -ex
[[ $HOSTNAME =~ -([0-9]+)$ ]] || exit 1
ORDINAL=${BASH_REMATCH[1]}
# 主节点不需要克隆
[[ $ORDINAL -eq 0 ]] && exit 0
# 检查数据目录是否已有数据
[[ -d /var/lib/mysql/mysql ]] && exit 0
# 从上一个节点克隆数据
# mysql-0 -> mysql-1 -> mysql-2
ncat --recv-only mysql-$((ORDINAL-1)).mysql-headless 3307 | xbstream -x -C /var/lib/mysql
xtrabackup --prepare --target-dir=/var/lib/mysql
volumeMounts:
- name: data
mountPath: /var/lib/mysql
subPath: mysql
- name: conf
mountPath: /etc/mysql/conf.d
containers:
- name: mysql
image: mysql:8.0.36
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-secret
key: root-password
- name: MYSQL_DATABASE
value: appdb
ports:
- name: mysql
containerPort: 3306
volumeMounts:
- name: data
mountPath: /var/lib/mysql
subPath: mysql
- name: conf
mountPath: /etc/mysql/conf.d
resources:
requests:
cpu: "1000m"
memory: "2Gi"
limits:
cpu: "4000m"
memory: "8Gi"
livenessProbe:
exec:
command: ["mysqladmin", "ping", "-h", "localhost"]
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
readinessProbe:
exec:
command:
- mysql
- -h
- localhost
- -e
- "SELECT 1"
initialDelaySeconds: 5
periodSeconds: 2
timeoutSeconds: 1
# Sidecar: xtrabackup服务,供其他节点克隆数据使用
- name: xtrabackup
image: gcr.io/google-samples/xtrabackup:1.0
ports:
- name: xtrabackup
containerPort: 3307
command:
- bash
- -c
- |
# 启动xtrabackup服务供其他节点克隆
cd /var/lib/mysql
if [[ -f xtrabackup_slave_info && "x$(<xtrabackup_slave_info)" != "x" ]]; then
cat xtrabackup_slave_info | sed -E 's/;$//g' > change_master_to.sql.in
rm -f xtrabackup_slave_info xtrabackup_binlog_info
elif [[ -f xtrabackup_binlog_info ]]; then
[[ $(cat xtrabackup_binlog_info) =~ ^(.*?)[[:space:]]+(.*?)$ ]] || exit 1
rm -f xtrabackup_binlog_info
echo "CHANGE MASTER TO MASTER_LOG_FILE='${BASH_REMATCH[1]}',\
MASTER_LOG_POS=${BASH_REMATCH[2]}" > change_master_to.sql.in
fi
if [[ -f change_master_to.sql.in ]]; then
echo "从节点初始化复制..."
mysql -h 127.0.0.1 \
-e "$(< change_master_to.sql.in), \
MASTER_HOST='mysql-0.mysql-headless', \
MASTER_USER='root', \
MASTER_PASSWORD='$MYSQL_ROOT_PASSWORD', \
MASTER_CONNECT_RETRY=10; \
START SLAVE;" || exit 1
mv change_master_to.sql.in change_master_to.sql.orig
fi
# 提供xtrabackup克隆服务
exec ncat --listen --keep-open --send-only --max-conns=1 3307 -c \
"xtrabackup --backup --slave-info --stream=xbstream --host=127.0.0.1 --user=root --password=$MYSQL_ROOT_PASSWORD"
volumeMounts:
- name: data
mountPath: /var/lib/mysql
subPath: mysql
- name: conf
mountPath: /etc/mysql/conf.d
volumes:
- name: conf
emptyDir: {}
- name: config-map
configMap:
name: mysql-config
# volumeClaimTemplates:为每个Pod自动创建PVC
# PVC名称格式:<模板名>-<Pod名>,如 data-mysql-0
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: "fast-ssd" # 使用SSD存储
resources:
requests:
storage: 100Gi简单的单节点有状态应用(Redis单例)
# redis-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: redis
namespace: production
spec:
serviceName: redis-headless
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7.2-alpine
command:
- redis-server
- --requirepass
- $(REDIS_PASSWORD)
- --appendonly
- "yes"
- --save
- "60 1"
env:
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: redis-secret
key: password
ports:
- containerPort: 6379
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
cpu: "200m"
memory: "512Mi"
limits:
cpu: "1000m"
memory: "2Gi"
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: "standard"
resources:
requests:
storage: 20Gi四、生产最佳实践
StatefulSet的扩缩容注意事项
StatefulSet扩容时,按顺序(0、1、2...)依次创建新Pod,每个Pod就绪后才创建下一个。
缩容时,按反顺序(最大序号优先)依次删除。删除前会先停止Pod,PVC不会被自动删除(这是保护措施)。如果需要删除PVC,必须手动执行。
重要:StatefulSet缩容时,如果有Pod处于Pending或不健康状态,缩容会被阻止,必须先修复这个Pod。这个特性保护了数据安全,但有时候也会让你陷入"处理异常Pod"的困境。
数据备份策略
有状态应用的数据是最宝贵的,备份不能省:
# MySQL备份CronJob
apiVersion: batch/v1
kind: CronJob
metadata:
name: mysql-backup
namespace: production
spec:
schedule: "0 2 * * *" # 每天凌晨2点
jobTemplate:
spec:
template:
spec:
containers:
- name: mysql-backup
image: mysql:8.0.36
command:
- sh
- -c
- |
mysqldump -h mysql-0.mysql-headless \
-u root -p$MYSQL_ROOT_PASSWORD \
--all-databases \
> /backup/full-$(date +%Y%m%d).sql五、踩坑实录
坑一:StatefulSet删除后PVC残留,新部署时数据错乱
有次误操作,把StatefulSet删了重建,以为会重新初始化。结果K8s保留了旧的PVC,新的StatefulSet重新绑定了这些PVC,新的Pod用上了旧数据。好在这个场景发现得及时,没有造成数据丢失,但如果期望的是全新部署,这就是一个严重问题。
养成习惯:删除StatefulSet前,先明确是否需要保留数据。如果要清空重来,记得手动删除对应的PVC:
kubectl delete statefulset mysql -n production
# 如果需要清空数据(谨慎!)
kubectl delete pvc -l app=mysql -n production坑二:volumeClaimTemplate里的storageClassName写错导致PVC一直Pending
新集群上部署MySQL StatefulSet,Pod一直是Pending状态,describe Pod看到是waiting for volume to be created。describe PVC看到状态是Pending,原因是storageclass "fast-ssd" not found。
新集群的StorageClass名称和老集群不同,忘了更新配置。而且StorageClass的名称错误不会在StatefulSet创建时报错,必须等到PVC创建失败才能发现。
在部署有状态应用前,先检查集群的StorageClass:
kubectl get storageclass坑三:OrderedReady导致滚动更新极慢
MySQL 3个节点的StatefulSet做版本升级,用OrderedReady策略,按从高序号到低序号逐一更新。更新一个节点需要等待MySQL启动(约90秒),3个节点共270秒,加上各种等待时间,整个更新过程将近8分钟。
对于不需要有序启动的场景(比如Redis多从节点,从节点之间没有依赖关系),可以用Parallel模式:
podManagementPolicy: Parallel但对于有主从关系的集群(MySQL、RabbitMQ),必须保持OrderedReady,不能为了速度妥协正确性。
六、总结
StatefulSet是K8s里少数几个有真正复杂语义的对象之一,用好它的关键是理解它的三个保证:稳定的Pod名称和DNS、有序的生命周期、持久化存储的稳定绑定。
对于Java工程师来说,需要直接管理StatefulSet的场景主要是:数据库(MySQL、PostgreSQL)、消息中间件(RabbitMQ、Kafka)、缓存(Redis集群)、分布式协调(ZooKeeper、etcd)。
这些有状态组件越来越多地有了专门的K8s Operator(如MySQL Operator、Redis Operator),Operator在StatefulSet的基础上封装了备份、恢复、故障转移等运维操作,在生产环境中推荐优先使用Operator,而不是裸StatefulSet。
