springboot零停机发布方案

环境:阿里云云效 + APISIX + Spring Boot(双节点)

适用场景:单台 ECS(IP: 1.2.3.4)上以 /home/backend/demo-node1/home/backend/demo-node2 目录运行的两个 Spring Boot 服务实例,通过 APISIX 网关做流量灰度与切换,并使用云效流水线实现一键无感发布 / 回滚。


1. 系统架构

                      ┌───────────────┐
                      │  开发 / CI/CD │
                      └───────┬───────┘
                              │ (云效 Pipeline)
                      ┌───────▼───────┐
                      │   云效制品库   │
                      └───────┬───────┘
                              │
            ┌─────────────────▼──────────────────┐
            │ ECS 1.2.3.4 (CentOS 7)       │
            │                                     │
            │  ┌──────────┐       ┌──────────┐    │
            │  │ demo‑node1│       │ demo‑node2│    │
            │  │ :8081    │       │ :8082    │    │
            │  └──────────┘       └──────────┘    │
            │         ▲                  ▲        │
            │         │ (health check)   │        │
            │  ┌──────┴──────────────────┴─────┐  │
            │  │      Apache APISIX (9000)     │  │
            │  └───────────────────────────────┘  │
            └─────────────────────────────────────┘

2. 主机与端口

角色IP端口路径
APISIX 网关1.2.3.49000 / 9180 (dashboard)/usr/local/apisix
Spring Boot node11.2.3.48081/home/backend/demo-node1
Spring Boot node21.2.3.48082/home/backend/demo-node2

3. 部署流水线核心步骤(云效)

构建配置

阿里云云效构建配置

节点1部署配置

阿里云云效节点1配置

节点2部署配置

阿里云云效节点2配置


4. 完整配置与脚本

下面各文件已按原始目录结构列出,内容未做任何改动

安装Docker

点击查看教程

config.yaml

deployment:
  admin:
    admin_key_required: true            # 建议开发后期再打开
    admin_key:
      - name: admin
        role: admin
        key: CKjcGDoqxcJfpIXWigyqjdUSACvwzEJy
    allow_admin:
      - 127.0.0.0/24
      - 172.17.0.0/16                   # Docker 默认桥接
      - 0.0.0.0/0                    # 仅本地演示可全开

apisix-manager.sh

#!/bin/bash

# APISIX 80端口管理脚本

CONTAINER_NAME="apisix-main"
NETWORK_NAME="apisix-quickstart-net"
ETCD_HOST="http://etcd-quickstart:2379"
APISIX_IMAGE="apache/apisix:3.12.0-debian"

# 停止APISIX
stop() {
    echo "停止所有APISIX相关容器..."
    
    # 停止所有可能的APISIX容器名称
    for name in apisix-main apisix-no-auth apisix-quickstart apisix-quickstart-80; do
        if docker ps -a --format "{{.Names}}" | grep -q "^${name}$"; then
            echo "停止容器: $name"
            docker stop $name 2>/dev/null
            docker rm $name 2>/dev/null
        fi
    done
    
    echo "所有APISIX容器已停止"
}

# 启动APISIX (80端口)
start() {
    echo "启动APISIX容器 (80端口)..."
    
    # 先检查容器是否已存在
    if docker ps -a --format "{{.Names}}" | grep -q "^${CONTAINER_NAME}$"; then
        echo "容器 $CONTAINER_NAME 已存在,先删除..."
        docker stop $CONTAINER_NAME 2>/dev/null
        docker rm $CONTAINER_NAME 2>/dev/null
    fi
    
    # 启动新容器
      CONTAINER_ID=$(docker run -d --name $CONTAINER_NAME \
      --network $NETWORK_NAME \
      -v $(pwd)/config.yaml:/usr/local/apisix/conf/config.yaml:ro \
      -p 80:9080 \
      -p 443:9443 \
      -p 9180:9180 \
      -e APISIX_DEPLOYMENT_ETCD_HOST="[\"$ETCD_HOST\"]" \
      $APISIX_IMAGE 2>&1)

    
    if [ $? -eq 0 ]; then
        echo "容器启动成功,ID: ${CONTAINER_ID:0:12}"
        echo "等待服务启动..."
        sleep 5
        
        # 检查容器是否真正运行
        if docker ps --format "{{.Names}}" | grep -q "^${CONTAINER_NAME}$"; then
            echo "APISIX已成功启动在80端口"
        else
            echo "容器启动失败,查看日志:"
            docker logs $CONTAINER_NAME 2>/dev/null | tail -10
        fi
    else
        echo "容器启动失败: $CONTAINER_ID"
    fi
}

# 重启
restart() {
    stop
    sleep 2
    start
}

# 状态检查
status() {
    if docker ps --format "{{.Names}}" | grep -q "^${CONTAINER_NAME}$"; then
        echo "APISIX运行中"
        echo "端口映射:"
        docker port $CONTAINER_NAME
    else
        echo "APISIX未运行"
    fi
}

case "$1" in
    start)
        start
        ;;
    stop)
        stop
        ;;
    restart)
        restart
        ;;
    status)
        status
        ;;
    *)
        echo "用法: $0 {start|stop|restart|status}"
        exit 1
        ;;
esac

apisix-dashboard-manager.sh

#!/bin/bash

# APISIX Dashboard Docker 管理脚本
# 支持安装、启动、停止、卸载功能

# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

# 配置变量
DOCKER_IMAGE="apache/apisix-dashboard"
CONTAINER_NAME="apisix-dashboard"
DASHBOARD_PORT="9000"
CONFIG_DIR="/tmp/apisix-dashboard-config"
CONFIG_FILE="$CONFIG_DIR/conf.yaml"

# 日志函数
log_info() {
    echo -e "${BLUE}[INFO]${NC} $1"
}

log_success() {
    echo -e "${GREEN}[SUCCESS]${NC} $1"
}

log_warning() {
    echo -e "${YELLOW}[WARNING]${NC} $1"
}

log_error() {
    echo -e "${RED}[ERROR]${NC} $1"
}

# 检查Docker是否安装
check_docker() {
    if ! command -v docker &> /dev/null; then
        log_error "Docker 未安装,请先安装 Docker"
        exit 1
    fi
    
    if ! docker info &> /dev/null; then
        log_error "Docker 服务未运行,请启动 Docker 服务"
        exit 1
    fi
}

# 检查etcd是否运行
check_etcd() {
    log_info "检查 etcd 服务状态..."
    
    # 检查APISIX快速安装的etcd容器
    if docker ps --format "{{.Names}}" | grep -q "etcd-quickstart"; then
        log_success "发现 APISIX 快速安装的 etcd 容器正在运行"
        return 0
    fi
    
    # 检查本地etcd服务
    if curl -s http://127.0.0.1:2379/health &> /dev/null; then
        log_success "etcd 服务运行正常"
        return 0
    fi
    
    log_warning "未发现运行中的 etcd 服务"
    log_warning "请确保以下任一条件满足:"
    log_warning "1. APISIX 快速安装脚本已运行(etcd-quickstart 容器存在)"
    log_warning "2. 本地 etcd 服务正在 127.0.0.1:2379 运行"
    
    read -p "是否继续安装?(y/N): " -n 1 -r
    echo
    if [[ ! $REPLY =~ ^[Yy]$ ]]; then
        exit 1
    fi
}

# 生成配置文件
generate_config() {
    log_info "生成配置文件..."
    mkdir -p "$CONFIG_DIR"
    
    # 检测etcd连接地址
    local etcd_endpoint="http://127.0.0.1:2379"
    local admin_api_endpoint="http://127.0.0.1:9180"
    
    # 如果发现APISIX快速安装的etcd容器,使用容器网络连接
    if docker ps --format "{{.Names}}" | grep -q "etcd-quickstart"; then
        log_info "检测到 APISIX 快速安装环境,配置容器网络连接"
        etcd_endpoint="http://etcd-quickstart:2379"
        admin_api_endpoint="http://apisix-quickstart:9180"
    fi
    
    cat > "$CONFIG_FILE" << EOF
conf:
  listen:
    host: 0.0.0.0
    port: 9000
  etcd:
    endpoints:
      - $etcd_endpoint
  log:
    error_log:
      level: warn
      file_path: logs/error.log
    access_log:
      file_path: logs/access.log
authentication:
  secret: secret
  expire_time: 3600
  users:
    - username: admin
      password: admin
plugins:
  - api-breaker
  - authz-keycloak
  - basic-auth
  - batch-requests
  - consumer-restriction
  - cors
  - echo
  - fault-injection
  - grpc-transcode
  - hmac-auth
  - http-logger
  - ip-restriction
  - jwt-auth
  - kafka-logger
  - key-auth
  - limit-conn
  - limit-count
  - limit-req
  - node-status
  - openid-connect
  - prometheus
  - proxy-cache
  - proxy-mirror
  - proxy-rewrite
  - redirect
  - referer-restriction
  - request-id
  - request-validation
  - response-rewrite
  - serverless-post-function
  - serverless-pre-function
  - sls-logger
  - syslog
  - tcp-logger
  - udp-logger
  - uri-blocker
  - wolf-rbac
  - zipkin
  - server-info
  - traffic-split
apisix:
  base_url: $admin_api_endpoint
  api_key: ""
EOF
    
    log_success "配置文件已生成: $CONFIG_FILE"
    log_info "etcd 连接地址: $etcd_endpoint"
    log_info "Admin API 地址: $admin_api_endpoint"
}

# 检查容器状态
check_container_status() {
    if docker ps -q -f name="$CONTAINER_NAME" | grep -q .; then
        echo "running"
    elif docker ps -aq -f name="$CONTAINER_NAME" | grep -q .; then
        echo "stopped"
    else
        echo "not_exists"
    fi
}

# 安装Dashboard
install_dashboard() {
    log_info "开始安装 APISIX Dashboard..."
    
    # 检查是否已经安装
    status=$(check_container_status)
    if [ "$status" != "not_exists" ]; then
        log_warning "Dashboard 容器已存在"
        read -p "是否重新安装?这将删除现有容器 (y/N): " -n 1 -r
        echo
        if [[ $REPLY =~ ^[Yy]$ ]]; then
            uninstall_dashboard
        else
            return 0
        fi
    fi
    
    # 检查依赖
    check_docker
    check_etcd
    
    # 生成配置文件
    generate_config
    
    # 拉取镜像
    log_info "拉取 Docker 镜像..."
    if ! docker pull "$DOCKER_IMAGE"; then
        log_error "拉取镜像失败"
        exit 1
    fi
    
    # 创建并启动容器
    log_info "创建并启动容器..."
    
    # 检测是否需要连接到APISIX网络
    local network_option=""
    if docker ps --format "{{.Names}}" | grep -q "etcd-quickstart"; then
        # 检查apisix-quickstart-net网络是否存在
        if docker network ls --format "{{.Name}}" | grep -q "apisix-quickstart-net"; then
            log_info "连接到 APISIX 快速安装网络"
            network_option="--network apisix-quickstart-net"
        fi
    fi
    
    if docker run -d \
        --name "$CONTAINER_NAME" \
        $network_option \
        -p "$DASHBOARD_PORT:9000" \
        -v "$CONFIG_FILE:/usr/local/apisix-dashboard/conf/conf.yaml" \
        "$DOCKER_IMAGE"; then
        
        log_success "APISIX Dashboard 安装成功!"
        log_info "访问地址: http://127.0.0.1:$DASHBOARD_PORT"
        log_info "默认用户名: admin"
        log_info "默认密码: admin"
    else
        log_error "容器启动失败"
        exit 1
    fi
}

# 启动Dashboard
start_dashboard() {
    log_info "启动 APISIX Dashboard..."
    
    status=$(check_container_status)
    case $status in
        "running")
            log_warning "Dashboard 已经在运行中"
            ;;
        "stopped")
            if docker start "$CONTAINER_NAME"; then
                log_success "Dashboard 启动成功"
                log_info "访问地址: http://127.0.0.1:$DASHBOARD_PORT"
            else
                log_error "Dashboard 启动失败"
                exit 1
            fi
            ;;
        "not_exists")
            log_error "Dashboard 容器不存在,请先安装"
            exit 1
            ;;
    esac
}

# 停止Dashboard
stop_dashboard() {
    log_info "停止 APISIX Dashboard..."
    
    status=$(check_container_status)
    case $status in
        "running")
            if docker stop "$CONTAINER_NAME"; then
                log_success "Dashboard 已停止"
            else
                log_error "停止 Dashboard 失败"
                exit 1
            fi
            ;;
        "stopped")
            log_warning "Dashboard 已经停止"
            ;;
        "not_exists")
            log_error "Dashboard 容器不存在"
            exit 1
            ;;
    esac
}

# 查看状态
show_status() {
    log_info "检查 APISIX Dashboard 状态..."
    
    status=$(check_container_status)
    case $status in
        "running")
            log_success "Dashboard 正在运行"
            echo
            echo "容器信息:"
            docker ps --filter name="$CONTAINER_NAME" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
            echo
            log_info "访问地址: http://127.0.0.1:$DASHBOARD_PORT"
            ;;
        "stopped")
            log_warning "Dashboard 已停止"
            ;;
        "not_exists")
            log_error "Dashboard 容器不存在,请先安装"
            ;;
    esac
}

# 查看日志
show_logs() {
    log_info "显示 APISIX Dashboard 日志..."
    
    status=$(check_container_status)
    if [ "$status" = "not_exists" ]; then
        log_error "Dashboard 容器不存在"
        exit 1
    fi
    
    echo "最近50行日志:"
    docker logs --tail 50 "$CONTAINER_NAME"
    echo
    read -p "按回车键继续..."
}

