196 lines
6.8 KiB
Bash
Executable File
196 lines
6.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# 数据库分库备份脚本(本地 + S3 兼容对象存储)
|
||
# 用法:backup.sh [assets|metrics|all]
|
||
# assets -> 备份 assets.db,保留 14 份(建议每日)
|
||
# metrics -> 备份 metrics.db,保留 4 份(建议每周)
|
||
# all -> 两者都备份
|
||
#
|
||
# S3 上传(可选):配置 .env 中 S3_* 项后,备份文件自动上传到
|
||
# 任意 S3 兼容对象存储(Cloudflare R2 / MinIO / 阿里云 OSS / 腾讯云 COS 等),
|
||
# 远端保留份数与本地一致。未配置 S3 时仅本地备份(向后兼容)。
|
||
# 实现为纯 Python 标准库(AWS SigV4 签名),零额外依赖。
|
||
set -euo pipefail
|
||
|
||
APP_DIR="${APP_DIR:-/opt/vps-manager}"
|
||
cd "$APP_DIR"
|
||
|
||
TARGET="${1:-all}"
|
||
|
||
python3 - "$TARGET" <<'PYEOF'
|
||
import hashlib
|
||
import hmac
|
||
import os
|
||
import sqlite3
|
||
import sys
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
import xml.etree.ElementTree as ET
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
target = sys.argv[1]
|
||
data_dir = Path("data")
|
||
backup_root = data_dir / "backups"
|
||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
|
||
# ---------------- 本地备份 ----------------
|
||
|
||
def backup(db_name: str, keep: int) -> Path | None:
|
||
src = data_dir / db_name
|
||
stem = db_name.replace(".db", "")
|
||
if not src.exists():
|
||
print(f"[skip] {db_name} 不存在")
|
||
return None
|
||
out_dir = backup_root / stem
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
dst = out_dir / f"{stem}_{ts}.db"
|
||
|
||
con = sqlite3.connect(str(src))
|
||
bkp = sqlite3.connect(str(dst))
|
||
con.backup(bkp)
|
||
bkp.close()
|
||
con.close()
|
||
|
||
files = sorted(
|
||
out_dir.glob(f"{stem}_*.db"),
|
||
key=lambda p: p.stat().st_mtime,
|
||
reverse=True,
|
||
)
|
||
for old in files[keep:]:
|
||
old.unlink()
|
||
print(f"[ok] {db_name} -> {dst}(保留 {keep} 份)")
|
||
return dst
|
||
|
||
# ---------------- S3 上传(纯标准库 SigV4) ----------------
|
||
|
||
def load_env(path: Path) -> dict:
|
||
"""极简 .env 解析(KEY=VALUE,忽略注释/空行),os.environ 优先"""
|
||
env = {}
|
||
if path.exists():
|
||
for line in path.read_text().splitlines():
|
||
line = line.strip()
|
||
if not line or line.startswith("#") or "=" not in line:
|
||
continue
|
||
k, v = line.split("=", 1)
|
||
env[k.strip()] = v.strip().strip('"').strip("'")
|
||
for k, v in os.environ.items():
|
||
if v:
|
||
env[k] = v
|
||
return env
|
||
|
||
|
||
def _sign(key: bytes, msg: str) -> bytes:
|
||
return hmac.new(key, msg.encode(), hashlib.sha256).digest()
|
||
|
||
|
||
def _sig_key(secret: str, date: str, region: str, service: str) -> bytes:
|
||
k = _sign(f"AWS4{secret}".encode(), date)
|
||
k = _sign(k, region)
|
||
k = _sign(k, service)
|
||
return _sign(k, "aws4_request")
|
||
|
||
|
||
def _s3_request(endpoint: str, access: str, secret: str, region: str,
|
||
method: str, bucket: str, key: str, query: str,
|
||
body: bytes, content_type: str = "application/octet-stream") -> bytes:
|
||
"""S3 API 请求(path-style),返回响应体"""
|
||
now = datetime.now(timezone.utc)
|
||
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||
date = now.strftime("%Y%m%d")
|
||
payload_hash = hashlib.sha256(body).hexdigest()
|
||
host = urllib.parse.urlparse(endpoint).netloc
|
||
q = urllib.parse.quote(key, safe="/")
|
||
|
||
canonical_uri = f"/{bucket}/{q}"
|
||
canonical_query = query # 已按 RFC3986 编码
|
||
canonical_headers = (
|
||
f"host:{host}\n"
|
||
f"x-amz-content-sha256:{payload_hash}\n"
|
||
f"x-amz-date:{amz_date}\n"
|
||
)
|
||
signed_headers = "host;x-amz-content-sha256;x-amz-date"
|
||
canonical_request = "\n".join(
|
||
[method, canonical_uri, canonical_query, canonical_headers, signed_headers, payload_hash]
|
||
)
|
||
scope = f"{date}/{region}/s3/aws4_request"
|
||
string_to_sign = "\n".join(
|
||
["AWS4-HMAC-SHA256", amz_date, scope,
|
||
hashlib.sha256(canonical_request.encode()).hexdigest()]
|
||
)
|
||
signature = hmac.new(
|
||
_sig_key(secret, date, region, "s3"), string_to_sign.encode(), hashlib.sha256
|
||
).hexdigest()
|
||
auth = (
|
||
f"AWS4-HMAC-SHA256 Credential={access}/{scope}, "
|
||
f"SignedHeaders={signed_headers}, Signature={signature}"
|
||
)
|
||
|
||
url = f"{endpoint.rstrip('/')}/{bucket}/{q}"
|
||
if query:
|
||
url += "?" + query
|
||
req = urllib.request.Request(
|
||
url, data=body, method=method,
|
||
headers={
|
||
"Authorization": auth,
|
||
"x-amz-content-sha256": payload_hash,
|
||
"x-amz-date": amz_date,
|
||
"Content-Type": content_type,
|
||
},
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=90) as resp:
|
||
return resp.read()
|
||
except urllib.error.HTTPError as e:
|
||
raise RuntimeError(f"S3 {method} {key} 失败: HTTP {e.code} {e.read().decode(errors='replace')[:300]}")
|
||
|
||
|
||
def s3_upload(env: dict, dst: Path, stem: str, keep: int) -> None:
|
||
"""上传单个备份文件到 S3,并清理远端过期备份(保留 keep 份)"""
|
||
endpoint = env.get("S3_ENDPOINT_URL", "")
|
||
access = env.get("S3_ACCESS_KEY", "")
|
||
secret = env.get("S3_SECRET_KEY", "")
|
||
bucket = env.get("S3_BUCKET", "")
|
||
if not (endpoint and access and secret and bucket):
|
||
print("[s3] 未配置 S3_*(ENDPOINT/ACCESS_KEY/SECRET_KEY/BUCKET),跳过远端备份")
|
||
return
|
||
region = env.get("S3_REGION", "auto")
|
||
prefix = env.get("S3_PREFIX", "vps-manager").rstrip("/")
|
||
key = f"{prefix}/{stem}/{dst.name}"
|
||
|
||
body = dst.read_bytes()
|
||
_s3_request(endpoint, access, secret, region, "PUT", bucket, key, "", body)
|
||
print(f"[s3] {dst.name} -> {endpoint}/{bucket}/{key}")
|
||
|
||
# 列出同目录下已有备份,按 key 排序(时间戳命名即字典序),清理过期对象
|
||
# 注意:prefix 必须带参数名且值按 RFC3986 编码(含 / -> %2F),否则会列出全桶并误删其他目录
|
||
list_query = "list-type=2&prefix=" + urllib.parse.quote(f"{prefix}/{stem}/", safe="")
|
||
resp = _s3_request(endpoint, access, secret, region, "GET", bucket, "", list_query, b"")
|
||
root = ET.fromstring(resp)
|
||
ns = {"s3": "http://s3.amazonaws.com/doc/2006-03-01/"}
|
||
keys = [e.text for e in root.findall(".//s3:Key", ns) if e.text]
|
||
keys.sort(reverse=True)
|
||
for old_key in keys[keep:]:
|
||
_s3_request(endpoint, access, secret, region, "DELETE", bucket, old_key, "", b"")
|
||
print(f"[s3] 清理过期备份: {old_key}")
|
||
|
||
|
||
def run(db_name: str, keep: int) -> None:
|
||
dst = backup(db_name, keep)
|
||
if dst is None:
|
||
return
|
||
try:
|
||
env = load_env(Path(".env"))
|
||
s3_upload(env, dst, db_name.replace(".db", ""), keep)
|
||
except Exception as e:
|
||
# 本地备份已成功;S3 失败需要显式暴露(systemd 会标记 failed)
|
||
print(f"[s3][error] {e}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
|
||
if target in ("assets", "all"):
|
||
run("assets.db", 14)
|
||
if target in ("metrics", "all"):
|
||
run("metrics.db", 4)
|
||
PYEOF
|