K8s 实战复盘:用 StatefulSet + 无头服务 + NFS 搭建 MySQL 主从集群
0. 一句话看清整个架构
kubectl(在 master 上执行) │ 无头服务 mysql(clusterIP: None,DNS 直连 Pod) mysql-0.mysql.mysql.svc.cluster.local → 主库 master mysql-1.mysql.mysql.svc.cluster.local → 从库 slave StatefulSet mysql(副本=2) mysql-0 ──挂载──> PVC(data-mysql-0) ──绑定──> PV(mysql-pv-0) ──NFS──> /data/nfs/mysql-0 mysql-1 ──挂载──> PVC(data-mysql-1) ──绑定──> PV(mysql-pv-1) ──NFS──> /data/nfs/mysql-1
核心一句话:有状态应用靠 StatefulSet 拿「稳定名字 + 稳定存储」,主从之间靠无头服务的 DNS 互相点名。
1. 为什么要"这么多组件"?(先建立心智模型)
回答"为什么要这么搭",就是回答简历里的知识点:
| 需求 | 用到的 K8s 概念 | 解决什么 |
|---|---|---|
| 主从要有固定身份(谁是主、谁是从) | StatefulSet(稳定 Pod 名 mysql-0/1) | 普通 Deployment 的 Pod 名是随机哈希,无法区分角色 |
| 从库要"精确"连主库那一个 Pod | 无头服务 Headless(clusterIP: None) | 普通 Service 会轮询负载均衡,破坏主从关系 |
| 数据不能因 Pod 重启/删除而丢 | PV/PVC + NFS | 数据落到共享盘,Pod 没了数据还在 |
| 一 Pod 一磁盘、自动建 PVC | volumeClaimTemplates | 每起一个 Pod 自动生成一个 PVC |
| 主/从用不同 my.cnf | ConfigMap + initContainer | 启动前按序号(0/1)把对应配置放进去 |
记忆口诀:Deployment 管无状态,StatefulSet 管「有名、有盘、有序」。
2. 完整搭建流程(含全部 yaml + 命令)
Step 1. NFS 共享存储(地基)
# 三台都装客户端 dnf install -y nfs-utils # master(NFS 服务器)导出两个目录,分别给主从 mkdir -p /data/nfs/mysql-0 /data/nfs/mysql-1 chmod -R 777 /data/nfs # ⚠️ 必须含 x!见坑 2 cat >> /etc/exports <<'EOF' /data/nfs/mysql-0 *(rw,no_root_squash,no_all_squash,sync) /data/nfs/mysql-1 *(rw,no_root_squash,no_all_squash,sync) EOF systemctl enable --now rpcbind nfs-server exportfs -ra showmount -e <master_ip> # 验证两个导出
Step 2. 命名空间 + ConfigMap(主从配置按角色区分)
apiVersion: v1
kind: ConfigMap
metadata: { name: mysql-config, namespace: mysql }
data:
primary.cnf: | # 主库:开 binlog
[mysqld]
server-id=1
log-bin=mysql-bin
binlog-format=ROW
gtid_mode=ON # ⚠️ MySQL 5.7 必须手动开!
enforce-gtid-consistency=ON # ⚠️ 见坑 3
replica.cnf: | # 从库:只读
[mysqld]
server-id=2
read-only=ON
gtid_mode=ON
enforce-gtid-consistency=ON
kubectl create namespace mysql kubectl apply -f mysql-config.yml
Step 3. 两个 PV(静态供给,NFS)
apiVersion: v1
kind: PersistentVolume
metadata: { name: mysql-pv-0 }
spec:
capacity: { storage: 2Gi }
accessModes: [ReadWriteOnce]
persistentVolumeReclaimPolicy: Retain
storageClassName: "" # ⚠️ 空字符串="只要静态 PV",避免被动态供给抢
nfs:
server: <master_ip>
path: /data/nfs/mysql-0
---
# mysql-pv-1 同结构,path 换成 /data/nfs/mysql-1
kubectl apply -f mysql-pv.yml kubectl get pv # 两个都应为 Available
Step 4. 无头服务(Headless)
apiVersion: v1
kind: Service
metadata: { name: mysql, namespace: mysql }
spec:
clusterIP: None # ← 无头服务:不分配 VIP
selector: { app: mysql }
ports:
- port: 3306
name: mysql
Step 5. StatefulSet(核心,含 initContainer 按角色选配置)
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: mysql, namespace: mysql }
spec:
serviceName: mysql # 必须 = 无头服务名
replicas: 2
selector: { matchLabels: { app: mysql } }
template:
metadata: { labels: { app: mysql } }
spec:
initContainers:
- name: init-mysql
image: hb.reg.com/k8s/mysql:5.7
command: # ⚠️ 多行脚本用这种分行写法,见坑 1
- bash
- -c
- |
if [[ "$HOSTNAME" =~ -([0-9]+)$ ]]; then ORD=${BASH_REMATCH[1]}; fi
if [ "$ORD" = "0" ]; then
cp /mnt/config-map/primary.cnf /etc/mysql/conf.d/mysql.cnf
else
cp /mnt/config-map/replica.cnf /etc/mysql/conf.d/mysql.cnf
fi
volumeMounts:
- { name: config-map, mountPath: /mnt/config-map }
- { name: conf, mountPath: /etc/mysql/conf.d }
containers:
- name: mysql
image: hb.reg.com/k8s/mysql:5.7
env:
- name: MYSQL_ROOT_PASSWORD
value: "1"
ports: [ { containerPort: 3306 } ]
volumeMounts:
- { name: data, mountPath: /var/lib/mysql } # 数据落盘
- { name: conf, mountPath: /etc/mysql/conf.d }
volumes:
- { name: conf, emptyDir: {} }
- { name: config-map, configMap: { name: mysql-config } }
volumeClaimTemplates: # 每 Pod 自动建一个 PVC
- metadata: { name: data }
spec:
accessModes: [ReadWriteOnce]
storageClassName: ""
resources: { requests: { storage: 2Gi } }
kubectl apply -f mysql-statefulset.yml kubectl -n mysql get pod -w # 观察有序创建:先 mysql-0 Running,才起 mysql-1 kubectl -n mysql get sts,pvc # 期望 data-mysql-0/1 Bound
Step 6. 建立主从复制(GTID 自动追平)
# ① 主库建复制账号(⚠️ 已存在会报 ERROR 1396,属正常)
kubectl -n mysql exec -it mysql-0 -- mysql -uroot -p1 -e "
CREATE USER IF NOT EXISTS 'repl'@'%' IDENTIFIED WITH mysql_native_password BY 'Repl@123';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
FLUSH PRIVILEGES;"
# ② 从库指向主库并开启复制
kubectl -n mysql exec -it mysql-1 -- mysql -uroot -p1 -e "
CHANGE MASTER TO
MASTER_HOST='mysql-0.mysql', # 无头服务 DNS 直指主库
MASTER_USER='repl',
MASTER_PASSWORD='Repl@123',
MASTER_AUTO_POSITION=1; # GTID 自动定位
START SLAVE;"
# ③ 验证复制状态(两个都是 Yes 即成功)
kubectl -n mysql exec -it mysql-1 -- mysql -uroot -p1 -e "SHOW SLAVE STATUS\G" | grep -E "Slave_IO_Running|Slave_SQL_Running"
MySQL 8.0.23+ 官方写法:
CHANGE REPLICATION SOURCE TO ... SOURCE_AUTO_POSITION=1(CHANGE MASTER TO也可用,仅 deprecated)。
Step 7. 终极验证:主写从读
# 主库建库建表插数据 kubectl -n mysql exec -it mysql-0 -- mysql -uroot -p1 -e "CREATE DATABASE testdb; USE testdb; CREATE TABLE t(id INT); INSERT INTO t VALUES(1);" # 从库查(能看到 1 = 复制打通) kubectl -n mysql exec -it mysql-1 -- mysql -uroot -p1 -e "SELECT * FROM testdb.t;"
3. 🕳️ 四个坑
坑 1:YAML command: ["bash","-c",|] 直接解析失败
症状:kubectl apply 报 error converting YAML to JSON: line 20: found character '|' that cannot start any token。
原因:多行字面量块标记 | 不能放在 flow 序列 [...] 里面,YAML 解析器不认识。
修复:把 command 写成分行列表:
command:
- bash
- -c
- |
这里写多行脚本
同批的小坑:
-
Kind: StatefulSet→ 必须小写kind; -
{ name:MYSQL_ROOT_PASSWORD, vlue: "1" }→name:后要空格、vlue是value拼错。
校验命令(改完必跑,秒查):
kubectl apply -f xxx.yml --dry-run=client -o yaml
坑 2(最阴险):NFS 目录缺 x,容器内 Permission denied
症状:StatefulSet 建出来了、PVC 也 Bound,但 Pod 主容器 CrashLoopBackOff,日志:
mysqld: Can't create/write to file '/var/lib/mysql/is_writable' (Errcode: 13 - Permission denied) [ERROR] --initialize specified but the data directory exists and is not writable. Aborting.
现象很诡异:root 能手动写、UID 999 写不了,明明目录是 666。
根因:在 Linux/NFS 里,要在目录里创建文件,除了该目录要有 write,整条路径还都要有 execute(x)——因为要"进入/遍历"这个目录。问题出在上层目录 /data/nfs 是 666(没有 x),容器里的受限用户(UID 999,MySQL 的 mysql 用户)进不去;root 能无视权限进去,所以只有 root 正常。
复现定位技巧(用对照法,判断是哪一级缺 x):
setpriv --reuid 999 --regid 0 --clear-groups sh -c "touch /path/.t && echo OK || echo FAIL"
修复(给路径各级补上 x,最省事直接 777):
chmod 777 /data/nfs /data/nfs/mysql-0 /data/nfs/mysql-1
坑 3:MySQL 5.7 默认 GTID 关闭,MASTER_AUTO_POSITION 用不了
症状:
ERROR 1777 (HY000): CHANGE MASTER TO MASTER_AUTO_POSITION = 1 cannot be executed because @@GLOBAL.GTID_MODE = OFF.
原因:课件假设 MySQL 8.0(默认开 GTID),但练习环境是 5.7,默认 gtid_mode=OFF。GTID 没开,MASTER_AUTO_POSITION=1 自然不可用。
修复:ConfigMap 主/从 my.cnf 都加,然后重启 Pod:
[mysqld] gtid_mode=ON enforce-gtid-consistency=ON
⚠️ 关键:
gtid_mode必须在 MySQL 数据目录初始化之前就得开着。如果 PVC 里已经是 GTID OFF 初始化的数据,改配置后 MySQL 不允许直接切 ON(有事务数据时更严格)。练习环境删 PVC + 清 NFS 数据目录重建最干净。
坑 4:删 PVC 后 PV 停在 Released,新 PVC 一直 Pending
症状:重建 StatefulSet 后 get pvc 显示 data-mysql-0 Pending,PV 是 Released。
原因:PV 用了 persistentVolumeReclaimPolicy: Retain。删掉 PVC 后,PV 变为 Released(数据保留),但不会自动回到 Available 去接新的 PVC(Retain 的设计就是"留给管理员手动处理")。
修复(手动摘除 claimRef,让 PV 重新 Available):
kubectl patch pv mysql-pv-0 --type json -p='[{"op":"remove","path":"/spec/claimRef"}]'
kubectl patch pv mysql-pv-1 --type json -p='[{"op":"remove","path":"/spec/claimRef"}]'
小知识:重建后 PVC↔PV 可能"交叉绑定"(0 绑到 pv1、1 绑到 pv0)——不影响功能,两个都是独立 2Gi NFS 目录。
4. 面试讲知识点(从里到外)
4.1 StatefulSet —— 有状态应用的"三个稳定"
| 稳定项 | 含义 | 对 MySQL 的意义 |
|---|---|---|
| 稳定网络标识 | Pod 名固定 mysql-0/mysql-1(不是随机哈希) | 主从靠固定名字区分角色 |
| 稳定存储 | 每个 Pod 绑定自己的 PVC,删 Pod 数据不丢 | 数据库数据必须持久 |
| 有序部署/伸缩 | 按 0→1→2 顺序启动、反向缩容 | 先起主库,从库才能连 |
4.2 无头服务 —— 让 Pod 能"点名"
-
普通 Service:分配 ClusterIP(VIP),做负载均衡,客户端不知道连的是哪个 Pod;
-
无头服务:
clusterIP: None,不做负载均衡,DNS 直接解析到每个 Pod 的真实 IP; -
DNS 规则:
<Pod名>.<服务名>.<命名空间>.svc.cluster.local→mysql-0.mysql.mysql...直指主库; -
为什么主从必须用它:从库要"精确"连主库那一个 Pod,普通 Service 会轮询分给两个,主从就乱了。
4.3 PV / PVC —— 存储的"供给侧"和"需求侧"
-
PV:集群级存储资源,管理员准备,声明"有哪些存储";
-
PVC:用户申请单,声明"我要多大、什么访问模式";
-
绑定:PVC 找容量、访问模式、storageClass 都匹配的 PV 绑定;
-
volumeClaimTemplates:StatefulSet 专属,为每个 Pod 自动生成一个 PVC(
data-mysql-0/1),实现"一 Pod 一磁盘"。
PV 关键字段速记(别人问你 PV 怎么配):
-
capacity: 容量多大; -
accessModes: 谁能用(RWO 单节点/ROX 多读/RWX 多写/RWOP 单 Pod)——数据库用 RWO; -
persistentVolumeReclaimPolicy: PVC 删了怎么办(Retain 保留/Delete 删除/Recycle 废弃); -
storageClassName: 和 PVC 对暗号(""=只要静态 PV); -
nfs.server/path: 数据实际存在哪。
PV 四种状态:Available(可用)→Bound(已绑定)→Released(绑定解除数据在)→Failed(出错)。
4.4 NFS —— 学习环境首选共享存储
-
NFS Server(装在 master)导出目录 → 各节点装 nfs-utils 作客户端 → K8s 的 PV 里写 nfs.server+path。
-
选它:简单、多节点能挂同一份数据,够支撑主从演示。生产常用 Ceph、云盘等。
4.5 MySQL 主从复制原理(面试重点)
主库写 binlog → 从库 IO 线程拉 binlog 存 relay log → 从库 SQL 线程重放 relay log
三个关键配置:
-
server-id:每实例唯一(主 1 从 2),复制靠它识别身份; -
log-bin:主库必须开(记录变更); -
read-only:从库只读,防误写。
两种定位方式:
-
老方式:binlog 文件名 + 位置(要手动记,易错);
-
GTID(推荐):全局事务 ID,
MASTER_AUTO_POSITION=1自动追平,无需记位置。
验证:SHOW SLAVE STATUS\G 看 Slave_IO_Running 和 Slave_SQL_Running 都 = Yes。
5. 两种常见报错速查(实验时你会遇到)
| 报错 | 含义 | 处理 |
|---|---|---|
ERROR 1396 Operation CREATE USER failed for 'repl'@'%' | 账号已存在(之前建过) | 不是错,GRANT + FLUSH 成功即可用;可改 CREATE USER IF NOT EXISTS |
ERROR 3081 This operation cannot be performed with running replication threads | 复制已经在跑了,你重复 CHANGE MASTER | 先 STOP SLAVE; 再 CHANGE MASTER;或直接查 SHOW SLAVE STATUS 确认已正常 |
6. 复盘清单(做完自检)
kubectl get nodes三节点 Readykubectl -n mysql get pods→ mysql-0、mysql-1 都 1/1 Runningkubectl -n mysql get pvc→ data-mysql-0/1 都 Bound- 从库
SHOW SLAVE STATUS\G→ Slave_IO/SQL_Running 都 Yes,Seconds_Behind=0 - 主写
INSERT→ 从库SELECT能查到 ✅
更多推荐
所有评论(0)