# 卸载Dashboard
uninstall_dashboard() {
    log_info "卸载 APISIX Dashboard..."
    
    status=$(check_container_status)
    if [ "$status" = "not_exists" ]; then
        log_warning "Dashboard 容器不存在"
        return 0
    fi
    
    read -p "确认卸载 APISIX Dashboard?这将删除容器和配置文件 (y/N): " -n 1 -r
    echo
    if [[ ! $REPLY =~ ^[Yy]$ ]]; then
        log_info "取消卸载"
        return 0
    fi
    
    # 停止并删除容器
    if [ "$status" = "running" ]; then
        docker stop "$CONTAINER_NAME"
    fi
    
    docker rm "$CONTAINER_NAME"
    
    # 删除配置文件
    if [ -d "$CONFIG_DIR" ]; then
        rm -rf "$CONFIG_DIR"
    fi
    
    log_success "APISIX Dashboard 已卸载"
}

# 显示主菜单
show_menu() {
    clear
    echo -e "${BLUE}================================${NC}"
    echo -e "${BLUE}   APISIX Dashboard 管理工具${NC}"
    echo -e "${BLUE}================================${NC}"
    echo
    echo "1. 安装 Dashboard"
    echo "2. 启动 Dashboard"
    echo "3. 停止 Dashboard"
    echo "4. 查看状态"
    echo "5. 查看日志"
    echo "6. 卸载 Dashboard"
    echo "7. 退出"
    echo
}

# 主函数
main() {
    while true; do
        show_menu
        read -p "请选择操作 [1-7]: " choice
        echo
        
        case $choice in
            1)
                install_dashboard
                ;;
            2)
                start_dashboard
                ;;
            3)
                stop_dashboard
                ;;
            4)
                show_status
                ;;
            5)
                show_logs
                ;;
            6)
                uninstall_dashboard
                ;;
            7)
                log_info "退出程序"
                exit 0
                ;;
            *)
                log_error "无效选择,请输入 1-7"
                ;;
        esac
        
        echo
        read -p "按回车键继续..."
    done
}

# 检查是否以root权限运行
if [ "$EUID" -eq 0 ]; then
    log_warning "建议不要以 root 权限运行此脚本"
fi

# 启动主程序
main

apisix_config_manager.sh

#!/bin/bash

# APISIX 配置管理脚本
# 支持一键导出和导入所有配置
# 作者: Manus AI
# 版本: 1.0

# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

# 默认配置
DEFAULT_APISIX_HOST="1.2.3.4"
DEFAULT_APISIX_PORT="9180"
DEFAULT_API_KEY="CKjcGDoqxcJfpIXWigyqjdUSACvwzEJy"

# 配置文件列表
CONFIG_TYPES=(
    "routes"
    "upstreams"
    "services"
    "consumers"
    "ssls"
    "global_rules"
    "plugin_configs"
    "stream_routes"
    "plugins/list"
)

# 配置文件名映射
declare -A CONFIG_FILES=(
    ["routes"]="apisix_routes.json"
    ["upstreams"]="apisix_upstreams.json"
    ["services"]="apisix_services.json"
    ["consumers"]="apisix_consumers.json"
    ["ssls"]="apisix_ssls.json"
    ["global_rules"]="apisix_global_rules.json"
    ["plugin_configs"]="apisix_plugin_configs.json"
    ["stream_routes"]="apisix_stream_routes.json"
    ["plugins/list"]="apisix_plugins.json"
)

# 显示标题
show_header() {
    echo -e "${BLUE}================================================${NC}"
    echo -e "${BLUE}           APISIX 配置管理工具${NC}"
    echo -e "${BLUE}================================================${NC}"
    echo ""
}

# 显示菜单
show_menu() {
    echo -e "${YELLOW}请选择操作:${NC}"
    echo -e "${GREEN}1.${NC} 一键导出所有配置"
    echo -e "${GREEN}2.${NC} 一键导入所有配置"
    echo -e "${GREEN}3.${NC} 退出"
    echo ""
}

