资讯动态

Kubernetes MySQL主从集群:StatefulSet部署与排障全记录

发布时间:2026/10/14 9:05:38 来源:尧图企业网站定制
最近在给一个项目搭MySQL主从集群数据量不大但也需要读写分离顺手就准备直接在Kubernetes上落地。一开始图省事直接上了Deployment结果被现实教育了一顿从库Pod起来后一直在找主库、主库一重启复制链就断、两个副本还差点把同一份数据弄花。后来才明白这类有状态应用根本不是Deployment能Hold住的真正要用的控制器是StatefulSet。折腾两天之后终于把MySQL 5.7的主从集群用StatefulSet跑稳了。这篇就把整个思路、配置和踩坑过程完整记录下来适合已经在K8s上跑过无状态应用、但第一次碰有状态服务的读者参考。前面两篇聊单Pod存储卷和PV/PVC静态供给时我自己也在整理一套思路Kubernetes里的数据存储难点往往不在卷怎么挂而在控制器和卷怎么配合。StatefulSet就是这套配合关系的核心答案它要解决的三个问题正是Deployment搞不定的稳定网络标识、专属存储、有序启停。1. 为什么Deployment做不了MySQL主从一次失败经历引出的三个硬伤1.1 随机Pod名称带来的网络身份不稳定第一次用Deployment部署MySQL主从时我特意做了个主库Deployment和一个从库Deployment主库通过Service暴露从库启动时通过环境变量拿到主库Service的地址。这套方案单看似乎没什么毛病主库Service的ClusterIP是固定的从库连接它不就行了真正的问题出在主库Pod重建上。Deployment创建的Pod名称是随机的每次重建都是新名字比如mysql-master-7d8f9c6d5-abc12。如果你在从库里配置的是Pod的DNS名字那Pod一换名从库就找不到主库了。有人会说用Service名字不就行了Service会做负载均衡但主从复制要求从库连接的是那个具体的主库实例而不是随便一个主库Pod。更麻烦的是如果主库Deployment扩容成两个副本两个MySQL实例会争抢同一个数据目录这本身就蕴含着严重问题。当时我的主库一升级镜像K8s先把旧Pod杀掉再拉新Pod从库的IO线程立刻报错显示无法连接主库。后来才意识到有状态应用需要的是无论Pod怎么重建名字和网络身份都不变的能力而Deployment的设计目标恰恰是无状态、可替换。1.2 共享存储卷在多副本下的数据混乱Deployment的Pod模板里可以用PVC一个Deployment的所有副本默认共享同一份PVC。为了验证这个行为我试着用同一个PVC挂到两个MySQL副本上结果第二个Pod起来后MySQL直接报错数据目录被锁死因为两个实例同时读写同一份数据文件。MySQL这类应用每个实例必须有自己独立的数据卷。两个Pod写同一个目录轻则表损坏重则整个数据目录报废。Deployment的副本共享PVC先天就不适合这种一实例一卷有状态应用。当然有人会在Deployment里给每个副本手动建独立PVC然后按序号调度这就等于自己山寨了一个简陋版StatefulSet还不如直接用官方控制器。1.3 没有启动顺序主从角色很难自动对齐主从复制有个客观要求从库启动时要能连上主库并确认主库的binlog文件和偏移量。如果主库还没就绪从库先起来了从库的初始化脚本大概率会在连接失败中退出或者更糟——从库用错误的位点开始复制导致数据不一致。Deployment对启动顺序没有任何保障两个副本同时创建谁先Ready完全看调度运气。StatefulSet则不同它对Pod的创建顺序有严格约束序号0的Pod必须创建完成并Ready后才会创建序号1的Pod。这一点对MySQL主从来说几乎是天然为它设计的——主库做序号0从库做序号1从库起来时主库一定已经准备好了。2. StatefulSet与Volume的配合机制稳定标识、专属存储与有序调度2.1 Headless Service才是StatefulSet的内网名片StatefulSet和普通Deployment最大的表象区别是Pod名称名称格式固定为状态集名称-序号。但光有稳定名字还不够K8s里Pod无论叫什么重启后IP都会变。如果从库把主库IP记死了那主库一重建IP一换复制必然断。解决办法是给StatefulSet配一个Headless Service。Headless Service的YAML里设置clusterIP: None它不提供负载均衡也不分配虚拟IP而是通过DNS把某个Pod名字直接解析到对应的Pod IP。配合StatefulSet每个Pod自动获得一个稳定的完全限定域名格式为pod-name.headless-service-name.namespace.svc.cluster.local比如StatefulSet叫mysqlHeadless Service叫mysql命名空间是default那么主库的DNS就是mysql-0.mysql.default.svc.cluster.local这个域名不随Pod IP变化而变化。Pod重建后K8s会更新DNS记录从库下次连接时拿到的还是同一个域名但解析出来的是新的Pod IP。这正是稳定网络标识的核心含义名称稳定但不等于IP不变。2.2 volumeClaimTemplates每个副本自动获得独立PVCStatefulSet里有一个Deployment没有的字段volumeClaimTemplates。它相当于PVC模板可以用来声明数据卷的结构。StatefulSet创建副本时会依据这个模板自动为每个序号创建独立的PVC。PVC的命名规则是volumeClaimTemplate名称-statefulset名称-序号比如我在volumeClaimTemplates里定义了dataStatefulSet名字是mysql两个副本会生成data-mysql-0>apiVersion: v1 kind: Service metadata: name: mysql namespace: mysql-demo labels: app: mysql spec: clusterIP: None selector: app: mysql ports: - port: 3306 targetPort: 3306 publishNotReadyAddresses: true这里两个关键点clusterIP: None把Service变成Headless不分配虚拟IPDNS直接解析到Pod IP。publishNotReadyAddresses: true让未就绪Pod的DNS也生效。MySQL从库在初始化阶段可能还没就绪但需要它通过域名找到主库。没有这个参数只有Ready的Pod才参与DNS解析从库可能找不到主库域名。4.2 主从分离的my.cnf配置ConfigMapMySQL的主从复制核心靠binlog。主库开binlog从库通过IO线程拉取主库binlog写入本地relay log再由SQL线程重放relay log来同步数据。所以主库必须配置log_bin从库要配置relay_log两个角色的server_id必须不同。我用两个ConfigMap来管理配置角色区分更直观。主库配置apiVersion: v1 kind: ConfigMap metadata: name: mysql-master-config namespace: mysql-demo data: my.cnf: | [mysqld] log-binmysql-bin server-id1 binlog_formatROW expire_logs_days7 max_binlog_size512M从库配置把server-id改成2并增加relay_log相关配置apiVersion: v1 kind: ConfigMap metadata: name: mysql-slave-config namespace: mysql-demo data: my.cnf: | [mysqld] server-id2 relay_logmysql-relay-bin log_binmysql-bin binlog_formatROW expire_logs_days7从库也开启binlog是有意的这样从库本身可以作为下一层级的主库做级联复制时会省事很多。这里还有个建议不要把server-id写死在ConfigMap里而是通过Pod序号动态生成。因为如果未来扩容多个从库写死的server-id必然冲突。动态生成方式见4.4的脚本。4.3 StatefulSet主体与初始化容器设计StatefulSet的完整定义里最重要的部分是spec.serviceName必须指向刚才创建的Headless Service名称volumeClaimTemplates定义数据卷模板。下面只贴主体结构apiVersion: apps/v1 kind: StatefulSet metadata: name: mysql namespace: mysql-demo spec: serviceName: mysql replicas: 2 selector: matchLabels: app: mysql template: metadata: labels: app: mysql spec: initContainers: - name: init-mysql image: busybox:1.36 command: - sh - -c - | ordinal$(echo ${HOSTNAME##*-}) cp /tmp/config/my.cnf /etc/mysql/conf.d/my.cnf echo server-id$((100 10#$ordinal)) /etc/mysql/conf.d/server-id.cnf volumeMounts: - name: config mountPath: /tmp/config - name: conf mountPath: /etc/mysql/conf.d containers: - name: mysql image: mysql:5.7 imagePullPolicy: IfNotPresent env: - name: MYSQL_ROOT_PASSWORD valueFrom: secretKeyRef: name: mysql-secret key: root-password - name: MYSQL_REPL_USER valueFrom: secretKeyRef: name: mysql-secret key: repl-user - name: MYSQL_REPL_PASSWORD valueFrom: secretKeyRef: name: mysql-secret key: repl-password ports: - containerPort: 3306 name: mysql volumeMounts: - name: data mountPath: /var/lib/mysql - name: conf mountPath: /etc/mysql/conf.d livenessProbe: exec: command: [mysqladmin, ping, -h, 127.0.0.1, -uroot, -p${MYSQL_ROOT_PASSWORD}] initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: exec: command: [mysqladmin, ping, -h, 127.0.0.1, -uroot, -p${MYSQL_ROOT_PASSWORD}] initialDelaySeconds: 10 periodSeconds: 5 volumeClaimTemplates: - metadata: name: data spec: accessModes: [ReadWriteOnce] storageClassName: local-path resources: requests: storage: 10Gi这个初始化容器做的事情是根据Pod名称中的序号动态生成server-id。HOSTNAME环境变量就是Pod名例如mysql-1取-后面的数字得到序号1server-id就变成101。这样未来扩容到第三个副本时自动得到102不会冲突。主库和从库的my.cnf也是这里拷贝的但上面的脚本copy的是同一个文件主从配置区分不够清楚。如果希望主从不共用同一份ConfigMap这里可以写成先判断序号0就拷贝主库配置非0拷贝从库配置ordinal$(echo ${HOSTNAME##*-}) if [ $ordinal 0 ]; then cp /tmp/config/master.cnf /etc/mysql/conf.d/my.cnf else cp /tmp/config/slave.cnf /etc/mysql/conf.d/my.cnf fi echo server-id$((100 10#$ordinal)) /etc/mysql/conf.d/server-id.cnf4.4 从库自动配置主从复制的脚本逻辑有人习惯把主从初始化逻辑全部写在initContainer里但initContainer运行时MySQL还没启动没法执行SQL。更优雅的做法是利用MySQL官方镜像的docker-entrypoint-initdb.d目录当数据目录为空并完成初始化后镜像会自动执行该目录下的.sh和.sql脚本。把这个目录挂载成ConfigMap卷脚本只在数据目录首次初始化时执行一次。Pod后续重建时数据目录非空脚本会自动跳过正好避免了重复配置从库。主从初始化脚本大致如下我放在ConfigMap里#!/bin/bash set -e ordinal$(echo ${HOSTNAME##*-}) if [ $ordinal 0 ]; then mysql -uroot -p${MYSQL_ROOT_PASSWORD} EOF CREATE USER IF NOT EXISTS ${MYSQL_REPL_USER}% IDENTIFIED BY ${MYSQL_REPL_PASSWORD}; GRANT REPLICATION SLAVE ON *.* TO ${MYSQL_REPL_USER}%; FLUSH PRIVILEGES; EOF else MASTER_HOSTmysql-0.mysql.${POD_NAMESPACE}.svc.cluster.local until mysql -h${MASTER_HOST} -u${MYSQL_REPL_USER} -p${MYSQL_REPL_PASSWORD} -e SELECT 1 /dev/null 21; do sleep 2 done MASTER_STATUS$(mysql -h${MASTER_HOST} -u${MYSQL_REPL_USER} -p${MYSQL_REPL_PASSWORD} -e SHOW MASTER STATUS\G | awk /File:/{file$2} /Position:/{pos$2} END{print file, pos}) MASTER_LOG_FILE$(echo ${MASTER_STATUS} | cut -d -f1) MASTER_LOG_POS$(echo ${MASTER_STATUS} | cut -d -f2) mysql -uroot -p${MYSQL_ROOT_PASSWORD} EOF CHANGE MASTER TO MASTER_HOST${MASTER_HOST}, MASTER_USER${MYSQL_REPL_USER}, MASTER_PASSWORD${MYSQL_REPL_PASSWORD}, MASTER_LOG_FILE${MASTER_LOG_FILE}, MASTER_LOG_POS${MASTER_LOG_POS}; START SLAVE; EOF fi解释一下脚本的逻辑主库序号0做的事情很单纯创建复制专用账号授权REPLICATION SLAVE权限。注意账号host要写成%否则从库跨Pod连接会被拒绝。从库序号非0做的事情分三步。第一步轮询等待主库可连接这是状态集有序创建之外的第二道保险。第二步连接主库执行SHOW MASTER STATUS从输出里解析出当前binlog文件名和偏移量。第三步用获取到的位点执行CHANGE MASTER TO并启动复制。一个常见的误区是手工写死MASTER_LOG_FILE比如mysql-bin.000001。如果主库binlog已经滚动过这个值就是错的。正确做法就是动态读取新初始化的主库上面即使已经生成了几个binlog文件SHOW MASTER STATUS会告诉你当前写到哪个文件哪个位置。4.5 Secret与权限的注入方式账号密码不要写在明文环境变量里。用Kubernetes Secret保管apiVersion: v1 kind: Secret metadata: name: mysql-secret namespace: mysql-demo type: Opaque stringData: root-password: YourRootPass repl-user: repl repl-password: YourReplPassStatefulSet里通过env.valueFrom.secretKeyRef引用。这样YAML提交到仓库里不会泄露密码运维同学也只用维护Secret不用去改一堆YAML。需要注意MySQL官方镜像的entrypoint在数据目录初始化时会用MYSQL_ROOT_PASSWORD环境变量设置root密码。这个变量来自Secret因此依赖Secret要先于StatefulSet创建。如果Secret改名或删了重建StatefulSet里的Pod不会自动感知要滚动重启才会更新环境变量这点排错时容易迷惑人。5. 验证主从复制与故障排查实录5.1 从零验证查看Pod、PVC与复制状态资源全部创建后先观察整体状态kubectl get sts -n mysql-demo kubectl get pods -n mysql-demo -o wide kubectl get pvc -n mysql-demo正常情况下mysql-0先Readymysql-1后Ready。PVC应该有两个名字分别是>kubectl exec -n mysql-demo mysql-1 -- mysql -uroot -p${MYSQL_ROOT_PASSWORD} -e SHOW SLAVE STATUS\G关注两个线程的状态Slave_IO_Running: Yes Slave_SQL_Running: Yes两个都是Yes说明IO线程在拉取binlog、SQL线程在重放主从复制正常工作。再看Seconds_Behind_Master: 0说明从库没有延迟。然后实际写一条数据验证kubectl exec -n mysql-demo mysql-0 -- mysql -uroot -p${MYSQL_ROOT_PASSWORD} -e CREATE DATABASE testdb; CREATE TABLE testdb.t1(id INT PRIMARY KEY, name VARCHAR(50)); INSERT INTO testdb.t1 VALUES(1, hello);再从从库查kubectl exec -n mysql-demo mysql-1 -- mysql -uroot -p${MYSQL_ROOT_PASSWORD} -e SELECT * FROM testdb.t1;能看到刚插入的数据复制链路就通了。5.2 踩坑一从库IO线程一直Connecting第一次部署时从库的Slave_IO_Running一直显示Connecting连不上主库。排查步骤要按层级来先确认主从之间的网络通不通。从从库Pod里ping主库域名或者直接用mysql客户端测试主库3306端口kubectl exec -n mysql-demo mysql-1 -- mysql -urepl -pYourReplPass -hmysql-0.mysql -e SELECT 1执行报错提示Access denied时说明网络是通的复制账号权限有问题。检查主库上的授权是否加了%主机SELECT user, host FROM mysql.user WHERE userrepl;如果host不对从库连接会被拒绝。网络不通的话优先检查Headless Service的selector是不是和StatefulSet的Pod标签一致。我的Service里selector只写了app: mysql但StatefulSet的template里的标签恰好也是app: mysql当时没问题但如果pod标签有版本号比如app: mysql, version: 5.7而Service只选了app: mysql也会匹配一般不会导致问题。真正容易错的是spec.serviceName没写对或Service端口定义和MySQL实际监听端口不一致。还有一点如果用了publishNotReadyAddresses: false默认值从库刚启动还没Ready时DNS解析不到主库。从库执行CHANGE MASTER TO时填的是域名解析失败就报Unknown MySQL Server Host。加上publishNotReadyAddresses: true能省掉大量这种头疼问题。5.3 踩坑二Pod重建后数据保留但主从信息丢失测试Pod删除重建时数据保留了但从库复制状态丢了。查了下原因问题出在我的初始化脚本挂载位置。我第一次把初始化脚本放进了initContainer里并且用标志文件判断是否执行。但initContainer里执行初始化时MySQL还没启动只能用mysql_install_db初始化数据目录再做授权而CHANGE MASTER TO这种SQL必须等MySQL起来才能执行所以我把这步挪到了docker-entrypoint-initdb.d里。后来发现Pod被删除重建时数据目录非空entrypoint会跳过initdb目录从库上已经配置好的复制信息按理说应该保留在数据目录里。真正让复制丢失的原因是我用的从库PVC之前被误删过重建后PVC绑定了全新的PV数据目录是空的初始化脚本又执行了一次。从库拿到了主库当前的位点重新复制但早期写入的部分老数据就丢了。这个问题的教训是PVC和PV的生命周期要当成数据库备份一样对待PVC不是随便删的临时对象。每次删StatefulSet前先想清楚数据是保留还是销毁别一股脑地kubectl delete sts mysql连带PVC一起干掉。5.4 踩坑三主库容器被kill后从库报连接错误测试环境里有人直接kubectl delete pod mysql-0模拟主库宕机结果从库报错Last_IO_Error: error reconnecting to master replmysql-0.mysql:3306 - retry-time: 60 retries: 1这是因为从库IO线程长连接被断开会自动重连。但有时候重连抓取不到正确的binlog位点或者主库重建后要等一段时间才能接受连接。处理方式是在从库上手动重置复制STOP SLAVE; START SLAVE;两条命令执行完从库会重新解析主库域名拿到新的IP再次建立连接。如果还不行可能需要RESET SLAVE ALL;后重新执行CHANGE MASTER TO。但有一点必须清楚如果主库的数据卷也丢失了光靠从库重连救不回来。主从复制不是备份从库只是主库binlog的消费端主库数据彻底丢了从库上的数据大概率也不完整。生产环境要在StatefulSet之外另做定时备份把数据导出到对象存储之类的独立地方。5.5 扩容新从库的实操步骤StatefulSet扩副本很简单kubectl scale sts mysql --replicas3 -n mysql-demo新增的mysql-2启动后会走和mysql-1一样的初始化逻辑等待主库就绪、动态获取binlog位点、配置复制关系。因为server-id是动态生成的从库之间不会冲突。不过要留意一点新增从库的数据目录初始是空的它会从主库当前位点开始复制主库上历史的binlog如果已经清理过了新从库可能拿不到足够完整的binlog来补齐历史数据导致从库数据比主库少。这种场景需要先用mysqldump或xtrabackup做一次全量备份恢复再配置复制而不是直接让它空库同步。6. 最后再分享两个实战心得这套方案在测试环境跑了一段时间整体还算稳定但有几个细节值得再啰嗦一遍。第一个心得是StatefulSet的YAML不是一次写对就完事的。MySQL镜像的初始化机制、Entrypoint目录行为、镜像版本差异都会影响初始化脚本的执行效果。先在测试环境完整走一遍从零部署到数据写入、Pod重建、数据验证的全流程再考虑放到预发或生产。我在测试环境发现脚本在MySQL 8.0下语法不对就是因为事先没做版本兼容验证。第二个心得是kubectl describe是排错的第一工具。Pod Pending了看事件里是不是PVC没绑上PVC Pending看事件里是不是StorageClass不存在Pod反复重启看事件里是不是探针失败。绝大多数问题在kubectl describe的输出里会直接给出线索比瞎猜日志高效得多。第三个心得是域名连接比IP连接省心得多。所有主从配置里主库地址一律用mysql-0.mysql这种带服务名的DNS不要记录具体IP。数据库主从链路里IP漂移是常态DNS才是稳定的抽象层。这个原则不只在K8s里适用在裸机部署MySQL主从时也一样。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