书接上回,自动回切脚本

· 记录

请将以下内容复制并保存为 /usr/local/bin/cf_failback.sh 文件在您的服务器A上。

请务必替换脚本顶部的所有 [占位符] 为您实际的值。

#!/bin/bash
# --- 配置区域 ---
# 请替换以下所有占位符为您的实际值
CF_API_TOKEN="[Cloudflare API令牌]"
CF_ZONE_ID="[Cloudflare区域ID]"
CF_RECORD_ID="[Cloudflare DNS记录ID]"
DOMAIN_NAME="[您的博客域名]"
PRIMARY_IP="[服务器A的公网IP]" # 恢复后的主服务器IP
SECONDARY_IP="[服务器B的公网IP]" # 临时主服务器IP
TS_IP_A="[服务器A的Tailscale IP]"
TS_IP_B="[服务器B的Tailscale IP]"
TG_BOT_TOKEN="[TG机器人令牌]"
TG_CHAT_ID="[TG频道或用户的ChatID]"
DB_ROOT_PASSWORD="[数据库ROOT密码]"
DB_REPL_PASSWORD="[数据库复制用的密码]"
CUSTOM_DB_PORT_A="[自定义数据库端口A]" # 服务器A的自定义数据库端口
CUSTOM_DB_PORT_B="[自定义数据库端口B]" # 服务器B的自定义数据库端口
SSH_PORT="[自定义SSH端口]"
LOGIN_USER="[您的登录用户名]"
DOCKER_COMPOSE_DIR="/home/$LOGIN_USER/docker/typecho"
DOCKER_COMPOSE_PATH="/home/$LOGIN_USER/docker/typecho/docker-compose.yml"
APP_PATH="/home/$LOGIN_USER/docker/typecho/app/"
MARIADB_CONF_PATH="/home/$LOGIN_USER/docker/typecho/mariadb/conf.d/"

# SSH私钥路径,确保这是[您的登录用户名]在服务器A上的私钥路径
# 通常是 ~/.ssh/id_ed25519 或 ~/.ssh/id_rsa
SSH_PRIVATE_KEY="/home/$LOGIN_USER/.ssh/id_ed25519" 

# --- 脚本行为配置 ---
LOG_FILE="/tmp/cf_failback.log"
DUMP_FILE="/tmp/reverse_definitive_dump.sql" # 临时快照文件

# --- 脚本主体 ---
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"; }
error_exit() {
    log "ERROR: $1"
    send_telegram_notification "❌ **Typecho 回切失败** ❌%0A%0A**错误**: $1%0A%0A请检查服务器A日志 \`$LOG_FILE\` 进行手动恢复。"
    exit 1
}

send_telegram_notification() {
    if [[ -n "$TG_BOT_TOKEN" && -n "$TG_CHAT_ID" ]]; then
        curl -s -o /dev/null -X POST "https://api.telegram.org/bot$TG_BOT_TOKEN/sendMessage" -d chat_id="$TG_CHAT_ID" -d text="$1" -d parse_mode="Markdown"
    fi
}

log "--- 开始自动回切流程 ---"
send_telegram_notification "🔄 **Typecho 正在执行自动回切** 🔄%0A%0A**状态**: 尝试将服务从服务器B切回服务器A。请勿手动操作DNS或数据库。"

# 确保服务器A的MariaDB容器已启动并可访问
log "确保服务器A的MariaDB容器已启动..."
cd "$DOCKER_COMPOSE_DIR" || error_exit "无法进入docker-compose目录: $DOCKER_COMPOSE_DIR"
docker-compose up -d mariadb || error_exit "无法启动服务器A的MariaDB容器"
sleep 5 # 等待容器完全启动

# 1. 在服务器B(临时主库)上,创建带坐标的完美快照并传输到A
log "阶段二:数据反向同步 (B -> A)"
log "在服务器B上暴露数据库端口并创建快照..."

