mysql_recovery.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. import argparse
  2. import hashlib
  3. import json
  4. import os
  5. import secrets
  6. import subprocess
  7. import time
  8. from datetime import UTC, datetime
  9. from pathlib import Path
  10. from uuid import uuid4
  11. ROOT = Path(__file__).resolve().parents[1]
  12. BACKUPS = ROOT / ".work" / "backups"
  13. DATABASES = {"mysql": "zhifu", "mysql-oms": "oms"}
  14. DUMP = (
  15. 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump -uroot '
  16. "--single-transaction --quick --hex-blob --order-by-primary "
  17. "--skip-comments --skip-dump-date --skip-add-drop-table "
  18. "--set-gtid-purged=OFF --no-tablespaces "
  19. )
  20. def docker(*args, **kwargs):
  21. return subprocess.run(["docker", *args], check=True, timeout=180, **kwargs)
  22. def digest(path):
  23. with path.open("rb") as stream:
  24. return hashlib.file_digest(stream, "sha256").hexdigest()
  25. def backup():
  26. directory = BACKUPS / (datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid4().hex[:8])
  27. directory.mkdir(parents=True)
  28. manifest = {"format": 1, "started_at": datetime.now(UTC).isoformat(), "databases": {}}
  29. for service, database in DATABASES.items():
  30. source = docker("compose", "ps", "-q", service, cwd=ROOT, capture_output=True).stdout
  31. source_id = source.decode().strip()
  32. if not source_id:
  33. raise RuntimeError(f"{service}: MySQL 容器未启动")
  34. metadata = json.loads(docker("inspect", source_id, capture_output=True).stdout)[0]
  35. path = directory / f"{database}.sql"
  36. started = datetime.now(UTC).isoformat()
  37. with path.open("xb") as output:
  38. docker("exec", source_id, "sh", "-c", DUMP + database, stdout=output)
  39. manifest["databases"][database] = {
  40. "file": path.name,
  41. "sha256": digest(path),
  42. "bytes": path.stat().st_size,
  43. "image_id": metadata["Image"],
  44. "started_at": started,
  45. "finished_at": datetime.now(UTC).isoformat(),
  46. }
  47. manifest["finished_at"] = datetime.now(UTC).isoformat()
  48. (directory / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
  49. print(directory)
  50. return directory
  51. def restore(directory):
  52. directory = directory.resolve(strict=True)
  53. if not directory.is_relative_to(BACKUPS.resolve()):
  54. raise ValueError("恢复文件必须位于项目 .work/backups")
  55. manifest = json.loads((directory / "manifest.json").read_text(encoding="utf-8"))
  56. if manifest["format"] != 1 or set(manifest["databases"]) != set(DATABASES.values()):
  57. raise ValueError("备份清单格式不符合要求")
  58. for database, entry in manifest["databases"].items():
  59. if entry["file"] != f"{database}.sql":
  60. raise ValueError("备份文件名称不符合要求")
  61. path = (directory / entry["file"]).resolve(strict=True)
  62. if not path.is_relative_to(directory) or digest(path) != entry["sha256"]:
  63. raise ValueError("备份文件 SHA256 校验失败")
  64. started = time.monotonic()
  65. report = {"started_at": datetime.now(UTC).isoformat(), "databases": {}}
  66. for database, entry in manifest["databases"].items():
  67. name = "zhifu-restore-" + database + "-" + uuid4().hex[:12]
  68. environment = {**os.environ, "MYSQL_ROOT_PASSWORD": secrets.token_urlsafe(32)}
  69. docker(
  70. "run",
  71. "-d",
  72. "--name",
  73. name,
  74. "--network",
  75. "none",
  76. "--tmpfs",
  77. "/var/lib/mysql:rw,size=1g",
  78. "--memory",
  79. "2g",
  80. "--label",
  81. "zhifu.purpose=recovery-verification",
  82. "--label",
  83. "zhifu.recovery-run=" + os.environ.get("ZHIFU_RECOVERY_RUN_ID", "standalone"),
  84. "-e",
  85. "MYSQL_ROOT_PASSWORD",
  86. "-e",
  87. f"MYSQL_DATABASE={database}",
  88. entry["image_id"],
  89. env=environment,
  90. capture_output=True,
  91. )
  92. try:
  93. for _ in range(90):
  94. ready = subprocess.run(
  95. [
  96. "docker",
  97. "exec",
  98. name,
  99. "sh",
  100. "-c",
  101. 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -h127.0.0.1 -uroot -e "SELECT 1"',
  102. ],
  103. capture_output=True,
  104. timeout=5,
  105. )
  106. if ready.returncode == 0:
  107. break
  108. time.sleep(1)
  109. else:
  110. raise TimeoutError("独立 MySQL 恢复容器启动超时")
  111. with (directory / entry["file"]).open("rb") as source:
  112. docker(
  113. "exec",
  114. "-i",
  115. name,
  116. "sh",
  117. "-c",
  118. 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot ' + database,
  119. stdin=source,
  120. )
  121. restored = directory / f"{database}.restored.sql"
  122. with restored.open("wb") as output:
  123. docker("exec", name, "sh", "-c", DUMP + database, stdout=output)
  124. if digest(restored) != entry["sha256"]:
  125. raise RuntimeError(f"{database}: 恢复后的完整 SQL 内容校验失败")
  126. counts = (
  127. "'orders', (SELECT COUNT(*) FROM order_projections), "
  128. "'tickets', (SELECT COUNT(*) FROM tickets), "
  129. "'tenants', (SELECT COUNT(*) FROM tenants), "
  130. if database == "zhifu"
  131. else "'orders', (SELECT COUNT(*) FROM orders), "
  132. )
  133. query = (
  134. "SELECT JSON_OBJECT('revision', (SELECT version_num FROM alembic_version), "
  135. + counts
  136. + "'tables', (SELECT COUNT(*) FROM information_schema.tables "
  137. "WHERE table_schema=DATABASE()))"
  138. )
  139. result = docker(
  140. "exec",
  141. name,
  142. "sh",
  143. "-c",
  144. 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot -N -B ' + database + ' -e "$1"',
  145. "query",
  146. query,
  147. capture_output=True,
  148. )
  149. report["databases"][database] = {
  150. **json.loads(result.stdout),
  151. "checksum_equal": True,
  152. "bytes": entry["bytes"],
  153. }
  154. finally:
  155. docker("rm", "-f", name, capture_output=True)
  156. report["seconds"] = round(time.monotonic() - started, 2)
  157. (directory / "restore-report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
  158. print(json.dumps(report, indent=2))
  159. return report
  160. if __name__ == "__main__":
  161. parser = argparse.ArgumentParser(description="MySQL 备份与独立容器恢复验证")
  162. parser.add_argument("action", choices=["backup", "verify", "exercise"])
  163. parser.add_argument("--directory", type=Path)
  164. args = parser.parse_args()
  165. if args.action == "verify":
  166. if args.directory is None:
  167. parser.error("verify 需要 --directory")
  168. restore(args.directory)
  169. else:
  170. directory = backup()
  171. if args.action == "exercise":
  172. restore(directory)