事故现场
一次私有化部署的客户,K8s集群所在的物理机房经历了一次意外断电,UPS没扛住,整个集群硬关机。
大部分无状态服务重启后自动恢复了------这也是K8s的优势所在。但MySQL没那么好说话。Pod起来了,容器起来了,mysqld进程直接CrashLoopBackOff:
[ERROR] InnoDB: Page [page id: space=2, page number=305] log sequence number 294572830 is in the future!
[ERROR] InnoDB: Your database may be corrupt or you may have copied the InnoDB tablespace but not the InnoDB redo log files.
[ERROR] InnoDB: Plugin initialization aborted with error Generic error
[ERROR] Plugin 'InnoDB' init function returned error.
[ERROR] Plugin 'InnoDB' registration as a STORAGE ENGINE failed.
[ERROR] Failed to initialize builtin plugins.
[ERROR] Aborting
翻译成人话:InnoDB的redo log和数据页对不上了。断电那一刻,有些脏页刚写了一半,redo log也没来得及flush。MySQL很诚实------"我对不上账,不干了"。
这种情况在物理机上不算罕见,但在K8s里恢复起来多了几层复杂度------你不能直接改配置文件,因为Pod重启就回到镜像状态;你不能随便进容器操作,因为CrashLoopBackOff意味着容器活不过几秒。
下面是完整的恢复过程。我们踩了几个坑,最后数据一条没丢地救回来了。
恢复思路
核心策略分三步:
第一步:用 innodb_force_recovery 强制启动MySQL(只读模式)
第二步:mysqldump 导出全量数据
第三步:重建一个干净的MySQL实例,导入数据
为什么不直接修复原库?因为强制恢复模式下的MySQL是"带伤运行",InnoDB的内部状态可能已经不一致。即使能起来、能读数据,继续写入是有风险的。最稳妥的方式是:趁它还能读,赶紧把数据倒出来,然后在一个干净的实例上重建。
第一步:理解 innodb_force_recovery
在动手之前,先搞清楚这个参数。innodb_force_recovery 是MySQL的"急救模式开关",取值从1到6,数字越大越暴力,跳过的检查越多,数据丢失的风险也越大。
原则:从小到大试,能用最小值启动就不要用更大的。
| 级别 | 含义 | 跳过了什么 | 风险 |
|---|---|---|---|
1 (SRV_FORCE_IGNORE_CORRUPT) |
忽略损坏页 | 遇到损坏的数据页时不崩溃,而是跳过 | 低。损坏页上的数据可能读不到,但其他数据完好 |
2 (SRV_FORCE_NO_BACKGROUND) |
阻止后台线程 | 不启动master thread和purge thread | 低。相当于冻结了InnoDB的后台清理操作 |
3 (SRV_FORCE_NO_TRX_UNDO) |
跳过事务回滚 | 不做crash recovery中的undo回滚 | 中。断电时未提交的事务不会被回滚,可能看到"半成品"数据 |
4 (SRV_FORCE_NO_IBUF_MERGE) |
跳过insert buffer合并 | 不合并二级索引的change buffer | 中。二级索引可能和主键数据不一致 |
5 (SRV_FORCE_NO_UNDO_LOG_SCAN) |
跳过undo log扫描 | 不查看undo log,把未完成的事务视为已提交 | 高。可能把脏数据当成正常数据 |
6 (SRV_FORCE_NO_LOG_REDO) |
跳过redo log前滚 | 不做redo log的前滚恢复 | 最高。基本上是"不管日志说什么,直接用磁盘上现有的数据页" |
每一级都包含前面所有级别的行为。也就是说设置为3,意味着同时生效1、2、3。
重要:级别>=4时,MySQL会拒绝所有写入操作(INSERT/UPDATE/DELETE),只允许SELECT和mysqldump。
实际上从级别1开始,MySQL就已经是"受限模式"了。官方文档明确说了:innodb_force_recovery > 0 时不应该用于正常生产,只用于数据抢救。
第二步:创建ConfigMap覆盖MySQL配置
K8s里MySQL的配置文件在镜像里或者已有ConfigMap里。我们需要创建(或修改)一个ConfigMap,注入 innodb_force_recovery 参数。
2.1 先看当前MySQL的部署方式
bash
# 确认MySQL的部署资源类型(Deployment/StatefulSet)
kubectl get statefulset,deployment -n <namespace> | grep mysql
# 查看当前Pod的挂载情况
kubectl describe pod <mysql-pod-name> -n <namespace> | grep -A 10 "Volumes"
记下你的namespace、StatefulSet/Deployment名称、PVC名称------后面都要用。
2.2 创建恢复用的ConfigMap
创建一个 mysql-recovery-config.yaml:
yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: mysql-recovery-config
namespace: <namespace> # 替换为你的namespace
data:
recovery.cnf: |
[mysqld]
innodb_force_recovery = 1
先从级别1开始。应用这个ConfigMap:
bash
kubectl apply -f mysql-recovery-config.yaml
2.3 修改MySQL的StatefulSet/Deployment,挂载这个ConfigMap
bash
kubectl edit statefulset <mysql-statefulset-name> -n <namespace>
在 volumes 部分添加:
yaml
volumes:
- name: recovery-config
configMap:
name: mysql-recovery-config
在容器的 volumeMounts 部分添加:
yaml
volumeMounts:
- name: recovery-config
mountPath: /etc/mysql/conf.d/recovery.cnf
subPath: recovery.cnf
为什么用subPath? 因为
/etc/mysql/conf.d/目录下可能已经有其他配置文件,直接挂载整个目录会把原来的配置覆盖掉。用subPath只添加这一个文件,MySQL启动时会自动加载conf.d目录下所有的.cnf文件。
保存退出后,K8s会自动重建Pod。观察Pod状态:
bash
kubectl get pod -n <namespace> -w | grep mysql
2.4 如果级别1没起来,逐级提升
如果Pod还是CrashLoopBackOff,查看日志确认报错:
bash
kubectl logs <mysql-pod-name> -n <namespace> --previous
然后修改ConfigMap,把级别提升到2:
bash
kubectl edit configmap mysql-recovery-config -n <namespace>
把 innodb_force_recovery = 1 改成 innodb_force_recovery = 2。
因为ConfigMap更新后不会自动触发Pod重启(subPath挂载的限制),需要手动重启:
bash
kubectl delete pod <mysql-pod-name> -n <namespace>
Pod删除后StatefulSet会自动重建。继续观察日志。
依次尝试1→2→3→4→5→6,直到MySQL能正常启动。
我们当时的情况是在级别3启动成功了------redo log和undo log都有损坏,跳过事务回滚后MySQL终于愿意起来了。
bash
# 确认MySQL已经Running
kubectl get pod -n <namespace> | grep mysql
# 进容器验证能登录
kubectl exec -it <mysql-pod-name> -n <namespace> -- mysql -u root -p
# 检查一下库表是否还在
mysql> SHOW DATABASES;
mysql> USE <your_database>;
mysql> SHOW TABLES;
mysql> SELECT COUNT(*) FROM <some_important_table>;
看到数据还在的那一刻,心终于放下了一半。
第三步:导出全量数据
MySQL能起来了,但它现在是"带伤状态",随时可能再崩。争分夺秒把数据导出来。
3.1 用mysqldump导出
bash
# 方式一:直接在Pod内执行,导出到Pod本地再拷出来
kubectl exec -it <mysql-pod-name> -n <namespace> -- \
mysqldump -u root -p --all-databases \
--single-transaction \
--routines \
--triggers \
--events \
--set-gtid-purged=OFF \
> /tmp/full_backup.sql
# 把备份文件从Pod拷到本地
kubectl cp <namespace>/<mysql-pod-name>:/tmp/full_backup.sql ./full_backup.sql
bash
# 方式二:直接通过管道导出到本地(推荐,不占Pod磁盘空间)
kubectl exec -it <mysql-pod-name> -n <namespace> -- \
mysqldump -u root -p<password> --all-databases \
--single-transaction \
--routines \
--triggers \
--events \
--set-gtid-purged=OFF \
> ./full_backup.sql
几个参数说明:
--single-transaction:对InnoDB表使用一致性快照,不锁表。在force_recovery模式下尤其重要,因为此时FLUSH TABLES WITH READ LOCK可能会失败--routines:导出存储过程和函数--triggers:导出触发器--events:导出定时事件--set-gtid-purged=OFF:如果你用了GTID复制的话,导入时避免GTID冲突
3.2 如果mysqldump报错
force_recovery模式下,某些表可能因为数据页损坏而无法读取。mysqldump可能会在某个表上卡住或报错。
这时候按数据库逐个导出,跳过有问题的表:
bash
# 逐库导出
kubectl exec -it <mysql-pod-name> -n <namespace> -- \
mysqldump -u root -p<password> \
--single-transaction \
--routines --triggers --events \
--set-gtid-purged=OFF \
<database_name> > ./<database_name>_backup.sql
如果某张表始终读不出来:
bash
# 排除指定表
kubectl exec -it <mysql-pod-name> -n <namespace> -- \
mysqldump -u root -p<password> \
--single-transaction \
--set-gtid-purged=OFF \
<database_name> \
--ignore-table=<database_name>.<broken_table> \
> ./<database_name>_backup.sql
记录下哪些表没导出来,后面评估数据损失。
3.3 验证备份文件
导出完成后务必验证:
bash
# 检查文件大小是否合理
ls -lh ./full_backup.sql
# 检查文件结尾是否正常(正常的mysqldump文件最后几行)
tail -5 ./full_backup.sql
# 应该看到类似这样的内容:
# -- Dump completed on 2026-05-06 10:30:00
如果文件尾部没有 Dump completed 标记,说明导出过程中断了,需要重新导出。
第四步:重建一个干净的MySQL实例
数据导出来了,现在要建一个全新的MySQL来接管。
4.1 删除旧的PVC(或者先保留,用新的)
先别急着删旧PVC! 万一新实例导入有问题,旧数据还在。我们先用一个新的PVC创建新实例。
4.2 准备新的MySQL部署
创建一个 mysql-new.yaml:
yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: mysql-new-config
namespace: <namespace>
data:
my.cnf: |
[mysqld]
default-storage-engine=InnoDB
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
max_connections=500
innodb_buffer_pool_size=1G
innodb_log_file_size=256M
innodb_flush_log_at_trx_commit=1
sync_binlog=1
# 根据你原来的配置调整
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-new-data
namespace: <namespace>
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 50Gi # 根据你的数据量调整
storageClassName: <your-storage-class> # 替换为你的StorageClass
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql-new
namespace: <namespace>
spec:
serviceName: mysql-new
replicas: 1
selector:
matchLabels:
app: mysql-new
template:
metadata:
labels:
app: mysql-new
spec:
containers:
- name: mysql
image: mysql:8.0 # 保持和原来一致的版本
env:
- name: MYSQL_ROOT_PASSWORD
value: "<your-root-password>" # 或者用Secret引用
ports:
- containerPort: 3306
volumeMounts:
- name: mysql-data
mountPath: /var/lib/mysql
- name: mysql-config
mountPath: /etc/mysql/conf.d/my.cnf
subPath: my.cnf
resources:
requests:
memory: "2Gi"
cpu: "1"
limits:
memory: "4Gi"
cpu: "2"
volumes:
- name: mysql-config
configMap:
name: mysql-new-config
- name: mysql-data
persistentVolumeClaim:
claimName: mysql-new-data
---
apiVersion: v1
kind: Service
metadata:
name: mysql-new-svc
namespace: <namespace>
spec:
selector:
app: mysql-new
ports:
- port: 3306
targetPort: 3306
clusterIP: None
部署新实例:
bash
kubectl apply -f mysql-new.yaml
# 等待Pod Running
kubectl get pod -n <namespace> -w | grep mysql-new
4.3 导入数据到新实例
bash
# 方式一:先拷进Pod再导入
kubectl cp ./full_backup.sql <namespace>/<mysql-new-pod>:/tmp/full_backup.sql
kubectl exec -it <mysql-new-pod> -n <namespace> -- \
mysql -u root -p<password> < /tmp/full_backup.sql
bash
# 方式二:直接通过管道导入(推荐)
kubectl exec -i <mysql-new-pod> -n <namespace> -- \
mysql -u root -p<password> < ./full_backup.sql
如果数据量大(几十GB),导入可能需要一段时间。可以在导入前临时调大一些参数加速:
bash
kubectl exec -it <mysql-new-pod> -n <namespace> -- mysql -u root -p<password> -e "
SET GLOBAL innodb_flush_log_at_trx_commit = 0;
SET GLOBAL sync_binlog = 0;
SET GLOBAL max_allowed_packet = 1073741824;
"
导入完成后务必改回来:
bash
kubectl exec -it <mysql-new-pod> -n <namespace> -- mysql -u root -p<password> -e "
SET GLOBAL innodb_flush_log_at_trx_commit = 1;
SET GLOBAL sync_binlog = 1;
"
4.4 验证新实例数据完整性
bash
kubectl exec -it <mysql-new-pod> -n <namespace> -- mysql -u root -p<password>
sql
-- 检查数据库列表
SHOW DATABASES;
-- 逐库检查表数量
SELECT TABLE_SCHEMA, COUNT(*) as table_count
FROM information_schema.TABLES
WHERE TABLE_SCHEMA NOT IN ('information_schema','mysql','performance_schema','sys')
GROUP BY TABLE_SCHEMA;
-- 检查关键业务表的行数
USE <your_database>;
SELECT COUNT(*) FROM <important_table_1>;
SELECT COUNT(*) FROM <important_table_2>;
-- 检查用户和权限
SELECT user, host FROM mysql.user;
把新旧实例的表数量、行数对比一下,确认数据一致。
第五步:切换流量到新实例
数据验证没问题后,最后一步是把应用的数据库连接切到新实例。
5.1 修改Service指向
最简单的方式是把原来MySQL Service的selector改成指向新Pod:
bash
kubectl edit service <mysql-service-name> -n <namespace>
把 selector 改成 app: mysql-new。
或者更干净的方式------删除旧的StatefulSet和Service,把新的重命名成旧的名字:
bash
# 先缩容旧实例(不删PVC)
kubectl scale statefulset <old-mysql> -n <namespace> --replicas=0
# 给新Service/StatefulSet改名(需要先删再建,K8s不支持直接rename)
# 修改mysql-new.yaml中的名称为原来的名称
# 然后重新apply
5.2 清理
确认新实例运行稳定后(建议观察至少24小时):
bash
# 删除旧的PVC(确认不需要了再删!)
kubectl delete pvc <old-mysql-pvc> -n <namespace>
# 删除恢复用的ConfigMap
kubectl delete configmap mysql-recovery-config -n <namespace>
完整流程速查
整个恢复过程浓缩成一张命令清单:
bash
# ===== 阶段一:强制恢复启动 =====
# 1. 创建recovery ConfigMap
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: mysql-recovery-config
namespace: <namespace>
data:
recovery.cnf: |
[mysqld]
innodb_force_recovery = 1
EOF
# 2. 挂载到MySQL Pod(编辑StatefulSet添加volume和volumeMount)
kubectl edit statefulset <mysql-sts> -n <namespace>
# 3. 如果起不来,逐级提升(1→2→3→4→5→6)
kubectl edit configmap mysql-recovery-config -n <namespace>
kubectl delete pod <mysql-pod> -n <namespace>
# 4. 查看日志确认是否启动
kubectl logs <mysql-pod> -n <namespace> -f
# ===== 阶段二:导出数据 =====
# 5. 全量导出
kubectl exec -it <mysql-pod> -n <namespace> -- \
mysqldump -u root -p<password> --all-databases \
--single-transaction --routines --triggers --events \
--set-gtid-purged=OFF > ./full_backup.sql
# 6. 验证备份
tail -5 ./full_backup.sql
# ===== 阶段三:重建导入 =====
# 7. 部署新MySQL实例
kubectl apply -f mysql-new.yaml
# 8. 导入数据
kubectl exec -i <mysql-new-pod> -n <namespace> -- \
mysql -u root -p<password> < ./full_backup.sql
# 9. 验证数据
kubectl exec -it <mysql-new-pod> -n <namespace> -- \
mysql -u root -p<password> -e "SHOW DATABASES;"
# 10. 切换流量 & 清理旧资源
kubectl scale statefulset <old-mysql> -n <namespace> --replicas=0
几个踩过的坑
1. ConfigMap更新后Pod不会自动重载
用subPath挂载的ConfigMap,修改后不会自动更新到Pod里。必须手动删Pod触发重建。这跟直接挂载目录的行为不同,容易踩坑。
2. 不要在force_recovery模式下执行任何写操作
即使级别1-3允许写入,也不要做。这时候InnoDB的状态是不可信的,写入可能导致二次损坏。只做SELECT和mysqldump。
3. mysqldump的--single-transaction不是万能的
它依赖InnoDB的MVCC。如果InnoDB本身都坏了,一致性快照的可靠性要打个问号。但在force_recovery模式下,这已经是最好的选择了。
4. 导入大数据库时先关掉binlog和双写
前面提到的临时关闭 innodb_flush_log_at_trx_commit 和 sync_binlog,能让导入速度快3-5倍。但记得导入完改回来,这两个参数关掉意味着掉电会丢数据------而我们刚经历过一次掉电。
5. 旧PVC不要着急删
数据恢复最怕的是"二次事故"。旧PVC保留至少一周,确认新实例完全稳定、业务数据完全正确后再清理。磁盘空间不够的话,可以先把旧PVC的数据tar打包存到对象存储。
写在后面
这次事故之后我们做了几个改进:
- MySQL备份策略:从"每天全量备份"改成"每6小时增量+每天全量",备份存到独立的对象存储
- UPS监控:加了UPS电量告警,低于30%时自动触发K8s节点drain
- 数据库有状态服务的部署建议:生产环境的MySQL尽量不要跑在K8s里。如果一定要跑,至少保证PV用的是带掉电保护的SSD,并且做好主从复制
数据库这东西,平时觉得它就在那里、稳如磐石。等它真出问题了,你才意识到备份和高可用不是成本,是保险。