# 临时修改服务器B的docker-compose.yml以暴露数据库端口
# 注意:这里使用EOF块来传递多行命令,并确保变量在远程shell中正确展开
ssh -i "$SSH_PRIVATE_KEY" -p "$SSH_PORT" "$LOGIN_USER"@"$SECONDARY_IP" << EOF_REMOTE_B_DB_EXPOSE
    # 恢复原始docker-compose.yml(如果存在备份),以防之前调试导致文件损坏
    if [ -f "$DOCKER_COMPOSE_PATH.bak" ]; then
        mv "$DOCKER_COMPOSE_PATH.bak" "$DOCKER_COMPOSE_PATH"
    fi
    # 备份当前docker-compose.yml
    cp "$DOCKER_COMPOSE_PATH" "$DOCKER_COMPOSE_PATH.bak"

    # --- 核心修改逻辑:先删除所有ports,再插入正确的ports ---
    # 1. 删除mariadb服务下所有现有的ports块
    # 找到mariadb服务下的ports行,并删除其及后续缩进相同的行,直到缩进减少或遇到下一个顶级key
    # 假设mariadb服务在docker-compose.yml中是2个空格缩进,ports是4个空格缩进,其内容是6个空格缩进
    sed -i '/^  mariadb:/,/^[^ ]/ {
        /^    ports:/,/^      -[^ ]/ {
            /^    ports:/d
            /^      -/d
        }
    }' "$DOCKER_COMPOSE_PATH"

    # 2. 在mariadb服务下插入新的、正确的ports块
    # 插入ports配置,确保缩进正确。mariadb服务下ports应与image、container_name等同级。
    # 假设mariadb服务在docker-compose.yml中是2个空格缩进,ports是4个空格缩进,-是6个空格缩进
    sed -i '/^  mariadb:/a\    ports:\n      - "'"$TS_IP_B"':'"$CUSTOM_DB_PORT_B"':3306"' "$DOCKER_COMPOSE_PATH"
    # --- 核心修改逻辑结束 ---

    cd "$DOCKER_COMPOSE_DIR" || exit 1
    docker-compose up -d mariadb --force-recreate || exit 1

    # 循环等待,直到端口映射在docker-compose ps中显示
    echo "等待MariaDB容器端口映射生效..."
    for i in \$(seq 1 30); do # 最多等待2.5分钟
        CONTAINER_PORTS=\$(docker-compose ps mariadb | grep typecho_db | awk '{print \$NF}')
        if echo "\$CONTAINER_PORTS" | grep -q "$TS_IP_B:$CUSTOM_DB_PORT_B->3306/tcp"; then
            echo "MariaDB容器端口映射已生效: \$CONTAINER_PORTS"
            break
        fi
        echo "等待端口映射... (\$i/30) - 当前端口: \$CONTAINER_PORTS"
        sleep 5
    done
    CONTAINER_PORTS=\$(docker-compose ps mariadb | grep typecho_db | awk '{print \$NF}')
    if ! echo "\$CONTAINER_PORTS" | grep -q "$TS_IP_B:$CUSTOM_DB_PORT_B->3306/tcp"; then
        echo "ERROR: MariaDB容器端口映射未能生效。请手动检查。"
        exit 1
    fi

    sudo ufw allow "$CUSTOM_DB_PORT_B"/tcp || echo "警告: 无法在服务器B上放行防火墙端口,请手动检查。"
EOF_REMOTE_B_DB_EXPOSE
if [ $? -ne 0 ]; then error_exit "无法在服务器B上暴露数据库端口或重启容器。"; fi

# 在服务器B上创建快照
# 明确指定通过TCP连接到容器内部的3306端口
ssh -i "$SSH_PRIVATE_KEY" -p "$SSH_PORT" "$LOGIN_USER"@"$SECONDARY_IP" << EOF_REMOTE_B_SNAPSHOT
    cd "$DOCKER_COMPOSE_DIR" || exit 1
    # 增加额外的等待时间,确保MariaDB服务完全就绪
    echo "等待MariaDB服务内部完全就绪..."
    sleep 15 # 额外等待15秒

    # 移除 --connect-timeout 参数
    docker-compose exec mariadb mysqldump -h 127.0.0.1 -P 3306 -u root -p'$DB_ROOT_PASSWORD' --single-transaction --master-data=2 typecho_blog > "$DUMP_FILE" || exit 1
EOF_REMOTE_B_SNAPSHOT
if [ $? -ne 0 ]; then error_exit "无法在服务器B上创建数据库快照"; fi

# 将快照传输到服务器A
scp -i "$SSH_PRIVATE_KEY" -P "$SSH_PORT" "$LOGIN_USER"@"$SECONDARY_IP":"$DUMP_FILE" "$DUMP_FILE" || error_exit "无法将数据库快照从服务器B传输到服务器A"