# 去除字符串两边的空格
trim_spaces() {
    local var="$1"
    # 去除前导空格
    var="${var#"${var%%[![:space:]]*}"}"
    # 去除尾随空格
    var="${var%"${var##*[![:space:]]}"}"
    echo "$var"
}

# 检查 APISIX 连接
check_apisix_connection() {
    local host="$1"
    local port="$2"
    local api_key="$3"
    
    echo -e "${YELLOW}正在检查 APISIX 连接...${NC}"
    
    local response=$(curl -s -w "%{http_code}" -H "X-API-KEY: $api_key" \
        "http://$host:$port/apisix/admin/routes" -o /dev/null)
    
    if [ "$response" = "200" ]; then
        echo -e "${GREEN}✓ APISIX 连接成功${NC}"
        return 0
    else
        echo -e "${RED}✗ APISIX 连接失败 (HTTP状态码: $response)${NC}"
        echo -e "${RED}请检查服务器地址、端口和API密钥${NC}"
        return 1
    fi
}

# 获取用户输入的配置信息
get_apisix_config() {
    echo -e "${YELLOW}请输入 APISIX 配置信息:${NC}"
    
    read -p "APISIX 服务器地址 [默认: $DEFAULT_APISIX_HOST]: " APISIX_HOST
    APISIX_HOST=$(trim_spaces "${APISIX_HOST:-$DEFAULT_APISIX_HOST}")
    
    read -p "APISIX 端口 [默认: $DEFAULT_APISIX_PORT]: " APISIX_PORT
    APISIX_PORT=$(trim_spaces "${APISIX_PORT:-$DEFAULT_APISIX_PORT}")
    
    read -p "API 密钥 [默认: $DEFAULT_API_KEY]: " API_KEY
    API_KEY=$(trim_spaces "${API_KEY:-$DEFAULT_API_KEY}")
    
    echo ""
}

# 导出配置
export_configs() {
    echo -e "${BLUE}开始导出 APISIX 配置...${NC}"
    echo ""
    
    # 获取配置信息
    get_apisix_config
    
    # 检查连接
    if ! check_apisix_connection "$APISIX_HOST" "$APISIX_PORT" "$API_KEY"; then
        return 1
    fi
    
    # 获取导出目录
    read -p "请输入导出目录路径 [默认: ./apisix_export]: " EXPORT_DIR
    EXPORT_DIR=$(trim_spaces "${EXPORT_DIR:-./apisix_export}")
    
    # 创建导出目录
    if [ ! -d "$EXPORT_DIR" ]; then
        mkdir -p "$EXPORT_DIR"
        echo -e "${GREEN}✓ 创建导出目录: $EXPORT_DIR${NC}"
    fi
    
    echo ""
    echo -e "${YELLOW}正在导出配置文件...${NC}"
    
    # 导出各类配置
    local success_count=0
    local total_count=${#CONFIG_TYPES[@]}
    
    for config_type in "${CONFIG_TYPES[@]}"; do
        local file_name="${CONFIG_FILES[$config_type]}"
        local output_file="$EXPORT_DIR/$file_name"
        
        echo -n "导出 $config_type ... "
        
        local response=$(curl -s -H "X-API-KEY: $API_KEY" \
            "http://$APISIX_HOST:$APISIX_PORT/apisix/admin/$config_type")
        
        if [ $? -eq 0 ] && [ -n "$response" ]; then
            echo "$response" | python3 -m json.tool > "$output_file" 2>/dev/null
            if [ $? -eq 0 ]; then
                echo -e "${GREEN}✓${NC}"
                ((success_count++))
            else
                echo -e "${RED}✗ (JSON格式化失败)${NC}"
            fi
        else
            echo -e "${RED}✗ (请求失败)${NC}"
        fi
    done
    
    echo ""
    
    # 创建配置汇总报告
    create_export_summary "$EXPORT_DIR"
    
    # 创建压缩包
    local timestamp=$(date +%Y%m%d_%H%M%S)
    local archive_name="apisix_config_export_$timestamp.tar.gz"
    
    echo -e "${YELLOW}正在创建压缩包...${NC}"
    cd "$(dirname "$EXPORT_DIR")"
    tar -czf "$archive_name" "$(basename "$EXPORT_DIR")" 2>/dev/null
    
    if [ $? -eq 0 ]; then
        echo -e "${GREEN}✓ 压缩包已创建: $archive_name${NC}"
    fi
    
    echo ""
    echo -e "${GREEN}导出完成! ($success_count/$total_count 个配置文件导出成功)${NC}"
    echo -e "${BLUE}导出目录: $EXPORT_DIR${NC}"
    echo ""
}

# 创建导出汇总报告
create_export_summary() {
    local export_dir="$1"
    local summary_file="$export_dir/export_summary.md"
    
    cat > "$summary_file" << EOF
# APISIX 配置导出报告

## 导出信息
- **导出时间**: $(date '+%Y年%m月%d日 %H:%M:%S')
- **服务器地址**: $APISIX_HOST:$APISIX_PORT
- **导出目录**: $export_dir

## 配置文件列表
EOF
    
    for config_type in "${CONFIG_TYPES[@]}"; do
        local file_name="${CONFIG_FILES[$config_type]}"
        local file_path="$export_dir/$file_name"
        
        if [ -f "$file_path" ]; then
            local file_size=$(du -h "$file_path" | cut -f1)
            echo "- ✓ $file_name ($file_size)" >> "$summary_file"
        else
            echo "- ✗ $file_name (导出失败)" >> "$summary_file"
        fi
    done
    
    cat >> "$summary_file" << EOF

## 使用说明
1. 所有配置文件均为 JSON 格式
2. 可使用本脚本的导入功能恢复配置
3. 建议定期备份配置文件

---
*由 APISIX 配置管理工具自动生成*
EOF
    
    echo -e "${GREEN}✓ 导出汇总报告已创建: export_summary.md${NC}"
}

# 导入配置
import_configs() {
    echo -e "${BLUE}开始导入 APISIX 配置...${NC}"
    echo ""
    
    # 获取配置信息
    get_apisix_config
    
    # 检查连接
    if ! check_apisix_connection "$APISIX_HOST" "$APISIX_PORT" "$API_KEY"; then
        return 1
    fi
    
    # 获取导入目录
    read -p "请输入配置文件目录路径: " IMPORT_DIR
    IMPORT_DIR=$(trim_spaces "$IMPORT_DIR")
    
    if [ -z "$IMPORT_DIR" ]; then
        echo -e "${RED}✗ 目录路径不能为空${NC}"
        return 1
    fi
    
    if [ ! -d "$IMPORT_DIR" ]; then
        echo -e "${RED}✗ 目录不存在: $IMPORT_DIR${NC}"
        return 1
    fi
    
    echo ""
    echo -e "${YELLOW}正在检查配置文件...${NC}"
    
    # 检查配置文件是否存在
    local missing_files=()
    local existing_files=()
    
    for config_type in "${CONFIG_TYPES[@]}"; do
        # 跳过插件列表,因为它是只读的
        if [ "$config_type" = "plugins/list" ]; then
            continue
        fi
        
        local file_name="${CONFIG_FILES[$config_type]}"
        local file_path="$IMPORT_DIR/$file_name"
        
        if [ -f "$file_path" ]; then
            existing_files+=("$config_type:$file_path")
            echo -e "${GREEN}✓${NC} $file_name"
        else
            missing_files+=("$file_name")
            echo -e "${YELLOW}!${NC} $file_name (文件不存在,将跳过)"
        fi
    done
    
    if [ ${#existing_files[@]} -eq 0 ]; then
        echo -e "${RED}✗ 没有找到任何可导入的配置文件${NC}"
        return 1
    fi
    
    echo ""
    echo -e "${YELLOW}警告: 导入操作将覆盖现有配置!${NC}"
    read -p "确认继续导入? (y/N): " confirm
    
    if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
        echo -e "${YELLOW}导入操作已取消${NC}"
        return 0
    fi
    
    echo ""
    echo -e "${YELLOW}正在导入配置...${NC}"
    
    local success_count=0
    local error_count=0
    
    # 导入配置的顺序很重要:先导入upstreams,再导入routes
    local import_order=("upstreams" "services" "consumers" "routes" "ssls" "global_rules" "plugin_configs" "stream_routes")
    
    for config_type in "${import_order[@]}"; do
        local file_name="${CONFIG_FILES[$config_type]}"
        local file_path="$IMPORT_DIR/$file_name"
        
        # 检查文件是否存在
        local found=false
        for item in "${existing_files[@]}"; do
            if [[ "$item" == "$config_type:"* ]]; then
                found=true
                break
            fi
        done
        
        if [ "$found" = false ]; then
            continue
        fi
        
        echo -n "导入 $config_type ... "
        
        # 读取配置文件
        local config_data=$(cat "$file_path")
        
        if [ -z "$config_data" ]; then
            echo -e "${RED}✗ (文件为空)${NC}"
            ((error_count++))
            continue
        fi
        
        # 解析JSON并导入每个配置项
        local items=$(echo "$config_data" | python3 -c "
import json, sys
try:
    data = json.load(sys.stdin)
    if 'list' in data:
        for item in data['list']:
            if 'value' in item and 'key' in item:
                # 提取ID
                key_parts = item['key'].split('/')
                if len(key_parts) > 0:
                    item_id = key_parts[-1]
                    print(f\"{item_id}|||{json.dumps(item['value'])}\")
except:
    pass
")
        
        if [ -z "$items" ]; then
            echo -e "${YELLOW}! (无配置项)${NC}"
            continue
        fi
        
        local item_success=0
        local item_total=0
        
        while IFS='|||' read -r item_id item_value; do
            if [ -n "$item_id" ] && [ -n "$item_value" ]; then
                ((item_total++))
                
                # 发送PUT请求导入配置
                local response=$(curl -s -w "%{http_code}" -X PUT \
                    -H "X-API-KEY: $API_KEY" \
                    -H "Content-Type: application/json" \
                    -d "$item_value" \
                    "http://$APISIX_HOST:$APISIX_PORT/apisix/admin/$config_type/$item_id" \
                    -o /dev/null)
                
                if [[ "$response" =~ ^20[0-9]$ ]]; then
                    ((item_success++))
                fi
            fi
        done <<< "$items"
        
        if [ $item_total -eq 0 ]; then
            echo -e "${YELLOW}! (无配置项)${NC}"
        elif [ $item_success -eq $item_total ]; then
            echo -e "${GREEN}✓ ($item_success/$item_total)${NC}"
            ((success_count++))
        else
            echo -e "${YELLOW}! ($item_success/$item_total)${NC}"
            ((error_count++))
        fi
    done
    
    echo ""
    
    if [ $success_count -gt 0 ]; then
        echo -e "${GREEN}导入完成! ($success_count 个配置类型导入成功)${NC}"
    fi
    
    if [ $error_count -gt 0 ]; then
        echo -e "${YELLOW}注意: $error_count 个配置类型导入时出现问题${NC}"
    fi
    
    echo ""
}

# 主函数
main() {
    show_header
    
    while true; do
        show_menu
        read -p "请输入选项 (1-3): " choice
        
        case $choice in
            1)
                echo ""
                export_configs
                ;;
            2)
                echo ""
                import_configs
                ;;
            3)
                echo -e "${GREEN}感谢使用 APISIX 配置管理工具!${NC}"
                exit 0
                ;;
            *)
                echo -e "${RED}无效选项,请重新选择${NC}"
                echo ""
                ;;
        esac
        
        echo ""
        read -p "按回车键继续..."
        echo ""
    done
}

# 检查依赖
check_dependencies() {
    local missing_deps=()
    
    if ! command -v curl &> /dev/null; then
        missing_deps+=("curl")
    fi
    
    if ! command -v python3 &> /dev/null; then
        missing_deps+=("python3")
    fi
    
    if ! command -v tar &> /dev/null; then
        missing_deps+=("tar")
    fi
    
    if [ ${#missing_deps[@]} -gt 0 ]; then
        echo -e "${RED}错误: 缺少必要的依赖程序:${NC}"
        for dep in "${missing_deps[@]}"; do
            echo -e "${RED}  - $dep${NC}"
        done
        echo ""
        echo -e "${YELLOW}请安装缺少的程序后重新运行脚本${NC}"
        exit 1
    fi
}

# 脚本入口
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    check_dependencies
    main "$@"
fi



apisix_ssl_manager.sh

#!/bin/bash

# APISIX SSL证书管理脚本
# 功能:查看、添加、删除、更新SSL证书配置
# 作者:Manus AI Assistant
# 版本:1.2

# ==================== 配置区域 ====================

# APISIX Admin API配置
APISIX_HOST="1.2.3.4"
APISIX_ADMIN_PORT="9180"
APISIX_API_KEY="CKjcGDoqxcJfpIXWigyqjdUSACvwzEJy"

# API基础URL
API_BASE_URL="http://${APISIX_HOST}:${APISIX_ADMIN_PORT}/apisix/admin"

# 全局变量存储证书列表
declare -a SSL_CERT_IDS
declare -a SSL_CERT_SNIS

# ==================== 工具函数 ====================

# 去除字符串两边空格
trim() {
    local var="$*"
    # 去除前导空格
    var="${var#"${var%%[![:space:]]*}"}"
    # 去除尾随空格
    var="${var%"${var##*[![:space:]]}"}"
    echo "$var"
}

# 安全读取用户输入
read_input() {
    local prompt="$1"
    local var_name="$2"
    local default_value="$3"
    
    if [ -n "$default_value" ]; then
        echo -n "$prompt [$default_value]: "
    else
        echo -n "$prompt: "
    fi
    
    read -r input
    input=$(trim "$input")
    
    if [ -z "$input" ] && [ -n "$default_value" ]; then
        input="$default_value"
    fi
    
    eval "$var_name='$input'"
}

# 检查文件是否存在
check_file() {
    local file_path="$1"
    if [ ! -f "$file_path" ]; then
        echo "❌ 错误:文件不存在 - $file_path"
        return 1
    fi
    return 0
}

# 验证证书文件
validate_cert_file() {
    local cert_file="$1"
    if ! openssl x509 -in "$cert_file" -noout -text >/dev/null 2>&1; then
        echo "❌ 错误:无效的证书文件 - $cert_file"
        return 1
    fi
    return 0
}

# 验证私钥文件
validate_key_file() {
    local key_file="$1"
    if ! openssl rsa -in "$key_file" -check -noout >/dev/null 2>&1; then
        # 尝试其他私钥格式
        if ! openssl pkey -in "$key_file" -check -noout >/dev/null 2>&1; then
            echo "❌ 错误:无效的私钥文件 - $key_file"
            return 1
        fi
    fi
    return 0
}

# 获取证书信息
get_cert_info() {
    local cert_file="$1"
    echo "📋 证书信息:"
    echo "   主题: $(openssl x509 -in "$cert_file" -noout -subject | sed 's/subject=//')"
    echo "   颁发者: $(openssl x509 -in "$cert_file" -noout -issuer | sed 's/issuer=//')"
    echo "   有效期: $(openssl x509 -in "$cert_file" -noout -dates | grep notBefore | sed 's/notBefore=//')  到  $(openssl x509 -in "$cert_file" -noout -dates | grep notAfter | sed 's/notAfter=//')"
    
    # 提取SAN域名
    local san_domains
    san_domains=$(openssl x509 -in "$cert_file" -noout -text | grep -A1 "Subject Alternative Name" | tail -1 | sed 's/DNS://g' | sed 's/,//g' | sed 's/^[[:space:]]*//')
    if [ -n "$san_domains" ]; then
        echo "   支持域名: $san_domains"
    fi
}

# 调用APISIX Admin API
call_api() {
    local method="$1"
    local endpoint="$2"
    local data="$3"
    
    local url="${API_BASE_URL}${endpoint}"
    local curl_cmd="curl -s -H 'X-API-KEY: $APISIX_API_KEY'"
    
    if [ "$method" = "GET" ]; then
        curl_cmd="$curl_cmd '$url'"
    elif [ "$method" = "DELETE" ]; then
        curl_cmd="$curl_cmd -X DELETE '$url'"
    elif [ "$method" = "PUT" ]; then
        curl_cmd="$curl_cmd -X PUT -d '$data' '$url'"
    fi
    
    eval "$curl_cmd"
}

# 获取证书ID通过序号
get_cert_id_by_index() {
    local index="$1"
    if [[ "$index" =~ ^[0-9]+$ ]] && [ "$index" -ge 1 ] && [ "$index" -le "${#SSL_CERT_IDS[@]}" ]; then
        echo "${SSL_CERT_IDS[$((index-1))]}"
        return 0
    else
        return 1
    fi
}

# ==================== 主要功能函数 ====================

# 显示当前SSL证书列表
show_ssl_list() {
    echo "🔍 正在获取SSL证书列表..."
    
    # 清空全局数组
    SSL_CERT_IDS=()
    SSL_CERT_SNIS=()
    
    local response
    response=$(call_api "GET" "/ssls")
    
    if [ $? -ne 0 ]; then
        echo "❌ 错误:无法连接到APISIX Admin API"
        return 1
    fi
    
    # 使用Python解析JSON响应并填充全局数组
    local cert_info
    cert_info=$(echo "$response" | python3 -c "
import json, sys
try:
    data = json.load(sys.stdin)
    if 'list' not in data or len(data['list']) == 0:
        print('EMPTY')
        sys.exit(0)
    
    cert_ids = []
    cert_snis = []
    
    print('📋 当前SSL证书配置:')
    print('=' * 80)
    
    for i, ssl in enumerate(data['list'], 1):
        ssl_data = ssl['value']
        cert_id = ssl_data['id']
        sni_list = ssl_data['snis']
        
        cert_ids.append(cert_id)
        cert_snis.append(','.join(sni_list))
        
        print(str(i) + '. ID: ' + cert_id)
        print('   SNI域名: ' + ', '.join(sni_list))
        
        # 显示过期时间
        if 'validity_end' in ssl_data and ssl_data['validity_end']:
            import datetime
            expire_time = datetime.datetime.fromtimestamp(ssl_data['validity_end'])
            print('   过期时间: ' + expire_time.strftime('%Y-%m-%d %H:%M:%S'))
        else:
            print('   过期时间: 未知')
        
        # 显示更新时间
        if 'update_time' in ssl_data:
            import datetime
            update_time = datetime.datetime.fromtimestamp(ssl_data['update_time'])
            print('   更新时间: ' + update_time.strftime('%Y-%m-%d %H:%M:%S'))
        
        print('   ' + '-' * 60)
    
    # 输出证书ID列表,用于bash数组
    print('CERT_IDS:' + '|'.join(cert_ids))
    print('CERT_SNIS:' + '|'.join(cert_snis))
        
except json.JSONDecodeError:
    print('❌ 错误:API响应格式错误')
except Exception as e:
    print('❌ 错误:' + str(e))
")
    
    if echo "$cert_info" | grep -q "EMPTY"; then
        echo "📝 当前没有配置SSL证书"
        return 0
    fi
    
    if echo "$cert_info" | grep -q "❌ 错误"; then
        echo "$cert_info"
        return 1
    fi
    
    # 解析证书ID和SNI信息到全局数组
    local cert_ids_line cert_snis_line
    cert_ids_line=$(echo "$cert_info" | grep "^CERT_IDS:" | sed 's/CERT_IDS://')
    cert_snis_line=$(echo "$cert_info" | grep "^CERT_SNIS:" | sed 's/CERT_SNIS://')
    
    if [ -n "$cert_ids_line" ]; then
        IFS='|' read -ra SSL_CERT_IDS <<< "$cert_ids_line"
        IFS='|' read -ra SSL_CERT_SNIS <<< "$cert_snis_line"
    fi
    
    # 显示证书信息(去除辅助行)
    echo "$cert_info" | grep -v "^CERT_IDS:" | grep -v "^CERT_SNIS:"
}

# 添加SSL证书
add_ssl_cert() {
    echo "➕ 添加SSL证书"
    echo "=" * 50
    
    # 输入证书ID
    local cert_id
    read_input "请输入证书ID(用于标识此证书)" cert_id
    if [ -z "$cert_id" ]; then
        echo "❌ 证书ID不能为空"
        return 1
    fi
    
    # 输入证书文件路径
    local cert_file
    read_input "请输入证书文件路径(支持.crt, .pem, .cert等格式)" cert_file
    if [ -z "$cert_file" ]; then
        echo "❌ 证书文件路径不能为空"
        return 1
    fi
    
    # 检查证书文件
    if ! check_file "$cert_file" || ! validate_cert_file "$cert_file"; then
        return 1
    fi
    
    # 输入私钥文件路径
    local key_file
    read_input "请输入私钥文件路径(支持.key, .pem等格式)" key_file
    if [ -z "$key_file" ]; then
        echo "❌ 私钥文件路径不能为空"
        return 1
    fi
    
    # 检查私钥文件
    if ! check_file "$key_file" || ! validate_key_file "$key_file"; then
        return 1
    fi
    
    # 显示证书信息
    get_cert_info "$cert_file"
    
    # 输入SNI域名
    echo ""
    echo "请输入要绑定的域名(SNI),多个域名用逗号分隔"
    echo "例如:example.com,*.example.com,api.example.com"
    local sni_input
    read_input "SNI域名" sni_input
    if [ -z "$sni_input" ]; then
        echo "❌ SNI域名不能为空"
        return 1
    fi
    
    # 处理SNI域名列表
    local sni_array=""
    IFS=',' read -ra ADDR <<< "$sni_input"
    for domain in "${ADDR[@]}"; do
        domain=$(trim "$domain")
        if [ -n "$domain" ]; then
            if [ -z "$sni_array" ]; then
                sni_array="\"$domain\""
            else
                sni_array="$sni_array,\"$domain\""
            fi
        fi
    done
    
    # 确认配置
    echo ""
    echo "📋 配置确认:"
    echo "   证书ID: $cert_id"
    echo "   证书文件: $cert_file"
    echo "   私钥文件: $key_file"
    echo "   SNI域名: $sni_input"
    echo ""
    
    local confirm
    read_input "确认添加此SSL证书?(y/N)" confirm "n"
    if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
        echo "❌ 操作已取消"
        return 1
    fi
    
    # 读取证书和私钥内容
    echo "🔄 正在读取证书文件..."
    local cert_content
    cert_content=$(cat "$cert_file" | sed ':a;N;$!ba;s/\n/\\n/g')
    
    echo "🔄 正在读取私钥文件..."
    local key_content
    key_content=$(cat "$key_file" | sed ':a;N;$!ba;s/\n/\\n/g')
    
    # 构建JSON数据
    local json_data="{
        \"cert\": \"$cert_content\",
        \"key\": \"$key_content\",
        \"snis\": [$sni_array]
    }"
    
    # 调用API添加证书
    echo "🔄 正在添加SSL证书..."
    local response
    response=$(call_api "PUT" "/ssls/$cert_id" "$json_data")
    
    if echo "$response" | grep -q "\"key\""; then
        echo "✅ SSL证书添加成功!"
        echo "   证书ID: $cert_id"
        echo "   绑定域名: $sni_input"
    else
        echo "❌ SSL证书添加失败"
        echo "   错误信息: $response"
        return 1
    fi
}

# 删除SSL证书
delete_ssl_cert() {
    echo "🗑️  删除SSL证书"
    echo "=" * 50
    
    # 先显示当前证书列表
    show_ssl_list
    
    if [ ${#SSL_CERT_IDS[@]} -eq 0 ]; then
        echo "没有可删除的SSL证书"
        return 0
    fi
    
    echo ""
    echo "请选择要删除的证书:"
    echo "输入序号 (1-${#SSL_CERT_IDS[@]}) 或直接输入证书ID"
    
    # 输入要删除的证书
    local cert_input
    read_input "证书序号或ID" cert_input
    if [ -z "$cert_input" ]; then
        echo "❌ 输入不能为空"
        return 1
    fi
    
    # 判断输入的是序号还是证书ID
    local cert_id
    if [[ "$cert_input" =~ ^[0-9]+$ ]]; then
        # 输入的是序号
        cert_id=$(get_cert_id_by_index "$cert_input")
        if [ $? -ne 0 ]; then
            echo "❌ 无效的序号: $cert_input"
            return 1
        fi
        echo "选择的证书: $cert_id (序号: $cert_input)"
    else
        # 输入的是证书ID
        cert_id="$cert_input"
        echo "选择的证书: $cert_id"
    fi
    
    # 确认删除
    local confirm
    read_input "确认删除SSL证书 '$cert_id'?(y/N)" confirm "n"
    if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
        echo "❌ 操作已取消"
        return 1
    fi
    
    # 调用API删除证书
    echo "🔄 正在删除SSL证书..."
    local response
    response=$(call_api "DELETE" "/ssls/$cert_id")
    
    if echo "$response" | grep -q "\"deleted\""; then
        echo "✅ SSL证书删除成功!"
        echo "   证书ID: $cert_id"
    else
        echo "❌ SSL证书删除失败"
        echo "   错误信息: $response"
        return 1
    fi
}

# 更新域名绑定的证书
update_domain_cert() {
    echo "🔄 更新域名绑定的证书"
    echo "=" * 50
    
    # 先显示当前证书列表
    show_ssl_list
    
    if [ ${#SSL_CERT_IDS[@]} -eq 0 ]; then
        echo "没有可更新的SSL证书"
        return 0
    fi
    
    echo ""
    echo "请选择要更新的证书:"
    echo "输入序号 (1-${#SSL_CERT_IDS[@]}) 或直接输入证书ID"
    
    # 输入要更新的证书
    local cert_input
    read_input "证书序号或ID" cert_input
    if [ -z "$cert_input" ]; then
        echo "❌ 输入不能为空"
        return 1
    fi
    
    # 判断输入的是序号还是证书ID
    local cert_id
    if [[ "$cert_input" =~ ^[0-9]+$ ]]; then
        # 输入的是序号
        cert_id=$(get_cert_id_by_index "$cert_input")
        if [ $? -ne 0 ]; then
            echo "❌ 无效的序号: $cert_input"
            return 1
        fi
        echo "选择的证书: $cert_id (序号: $cert_input)"
    else
        # 输入的是证书ID
        cert_id="$cert_input"
        echo "选择的证书: $cert_id"
    fi
    
    # 获取当前证书信息
    echo "🔍 正在获取当前证书信息..."
    local current_response
    current_response=$(call_api "GET" "/ssls/$cert_id")
    
    if ! echo "$current_response" | grep -q "\"snis\""; then
        echo "❌ 错误:找不到证书ID '$cert_id'"
        return 1
    fi
    
    # 显示当前SNI信息
    echo "$current_response" | python3 -c "
import json, sys
try:
    data = json.load(sys.stdin)
    ssl_data = data['value']
    print('📋 当前证书信息:')
    print('   证书ID: ' + ssl_data['id'])
    print('   当前SNI域名: ' + ', '.join(ssl_data['snis']))
except:
    print('❌ 错误:无法解析证书信息')
    sys.exit(1)
"
    
    if [ $? -ne 0 ]; then
        return 1
    fi
    
    echo ""
    echo "选择更新方式:"
    echo "1. 更新证书文件(保持域名不变)"
    echo "2. 更新SNI域名(保持证书不变)"
    echo "3. 同时更新证书文件和SNI域名"
    
    local update_type
    read_input "请选择更新方式 (1-3)" update_type
    
    case "$update_type" in
        1)
            update_cert_files "$cert_id" "$current_response"
            ;;
        2)
            update_sni_domains "$cert_id" "$current_response"
            ;;
        3)
            update_cert_and_sni "$cert_id"
            ;;
        *)
            echo "❌ 无效的选择"
            return 1
            ;;
    esac
}

# 更新证书文件
update_cert_files() {
    local cert_id="$1"
    local current_response="$2"
    
    echo "🔄 更新证书文件"
    
    # 输入新的证书文件路径
    local cert_file
    read_input "请输入新的证书文件路径" cert_file
    if [ -z "$cert_file" ] || ! check_file "$cert_file" || ! validate_cert_file "$cert_file"; then
        return 1
    fi
    
    # 输入新的私钥文件路径
    local key_file
    read_input "请输入新的私钥文件路径" key_file
    if [ -z "$key_file" ] || ! check_file "$key_file" || ! validate_key_file "$key_file"; then
        return 1
    fi
    
    # 显示新证书信息
    get_cert_info "$cert_file"
    
    # 获取当前SNI域名
    local current_snis
    current_snis=$(echo "$current_response" | python3 -c "
import json, sys
data = json.load(sys.stdin)
snis = data['value']['snis']
print(','.join(['\"' + sni + '\"' for sni in snis]))
")
    
    # 确认更新
    local confirm
    read_input "确认更新证书文件?(y/N)" confirm "n"
    if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
        echo "❌ 操作已取消"
        return 1
    fi
    
    # 执行更新
    perform_cert_update "$cert_id" "$cert_file" "$key_file" "$current_snis"
}

# 更新SNI域名
update_sni_domains() {
    local cert_id="$1"
    local current_response="$2"
    
    echo "🔄 更新SNI域名"
    
    # 输入新的SNI域名
    local sni_input
    read_input "请输入新的SNI域名(多个域名用逗号分隔)" sni_input
    if [ -z "$sni_input" ]; then
        echo "❌ SNI域名不能为空"
        return 1
    fi
    
    # 处理SNI域名列表
    local sni_array=""
    IFS=',' read -ra ADDR <<< "$sni_input"
    for domain in "${ADDR[@]}"; do
        domain=$(trim "$domain")
        if [ -n "$domain" ]; then
            if [ -z "$sni_array" ]; then
                sni_array="\"$domain\""
            else
                sni_array="$sni_array,\"$domain\""
            fi
        fi
    done
    
    # 获取当前证书内容
    local current_cert current_key
    current_cert=$(echo "$current_response" | python3 -c "
import json, sys
data = json.load(sys.stdin)
print(data['value']['cert'])
")
    current_key=$(echo "$current_response" | python3 -c "
import json, sys
data = json.load(sys.stdin)
print(data['value']['key'])
")
    
    # 确认更新
    local confirm
    read_input "确认更新SNI域名为 '$sni_input'?(y/N)" confirm "n"
    if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
        echo "❌ 操作已取消"
        return 1
    fi
    
    # 构建JSON数据
    local json_data="{
        \"cert\": \"$current_cert\",
        \"key\": \"$current_key\",
        \"snis\": [$sni_array]
    }"
    
    # 执行更新
    echo "🔄 正在更新SNI域名..."
    local response
    response=$(call_api "PUT" "/ssls/$cert_id" "$json_data")
    
    if echo "$response" | grep -q "\"key\""; then
        echo "✅ SNI域名更新成功!"
        echo "   证书ID: $cert_id"
        echo "   新域名: $sni_input"
    else
        echo "❌ SNI域名更新失败"
        echo "   错误信息: $response"
        return 1
    fi
}

# 同时更新证书和SNI
update_cert_and_sni() {
    local cert_id="$1"
    
    echo "🔄 同时更新证书文件和SNI域名"
    
    # 输入新的证书文件路径
    local cert_file
    read_input "请输入新的证书文件路径" cert_file
    if [ -z "$cert_file" ] || ! check_file "$cert_file" || ! validate_cert_file "$cert_file"; then
        return 1
    fi
    
    # 输入新的私钥文件路径
    local key_file
    read_input "请输入新的私钥文件路径" key_file
    if [ -z "$key_file" ] || ! check_file "$key_file" || ! validate_key_file "$key_file"; then
        return 1
    fi
    
    # 显示新证书信息
    get_cert_info "$cert_file"
    
    # 输入新的SNI域名
    local sni_input
    read_input "请输入新的SNI域名(多个域名用逗号分隔)" sni_input
    if [ -z "$sni_input" ]; then
        echo "❌ SNI域名不能为空"
        return 1
    fi
    
    # 处理SNI域名列表
    local sni_array=""
    IFS=',' read -ra ADDR <<< "$sni_input"
    for domain in "${ADDR[@]}"; do
        domain=$(trim "$domain")
        if [ -n "$domain" ]; then
            if [ -z "$sni_array" ]; then
                sni_array="\"$domain\""
            else
                sni_array="$sni_array,\"$domain\""
            fi
        fi
    done
    
    # 确认更新
    echo ""
    echo "📋 更新确认:"
    echo "   证书ID: $cert_id"
    echo "   新证书文件: $cert_file"
    echo "   新私钥文件: $key_file"
    echo "   新SNI域名: $sni_input"
    echo ""
    
    local confirm
    read_input "确认执行更新?(y/N)" confirm "n"
    if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
        echo "❌ 操作已取消"
        return 1
    fi
    
    # 执行更新
    perform_cert_update "$cert_id" "$cert_file" "$key_file" "$sni_array"
}

# 执行证书更新
perform_cert_update() {
    local cert_id="$1"
    local cert_file="$2"
    local key_file="$3"
    local sni_array="$4"
    
    # 读取证书和私钥内容
    echo "🔄 正在读取证书文件..."
    local cert_content
    cert_content=$(cat "$cert_file" | sed ':a;N;$!ba;s/\n/\\n/g')
    
    echo "🔄 正在读取私钥文件..."
    local key_content
    key_content=$(cat "$key_file" | sed ':a;N;$!ba;s/\n/\\n/g')
    
    # 构建JSON数据
    local json_data="{
        \"cert\": \"$cert_content\",
        \"key\": \"$key_content\",
        \"snis\": [$sni_array]
    }"
    
    # 调用API更新证书
    echo "🔄 正在更新SSL证书..."
    local response
    response=$(call_api "PUT" "/ssls/$cert_id" "$json_data")
    
    if echo "$response" | grep -q "\"key\""; then
        echo "✅ SSL证书更新成功!"
        echo "   证书ID: $cert_id"
    else
        echo "❌ SSL证书更新失败"
        echo "   错误信息: $response"
        return 1
    fi
}

# 测试SSL证书
test_ssl_cert() {
    echo "🧪 测试SSL证书"
    echo "=" * 50
    
    # 输入要测试的域名
    local domain
    read_input "请输入要测试的域名" domain
    if [ -z "$domain" ]; then
        echo "❌ 域名不能为空"
        return 1
    fi
    
    echo "🔄 正在测试SSL证书..."
    
    # 测试SSL连接
    echo "1. 测试SSL连接..."
    if echo | timeout 10 openssl s_client -connect "${APISIX_HOST}:443" -servername "$domain" >/dev/null 2>&1; then
        echo "   ✅ SSL连接成功"
    else
        echo "   ❌ SSL连接失败"
    fi
    
    # 获取证书信息
    echo "2. 获取证书信息..."
    local cert_info
    cert_info=$(echo | timeout 10 openssl s_client -connect "${APISIX_HOST}:443" -servername "$domain" 2>/dev/null | openssl x509 -noout -subject -issuer -dates 2>/dev/null)
    
    if [ -n "$cert_info" ]; then
        echo "   ✅ 证书信息获取成功"
        echo "$cert_info" | sed 's/^/      /'
    else
        echo "   ❌ 无法获取证书信息"
    fi
    
    # 测试HTTPS访问
    echo "3. 测试HTTPS访问..."
    local http_status
    http_status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 10 "https://$domain/" 2>/dev/null)
    
    if [ "$http_status" = "200" ] || [ "$http_status" = "301" ] || [ "$http_status" = "302" ]; then
        echo "   ✅ HTTPS访问成功 (HTTP状态码: $http_status)"
    else
        echo "   ⚠️  HTTPS访问异常 (HTTP状态码: $http_status)"
    fi
}

# 显示配置信息
show_config() {
    echo "⚙️  当前配置信息"
    echo "=" * 50
    echo "APISIX主机: $APISIX_HOST"
    echo "Admin端口: $APISIX_ADMIN_PORT"
    echo "API密钥: ${APISIX_API_KEY:0:8}***"
    echo "API地址: $API_BASE_URL"
    echo ""
    
    # 测试API连接
    echo "🔄 测试API连接..."
    local response
    response=$(call_api "GET" "/ssls" 2>/dev/null)
    
    if echo "$response" | grep -q "list"; then
        echo "✅ API连接正常"
    else
        echo "❌ API连接失败"
        echo "   请检查APISIX服务状态和配置"
    fi
}

# ==================== 主菜单 ====================

show_menu() {
    echo ""
    echo "🔐 APISIX SSL证书管理工具"
    echo "=" * 50
    echo "1. 查看SSL证书列表"
    echo "2. 添加SSL证书"
    echo "3. 删除SSL证书"
    echo "4. 更新域名绑定的证书"
    echo "5. 测试SSL证书"
    echo "6. 显示配置信息"
    echo "0. 退出"
    echo "=" * 50
}

# 主程序
main() {
    # 检查依赖
    if ! command -v curl >/dev/null 2>&1; then
        echo "❌ 错误:需要安装curl"
        exit 1
    fi
    
    if ! command -v openssl >/dev/null 2>&1; then
        echo "❌ 错误:需要安装openssl"
        exit 1
    fi
    
    if ! command -v python3 >/dev/null 2>&1; then
        echo "❌ 错误:需要安装python3"
        exit 1
    fi
    
    # 显示欢迎信息
    echo "🔐 APISIX SSL证书管理工具 v1.2"
    echo "当前配置 - 主机: $APISIX_HOST, 端口: $APISIX_ADMIN_PORT"
    
    # 主循环
    while true; do
        show_menu
        
        local choice
        read_input "请选择操作" choice
        
        case "$choice" in
            1)
                show_ssl_list
                ;;
            2)
                add_ssl_cert
                ;;
            3)
                delete_ssl_cert
                ;;
            4)
                update_domain_cert
                ;;
            5)
                test_ssl_cert
                ;;
            6)
                show_config
                ;;
            0)
                echo "👋 再见!"
                exit 0
                ;;
            *)
                echo "❌ 无效的选择,请重新输入"
                ;;
        esac
        
        echo ""
        read -p "按回车键继续..." -r
    done
}

