k8s部署MySQL原创
# 1. 为什么不能用 Deployment 跑 MySQL
网上流传最广的「k8s 部署 MySQL」模板,是一个 Deployment 加一个 PersistentVolumeClaim。只要副本数是 1,它能跑;一旦有人顺手把 replicas 调到 3,就会遇到下面两种结果之一:
版本说明
本文原稿写于 2022-03(基于 Kubernetes 1.20),2026-08 重写。
重写后的清单在 1.24–1.31 上通用:StatefulSet / Service / Secret / ConfigMap 的 API 版本自 apps/v1 稳定以来未变;local 类型 PV 与 volumeBindingMode 自 1.14 起 GA。原稿用 Deployment + 3 副本共享单个 PVC 的写法有数据损坏风险,已整体替换,理由见第 1 节。
- 多节点集群:PVC 是
ReadWriteOnce,块存储同一时刻只能被一个节点挂载。第二、第三个 Pod 永远停在Pending,事件里是volume node affinity conflict或多挂载冲突。 - 单节点集群:三个 Pod 落在同一个节点上,
ReadWriteOnce的限制被满足了,于是三个 mysqld 进程同时打开同一份 datadir。InnoDB 靠文件锁保护,后启动的实例通常会报Unable to lock ./ibdata1, error: 11退出——但如果锁没生效(比如底层是 NFS),结果是表空间被并发写坏,且不会立刻表现出来。
根因是 Deployment 管理的是一组可互换的 Pod:它们共享同一份 spec、同一个 PVC、名字随机、启停无序。而数据库需要的恰恰相反——每个实例要有稳定的身份、独占的数据目录、确定的启动顺序。这正是 StatefulSet 的定义:稳定的网络标识(mysql-0、mysql-1)、volumeClaimTemplates 给每个副本生成独立的 PVC、按序号顺序创建和逆序删除。
还有一层:即使用了 StatefulSet,多个 MySQL 副本之间也不会自动组成主从。StatefulSet 只保证「三个各自独立、身份稳定的 mysqld」,复制关系需要 init 容器写 server-id、配主从,或者直接上 Operator(如 Oracle MySQL Operator、Percona XtraDB Cluster Operator)。本文只做单实例,把存储和配置这两件事做对;要高可用请走 Operator,不要自己拼 YAML。
# 2. 存储:单节点用 local PV,多节点用 StorageClass
先准备存储。单节点或测试环境用 local 类型的 PV(注意不是 hostPath):
apiVersion: v1
kind: PersistentVolume
metadata:
name: mysql-pv-0
spec:
capacity:
storage: 20Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain # 数据库数据不要用 Delete
storageClassName: local-storage
local:
path: /mnt/data/mysql # 该路径必须在下面 nodeAffinity 指定的节点上预先存在
nodeAffinity: # local PV 必填,否则 Pod 可能被调度到没有这份数据的节点
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- node-1
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: local-storage
provisioner: kubernetes.io/no-provisioner # local PV 无动态供应,PV 需手工创建
volumeBindingMode: WaitForFirstConsumer # 等 Pod 调度确定后再绑定,避免绑到错误节点上的 PV
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
为什么用 local 而不是 hostPath:两者都指向节点本地路径,但 hostPath PV 没有节点归属的概念——Pod 一旦漂移到别的节点,挂上来的是一个空目录,数据库会以为自己是全新实例并重新初始化,等于静默丢数据。local PV 强制要求 nodeAffinity,调度器会保证 Pod 只能落在数据所在的节点上。
多节点生产环境跳过上面这段,直接用云厂商的 CSI StorageClass:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: mysql-ssd
provisioner: ebs.csi.aws.com # in-tree 的 kubernetes.io/aws-ebs 已在 1.27 移除
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Retain
parameters:
type: gp3
2
3
4
5
6
7
8
9
# 3. 配置与密码:ConfigMap 挂成文件,Secret 用 secretKeyRef 引用
这是原稿错得最隐蔽的一处:my.cnf 不能通过环境变量生效。MySQL 只读配置文件,所以 ConfigMap 必须以 volume 的形式挂到 /etc/mysql/conf.d/;而密码这类单值才走环境变量。
apiVersion: v1
kind: ConfigMap
metadata:
name: mysql-config
data:
my.cnf: |-
[mysqld]
bind-address=0.0.0.0
max_connections=1000
innodb_buffer_pool_size=1G # 按容器 memory limit 的 50–70% 设,见第 6 节
innodb_flush_log_at_trx_commit=1
sync_binlog=1
2
3
4
5
6
7
8
9
10
11
12
密码用 Secret。不要手写 base64——kubectl 的 stringData 或 --from-literal 会自动编码,手写容易把结尾的换行也编进去,导致密码莫名其妙不对:
kubectl create secret generic mysql-secret \
--from-literal=root-password='<在这里填一个强密码>'
2
等价的声明式写法(用 stringData 而不是 data):
apiVersion: v1
kind: Secret
metadata:
name: mysql-secret
type: Opaque
stringData:
root-password: "<在这里填一个强密码>"
2
3
4
5
6
7
Secret 里的 base64 是编码不是加密,默认在 etcd 中明文存储。要静态加密需要在 API Server 上配
EncryptionConfiguration;要避免密码进 Git,用 External Secrets Operator 或 Sealed Secrets。永远不要像原稿那样在 Deployment 里写value: "password"—— 那份明文会进 Git、进kubectl describe、进每一份集群备份。
# 4. StatefulSet + Headless Service
apiVersion: v1
kind: Service
metadata:
name: mysql # Headless Service,给 StatefulSet 提供稳定 DNS
spec:
clusterIP: None # 关键:None 才是 headless
selector:
app: mysql
ports:
- name: mysql
port: 3306
targetPort: 3306
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
spec:
serviceName: mysql # 必须与上面 Headless Service 同名
replicas: 1 # 单实例;多副本需要复制编排,见第 1 节
selector:
matchLabels:
app: mysql # 必须与 template.metadata.labels 完全一致,否则 API Server 直接拒绝
template:
metadata:
labels:
app: mysql
spec:
containers:
- name: mysql
image: mysql:8.0.40 # 锁定具体版本,绝不用 latest
ports:
- containerPort: 3306
name: mysql
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef: # 从 Secret 取,而不是明文写死
name: mysql-secret
key: root-password
volumeMounts:
- name: data
mountPath: /var/lib/mysql
- name: config
mountPath: /etc/mysql/conf.d # ConfigMap 挂成文件,MySQL 启动时自动读取
resources:
requests:
memory: "2Gi"
cpu: "500m"
limits:
memory: "2Gi" # 数据库建议 requests == limits,见第 6 节
livenessProbe:
exec:
command: ["mysqladmin", "ping", "-h", "127.0.0.1"]
initialDelaySeconds: 60 # 首次初始化 datadir 耗时较长,探针起太早会被反复杀掉
periodSeconds: 10
readinessProbe:
exec:
command: ["bash", "-c", "mysqladmin ping -h 127.0.0.1 && mysql -uroot -p\"$MYSQL_ROOT_PASSWORD\" -e 'select 1'"]
initialDelaySeconds: 30
periodSeconds: 5
volumes:
- name: config
configMap:
name: mysql-config
volumeClaimTemplates: # 每个副本一个独立 PVC,这是 StatefulSet 的核心
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: local-storage # 多节点环境改成 mysql-ssd
resources:
requests:
storage: 20Gi
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
为什么要 Headless Service(clusterIP: None):普通 Service 给的是一个 VIP,请求会被负载均衡到任意后端 Pod——对数据库来说这意味着写请求可能落到从库。Headless Service 不分配 VIP,而是为每个 Pod 生成独立 DNS 记录 mysql-0.mysql.<namespace>.svc.cluster.local,客户端可以明确指定连哪一个实例。单实例场景下你仍然可以用 mysql:3306 这个名字连,但保留 headless 是为了将来扩成主从时不用改架构。
为什么 livenessProbe 的 initialDelaySeconds 要给到 60:MySQL 容器第一次启动时要初始化 datadir(建系统表、建 root 账号),空盘上通常要 30–60 秒。探针起得太早会判定失败并重启容器,而重启又打断初始化,形成 CrashLoopBackOff 死循环——这是新手部署 MySQL 最常见的卡点。
# 5. 部署与验证
kubectl apply -f mysql-storage.yaml
kubectl apply -f mysql-config.yaml
kubectl apply -f mysql-secret.yaml
kubectl apply -f mysql-statefulset.yaml
2
3
4
逐层确认,每一步都有明确的期望输出:
# 1. PVC 是否真的绑上了
kubectl get pvc
2
期望看到 data-mysql-0 的 STATUS 为 Bound。注意 PVC 名字是 <volumeClaimTemplates 名>-<StatefulSet 名>-<序号>,这是 StatefulSet 自动拼的。若停在 Pending,用 kubectl describe pvc data-mysql-0 | tail -20 看事件。
# 2. Pod 是否 Ready
kubectl get pod mysql-0
2
期望 READY 为 1/1。若是 0/1 且 RESTARTS 在涨,多半是探针起太早,看 kubectl describe pod mysql-0 的 Events 是不是 Liveness probe failed。
# 3. MySQL 自身是否真的可用
kubectl logs mysql-0 --tail 20 | grep "ready for connections"
2
期望输出里有 /usr/sbin/mysqld: ready for connections. Version: '8.0.40'——只有这一行出现才算真正起来,Pod Running 不等于数据库可用。
# 4. 配置有没有真的生效(验证 ConfigMap 挂对了)
kubectl exec -it mysql-0 -- \
mysql -uroot -p"$(kubectl get secret mysql-secret -o jsonpath='{.data.root-password}' | base64 -d)" \
-e "SHOW VARIABLES LIKE 'max_connections'; SHOW VARIABLES LIKE 'innodb_buffer_pool_size';"
2
3
4
期望 max_connections 是 1000(而不是默认的 151)。如果还是 151,说明 ConfigMap 没挂进去或挂错了路径——这是「YAML 全部 apply 成功但配置根本没生效」的典型,只看 Pod 状态永远发现不了。
# 5. 数据是否真的持久化(最关键的一步)
kubectl exec -it mysql-0 -- mysql -uroot -p"<密码>" -e "CREATE DATABASE probe;"
kubectl delete pod mysql-0 # StatefulSet 会自动重建
kubectl wait --for=condition=ready pod/mysql-0 --timeout=180s
kubectl exec -it mysql-0 -- mysql -uroot -p"<密码>" -e "SHOW DATABASES;" | grep probe
2
3
4
5
期望最后一行输出 probe。如果 probe 不见了,说明数据写在容器可写层而不是 PV 上,此时看似一切正常的部署实际上每次重启都会丢库。这一步是整套流程里唯一能证明持久化真的成立的验证,不要跳过。
# 6. 坑与边界
1. innodb_buffer_pool_size 必须小于容器 memory limit,且要留出余量。 MySQL 的实际内存占用 = buffer pool + 每连接的 sort/join buffer + 线程栈 + 元数据缓存。buffer pool 设成 limit 的 50–70% 是安全线。设得太满会被 cgroup OOM Killer 直接杀掉容器——而且这次 OOM 不会出现在 MySQL 的错误日志里,日志会毫无征兆地中断,kubectl describe pod 里 Last State 显示 OOMKilled、退出码 137。查数据库问题时先看这个字段,能省掉几小时。
2. 数据库容器的 requests 建议等于 limits。 这样 Pod 拿到 Guaranteed QoS 等级。若 requests < limits(Burstable),节点内存吃紧时 kubelet 会优先驱逐 Burstable 的 Pod,数据库可能在半夜被赶走。
3. local PV 的目录必须预先在目标节点上手工创建。 local PV 不会自动 mkdir,路径不存在时 Pod 会一直卡在 ContainerCreating,事件是 MountVolume.NewMounter initialization failed。而且它没有容量隔离——PV 声明 20Gi,实际能写满整块盘。
4. 删除 StatefulSet 不会删除它创建的 PVC。 这是有意设计(保护数据),但也意味着重新 apply 同名 StatefulSet 时会直接复用旧 PVC 里的数据——包括旧的 root 密码。你改了 Secret 却发现新密码不生效,通常就是这个原因:MYSQL_ROOT_PASSWORD 只在 datadir 为空的首次初始化时生效,datadir 已存在时该环境变量完全被忽略。要改密码请用 ALTER USER,不要改 Secret。
5. 单实例 StatefulSet 不是高可用。 节点宕机时 Pod 无法在别处重建(local PV 钉死了节点),云盘场景下也需要几分钟的 detach/attach。要真高可用请用 MySQL Operator 做主从或组复制,或者干脆用云托管数据库——把有状态服务放进 k8s 的复杂度,很多团队最终评估下来并不划算。
6. 备份不能只靠 PV 快照。 云盘快照是崩溃一致的,不是事务一致的;InnoDB 能从中恢复,但恢复后需要 crash recovery,且没有 binlog 就做不了 PITR。生产上仍需 mysqldump / xtrabackup 定期逻辑或物理备份,并把备份存到集群之外。
# 7. 可复用要点
- 有状态服务用
StatefulSet+volumeClaimTemplates,不要用Deployment共享单个 PVC——后者在单节点会损坏数据,在多节点根本调度不起来。 - 配置文件走 ConfigMap 挂载,单值密码走 Secret 的
secretKeyRef;my.cnf无法通过环境变量生效。 - 镜像 tag 锁死到小版本,
latest会让某次重建撞上大版本跳变、datadir 不兼容、容器起不来。 - 部署完成的判据不是 Pod Running,而是「日志出现 ready for connections」+「配置项实测生效」+「删 Pod 重建后数据还在」三条同时成立。