# 2. 在服务器B上,执行一次性的文件同步到服务器A
log "在服务器B上执行一次性文件同步到服务器A..."
ssh -i "$SSH_PRIVATE_KEY" -p "$SSH_PORT" "$LOGIN_USER"@"$SECONDARY_IP" "rsync -avz -e 'ssh -i $SSH_PRIVATE_KEY -p $SSH_PORT' --delete --exclude='config.inc.php' \"$APP_PATH\" \"$LOGIN_USER\"@\"$PRIMARY_IP\":\"$APP_PATH\"" || error_exit "无法在服务器B上执行文件同步到服务器A"

# 3. 在服务器A上,清空并恢复数据库
log "在服务器A上清空并恢复数据库..."
docker-compose exec mariadb mysql -u root -p"$DB_ROOT_PASSWORD" -e "STOP SLAVE; DROP DATABASE IF EXISTS typecho_blog; CREATE DATABASE typecho_blog;" || error_exit "无法在服务器A上清空数据库"
cat "$DUMP_FILE" | docker-compose exec -T mariadb mysql -u root -p"$DB_ROOT_PASSWORD" typecho_blog || error_exit "无法在服务器A上从快照恢复数据"
# rm "$DUMP_FILE" # 暂时不删除,后面需要从中提取坐标

# 4. 在服务器A上,使用精确坐标启动同步 (从B同步)
log "在服务器A上配置并启动从服务器B的同步..."
# 从快照文件中提取坐标
MASTER_LOG_FILE_B=$(grep "CHANGE MASTER TO" "$DUMP_FILE" | sed -n "s/.*MASTER_LOG_FILE='\(.*\)'.*/\1/p")
MASTER_LOG_POS_B=$(grep "CHANGE MASTER TO" "$DUMP_FILE" | sed -n "s/.*MASTER_LOG_POS=\([0-9]*\).*/\1/p")

if [ -z "$MASTER_LOG_FILE_B" ] || [ -z "$MASTER_LOG_POS_B" ]; then
    error_exit "无法从快照文件提取MASTER_LOG_FILE和MASTER_LOG_POS信息。"
fi

docker-compose exec mariadb mysql -u root -p"$DB_ROOT_PASSWORD" -e "
    CHANGE MASTER TO
      MASTER_HOST='$TS_IP_B',
      MASTER_USER='repl',
      MASTER_PASSWORD='$DB_REPL_PASSWORD',
      MASTER_PORT=$CUSTOM_DB_PORT_B, # 使用服务器B的端口
      MASTER_LOG_FILE='$MASTER_LOG_FILE_B',
      MASTER_LOG_POS=$MASTER_LOG_POS_B;
    START SLAVE;
" || error_exit "无法在服务器A上配置并启动从服务器B的同步"

rm "$DUMP_FILE" # 此时可以安全删除临时快照文件

log "等待服务器A与服务器B数据完全同步..."
SYNC_STATUS=""
for i in $(seq 1 60); do # 最多等待5分钟 (60 * 5秒)
    SYNC_STATUS=$(docker-compose exec mariadb mysql -u root -p"$DB_ROOT_PASSWORD" -e "SHOW SLAVE STATUS\G" | grep -E 'Slave_IO_Running|Slave_SQL_Running|Seconds_Behind_Master')
    if echo "$SYNC_STATUS" | grep -q "Slave_IO_Running: Yes" && \
       echo "$SYNC_STATUS" | grep -q "Slave_SQL_Running: Yes" && \
       echo "$SYNC_STATUS" | grep -q "Seconds_Behind_Master: 0"; then
        log "服务器A与服务器B数据已完全同步。"
        break
    fi
    log "等待同步中... ($i/60) - 当前状态:\n$SYNC_STATUS"
    sleep 5
done
if ! echo "$SYNC_STATUS" | grep -q "Seconds_Behind_Master: 0"; then
    error_exit "服务器A未能与服务器B完全同步,请手动检查。"
fi