# 运行主程序
main "$@"

apisix_export/apisix_consumers.json

{
    "total": 0,
    "list": []
}

apisix_export/apisix_global_rules.json

{
    "total": 1,
    "list": [
        {
            "value": {
                "plugins": {
                    "prometheus": {
                        "_meta": {
                            "disable": false
                        }
                    }
                },
                "create_time": 1750493067,
                "id": "1",
                "update_time": 1750493067
            },
            "modifiedIndex": 351,
            "createdIndex": 351,
            "key": "/apisix/global_rules/1"
        }
    ]
}

apisix_export/apisix_plugins.json

[
    "real-ip",
    "ai",
    "client-control",
    "proxy-control",
    "request-id",
    "zipkin",
    "ext-plugin-pre-req",
    "fault-injection",
    "mocking",
    "serverless-pre-function",
    "cors",
    "ip-restriction",
    "ua-restriction",
    "referer-restriction",
    "csrf",
    "uri-blocker",
    "request-validation",
    "chaitin-waf",
    "multi-auth",
    "openid-connect",
    "cas-auth",
    "authz-casbin",
    "authz-casdoor",
    "wolf-rbac",
    "ldap-auth",
    "hmac-auth",
    "basic-auth",
    "jwt-auth",
    "jwe-decrypt",
    "key-auth",
    "consumer-restriction",
    "attach-consumer-label",
    "forward-auth",
    "opa",
    "authz-keycloak",
    "proxy-cache",
    "body-transformer",
    "ai-prompt-guard",
    "ai-prompt-template",
    "ai-prompt-decorator",
    "ai-rag",
    "ai-aws-content-moderation",
    "ai-proxy-multi",
    "ai-proxy",
    "ai-rate-limiting",
    "proxy-mirror",
    "proxy-rewrite",
    "workflow",
    "api-breaker",
    "limit-conn",
    "limit-count",
    "limit-req",
    "gzip",
    "server-info",
    "traffic-split",
    "redirect",
    "response-rewrite",
    "degraphql",
    "kafka-proxy",
    "grpc-transcode",
    "grpc-web",
    "http-dubbo",
    "public-api",
    "prometheus",
    "datadog",
    "loki-logger",
    "elasticsearch-logger",
    "echo",
    "loggly",
    "http-logger",
    "splunk-hec-logging",
    "skywalking-logger",
    "google-cloud-logging",
    "sls-logger",
    "tcp-logger",
    "kafka-logger",
    "rocketmq-logger",
    "syslog",
    "udp-logger",
    "file-logger",
    "clickhouse-logger",
    "tencent-cloud-cls",
    "inspect",
    "example-plugin",
    "aws-lambda",
    "azure-functions",
    "openwhisk",
    "openfunction",
    "serverless-post-function",
    "ext-plugin-post-req",
    "ext-plugin-post-resp"
]

