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.4 | 9000 / 9180 (dashboard) | /usr/local/apisix |
| Spring Boot node1 | 1.2.3.4 | 8081 | /home/backend/demo-node1 |
| Spring Boot node2 | 1.2.3.4 | 8082 | /home/backend/demo-node2 |
3. 部署流水线核心步骤(云效)
构建配置

节点1部署配置

节点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 "部署脚本执行完毕"