Files
vps-manager/deploy/backup.sh
T

196 lines
6.8 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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