feat(deployer): sync 前备份远程服务目录

rsync --delete 会清掉目标目录里的多余文件;先拷到 $HOME/bakeup/deployer-skill,失败则中止同步。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 23:45:45 +08:00
parent 130dcc36c5
commit 86ce602b28
5 changed files with 64 additions and 5 deletions
+3 -2
View File
@@ -84,8 +84,9 @@ GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR
避免同一台机器上多个项目的同名环境互相覆盖;需要固定名字时在 `_config.yaml` 写 `name:`。 避免同一台机器上多个项目的同名环境互相覆盖;需要固定名字时在 `_config.yaml` 写 `name:`。
重要边界:rsync 同步使用 `--delete`,远程多余的文件会被删除;scp 兜底不会删。 重要边界:rsync 同步使用 `--delete`,远程多余的文件会被删除;scp 兜底不会删。
数据库、证书等运行时数据必须放在排除目录或远程绝对路径挂载。涉及删除数据卷、 每次 sync 前会把远程服务目录拷到该机 `$HOME/bakeup/deployer-skill/<服务名>/<时间戳>/`,每服务只留最近 5 份;
清理远程文件的操作会先向你确认。 备份失败则中止同步。数据库、证书等运行时数据必须放在排除目录或远程绝对路径挂载。
涉及删除数据卷、清理远程文件的操作会先向你确认。
## 如何判断完成 ## 如何判断完成
+1 -1
View File
@@ -27,7 +27,7 @@ description: >-
## 边界 ## 边界
- 所有 sync/up/recreate/upgrade/restart 必须按单服务执行,禁止节点级批量。 - 所有 sync/up/recreate/upgrade/restart 必须按单服务执行,禁止节点级批量。
- `rsync` 带 `--delete`:运行时数据必须在 `data/`、`_data/` 或远程绝对路径挂载,否则会被清掉。 - `rsync` 带 `--delete`:sync 前先把远程服务目录拷到 `$HOME/bakeup/deployer-skill/<name>/<时间戳>/`(每服务留 5 份);运行时数据仍须在 `data/`、`_data/` 或远程绝对路径挂载,否则会被清掉。
- 仓库只放静态配置;证书、数据库、上传文件不进 Git,也不进同步范围。`unused/` 不参与发现与部署。 - 仓库只放静态配置;证书、数据库、上传文件不进 Git,也不进同步范围。`unused/` 不参与发现与部署。
- `node` 是 `~/.ssh/config` 的 Host 别名(可用 `user@host`)。用户没给别名就不要写假 node。 - `node` 是 `~/.ssh/config` 的 Host 别名(可用 `user@host`)。用户没给别名就不要写假 node。
- 密钥不入库。Compose 优先放远程 `.env`;Argo CD 的 dockerconfigjson / TLS 私钥只存在集群 Secret。 - 密钥不入库。Compose 优先放远程 `.env`;Argo CD 的 dockerconfigjson / TLS 私钥只存在集群 Secret。
+2 -2
View File
@@ -92,7 +92,7 @@ python3 <skill-dir>/scripts/deploy/sync.py <service-path>
脚本一律 `python3 -I -S <skill-dir>/scripts/deploy/<name>.py`。有 `make deploy TGT=` 才用 Makefile;没有就直接调脚本,不要读 Makefile 去发明封装。 脚本一律 `python3 -I -S <skill-dir>/scripts/deploy/<name>.py`。有 `make deploy TGT=` 才用 Makefile;没有就直接调脚本,不要读 Makefile 去发明封装。
`sync.py` 使用 `rsync -avz --delete`;本机无 rsync 时 scp 兜底(不 `--delete`)。运行时数据必须放在默认排除的 `data/`、`_data/`,或 compose 挂载的远程绝对路径,否则会被清掉。 `sync.py` 使用 `rsync -avz --delete`;本机无 rsync 时 scp 兜底(不 `--delete`)。同步前先把远程服务目录拷到该机 `$HOME/bakeup/deployer-skill/<服务名>/<时间戳>/`(每服务保留最近 5 份,备份失败则中止)。运行时数据必须放在默认排除的 `data/`、`_data/`,或 compose 挂载的远程绝对路径,否则会被清掉。
## 新增服务 / 环境 ## 新增服务 / 环境
@@ -111,7 +111,7 @@ python3 <skill-dir>/scripts/deploy/sync.py <service-path>
2. 每个环境写 `compose.yaml`;共享的 node/base_path 放 `.pouch/deployer/_config.yaml` 2. 每个环境写 `compose.yaml`;共享的 node/base_path 放 `.pouch/deployer/_config.yaml`
3. 环境有差异 → 在该环境的 `_config.yaml` 覆盖 3. 环境有差异 → 在该环境的 `_config.yaml` 覆盖
4. 同名冲突或需要固定远程目录名 → `_config.yaml` 写 `name:` 4. 同名冲突或需要固定远程目录名 → `_config.yaml` 写 `name:`
5. 首次部署前确认目标主机的远程目录不存在旧内容(rsync `--delete` 会清掉) 5. 首次部署:远程目录不存在或为空时跳过备份;已有内容会被拷到 `$HOME/bakeup/deployer-skill/` 再同步
## 下线服务 ## 下线服务
@@ -95,6 +95,7 @@ sync_exclude:
``` ```
运行时数据放这些目录或 compose 挂载的远程绝对路径(如 `/data01/docker/<svc>/`),避免被 `--delete` 清掉。 运行时数据放这些目录或 compose 挂载的远程绝对路径(如 `/data01/docker/<svc>/`),避免被 `--delete` 清掉。
sync 前的配置备份在远程 `$HOME/bakeup/deployer-skill/<服务名>/<时间戳>/`,不在同步范围内。
--- ---
+57
View File
@@ -12,12 +12,16 @@ from __future__ import annotations
import argparse import argparse
import os import os
import re
import shlex import shlex
import shutil import shutil
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
BAKEUP_KEEP = 5
BAKEUP_ROOT = "deployer-skill"
_SCRIPT_DIR = Path(__file__).resolve().parent _SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path: if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR)) sys.path.insert(0, str(_SCRIPT_DIR))
@@ -25,7 +29,60 @@ if str(_SCRIPT_DIR) not in sys.path:
from lib import DEFAULT_BASE_PATH, rsync_ssh_args, scp_base_args, service_info, ssh_cmd # noqa: E402 from lib import DEFAULT_BASE_PATH, rsync_ssh_args, scp_base_args, service_info, ssh_cmd # noqa: E402
def _safe_bakeup_name(name: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-.")
return cleaned or "service"
def _backup_remote(info: dict) -> int:
"""把远程服务目录拷到 $HOME/bakeup/deployer-skill/<name>/<timestamp>/,失败则中止 sync。"""
name = _safe_bakeup_name(info["name"])
remote_dir = info["remote_dir"]
script = f"""
set -eu
remote_dir={shlex.quote(remote_dir)}
name={shlex.quote(name)}
root={shlex.quote(BAKEUP_ROOT)}
keep={BAKEUP_KEEP}
if [ ! -d "$remote_dir" ]; then
echo "跳过备份: 远程目录不存在 $remote_dir"
exit 0
fi
if [ -z "$(find "$remote_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
echo "跳过备份: 远程目录为空 $remote_dir"
exit 0
fi
stamp=$(date +%Y%m%d-%H%M%S)
base="$HOME/bakeup/$root/$name"
dest="$base/$stamp"
mkdir -p "$dest"
chmod 700 "$HOME/bakeup" "$HOME/bakeup/$root" "$base" "$dest"
cp -a "$remote_dir"/. "$dest"/
ls -1d "$base"/[0-9][0-9][0-9][0-9]* 2>/dev/null | sort -r | tail -n +"$((keep + 1))" | while IFS= read -r old; do
rm -rf "$old"
done
echo "已备份到 $dest (保留最近 $keep 份)"
"""
cmd = ssh_cmd(info, script)
print(f"备份远程目录 {info['node']}:{remote_dir}")
print(f"备份位置: $HOME/bakeup/{BAKEUP_ROOT}/{name}/<时间戳>/")
print("-" * 60)
try:
completed = subprocess.run(cmd, check=False)
except FileNotFoundError:
print("错误: ssh 命令未找到,无法备份")
return 1
if completed.returncode:
print(f"错误: 远程备份失败,退出码: {completed.returncode};已中止同步")
return completed.returncode
print("-" * 60)
return 0
def sync_directory(info: dict) -> int: def sync_directory(info: dict) -> int:
backup_rc = _backup_remote(info)
if backup_rc:
return backup_rc
if shutil.which("rsync"): if shutil.which("rsync"):
return _sync_rsync(info) return _sync_rsync(info)
print("本机没有 rsync,改用 scp(不会删除远程多余文件)") print("本机没有 rsync,改用 scp(不会删除远程多余文件)")