apisix_export/apisix_plugin_configs.json

{
    "total": 0,
    "list": []
}

apisix_export/apisix_routes.json

{
    "total": 1,
    "list": [
        {
            "value": {
                "create_time": 1750424231,
                "upstream_id": "java-app-upstream",
                "status": 1,
                "host": "csapi.twenhub.com",
                "desc": "\u901a\u7528\u8def\u7531 - \u5904\u7406\u6240\u6709\u8bf7\u6c42",
                "priority": 1,
                "methods": [
                    "GET",
                    "POST",
                    "PUT",
                    "DELETE",
                    "OPTIONS",
                    "HEAD",
                    "PATCH"
                ],
                "name": "universal-route",
                "update_time": 1750666631,
                "id": "00000000000000000053",
                "uri": "/*"
            },
            "modifiedIndex": 561,
            "createdIndex": 54,
            "key": "/apisix/routes/00000000000000000053"
        }
    ]
}

apisix_export/apisix_services.json

{
    "total": 0,
    "list": []
}

apisix_export/apisix_ssls.json

{
    "total": 1,
    "list": [
        {
            "value": {
                "snis": [
                    "*.twenhub.com",
                    "twenhub.com",
                    "csapi.twenhub.com",
                    "csh5.twenhub.com"
                ],
                "create_time": 1750670207,
                "type": "server",
                "status": 1,
                "update_time": 1750670207,
                "cert": "-----BEGIN CERTIFICATE-----\nMIIGYjCCBMqgAwIBAgIQJYW/rQxALl6fxyeb7bQInzANBgkqhkiG9w0BAQsFADBK\nMQswCQYDVQQGEwJVUzEUMBIGA1UECgwLTGVvY2VydCBMTEMxJTAjBgNVBAMMHExl\nb2NlcnQgVExTIElzc3VpbmcgUlNBIENBIDEwHhcNMjUwNjAyMDYxMTQyWhcNMjYw\nNzAzMDYxMTQyWjAYMRYwFAYDVQQDDA0qLnR3ZW5odWIuY29tMIIBIjANBgkqhkiG\n9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1xBi+eGhG5rVlFF8Mn7cYZUpkJscobgsFwqQ\na2VRKngu9ib2lcJ3yT+w4Vv6E2Ua6rKZwn0BcYjQVVyiZUZX3D56kCAeRf9PdS+m\nIAyhHbS/acSOUH6ggXarkIDzOvGDyaCR9Q9c9Jm+gvorfyyIFARnnQWopq0CRiMb\nBO+qfvex/EUHwlBSh50hK9Tntq+n0UEGzFae1DR+oFp4CGxkoXsRSRFv5aVGeQPj\nVri8+n7k9ZzxZwtls3iHS47wv+gqIRs/rs+N98JxCrkLrzkWzpDTR698yfevhsvX\niBVCia6wCkF/PC3k7tSVAbZOaMoummsXb0F5BByk6nC456AZLwIDAQABo4IC9DCC\nAvAwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBQX2dPy0GnoHje3+VY6B+Yp05Vb\nkTBmBggrBgEFBQcBAQRaMFgwNAYIKwYBBQUHMAKGKGh0dHA6Ly9jZXJ0LnNzbC5j\nb20vTGVvY2VydC1UTFMtSS1SMS5jZXIwIAYIKwYBBQUHMAGGFGh0dHA6Ly9vY3Nw\ncy5zc2wuY29tMCUGA1UdEQQeMByCDSoudHdlbmh1Yi5jb22CC3R3ZW5odWIuY29t\nMCMGA1UdIAQcMBowCAYGZ4EMAQIBMA4GDCsGAQQBgqkwAQMBATAdBgNVHSUEFjAU\nBggrBgEFBQcDAgYIKwYBBQUHAwEwOQYDVR0fBDIwMDAuoCygKoYoaHR0cDovL2Ny\nbHMuc3NsLmNvbS9MZW9jZXJ0LVRMUy1JLVIxLmNybDAdBgNVHQ4EFgQUyQqQkcAW\nlwmp6/HI8MqaixgjnjkwDgYDVR0PAQH/BAQDAgWgMIIBgAYKKwYBBAHWeQIEAgSC\nAXAEggFsAWoAdwDLOPcViXyEoURfW8Hd+8lu8ppZzUcKaQWFsMsUwxRY5wAAAZcv\nTZlUAAAEAwBIMEYCIQCCUbAmZ57pq5mXPNt0Z26O91cTA0i13vn/sRNt8xQKMQIh\nAK0KUqNMmI7nqe64SKdeptYzfBLzyv2FBlcfmDABevMSAHcAlE5Dh/rswe+B8xkk\nJqgYZQHH0184AgE/cmd9VTcuGdgAAAGXL02ZaAAABAMASDBGAiEAtpxdjDjgByRb\nTmCPyQzC4DkDfbXGuH38rTuQ+scPDVsCIQChIQBHPt1k3ab96GXoifBJQHkDEBw/\nigw4vhUIVEoJigB2ANgJVTuUT3r/yBYZb5RPhauw+Pxeh1UmDxXRLnK7RUsUAAAB\nly9NmT4AAAQDAEcwRQIgb7Va+NWKhA6vxz2cEOP5u5opzfIXe9cRQ5qbbSfZfrMC\nIQDHP8WQdIOJdgADWgfRymgzJL60EVS5oy0kPPIaWBHkmjANBgkqhkiG9w0BAQsF\nAAOCAYEAJ9IKAhFG+EAGHs42t4sqvED6YkL0EwUTqRm+B5AcCOc2dh22pCvrPqVY\nKSzQDm/Aa2oIdH7ZrAYP9NDTufyvwJFYALWaeZvkIogid1ukcXQblwgWZ9byOv+/\nCPqiC5iriw2fDuAJVOtXpi7wOkFXUYCGxq5ZEL6fXzHxRYTTDN42hRISn/4/coJX\nH8O/0fV+7XvW/ZDG9QebFtG/3IbFsRUh6EobweOCXOYUYYW1kZuHeCyN/9BVl8LS\nt1WyoW1gumeBeJtCoMOoTJ4LfGjr49BhmoNvZYJApzUXk4PrW2i+SNmk2tJEcqee\nY+SFecihv94sMOx2NLdBmndvRoaQL1n08QjcjC1LjH+IUs72pscFXT62vquaQf1D\nMsZdwoa4dcy9s89CihDyNTWlJQr/ZOfuBRP1Tp1p0RPOKNFl9DS33eAr4kTzwWxv\nbOrdZDWdcAgQDYePJP+91agQwYXQszt6bhyneKm3kGMj2qHtLb1vhjT0dv0nrMTP\n7/wyebJK\n-----END CERTIFICATE-----\r\n-----BEGIN CERTIFICATE-----\n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\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIFfTCCBGWgAwIBAgIQUl2fDZZ6EH1gqmdbOsHpyDANBgkqhkiG9w0BAQsFADB7\nMQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD\nVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE\nAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTI0MDYyMTAwMDAwMFoXDTI4\nMTIzMTIzNTk1OVowTzELMAkGA1UEBhMCVVMxGDAWBgNVBAoMD1NTTCBDb3Jwb3Jh\ndGlvbjEmMCQGA1UEAwwdU1NMLmNvbSBUTFMgVHJhbnNpdCBSU0EgQ0EgUjIwggIi\nMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC6lZ8Z2WYAqAXZr43sbFCdaz+5\nMU8liFkrqzD5CcOL8AOaKtB1Ggx5t8GjUibUAYvuCNXeUzd4awBM4ARRoWp8xzRI\n+AG9YxUH5lWtsmyhfyW4E7VvpRpi5s3tOyQVv8rCpdNB0R/HYM4+wPxigadbL2Cc\n46fTcbeV2MBPOc9lyVJASewIXQQhI0+CsKOVA4AD3Cj27QhnpvJONchn+hVgvTTD\nVSkmlrvaFjH3KPzEJsRIiv6Wyc1bpkDyV6+bPzkzXtntXhdXb/RrAcky7ldxN5I0\nxZd2ESfMUU6yQbqkIfAaBj1Uxd7y780ir5beWijPQU1VsU4NcEpxwQ8CkcFK7Gos\nXIAOS1XhFbbO7QpfF6Nxe8QGAcC5Dx38f5vubR3PMheivltQSxlGJOhMlidqxehx\nbaxH47wRQoT2p/3MXHBDydUetOwJLlRxct6ms9px9qD8ubnUX4TmUc88tfWzFM8t\nWrKNrhQjo/GB/585rBv+MZ+8JwjySCjY0Tiy4WcwBo3sacCyXXWGoJH2hoq2QbVH\nW0QpQLSQ2wXClnyo6juVXRW++ZwtCu2MOx5xnadnqD2u/2fH+EAHsCplAtICVR8j\n/GeFQGWbdPSXTUrSy0OKm9BiQjJ9GCw4eUZa8zYbOKnsFH+2NtZN4pYLP3M7wS8I\nWceGUEkSP5Izm+qWZwIDAQABo4IBJzCCASMwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs\n4q8p74Klf9AwpLQwHQYDVR0OBBYEFCFBNGOOHZEMw922JLCoh3uNZWAIMA4GA1Ud\nDwEB/wQEAwIBhjASBgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdJQQWMBQGCCsGAQUF\nBwMBBggrBgEFBQcDAjAjBgNVHSAEHDAaMAgGBmeBDAECATAOBgwrBgEEAYKpMAED\nAQEwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB\nQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF\nBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB\nACZcAf5T5xGRWOoV+HvXXdj8P61F5OxDxY/lC/6feUgFY8qUO9yPipfxlDOgelM0\nYgFkHK4GLCuIKgfP+QpREuKMkJP6k4tylKAEdr+DdFgwHJEWwbPFg/sZHYWzUPEN\nc1ypStXWHXOXlk/fbIylocSSbqN9oRM50+YKeLFy6+1jK12QJVJKMiLYKxiRvhCa\nWyqLhFsVGeNNgbOdrkjRBNTYSv8s2qJA2xZSYF1bkeo3bK94qx/FTbwCOigSsK6y\neLeb585rLBNiO5MhPTY85Cbb8aZfkUryrqJoy993BoyKp9YDiM8QA3Mome64gxVZ\nFNKy3ieVQFNDMoHK9DZgfLk=\n-----END CERTIFICATE-----",
                "id": "1",
                "key": "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"
            },
            "modifiedIndex": 569,
            "createdIndex": 569,
            "key": "/apisix/ssls/1"
        }
    ]
}

