K8s集群断电后MySQL恢复实录:从InnoDB崩溃到数据完整迁移

事故现场

一次私有化部署的客户,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_commitsync_binlog,能让导入速度快3-5倍。但记得导入完改回来,这两个参数关掉意味着掉电会丢数据------而我们刚经历过一次掉电。

5. 旧PVC不要着急删

数据恢复最怕的是"二次事故"。旧PVC保留至少一周,确认新实例完全稳定、业务数据完全正确后再清理。磁盘空间不够的话,可以先把旧PVC的数据tar打包存到对象存储。

写在后面

这次事故之后我们做了几个改进:

  • MySQL备份策略:从"每天全量备份"改成"每6小时增量+每天全量",备份存到独立的对象存储
  • UPS监控:加了UPS电量告警,低于30%时自动触发K8s节点drain
  • 数据库有状态服务的部署建议:生产环境的MySQL尽量不要跑在K8s里。如果一定要跑,至少保证PV用的是带掉电保护的SSD,并且做好主从复制

数据库这东西,平时觉得它就在那里、稳如磐石。等它真出问题了,你才意识到备份和高可用不是成本,是保险。

相关推荐
敖正炀40 分钟前
MySQL 架构全景与特性总览
mysql
tkevinjd42 分钟前
MySQL1:分层架构
数据库·mysql·缓存
承渊政道1 小时前
从ROWNUM到LIMIT:KES、Oracle与PostgreSQL的执行顺序差异解析
数据库·数据仓库·sql·mysql·安全·postgresql·oracle
花生壳儿2 小时前
Docker容器安装MySQL数据库
数据库·mysql·docker
Cat_Rocky2 小时前
K8S中的优先级
云原生·容器·kubernetes
无小道2 小时前
Mysql——吃透事务以及隔离级别
mysql·面试·事务·隔离级别
2301_780789662 小时前
容器环境漏洞扫描:适配 K8s 架构的镜像与 Pod 安全检测方案
网络·安全·web安全·云原生·架构·kubernetes·ddos
运维老郭2 小时前
【K8s 调度三阶段 · 避坑完全指南】过滤→打分→绑定,9 成 Pending 都卡在第一关
运维·云原生·kubernetes
云游牧者2 小时前
深入理解K8S-Pod生命周期与资源管理-CSDN博客
kubernetes·探针·pod生命周期·pod优雅终止