From 4e1347ba4527f80eb689fd4cbd7f33cd2693d9c2 Mon Sep 17 00:00:00 2001 From: Nexus Agent Date: Sat, 6 Jun 2026 22:37:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(deploy):=20Docker=20=E6=84=9F=E7=9F=A5=20My?= =?UTF-8?q?SQL=20=E5=A4=87=E4=BB=BD=E4=B8=8E=20nx=20cron=20=E5=AE=89?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 升级/定时备份通过 docker exec 1Panel MySQL 容器 dump,不再依赖宿主机 mysqldump;nx 菜单新增巡检与备份 crontab 安装。 Co-authored-by: Cursor --- deploy/db_backup.sh | 113 +++------- deploy/install_ops_cron.sh | 54 +++++ deploy/mysql_dump_to_file.sh | 196 ++++++++++++++++++ deploy/nexus-1panel.sh | 46 +--- deploy/nx | 15 ++ ...god-menu-audit-round4-mysql-backup-cron.md | 36 ++++ 6 files changed, 335 insertions(+), 125 deletions(-) create mode 100644 deploy/install_ops_cron.sh create mode 100644 deploy/mysql_dump_to_file.sh create mode 100644 docs/changelog/2026-06-06-nx-god-menu-audit-round4-mysql-backup-cron.md diff --git a/deploy/db_backup.sh b/deploy/db_backup.sh index fa828fd3..b9a00351 100644 --- a/deploy/db_backup.sh +++ b/deploy/db_backup.sh @@ -1,102 +1,45 @@ #!/usr/bin/env bash -# Nexus — MySQL Auto-Backup Script -# Usage: Add to crontab for daily 3AM backup -# 0 3 * * * /path/to/nexus/deploy/db_backup.sh >> /var/log/nexus_backup.log 2>&1 +# Nexus — MySQL 定时备份(Docker 1Panel / 宿主机) # -# Reads DATABASE_URL from .env for connection parameters. -# Retains backups for 30 days, auto-deletes older ones. - +# 0 3 * * * NEXUS_ROOT=/opt/nexus /opt/nexus/deploy/db_backup.sh >> /var/log/nexus_backup.log 2>&1 +# +# 亦可通过: sudo bash deploy/install_ops_cron.sh set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -ENV_FILE="$PROJECT_DIR/.env" -BACKUP_DIR="$PROJECT_DIR/backups" -RETENTION_DAYS=30 +NEXUS_ROOT="${NEXUS_ROOT:-$(dirname "$SCRIPT_DIR")}" +BACKUP_DIR="${NEXUS_BACKUP_DIR:-/var/backups/nexus}" +RETENTION_DAYS="${NEXUS_BACKUP_RETENTION_DAYS:-30}" +DUMP_SH="${SCRIPT_DIR}/mysql_dump_to_file.sh" -# ── Load .env ── -if [ ! -f "$ENV_FILE" ]; then - echo "[$(date)] ERROR: .env not found at $ENV_FILE" +if [[ ! -x "$DUMP_SH" ]]; then + echo "[$(date)] ERROR: missing $DUMP_SH" exit 1 fi -# Parse DATABASE_URL from .env -# Format: mysql+asyncmy://user:password@host:port/dbname -DB_URL=$(grep -E '^NEXUS_DATABASE_URL=' "$ENV_FILE" | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'") -if [ -z "$DB_URL" ]; then - DB_URL=$(grep -E '^DATABASE_URL=' "$ENV_FILE" | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'") -fi - -if [ -z "$DB_URL" ]; then - echo "[$(date)] ERROR: DATABASE_URL not found in .env" - exit 1 -fi - -# Parse mysql URL: mysql+asyncmy://user:pass@host:port/dbname → user pass host port dbname -# Strip scheme -CONN="${DB_URL#*://}" -# Extract user:pass -USERPASS="${CONN%%@*}" -DB_USER="${USERPASS%%:*}" -DB_PASS="${USERPASS#*:}" -# Extract host:port/dbname -HOSTDB="${CONN#*@}" -DB_HOST="${HOSTDB%%:*}" -PORTDB="${HOSTDB#*:}" -DB_PORT="${PORTDB%%/*}" -DB_NAME="${PORTDB#*/}" -# Remove query params -DB_NAME="${DB_NAME%%\?*}" - -if [ -z "$DB_NAME" ] || [ -z "$DB_HOST" ] || [ -z "$DB_USER" ]; then - echo "[$(date)] ERROR: Failed to parse DATABASE_URL" - exit 1 -fi - -# ── Create backup directory ── mkdir -p "$BACKUP_DIR" +TIMESTAMP="$(date +%Y%m%d_%H%M%S)" +RAW_FILE="${BACKUP_DIR}/nexus_daily_${TIMESTAMP}.sql" +BACKUP_FILE="${RAW_FILE}.gz" -# ── Backup filename ── -TIMESTAMP=$(date +%Y%m%d_%H%M%S) -BACKUP_FILE="$BACKUP_DIR/nexus_${DB_NAME}_${TIMESTAMP}.sql.gz" - -# ── Run mysqldump ── -echo "[$(date)] Starting backup: $DB_NAME @ $DB_HOST" - -if [ -n "$DB_PASS" ]; then - MYSQL_PWD="$DB_PASS" mysqldump \ - -h "$DB_HOST" \ - -P "${DB_PORT:-3306}" \ - -u "$DB_USER" \ - --single-transaction \ - --routines \ - --triggers \ - --set-gtid-purged=OFF \ - "$DB_NAME" | gzip > "$BACKUP_FILE" -else - mysqldump \ - -h "$DB_HOST" \ - -P "${DB_PORT:-3306}" \ - -u "$DB_USER" \ - --single-transaction \ - --routines \ - --triggers \ - --set-gtid-purged=OFF \ - "$DB_NAME" | gzip > "$BACKUP_FILE" -fi - -# ── Verify backup ── -if [ -f "$BACKUP_FILE" ]; then - SIZE=$(du -h "$BACKUP_FILE" | cut -f1) - echo "[$(date)] Backup completed: $BACKUP_FILE ($SIZE)" -else - echo "[$(date)] ERROR: Backup file not created!" +echo "[$(date)] Starting backup → $BACKUP_FILE" +if ! NEXUS_ROOT="$NEXUS_ROOT" bash "$DUMP_SH" --output "$RAW_FILE" --root "$NEXUS_ROOT"; then + echo "[$(date)] ERROR: mysqldump failed" + rm -f "$RAW_FILE" exit 1 fi -# ── Cleanup old backups (retain 30 days) ── -DELETED=$(find "$BACKUP_DIR" -name "nexus_*.sql.gz" -mtime +${RETENTION_DAYS} -delete -print | wc -l) -if [ "$DELETED" -gt 0 ]; then +gzip -f "$RAW_FILE" +if [[ ! -f "$BACKUP_FILE" ]]; then + echo "[$(date)] ERROR: backup file not created" + exit 1 +fi + +SIZE="$(du -h "$BACKUP_FILE" | cut -f1)" +echo "[$(date)] Backup completed: $BACKUP_FILE ($SIZE)" + +DELETED="$(find "$BACKUP_DIR" -name 'nexus_*.sql.gz' -mtime +"${RETENTION_DAYS}" -delete -print 2>/dev/null | wc -l)" +if [[ "$DELETED" -gt 0 ]]; then echo "[$(date)] Cleaned up $DELETED old backup(s) (>${RETENTION_DAYS} days)" fi diff --git a/deploy/install_ops_cron.sh b/deploy/install_ops_cron.sh new file mode 100644 index 00000000..e46d3486 --- /dev/null +++ b/deploy/install_ops_cron.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Nexus — 安装/更新 host crontab:health_monitor + db_backup +# +# sudo bash deploy/install_ops_cron.sh +# NEXUS_ROOT=/opt/nexus sudo bash deploy/install_ops_cron.sh +set -euo pipefail + +NEXUS_ROOT="${NEXUS_ROOT:-/opt/nexus}" +DEPLOY_DIR="${NEXUS_ROOT}/deploy" +HEALTH_MARKER="nexus-health-monitor" +BACKUP_MARKER="nexus-mysql-backup" +HEALTH_LOG="${NEXUS_HEALTH_LOG:-/var/log/nexus_health.log}" +BACKUP_LOG="${NEXUS_BACKUP_LOG:-/var/log/nexus_backup.log}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } + +if [[ "$(id -u)" -ne 0 ]]; then + error "请使用 root 运行: sudo bash $0" + exit 1 +fi + +for f in "${DEPLOY_DIR}/health_monitor.sh" "${DEPLOY_DIR}/db_backup.sh"; do + if [[ ! -f "$f" ]]; then + error "缺少脚本: $f" + exit 1 + fi + chmod +x "$f" 2>/dev/null || true +done + +touch "$HEALTH_LOG" "$BACKUP_LOG" 2>/dev/null || true +chmod 644 "$HEALTH_LOG" "$BACKUP_LOG" 2>/dev/null || true + +HEALTH_LINE="* * * * * NEXUS_DEPLOY_DIR=${NEXUS_ROOT} ${DEPLOY_DIR}/health_monitor.sh >> ${HEALTH_LOG} 2>&1 # ${HEALTH_MARKER}" +BACKUP_LINE="0 3 * * * NEXUS_ROOT=${NEXUS_ROOT} ${DEPLOY_DIR}/db_backup.sh >> ${BACKUP_LOG} 2>&1 # ${BACKUP_MARKER}" + +existing="$(crontab -l 2>/dev/null || true)" +filtered="$(printf '%s\n' "$existing" | grep -v "$HEALTH_MARKER" | grep -v "$BACKUP_MARKER" | grep -v 'deploy/health_monitor.sh' | grep -v 'deploy/db_backup.sh' || true)" +{ + printf '%s\n' "$filtered" | sed '/^[[:space:]]*$/d' + echo "$HEALTH_LINE" + echo "$BACKUP_LINE" +} | crontab - + +info "已写入 crontab:" +crontab -l | grep -E "$HEALTH_MARKER|$BACKUP_MARKER" || true +info "健康巡检日志: $HEALTH_LOG" +info "MySQL 备份日志: $BACKUP_LOG" diff --git a/deploy/mysql_dump_to_file.sh b/deploy/mysql_dump_to_file.sh new file mode 100644 index 00000000..e91d078e --- /dev/null +++ b/deploy/mysql_dump_to_file.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# Nexus — MySQL dump(Docker 1Panel / 宿主机 mysqldump 通用) +# +# bash deploy/mysql_dump_to_file.sh --output /path/dump.sql [--root /opt/nexus] +# bash deploy/mysql_dump_to_file.sh --output /path/dump.sql --db-url 'mysql+aiomysql://...' +# +# 策略(按序): +# 1. DB host 为运行中容器名 → docker exec 该容器内 mysqldump(连 127.0.0.1) +# 2. 宿主机有 mysqldump 且 host 可解析 → 本机 mysqldump +# 3. 1panel-network 存在 → docker run --rm mysql:8 客户端 +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +NEXUS_ROOT="${NEXUS_ROOT:-/opt/nexus}" +OUTPUT="" +DB_URL="" + +usage() { + cat <<'EOF' +用法: mysql_dump_to_file.sh --output FILE.sql [--root /opt/nexus] [--db-url URL] +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --output|-o) OUTPUT="$2"; shift 2 ;; + --root) NEXUS_ROOT="$2"; shift 2 ;; + --db-url) DB_URL="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "未知参数: $1" >&2; usage; exit 1 ;; + esac +done + +if [[ -z "$OUTPUT" ]]; then + echo "缺少 --output" >&2 + usage + exit 1 +fi + +resolve_db_url() { + if [[ -n "$DB_URL" ]]; then + echo "$DB_URL" + return 0 + fi + local cname url + cname="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -E 'nexus-prod-nexus|nexus-nexus-1' | head -1 || true)" + if [[ -n "$cname" ]]; then + url="$(docker exec "$cname" grep -E '^NEXUS_DATABASE_URL=' /app/.env 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'" || true)" + [[ -n "$url" ]] && echo "$url" && return 0 + fi + for f in \ + "${NEXUS_ROOT}/docker/.env.prod" \ + "${NEXUS_ROOT}/.env" \ + ; do + [[ -f "$f" ]] || continue + url="$(grep -E '^NEXUS_DATABASE_URL=' "$f" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'" || true)" + [[ -n "$url" ]] && echo "$url" && return 0 + done + return 1 +} + +DB_URL="$(resolve_db_url || true)" +if [[ -z "$DB_URL" ]]; then + echo "未找到 NEXUS_DATABASE_URL" >&2 + exit 1 +fi + +python3 - "$DB_URL" "$OUTPUT" <<'PY' +import shutil +import subprocess +import sys +import urllib.parse + + +def docker_names() -> set[str]: + r = subprocess.run( + ["docker", "ps", "--format", "{{.Names}}"], + capture_output=True, + text=True, + check=False, + ) + return {ln.strip() for ln in (r.stdout or "").splitlines() if ln.strip()} + + +def network_exists(name: str) -> bool: + return ( + subprocess.run( + ["docker", "network", "inspect", name], + capture_output=True, + check=False, + ).returncode + == 0 + ) + + +def write_dump(cmd: list[str], env: dict[str, str], out: str) -> int: + r = subprocess.run(cmd, env=env, capture_output=True, text=True) + if r.returncode != 0: + sys.stderr.write(r.stderr or r.stdout or "mysqldump failed\n") + return r.returncode + with open(out, "w", encoding="utf-8") as fh: + fh.write(r.stdout) + return 0 + + +def dump_flags(db: str) -> list[str]: + return [ + "--single-transaction", + "--routines", + "--triggers", + "--set-gtid-purged=OFF", + db, + ] + + +url = sys.argv[1].replace("mysql+aiomysql://", "mysql://", 1) +url = url.replace("mysql+asyncmy://", "mysql://", 1) +out = sys.argv[2] +p = urllib.parse.urlparse(url) +user = urllib.parse.unquote(p.username or "") +password = urllib.parse.unquote(p.password or "") +host = p.hostname or "127.0.0.1" +if host == "host.docker.internal": + host = "127.0.0.1" +port = str(p.port or 3306) +db = (p.path or "/nexus").lstrip("/").split("?")[0] +env = {**__import__("os").environ, "MYSQL_PWD": password} +names = docker_names() + +# 1) MySQL 跑在 Docker 容器内(1Panel 常见:URL host = 容器名) +if host in names: + cmd = [ + "docker", + "exec", + "-e", + f"MYSQL_PWD={password}", + host, + "mysqldump", + "-h", + "127.0.0.1", + "-P", + port, + "-u", + user, + *dump_flags(db), + ] + rc = write_dump(cmd, env, out) + if rc == 0: + sys.exit(0) + sys.stderr.write(f"docker exec {host} mysqldump 失败,尝试其它方式…\n") + +# 2) 宿主机 mysqldump +if shutil.which("mysqldump"): + cmd = [ + "mysqldump", + "-h", + host, + "-P", + port, + "-u", + user, + *dump_flags(db), + ] + rc = write_dump(cmd, env, out) + if rc == 0: + sys.exit(0) + +# 3) 临时 mysql 客户端容器(1panel-network) +for net in ("1panel-network", "bridge"): + if not network_exists(net): + continue + cmd = [ + "docker", + "run", + "--rm", + "--network", + net, + "-e", + f"MYSQL_PWD={password}", + "mysql:8.0", + "mysqldump", + "-h", + host, + "-P", + port, + "-u", + user, + *dump_flags(db), + ] + rc = write_dump(cmd, env, out) + if rc == 0: + sys.exit(0) + +sys.stderr.write("所有 mysqldump 策略均失败(无宿主机客户端且 Docker 回退不可用)\n") +sys.exit(1) +PY diff --git a/deploy/nexus-1panel.sh b/deploy/nexus-1panel.sh index fa09e0a4..d56d3f6e 100755 --- a/deploy/nexus-1panel.sh +++ b/deploy/nexus-1panel.sh @@ -1051,55 +1051,21 @@ show_pending() { } backup_mysql() { - local root="$1" ts file db_url cname + local root="$1" ts file dump_sh if [[ "$NO_BACKUP" == true ]]; then warn "已跳过备份 (--no-backup)" return 0 fi - if ! command -v mysqldump >/dev/null 2>&1; then - warn "未安装 mysqldump,跳过 MySQL 备份" - return 0 - fi - cname="$(docker ps --format '{{.Names}}' | grep -E 'nexus-prod-nexus|nexus-nexus-1' | head -1 || true)" - db_url="" - if [[ -n "$cname" ]]; then - db_url="$(docker exec "$cname" grep -E '^NEXUS_DATABASE_URL=' /app/.env 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'" || true)" - fi - if [[ -z "$db_url" ]]; then - warn "未找到 NEXUS_DATABASE_URL(外置 MySQL 需先完成安装向导),跳过备份" + dump_sh="${root}/deploy/mysql_dump_to_file.sh" + if [[ ! -x "$dump_sh" ]]; then + warn "缺少 $dump_sh,跳过 MySQL 备份" return 0 fi mkdir -p "$NEXUS_BACKUP_DIR" ts="$(date +%Y%m%d-%H%M%S)" file="${NEXUS_BACKUP_DIR}/nexus-before-upgrade-${ts}.sql" - step "备份外置 MySQL → $file" - if ! python3 - "$db_url" "$file" <<'PY' -import sys -import urllib.parse -from subprocess import run - -url = sys.argv[1].replace("mysql+aiomysql://", "mysql://", 1) -out = sys.argv[2] -p = urllib.parse.urlparse(url) -user = urllib.parse.unquote(p.username or "") -password = urllib.parse.unquote(p.password or "") -host = p.hostname or "127.0.0.1" -if host == "host.docker.internal": - host = "127.0.0.1" -port = str(p.port or 3306) -db = (p.path or "/nexus").lstrip("/").split("?")[0] -env = {**__import__("os").environ, "MYSQL_PWD": password} -cmd = [ - "mysqldump", "-h", host, "-P", port, "-u", user, - "--single-transaction", "--routines", "--triggers", "--set-gtid-purged=OFF", db, -] -r = run(cmd, env=env, capture_output=True, text=True) -if r.returncode != 0: - sys.stderr.write(r.stderr or r.stdout or "mysqldump failed\n") - sys.exit(r.returncode) -open(out, "w", encoding="utf-8").write(r.stdout) -PY - then + step "备份 MySQL → $file" + if ! NEXUS_ROOT="$root" bash "$dump_sh" --output "$file" --root "$root"; then if [[ "$REQUIRE_BACKUP" == true ]]; then error "mysqldump 失败,已中止(--require-backup)" rm -f "$file" diff --git a/deploy/nx b/deploy/nx index bf1e1319..db459d32 100755 --- a/deploy/nx +++ b/deploy/nx @@ -220,6 +220,10 @@ ops_backup_now() { backup_mysql "$NEXUS_ROOT" } +ops_install_cron() { + bash "${NX_DIR}/install_ops_cron.sh" +} + ops_update() { local args=() PRUNE_IMAGES=false @@ -265,6 +269,7 @@ menu_main() { [7] 启动 / 停止 Compose 栈 [8] 重建镜像并启动(--build nexus) [9] 备份 MySQL + [e] 安装巡检/备份 cron(health_monitor + db_backup) ── 其它 ── [a] 检查 Git 是否有更新 @@ -338,6 +343,11 @@ EOF ops_backup_now pause_enter ;; + e|E) + require_root + ops_install_cron + pause_enter + ;; a|A) require_root cmd_check @@ -399,6 +409,10 @@ nx_main() { require_root is_stack_installed && ops_status || check_ports_preflight "$NEXUS_ROOT" ;; + cron) + require_root + ops_install_cron + ;; -h|--help) cat <