apisix_export/apisix_stream_routes.json

{
    "error_msg": "stream mode is disabled, can not add stream routes"
}

apisix_export/apisix_upstreams.json

{
    "total": 1,
    "list": [
        {
            "value": {
                "create_time": 1750395883,
                "retries": 2,
                "scheme": "http",
                "nodes": {
                    "172.18.0.1:8080": 1,
                    "172.18.0.1:8081": 1
                },
                "timeout": {
                    "send": 300,
                    "read": 300,
                    "connect": 5
                },
                "type": "least_conn",
                "retry_timeout": 60,
                "keepalive_pool": {
                    "size": 320,
                    "idle_timeout": 30,
                    "requests": 50
                },
                "checks": {
                    "active": {
                        "http_path": "/health",
                        "https_verify_certificate": true,
                        "healthy": {
                            "interval": 3,
                            "successes": 3,
                            "http_statuses": [
                                200,
                                302
                            ]
                        },
                        "unhealthy": {
                            "http_statuses": [
                                429,
                                404,
                                500,
                                501,
                                502,
                                503,
                                504,
                                505
                            ],
                            "http_failures": 3,
                            "tcp_failures": 3,
                            "timeouts": 2,
                            "interval": 3
                        },
                        "timeout": 5,
                        "type": "http",
                        "concurrency": 3
                    },
                    "passive": {
                        "unhealthy": {
                            "tcp_failures": 3,
                            "timeouts": 2,
                            "http_statuses": [
                                429,
                                500,
                                503
                            ],
                            "http_failures": 3
                        },
                        "type": "http",
                        "healthy": {
                            "http_statuses": [
                                200,
                                201,
                                202,
                                203,
                                204,
                                205,
                                206,
                                207,
                                208,
                                226,
                                300,
                                301,
                                302,
                                303,
                                304,
                                305,
                                306,
                                307,
                                308
                            ],
                            "successes": 2
                        }
                    }
                },
                "pass_host": "pass",
                "hash_on": "vars",
                "update_time": 1750653386,
                "id": "java-app-upstream",
                "name": "java-app-upstream"
            },
            "modifiedIndex": 551,
            "createdIndex": 16,
            "key": "/apisix/upstreams/java-app-upstream"
        }
    ]
}

apisix_export/export_summary.md

# APISIX 配置导出报告

## 导出信息
- **导出时间**: 2025年06月24日 09:50:33
- **服务器地址**: 1.2.3.4:9180
- **导出目录**: ./apisix_export

## 配置文件列表
- ✓ apisix_routes.json (4.0K)
- ✓ apisix_upstreams.json (4.0K)
- ✓ apisix_services.json (4.0K)
- ✓ apisix_consumers.json (4.0K)
- ✓ apisix_ssls.json (12K)
- ✓ apisix_global_rules.json (4.0K)
- ✓ apisix_plugin_configs.json (4.0K)
- ✓ apisix_stream_routes.json (4.0K)
- ✓ apisix_plugins.json (4.0K)

## 使用说明
1. 所有配置文件均为 JSON 格式
2. 可使用本脚本的导入功能恢复配置
3. 建议定期备份配置文件

---
*由 APISIX 配置管理工具自动生成*

ecs/demo-node1/deploy.sh

#!/usr/bin/env bash
#
# zero-deploy.sh - 零停机部署脚本(CentOS优化版 2025‑06‑A)
#
# 关键改动
#   1) 停止操作不再依赖jar文件,只需端口号
#   2) jar文件参数在停止操作时为可选
#   3) 针对CentOS环境优化进程检测
#   4) 单节点 PATCH,完全符合 APISIX Admin API 最佳实践
# -------------------------------------------------------------------

set -euo pipefail

####################### 可调参数 #####################################
BASE_DIR="$(dirname "$(readlink -f "$0")")"
LOG_DIR="$BASE_DIR/logs"

LOG_LEVEL="${LOG_LEVEL:-INFO}"                   # INFO | DEBUG

PORT1=8080 ; PROFILE1="prod-node1"
PORT2=8081 ; PROFILE2="prod-node2"

HEALTH_PATH="/health"
HEALTH_TIMEOUT=60
APP_START_TIMEOUT_SECONDS=$HEALTH_TIMEOUT

KEEPALIVE_IDLE_TIMEOUT=30                        # 与 upstream.keepalive_pool.idle_timeout 对齐
IDLE_TIMEOUT_BUFFER=$((KEEPALIVE_IDLE_TIMEOUT*2))
SHUTDOWN_TIMEOUT_SECONDS=60                    # 优雅停机超时时间(秒)

JAR_LINK="app.jar"
UPSTREAM_ID="java-app-upstream"
ADMIN_URL="http://127.0.0.1:9180/apisix/admin/upstreams/$UPSTREAM_ID"

HOST="172.18.0.1"                                # 可用 `ip route get 1.1.1.1 | awk '{print $7}'`
AUTH_HEADER=(-H "X-API-KEY: CKjcGDoqxcJfpIXWigyqjdUSACvwzEJy") # 请替换
########################################################################

mkdir -p "$LOG_DIR"