# 阶段三:服务切回 (DNS更新)
log "阶段三:服务切回 (DNS更新)"
log "更新Cloudflare DNS记录指向服务器A的公网IP..."
UPDATE_RESPONSE=$(curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records/$CF_RECORD_ID" \
     -H "Authorization: Bearer $CF_API_TOKEN" \
     -H "Content-Type:application/json" \
     --data '{"type":"A","name":"'$DOMAIN_NAME'","content":"'$PRIMARY_IP'","ttl":60,"proxied":true}')
log "Cloudflare API response: $UPDATE_RESPONSE"
if echo "$UPDATE_RESPONSE" | grep -q '"success":true'; then
    log "Cloudflare DNS已成功更新指向服务器A。"
else
    error_exit "Cloudflare DNS更新失败,请手动检查API响应。"
fi

log "等待DNS生效,建议等待60秒..."
sleep 60

# 阶段四:恢复原始架构 (MariaDB主从关系)
log "阶段四:恢复原始架构 (MariaDB主从关系)"

# 在服务器A上 (恢复Master角色)
log "在服务器A上恢复Master角色..."
# 确保mysql命令执行成功,并捕获其输出
MASTER_STATUS_RAW=$(docker-compose exec mariadb mysql -u root -p"$DB_ROOT_PASSWORD" -e "STOP SLAVE; RESET MASTER; SHOW MASTER STATUS;" 2>&1)
if [ $? -ne 0 ]; then
    error_exit "在服务器A上执行STOP SLAVE; RESET MASTER; SHOW MASTER STATUS; 失败: $MASTER_STATUS_RAW"
fi
log "服务器A SHOW MASTER STATUS 原始输出:\n$MASTER_STATUS_RAW" # 打印原始输出用于调试

# 修正AWK解析逻辑:直接从第二行提取File和Position
# 假设第一行是表头,第二行是数据
MASTER_FILE_A=$(echo "$MASTER_STATUS_RAW" | awk 'NR==2 {print $1}')
MASTER_POS_A=$(echo "$MASTER_STATUS_RAW" | awk 'NR==2 {print $2}')

if [ -z "$MASTER_FILE_A" ] || [ -z "$MASTER_POS_A" ]; then
    error_exit "无法在服务器A上获取新的Master日志坐标。原始输出:\n$MASTER_STATUS_RAW"
fi
log "服务器A新的Master坐标: File=$MASTER_FILE_A, Position=$MASTER_POS_A"

# 在服务器B上 (恢复Slave角色)
log "在服务器B上恢复Slave角色并指向服务器A..."
ssh -i "$SSH_PRIVATE_KEY" -p "$SSH_PORT" "$LOGIN_USER"@"$SECONDARY_IP" << EOF_REMOTE_B_SLAVE_CONFIG
    cd "$DOCKER_COMPOSE_DIR" || exit 1
    docker-compose exec mariadb mysql -u root -p'$DB_ROOT_PASSWORD' -e "
        STOP SLAVE;
        RESET SLAVE ALL;
        SET GLOBAL read_only = ON;
        CHANGE MASTER TO
          MASTER_HOST='$TS_IP_A',
          MASTER_USER='repl',
          MASTER_PASSWORD='$DB_REPL_PASSWORD',
          MASTER_PORT=$CUSTOM_DB_PORT_A, # 使用服务器A的端口
          MASTER_LOG_FILE='$MASTER_FILE_A',
          MASTER_LOG_POS=$MASTER_POS_A;
        START SLAVE;
    " || exit 1
EOF_REMOTE_B_SLAVE_CONFIG
if [ $? -ne 0 ]; then error_exit "无法在服务器B上恢复Slave角色并指向服务器A。"; fi

# 阶段五:收尾与最终验证
log "阶段五:收尾与最终验证"

# 在服务器B上,清理临时数据库端口暴露和防火墙规则
log "在服务器B上清理临时数据库端口暴露和防火墙规则..."
ssh -i "$SSH_PRIVATE_KEY" -p "$SSH_PORT" "$LOGIN_USER"@"$SECONDARY_IP" << EOF_REMOTE_B_CLEANUP
    # 恢复原始docker-compose.yml
    mv "$DOCKER_COMPOSE_PATH.bak" "$DOCKER_COMPOSE_PATH"
    cd "$DOCKER_COMPOSE_DIR" || exit 1
    docker-compose up -d mariadb --force-recreate || exit 1
    sudo ufw deny "$CUSTOM_DB_PORT_B"/tcp || echo "警告: 无法在服务器B上关闭防火墙端口,请手动检查。"
EOF_REMOTE_B_CLEANUP
if [ $? -ne 0 ]; then log "警告: 服务器B清理临时配置失败,请手动检查。"; fi


# 在服务器B上,恢复自动监控
log "在服务器B上恢复自动监控脚本..."
# 使用sed命令直接修改crontab文件,取消注释
# 注意:这里假设crontab文件在/var/spool/cron/crontabs/$LOGIN_USER,如果不是,请调整路径
ssh -i "$SSH_PRIVATE_KEY" -p "$SSH_PORT" "$LOGIN_USER"@"$SECONDARY_IP" "sudo sed -i 's/^#\\(\\* \\* \\* \\* \\* \\/usr\\/local\\/bin\\/cf_failover\\.sh\\)/\\1/' /var/spool/cron/crontabs/$LOGIN_USER" || error_exit "无法在服务器B上恢复自动监控脚本"


log "最终验证:检查服务器B的MariaDB同步状态..."
FINAL_SYNC_STATUS=""
for i in $(seq 1 30); do # 最多等待2.5分钟
    # 在远程执行docker-compose命令前,先cd到正确的目录
    FINAL_SYNC_STATUS=$(ssh -i "$SSH_PRIVATE_KEY" -p "$SSH_PORT" "$LOGIN_USER"@"$SECONDARY_IP" "cd \"$DOCKER_COMPOSE_DIR\" && docker-compose exec mariadb mysql -u root -p'$DB_ROOT_PASSWORD' -e 'SHOW SLAVE STATUS\\G'" | grep -E 'Slave_IO_Running|Slave_SQL_Running|Seconds_Behind_Master')
    
    # 检查grep的输出是否包含预期的状态
    if echo "$FINAL_SYNC_STATUS" | grep -q "Slave_IO_Running: Yes" && \
       echo "$FINAL_SYNC_STATUS" | grep -q "Slave_SQL_Running: Yes" && \
       echo "$FINAL_SYNC_STATUS" | grep -q "Seconds_Behind_Master: 0"; then
        log "服务器B与服务器A数据已完全同步,主从关系恢复正常。"
        break
    fi
    log "最终验证等待同步中... ($i/30) - 当前状态:\n$FINAL_SYNC_STATUS"
    sleep 5
done
if ! echo "$FINAL_SYNC_STATUS" | grep -q "Seconds_Behind_Master: 0"; then
    error_exit "最终验证失败:服务器B未能与服务器A完全同步,请手动检查。"
fi

log "--- 自动回切流程成功完成! ---"
send_telegram_notification "✅ **Typecho 自动回切成功** ✅%0A%0A**状态**: 服务已从服务器B成功切回服务器A,主从关系已恢复。%0A%0A**域名**: \`$DOMAIN_NAME\`%0A%0A系统已恢复正常监控。"
exit 0

自动回切脚本 cf_failback.sh 使用说明:

  1. 保存脚本:将上述完整脚本内容保存为 /usr/local/bin/cf_failback.sh 文件在您的服务器A上。
  2. 替换占位符在保存之前,请务必仔细替换脚本顶部的所有 [占位符] 为您实际的值。
    • CF_API_TOKEN, CF_ZONE_ID, CF_RECORD_ID, DOMAIN_NAME, PRIMARY_IP, SECONDARY_IP, TS_IP_A, TS_IP_B, TG_BOT_TOKEN, TG_CHAT_ID, DB_ROOT_PASSWORD, DB_REPL_PASSWORD, CUSTOM_DB_PORT_A, CUSTOM_DB_PORT_B, SSH_PORT, LOGIN_USER
    • 特别注意 SSH_PRIVATE_KEY 路径,确保它是 [您的登录用户名] 在服务器A上的SSH私钥的正确路径(例如 /home/[您的登录用户名]/.ssh/id_ed25519)。
  3. 授予执行权限: 在服务器A上执行:
    sudo chmod +x /usr/local/bin/cf_failback.sh
    
  4. 前置准备 (在服务器B上): 在运行回切脚本之前,请确保您已在服务器B上完成了以下准备工作:
    • 注释掉 cf_failover.sh 的 cron 任务sudo crontab -e,在 * * * * * /usr/local/bin/cf_failover.sh 前面加上 #
    • 删除故障切换锁文件sudo rm /tmp/failover_active.lock
    • 确保 docker-compose.yml 文件中 mariadb 服务下没有 ports 配置。脚本会动态添加。
    • 确保服务器B上 [您的登录用户名]ufwsed 命令有 NOPASSWD 权限(如文档“环境与防火墙准备”部分所述)。
  5. 执行回切: 当服务器A修复并准备好后,在服务器A上执行:
    sudo /usr/local/bin/cf_failback.sh
    
    脚本将自动完成数据反向同步、DNS切回和主从关系恢复。请密切关注脚本输出和日志文件 /tmp/cf_failback.log
  6. cron任务恢复恢复 cf_failover.sh 的 cron 任务sudo crontab -e,删掉 * * * * * /usr/local/bin/cf_failover.sh 前面的 #

本文作者: 𝓬𝓸𝓵𝓪 🚀
本文链接: http://142.171.55.106:8086/archives/62/
最后修改:
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!