###################### 通用函数 #######################################
# 检查必要工具是否存在
check_tools() {
  local missing_tools=()
  
  # 检查必要的命令
  command -v curl >/dev/null || missing_tools+=("curl")
  command -v jq >/dev/null || missing_tools+=("jq")
  
  # 对于停止操作,检查进程查找工具
  if [[ "$1" =~ ^(2|4)$ ]]; then
    if ! command -v lsof >/dev/null && ! command -v ss >/dev/null && ! command -v netstat >/dev/null; then
      missing_tools+=("lsof或ss或netstat")
    fi
  fi
  
  if [[ ${#missing_tools[@]} -gt 0 ]]; then
    echo "错误: 缺少必要工具: ${missing_tools[*]}" >&2
    echo "在CentOS上安装: yum install -y lsof jq curl" >&2
    exit 1
  fi
}

# API 调用(带重试)
api() {
  local method="GET" url data attempt=1 max=3 delay=1
  while [[ $# -gt 0 ]]; do
    case $1 in
      -X) method="$2"; shift 2;;
      -d) data="$2";  shift 2;;
      http*) url="$1"; shift;;
      *) shift;;
    esac
  done
  while :; do
    [[ $LOG_LEVEL == "DEBUG" ]] && echo "API attempt $attempt: $method $url"
    local rsp http_code body
    if [[ -n ${data:-} ]]; then
      rsp=$(curl -s -w '\n%{http_code}' --fail "${AUTH_HEADER[@]}" -X "$method" \
              -H 'Content-Type: application/json' -d "$data" "$url" 2>&1) || true
    else
      rsp=$(curl -s -w '\n%{http_code}' --fail "${AUTH_HEADER[@]}" -X "$method" \
              "$url" 2>&1) || true
    fi
    http_code=$(echo "$rsp" | tail -n1)
    body=$(echo "$rsp" | head -n -1)
    [[ $LOG_LEVEL == "DEBUG" ]] && echo "Response code: $http_code"

    if [[ $http_code =~ ^2[0-9][0-9]$ ]]; then
      [[ $LOG_LEVEL == "DEBUG" ]] && echo "HTTP $method $url -> $http_code SUCCESS"
      [[ -n $body && $LOG_LEVEL == "DEBUG" ]] && echo "Response body: $body"
      echo "$body"
      return 0
    fi
    if (( attempt >= max )); then
      echo "HTTP $method $url FAILED after $attempt attempts, code=$http_code, body=$body"
      return 1
    fi
    echo "HTTP $method $url attempt $attempt failed, retry after ${delay}s..."
    sleep $delay
    ((attempt++))
    delay=$((delay*2))
  done
}

# 获取当前 upstream.nodes
get_current_nodes() {
  api -X GET "$ADMIN_URL" | jq -r '.value.nodes // {}'
}

###################### 节点操作 #######################################
add_node() { # add_node host:port [weight]
  local node="$1" weight="${2:-0}"
  local nodes=$(get_current_nodes)
  if echo "$nodes" | jq -e --arg n "$node" 'has($n)' >/dev/null; then
    echo "Node $node already exists"
    return 0
  fi
  local patch=$( jq -n --arg node "$node" --argjson w "$weight" '{nodes:{($node):$w}}')
  api -X PATCH -d "$patch" "$ADMIN_URL"
  echo "Node $node added with weight=$weight"
}

update_node_weight() { # update_node_weight host:port weight
  local node="$1" weight="$2"
  [[ $LOG_LEVEL == "DEBUG" ]] && echo "update_node_weight: node=$node, weight=$weight"
  [[ $LOG_LEVEL == "DEBUG" ]] && echo "Creating patch..."
  local patch=$( jq -n --arg node "$node" --argjson w "$weight" '{nodes:{($node):$w}}')
  [[ $LOG_LEVEL == "DEBUG" ]] && echo "Calling API: PATCH $ADMIN_URL with patch: $patch"
  api -X PATCH -d "$patch" "$ADMIN_URL" >/dev/null
  [[ $LOG_LEVEL == "DEBUG" ]] && echo "API call completed"
  echo "Node $node weight updated -> $weight"
}

remove_node() { # remove_node host:port
  local node="$1"
  local patch=$( jq -n --arg node "$node" '{nodes:{($node):null}}')
  api -X PATCH -d "$patch" "$ADMIN_URL"
  echo "Node $node removed from upstream"
}

###################### 进程检测工具 ###################################
# CentOS兼容的端口进程检测
get_pid_by_port() {
  local port="$1"
  local pid=""
  
  # 优先使用lsof
  if command -v lsof >/dev/null; then
    pid=$(lsof -ti tcp:"$port" -sTCP:LISTEN 2>/dev/null || true)
  # 备用使用ss (较新的CentOS)
  elif command -v ss >/dev/null; then
    pid=$(ss -tlnp | grep ":$port " | sed 's/.*pid=\([0-9]*\).*/\1/' | head -n1 || true)
  # 最后使用netstat
  elif command -v netstat >/dev/null; then
    pid=$(netstat -tlnp 2>/dev/null | grep ":$port " | awk '{print $7}' | cut -d'/' -f1 | head -n1 || true)
  fi
  
  # 验证PID是否有效
  if [[ -n $pid && $pid =~ ^[0-9]+$ ]]; then
    if kill -0 "$pid" 2>/dev/null; then
      echo "$pid"
      return 0
    fi
  fi
  
  return 1
}

# 检查端口是否被占用
is_port_in_use() {
  local port="$1"
  get_pid_by_port "$port" >/dev/null
}

###################### 健康检查工具 ###################################
health_check() {
  local url="$1"
  local timeout="${2:-$APP_START_TIMEOUT_SECONDS}"
  local exptime=0
  
  echo "开始健康检查: $url"
  while true; do
    status_code=$(curl -L -o /dev/null --connect-timeout 5 -s -w %{http_code} "${url}" 2>/dev/null)
    if [ "$?" == "0" ] && [ "$status_code" == "200" ]; then
      echo "健康检查通过 (HTTP $status_code)"
      return 0
    fi
    
    echo "等待应用启动: $((exptime+1))/$timeout 秒 (状态码:${status_code:-连接失败})"
    
    sleep 1 || true
    exptime=$((exptime + 1))
    
    if [ $exptime -gt ${timeout} ]; then
      echo "健康检查超时 (${timeout}秒),应用启动失败"
      return 1
    fi
  done
}

wait_up()   { health_check "http://$HOST:$1/health"; }
wait_down() { ! health_check "http://$HOST:$1/health"; }

wait_apisix_healthy() { # port
  local node="$HOST:$1"
  [[ $LOG_LEVEL == "DEBUG" ]] && echo "等待APISIX配置下发: $node"
  
  for i in $(seq 0 14); do
    weight=$(get_current_nodes | jq -r --arg n "$node" '.[$n] // empty' 2>/dev/null)
    if [[ -n $weight ]]; then
      [[ $LOG_LEVEL == "DEBUG" ]] && echo "APISIX配置已下发: $node (权重:$weight)"
      return 0
    fi
    echo "等待APISIX配置下发: $((i+1))/15 秒"
    sleep 1 || true
  done
  echo "APISIX配置下发超时,但继续执行..."
  return 1
}

###################### 启停节点 #######################################
start_node() { # no port profile
  local no="$1"; local port="$2"; local profile="$3"; local node="$HOST:$port"
  echo "==== 开始启动 Node$no (端口:$port) ===="
  
  # 步骤1: 检查端口占用
  echo "步骤1/5: 检查端口可用性..."
  if is_port_in_use "$port"; then
    echo "端口 $port 已被占用,无法启动"
    return 1
  fi
  echo "端口 $port 可用"
  
  # 步骤2: 检查jar文件
  echo "步骤2/5: 检查应用文件..."
  if [[ ! -f "$JAR_LINK" ]]; then
    echo "错误: 应用文件 $JAR_LINK 不存在"
    return 1
  fi
  echo "应用文件检查通过"
  
  # 步骤3: 启动应用
  echo "步骤3/5: 启动Java应用 (配置:$profile)..."
  nohup java -jar "$JAR_LINK" --spring.profiles.active=prod,"$profile" \
        >"$LOG_DIR/node${no}.out" 2>&1 &
  local app_pid=$!
  echo "应用已启动,进程PID: $app_pid"
  
  # 步骤4: 等待应用健康检查通过
  echo "步骤4/5: 等待应用健康检查通过..."
  if ! wait_up "$port"; then
    echo "应用健康检查失败,启动中止"
    return 1
  fi
  echo "应用健康检查通过"
  
  # 步骤5: 注册到APISIX (权重为0)
  echo "步骤5/5: 注册到负载均衡器 (权重:0)..."
  add_node "$node" 0
  echo "等待APISIX配置下发..."
  wait_apisix_healthy "$port"
  echo "节点已注册到负载均衡器"
  
  # 步骤6: 激活流量 (权重设为1)
  echo "步骤6/6: 激活流量接收 (权重:0->1)..."
  update_node_weight "$node" 1
  
  echo "==== Node$no 完全启动成功,开始接收流量 ===="
}

stop_node() { # no port
  local no="$1"; local port="$2"; local node="$HOST:$port"
  echo "==== 开始停止 Node$no (端口:$port) ===="
  
  # 步骤1: 检查端口监听状态
  echo "步骤1/1: 检查端口监听状态..."
  local pid
  if ! pid=$(get_pid_by_port "$port"); then
    echo "端口 $port 没有进程监听,节点已停止"
    echo "==== Node$no 停止完成 (端口未监听) ===="
    return 0
  fi
  echo "找到监听进程 PID: $pid,开始停止流程..."
  
  # 步骤2: 降权重 (停止接收新请求)
  echo "步骤2/5: 从负载均衡器移除流量 (权重->0)..."
  update_node_weight "$node" 0 || echo "警告: 更新权重失败,但继续执行停止流程"
  echo "节点权重已降为0,停止接收新请求"
  
  # 步骤3: 等待现有连接耗尽
  echo "步骤3/5: 等待现有连接耗尽 (${IDLE_TIMEOUT_BUFFER}秒)..."
  local drain_time=0
  while [ $drain_time -lt $IDLE_TIMEOUT_BUFFER ]; do
    drain_time=$((drain_time + 1))
    echo "等待连接耗尽: ${drain_time}/${IDLE_TIMEOUT_BUFFER} 秒"
    sleep 1 || true
  done
  echo "连接耗尽等待完成"
  
  # 步骤4: 优雅停机
  echo "步骤4/5: 发送优雅停机信号 (TERM)..."
  kill -TERM "$pid" 2>/dev/null || {
    echo "发送TERM信号失败,进程可能已终止"
  }
  echo "TERM信号已发送,等待进程优雅终止..."
  
  # 等待进程终止 (带进度显示)
  local waited=0
  while kill -0 "$pid" 2>/dev/null; do
    if [ $waited -ge $SHUTDOWN_TIMEOUT_SECONDS ]; then
      echo "优雅停机超时 (${SHUTDOWN_TIMEOUT_SECONDS}s),执行强制终止..."
      kill -9 "$pid" 2>/dev/null || true
      sleep 2 || true
      break
    fi
    waited=$((waited + 1))
    echo "等待进程终止: ${waited}/${SHUTDOWN_TIMEOUT_SECONDS} 秒"
    sleep 1 || true
  done
  
  # 确认进程状态
  if kill -0 "$pid" 2>/dev/null; then
    echo "警告: 进程 $pid 终止失败,但继续清理"
  else
    echo "进程 $pid 已成功终止"
  fi
  
  # 步骤5: 从upstream完全移除
  echo "步骤5/5: 从APISIX完全移除节点..."
  remove_node "$node" || echo "警告: 从负载均衡器移除节点失败"
  
  echo "==== Node$no 完全停止成功 ===="
}

###################### 主逻辑 #########################################

# 显示用法
show_usage() {
  echo "用法: $0 [jar-file] <action>" >&2
  echo "  jar-file: jar文件路径 (启动操作时必须,停止操作时可选)" >&2
  echo "  action: 1=启动节点1 2=停止节点1 3=启动节点2 4=停止节点2" >&2
  echo "" >&2
  echo "示例:" >&2
  echo "  $0 app-1.0.jar 1     # 启动节点1" >&2
  echo "  $0 2                 # 停止节点1 (不需要jar文件)" >&2
  exit 1
}

# 解析参数
NEW_JAR="" ACTION=""

if [[ $# -eq 1 ]]; then
  # 只有一个参数,判断是jar文件还是action
  if [[ "$1" =~ ^[1-4]$ ]]; then
    ACTION="$1"
  elif [[ -f "$1" ]]; then
    NEW_JAR="$1"
  else
    echo "错误: 参数 '$1' 不是有效的jar文件或操作号" >&2
    show_usage
  fi
elif [[ $# -eq 2 ]]; then
  NEW_JAR="$1"
  ACTION="$2"
elif [[ $# -eq 0 ]]; then
  # 无参数,交互模式
  :
else
  show_usage
fi

# 验证action格式
if [[ -n $ACTION && ! $ACTION =~ ^[1-4]$ ]]; then
  echo "错误: action必须是1-4之间的数字" >&2
  show_usage
fi

# 检查必要工具
check_tools "${ACTION:-0}"

cd "$BASE_DIR"

# 处理jar文件链接(仅在启动操作或有新jar文件时)
if [[ -n $NEW_JAR ]]; then
  if [[ ! -f "$NEW_JAR" ]]; then
    echo "错误: jar文件 '$NEW_JAR' 不存在" >&2
    exit 1
  fi
  
  if [[ "$NEW_JAR" != "$JAR_LINK" ]]; then
    ln -f "$NEW_JAR" "$JAR_LINK"
    echo "已创建jar文件链接: $NEW_JAR -> $JAR_LINK"
  else
    echo "源文件和目标文件相同,跳过链接"
  fi
fi

# 交互式选择
if [[ -z $ACTION ]]; then
  cat <<'EOF'
==================== 选择操作 ====================
 1) 启动节点1      2) 停止节点1
 3) 启动节点2      4) 停止节点2
==================================================
EOF
  read -rp "请选择 (1‑4): " ACTION
  
  if [[ ! $ACTION =~ ^[1-4]$ ]]; then
    echo "无效操作: $ACTION"
    exit 1
  fi
fi

# 对于启动操作,确保有jar文件
if [[ $ACTION =~ ^(1|3)$ && ! -f "$JAR_LINK" ]]; then
  echo "错误: 启动操作需要jar文件,但 $JAR_LINK 不存在" >&2
  echo "请指定jar文件: $0 <jar-file> $ACTION" >&2
  exit 1
fi

# 执行操作
case "$ACTION" in
  1) start_node 1 "$PORT1" "$PROFILE1" ;;
  2) stop_node  1 "$PORT1" ;;
  3) start_node 2 "$PORT2" "$PROFILE2" ;;
  4) stop_node  2 "$PORT2" ;;
  *) echo "无效操作 $ACTION"; exit 1 ;;
esac

echo "==================== 脚本执行完毕 ===================="

ecs/demo-node1/说明.txt

主机
1.2.3.4

所在目录
/home/backend/demo-node1

ecs/demo-node2/deploy.sh

#!/usr/bin/env bash
#
# zero-deploy.sh - 零停机部署脚本(移除锁机制版 2025‑06‑A)
#
# 关键改动
#   1) 以 add_node/remove_node/update_node_weight 取代全量 update_nodes
#   2) 单节点 PATCH,完全符合 APISIX Admin API 最佳实践
#   3) 移除文件锁机制,简化部署流程,确保高效执行
# -------------------------------------------------------------------

set -euo pipefail

####################### 可调参数 #####################################
BASE_DIR="$(dirname "$(readlink "$0")")"
LOG_DIR="$BASE_DIR/logs"

LOG_LEVEL="${LOG_LEVEL:-INFO}"                   # INFO | DEBUG

PORT1=8080 ; PROFILE1="prod-node1"
PORT2=8081 ; PROFILE2="prod-node2"

HEALTH_PATH="/health"
HEALTH_TIMEOUT=60
APP_START_TIMEOUT_SECONDS=$HEALTH_TIMEOUT

KEEPALIVE_IDLE_TIMEOUT=30                        # 与 upstream.keepalive_pool.idle_timeout 对齐
IDLE_TIMEOUT_BUFFER=$((KEEPALIVE_IDLE_TIMEOUT*2))
SHUTDOWN_TIMEOUT_SECONDS=60                    # 优雅停机超时时间(秒)

JAR_LINK="app.jar"
UPSTREAM_ID="java-app-upstream"
ADMIN_URL="http://127.0.0.1:9180/apisix/admin/upstreams/$UPSTREAM_ID"

HOST="172.18.0.1"                                # 可用 `ip route get 1.1.1.1 | awk '{print $7}'`
AUTH_HEADER=(-H "X-API-KEY: CKjcGDoqxcJfpIXWigyqjdUSACvwzEJy") # 请替换
########################################################################

mkdir -p "$LOG_DIR"

###################### 通用函数 #######################################
# API 调用(带重试)
api() {
  local method="GET" url data attempt=1 max=3 delay=1
  while [[ $# -gt 0 ]]; do
    case $1 in
      -X) method="$2"; shift 2;;
      -d) data="$2";  shift 2;;
      http*) url="$1"; shift;;
      *) shift;;
    esac
  done
  while :; do
    [[ $LOG_LEVEL == "DEBUG" ]] && echo "API attempt $attempt: $method $url"
    local rsp http_code body
    if [[ -n ${data:-} ]]; then
      rsp=$(curl -s -w '\n%{http_code}' --fail "${AUTH_HEADER[@]}" -X "$method" \
              -H 'Content-Type: application/json' -d "$data" "$url" 2>&1) || true
    else
      rsp=$(curl -s -w '\n%{http_code}' --fail "${AUTH_HEADER[@]}" -X "$method" \
              "$url" 2>&1) || true
    fi
    http_code=$(echo "$rsp" | tail -n1)
    body=$(echo "$rsp" | head -n -1)
    [[ $LOG_LEVEL == "DEBUG" ]] && echo "Response code: $http_code"

    if [[ $http_code =~ ^2[0-9][0-9]$ ]]; then
      [[ $LOG_LEVEL == "DEBUG" ]] && echo "HTTP $method $url -> $http_code SUCCESS"
      [[ -n $body && $LOG_LEVEL == "DEBUG" ]] && echo "Response body: $body"
      echo "$body"
      return 0
    fi
    if (( attempt >= max )); then
      echo "HTTP $method $url FAILED after $attempt attempts, code=$http_code, body=$body"
      return 1
    fi
    echo "HTTP $method $url attempt $attempt failed, retry after ${delay}s..."
    sleep $delay
    ((attempt++))
    delay=$((delay*2))
  done
}

# 获取当前 upstream.nodes
get_current_nodes() {
  api -X GET "$ADMIN_URL" | jq -r '.value.nodes // {}'
}

###################### 节点操作 #######################################
add_node() { # add_node host:port [weight]
  local node="$1" weight="${2:-0}"
  local nodes=$(get_current_nodes)
  if echo "$nodes" | jq -e --arg n "$node" 'has($n)' >/dev/null; then
    echo "Node $node already exists"
    return 0
  fi
  local patch=$( jq -n --arg node "$node" --argjson w "$weight" '{nodes:{($node):$w}}')
  api -X PATCH -d "$patch" "$ADMIN_URL"
  echo "Node $node added with weight=$weight"
}

update_node_weight() { # update_node_weight host:port weight
  local node="$1" weight="$2"
  [[ $LOG_LEVEL == "DEBUG" ]] && echo "update_node_weight: node=$node, weight=$weight"
  [[ $LOG_LEVEL == "DEBUG" ]] && echo "Creating patch..."
  local patch=$( jq -n --arg node "$node" --argjson w "$weight" '{nodes:{($node):$w}}')
  [[ $LOG_LEVEL == "DEBUG" ]] && echo "Calling API: PATCH $ADMIN_URL with patch: $patch"
  api -X PATCH -d "$patch" "$ADMIN_URL" >/dev/null
  [[ $LOG_LEVEL == "DEBUG" ]] && echo "API call completed"
  echo "Node $node weight updated -> $weight"
}

remove_node() { # remove_node host:port
  local node="$1"
  local patch=$( jq -n --arg node "$node" '{nodes:{($node):null}}')
  api -X PATCH -d "$patch" "$ADMIN_URL"
  echo "Node $node removed from upstream"
}



###################### 健康检查工具 ###################################
health_check() {
  local url="$1"
  local timeout="${2:-$APP_START_TIMEOUT_SECONDS}"
  local exptime=0
  
  echo "开始健康检查: $url"
  while true; do
    status_code=$(curl -L -o /dev/null --connect-timeout 5 -s -w %{http_code} "${url}" 2>/dev/null)
    if [ "$?" == "0" ] && [ "$status_code" == "200" ]; then
      echo "健康检查通过 (HTTP $status_code)"
      return 0
    fi
    
    echo "等待应用启动: $((exptime+1))/$timeout 秒 (状态码:${status_code:-连接失败})"
    
    sleep 1 || true
    exptime=$((exptime + 1))
    
    if [ $exptime -gt ${timeout} ]; then
      echo "健康检查超时 (${timeout}秒),应用启动失败"
      return 1
    fi
  done
}

wait_up()   { health_check "http://$HOST:$1/health"; }
wait_down() { ! health_check "http://$HOST:$1/health"; }

wait_apisix_healthy() { # port
  local node="$HOST:$1"
  [[ $LOG_LEVEL == "DEBUG" ]] && echo "等待APISIX配置下发: $node"
  
  for i in $(seq 0 14); do
    weight=$(get_current_nodes | jq -r --arg n "$node" '.[$n] // empty' 2>/dev/null)
    if [[ -n $weight ]]; then
      [[ $LOG_LEVEL == "DEBUG" ]] && echo "APISIX配置已下发: $node (权重:$weight)"
      return 0
    fi
    echo "等待APISIX配置下发: $((i+1))/15 秒"
    sleep 1 || true
  done
  echo "APISIX配置下发超时,但继续执行..."
  return 1
}

###################### 启停节点 #######################################
start_node() { # no port profile
  local no="$1"; local port="$2"; local profile="$3"; local node="$HOST:$port"
  echo "==== 开始启动 Node$no (端口:$port) ===="
  
  # 步骤1: 检查端口占用
  echo "步骤1/5: 检查端口可用性..."
  if lsof -ti tcp:"$port" -sTCP:LISTEN >/dev/null; then
    echo "端口 $port 已被占用,无法启动"
    return 1
  fi
  echo "端口 $port 可用"
  
  # 步骤2: 启动应用
  echo "步骤2/5: 启动Java应用 (配置:$profile)..."
  nohup java -jar "$JAR_LINK" --spring.profiles.active=prod,"$profile" \
        >"$LOG_DIR/node${no}.out" 2>&1 &
  local app_pid=$!
  echo "应用已启动,进程PID: $app_pid"
  
  # 步骤3: 等待应用健康检查通过
  echo "步骤3/5: 等待应用健康检查通过..."
  if ! wait_up "$port"; then
    echo "应用健康检查失败,启动中止"
    return 1
  fi
  echo "应用健康检查通过"
  
  # 步骤4: 注册到APISIX (权重为0)
  echo "步骤4/5: 注册到负载均衡器 (权重:0)..."
  add_node "$node" 0
  echo "等待APISIX配置下发..."
  wait_apisix_healthy "$port"
  echo "节点已注册到负载均衡器"
  
  # 步骤5: 激活流量 (权重设为1)
  echo "步骤5/5: 激活流量接收 (权重:0->1)..."
  update_node_weight "$node" 1
  
  echo "==== Node$no 完全启动成功,开始接收流量 ===="
}

stop_node() { # no port
  local no="$1"; local port="$2"; local node="$HOST:$port"
  echo "==== 开始停止 Node$no (端口:$port) ===="
  
  # 步骤1: 获取进程ID
  echo "步骤1/5: 检查进程状态..."
  local pid=$(lsof -ti tcp:"$port" -sTCP:LISTEN || true)
  if [[ -z $pid ]]; then
    echo "进程未运行,仅从负载均衡器移除节点"
    echo "步骤5/5: 从APISIX移除节点..."
    remove_node "$node"
    echo "Node$no 停止完成 (进程未运行)"
    return 0
  fi
  echo "找到进程 PID: $pid"
  
  # 步骤2: 降权重 (停止接收新请求)
  echo "步骤2/5: 从负载均衡器移除流量 (权重->0)..."
  update_node_weight "$node" 0
  echo "节点权重已降为0,停止接收新请求"
  
  # 步骤3: 等待现有连接耗尽
  echo "步骤3/5: 等待现有连接耗尽 (${IDLE_TIMEOUT_BUFFER}秒)..."
  local drain_time=0
  while [ $drain_time -lt $IDLE_TIMEOUT_BUFFER ]; do
    drain_time=$((drain_time + 1))
    echo "等待连接耗尽: ${drain_time}/${IDLE_TIMEOUT_BUFFER} 秒"
    sleep 1 || true
  done
  echo "连接耗尽等待完成"
  
  # 步骤4: 优雅停机
  echo "步骤4/5: 发送优雅停机信号 (TERM)..."
  kill -TERM "$pid" 2>/dev/null || true
  echo "TERM信号已发送,等待进程优雅终止..."
  
  # 等待进程终止 (带进度显示)
  local waited=0
  while kill -0 "$pid" 2>/dev/null; do
    if [ $waited -ge $SHUTDOWN_TIMEOUT_SECONDS ]; then
      echo "优雅停机超时 (${SHUTDOWN_TIMEOUT_SECONDS}s),执行强制终止..."
      kill -9 "$pid" 2>/dev/null || true
      sleep 2 || true
      break
    fi
    waited=$((waited + 1))
    echo "等待进程终止: ${waited}/${SHUTDOWN_TIMEOUT_SECONDS} 秒"
    sleep 1 || true
  done
  
  # 确认进程状态
  if kill -0 "$pid" 2>/dev/null; then
    echo "进程 $pid 终止失败"
    return 1
  else
    echo "进程 $pid 已成功终止"
  fi
  
  # 步骤5: 从upstream完全移除
  echo "步骤5/5: 从APISIX完全移除节点..."
  remove_node "$node"
  
  echo "==== Node$no 完全停止成功 ===="
}

###################### 主逻辑 #########################################
[[ $# -lt 1 || $# -gt 2 ]] && {
  echo "用法: $0 <jar-file> [action]" >&2
  echo "action: 1=启动节点1 2=停止节点1 3=启动节点2 4=停止节点2" >&2
  exit 1
}
NEW_JAR="$1"; ACTION="${2:-}"

cd "$BASE_DIR"; [[ "$NEW_JAR" != "$JAR_LINK" ]] && ln -f "$NEW_JAR" "$JAR_LINK" || echo "源文件和目标文件相同,跳过链接"

if [[ -z $ACTION ]]; then
  cat <<'EOF'
==================== 选择操作 ====================
 1) 启动节点1      2) 停止节点1
 3) 启动节点2      4) 停止节点2
==================================================
EOF
  read -rp "请选择 (1‑4): " ACTION
fi

case "$ACTION" in
  1) start_node 1 "$PORT1" "$PROFILE1" ;;
  2) stop_node  1 "$PORT1" ;;
  3) start_node 2 "$PORT2" "$PROFILE2" ;;
  4) stop_node  2 "$PORT2" ;;
  *) echo "无效操作 $ACTION"; exit 1 ;;
esac

echo "==================== 脚本执行完毕 ===================="

ecs/demo-node2/说明.txt

主机
1.2.3.4

所在目录
/home/backend/demo-node2

springboot配置文件/application-prod-node1.yml

server:
  port: 8080

springboot配置文件/application-prod-node2.yml

server:
  port: 8081

springboot配置文件/application.yml

spring:
  application:
    name: java-app
  profiles:
    active: prod-node1

  jackson:
    time-zone: Asia/Shanghai              # 全局时区

  lifecycle:
    timeout-per-shutdown-phase: 120s       # 优雅停机总等待

  task:
    execution:                            # 默认异步/响应式线程池
      pool:
        core-size: 16
        max-size: 64
        queue-capacity: 1000
        keep-alive: 60s
      shutdown:
        await-termination: true
        await-termination-period: 120s

server:
  shutdown: graceful                      # Spring Boot 2.3+ 优雅停机
  port: 8080
  servlet:
    context-path: /
  tomcat:
    threads:
      max: 200
      min-spare: 10
    accept-count: 100                     # 已满时排队连接
    max-connections: 10000
    keep-alive-timeout: 20s               # Keep‑Alive 保持时长
    connection-timeout: 5s                # 握手超时

management:
  server:
    port: 8090                            # 管理端口隔离
  endpoints:
    web:
      base-path: /actuator
      exposure:
        include: health,info,prometheus   # shutdown 默认不暴露
  endpoint:
    health:
      show-details: when_authorized
  metrics:
    export:
      prometheus:
        enabled: true
        step: 15s

logging:
  file:
    path: ./logs          # Boot 负责把 LOG_HOME 解析到 ./logs
  level:
    root: INFO
    com.example: DEBUG

springboot配置文件/logback-spring.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="30 seconds">

	<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
	<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>

	<!-- 日志根目录:来自 logging.file.path -->
	<springProperty scope="context"
					name="LOG_HOME"
					source="logging.file.path"
					defaultValue="./logs"/>

	<!-- 按“天 + 10 MB” 滚动 -->
	<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
		<file>${LOG_HOME}/java-app.log</file>
		<immediateFlush>true</immediateFlush>

		<encoder>
			<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
			<charset>UTF-8</charset>
		</encoder>

		<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
			<fileNamePattern>${LOG_HOME}/archive/java-app-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
			<maxFileSize>10MB</maxFileSize>
			<maxHistory>30</maxHistory>
			<totalSizeCap>3GB</totalSizeCap>
		</rollingPolicy>
	</appender>

	<!-- 异步包装 -->
	<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
		<queueSize>8192</queueSize>
		<discardingThreshold>0</discardingThreshold>
		<neverBlock>true</neverBlock>
		<appender-ref ref="FILE"/>
	</appender>

	<!-- 根日志级别:INFO -->
	<root level="INFO">
		<appender-ref ref="CONSOLE"/>
		<appender-ref ref="ASYNC_FILE"/>
	</root>

	<!-- 如需包级别 DEBUG,在此追加 -->
	<!-- <logger name="com.example" level="DEBUG"/> -->

</configuration>

云效/节点1/部署脚本.txt

set -e  # 遇到错误立即退出
# ==== 环境变量配置 ====
# 制品实际下载到的路径
PACKAGE_PATH=/home/flowapp/demo.tgz                    
#应用目录
APP_HOME=/home/backend/demo-node1               
#jar 文件名
JAR_NAME=demo-0.0.1-SNAPSHOT.jar                         
log() { echo "[`date '+%F %T'`] $*"; }
mkdir -p "$(dirname "$PACKAGE_PATH")"
mkdir -p "$APP_HOME"
deploy_package() {
  # 检查制品文件是否存在
  if [ ! -f "$PACKAGE_PATH" ]; then
    log "错误: 制品文件不存在 $PACKAGE_PATH"
    exit 1
  fi
  log "解包 $PACKAGE_PATH 到 $APP_HOME"
  tar -zxf "$PACKAGE_PATH" -C "$APP_HOME"
  log "解包完成"
  #log "删除压缩包 $PACKAGE_PATH"
  #rm -f "$PACKAGE_PATH" && log "压缩包已删除" || log "压缩包删除失败,可忽略"
}
# 切换到应用目录
cd "$APP_HOME" || { echo "目录不存在: $APP_HOME"; exit 1; }
log "停止节点 1"
sh deploy.sh $JAR_NAME 2   # 参数 2 = 停止节点
deploy_package 
log "启动节点 1"
sh deploy.sh "$JAR_NAME" 1   # 参数 1 = 启动节点
log "部署脚本执行完毕"

云效/节点2/部署脚本.txt

set -e  # 遇到错误立即退出
# ==== 环境变量配置 ====
sleep 5
# 制品实际下载到的路径
PACKAGE_PATH=/home/flowapp/demo.tgz                    
#应用目录
APP_HOME=/home/backend/demo-node2               
#jar 文件名
JAR_NAME=demo-0.0.1-SNAPSHOT.jar                         
log() { echo "[`date '+%F %T'`] $*"; }
mkdir -p "$(dirname "$PACKAGE_PATH")"
mkdir -p "$APP_HOME"
deploy_package() {
  # 检查制品文件是否存在
  if [ ! -f "$PACKAGE_PATH" ]; then
    log "错误: 制品文件不存在 $PACKAGE_PATH"
    exit 1
  fi
  log "解包 $PACKAGE_PATH 到 $APP_HOME"
  tar -zxf "$PACKAGE_PATH" -C "$APP_HOME"
  log "解包完成"
  log "删除压缩包 $PACKAGE_PATH"
  rm -f "$PACKAGE_PATH" && log "压缩包已删除" || log "压缩包删除失败,可忽略"
}
# 切换到应用目录
cd "$APP_HOME" || { echo "目录不存在: $APP_HOME"; exit 1; }
log "停止节点 1"
sh deploy.sh $JAR_NAME 4   # 参数 4 = 停止节点
deploy_package 
log "启动节点 1"
sh deploy.sh "$JAR_NAME" 3   # 参数 3 = 启动节点
log "部署脚本执行完毕"