阿里云ECS Spring Boot 双节点无感发布
环境:Alibaba Cloud Linux 3 + Apache APISIX 3.14.1(Docker,内置 Dashboard)+ 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 (Alibaba Cloud Linux 3) │ │ │ │ ┌──────────┐ ┌──────────┐ │ │ │ demo-node1│ │ demo-node2│ │ │ │ :8081 │ │ :8082 │ │ │ └──────────┘ └──────────┘ │ │ ▲ ▲ │ │ │ (health check) │ │ │ ┌──────┴──────────────────┴─────┐ │ │ │ Apache APISIX 3.14.1 (9000) │ │ │ │ Admin / Dashboard (9180) │ │ │ └───────────────────────────────┘ │ └─────────────────────────────────────┘2. 主机与端口
| 角色 | IP | 端口 | 路径说明 | |
|---|---|---|---|---|
| APISIX 网关 | 1.2.3.4 | 9000(业务) / 9180(管理) | Docker 容器内 /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 |
说明:APISIX 通过 Docker 启动,容器内默认监听
9080/9443/9180,通过端口映射对外暴露为9000/9443/9180。
3. 部署流水线核心步骤(云效)
流水线整体思路保持不变:
1)构建 Spring Boot 可执行 jar 并上传到制品库;
2)流水线依次在 demo-node1、demo-node2 上执行部署脚本;
3)部署脚本调用 APISIX Admin API 动态调整 upstream,保证只有健康节点接收流量,从而实现零停机。
构建配置

节点 1 部署配置

节点 2 部署配置

4. 环境准备(Alibaba Cloud Linux 3)
4.1 操作系统与基础依赖
- 操作系统:Alibaba Cloud Linux 3(基于 Anolis OS 8,兼容 CentOS 8 / RHEL 8 生态)
- 包管理器:
dnf(兼容yum子命令)。 - 需要的系统工具(建议提前安装):
sudo dnf -y install curl lsof jq tar4.2 安装 Docker(Alibaba Cloud Linux 3)
按照阿里云官方文档,在 Alibaba Cloud Linux 3 上安装 Docker CE:
#!/usr/bin/env bash## 适用于 Alibaba Cloud Linux 3 的 Docker CE + Docker Compose 安装脚本
set -euo pipefail
echo ">>> 检查操作系统发行版信息..."if [ -f /etc/os-release ]; then . /etc/os-release echo "NAME=${NAME:-}, VERSION_ID=${VERSION_ID:-}"else echo "未找到 /etc/os-release,无法确认系统版本,仍将尝试安装。"fi
echo ">>> 第 1 步:安装 dnf(如已存在可跳过)..."sudo yum install -y dnf || true
echo ">>> 第 2 步:安装 Docker 存储驱动依赖..."sudo dnf install -y device-mapper-persistent-data lvm2
echo ">>> 第 3 步:添加 Docker CE 软件源,并安装 Alibaba Cloud Linux 3 专用 dnf 兼容插件..."sudo dnf config-manager --add-repo=https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.reposudo dnf -y install dnf-plugin-releasever-adapter --repo alinux3-plus
echo ">>> 第 4 步:安装 Docker CE..."sudo dnf -y install docker-ce --nobest
echo ">>> 第 5 步:启动并设置 Docker 开机自启..."sudo systemctl start dockersudo systemctl enable docker
echo ">>> 第 6 步:验证 Docker 安装..."docker -v || { echo 'Docker 安装验证失败,请检查上方输出。'; exit 1; }
echo ">>>(可选)安装 Docker Compose 插件..."if sudo dnf -y install docker-compose-plugin; then docker compose version || echo "Docker Compose 插件已安装,但版本检查失败,请手工排查。"else echo "Docker Compose 插件安装失败,可稍后手动执行:sudo dnf -y install docker-compose-plugin"fi
echo ">>> Docker CE 及(可选)Docker Compose 安装流程执行完成。"5. 部署 Apache APISIX 3.14.1(内置 Dashboard)
5.1 APISIX 配置文件 config.yaml
在任意工作目录(例如 /home/backend/apisix)创建 config.yaml:
deployment: # Admin API 与内置 Dashboard 安全配置 admin: admin_key_required: true # 默认即为 true,明确打开以保证安全 admin_key: - name: admin role: admin # 请在生产环境中改为安全随机值,例如 openssl rand -hex 16 key: CKjcGDoqxcJfpIXWigyqjdUSACvwzEJy allow_admin: - 127.0.0.0/24 # 本机 - 172.17.0.0/16 # Docker 默认 bridge 网段 # - 0.0.0.0/0 # 仅本地演示可放开,生产环境请严格收缩 enable_admin_ui: true # 启用 APISIX 内置 Dashboard
apisix: node_listen: 9080 # 容器内监听端口,通过 -p 映射为 9000 ssl: enable: true listen: - ip: 0.0.0.0 port: 9443如需自定义 etcd 地址,可在同一文件中补充
deployment.etcd段落,或使用环境变量覆盖,参考官方文档。
5.2 APISIX 容器管理脚本 apisix-manager.sh
以下脚本基于 Docker 运行 APISIX 3.14.1,并假定已经存在一个可用的 etcd 服务,容器名为 etcd-quickstart,位于 Docker 网络 apisix-quickstart-net 中(可通过官方 apisix-docker 示例或自建 etcd 部署)。
将脚本保存为 /home/backend/apisix/apisix-manager.sh,并赋予执行权限:
chmod +x /home/backend/apisix/apisix-manager.sh脚本内容:
#!/bin/bash
# APISIX Docker 管理脚本(监听 9000 / 9443 / 9180)
CONTAINER_NAME="apisix-main"NETWORK_NAME="apisix-quickstart-net"ETCD_HOST="http://etcd-quickstart:2379"APISIX_IMAGE="apache/apisix:3.14.1-debian" # 对应 APISIX 3.14.1 Docker 镜像CONFIG_FILE="$(cd "$(dirname "$0")" && pwd)/config.yaml"
stop_all() { echo "停止所有 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" >/dev/null 2>&1 || true docker rm "$name" >/dev/null 2>&1 || true fi done echo "所有 APISIX 容器已停止"}
start() { echo "启动 APISIX 容器(映射端口:9000->9080,9443->9443,9180->9180)..."
if [ ! -f "$CONFIG_FILE" ]; then echo "未找到配置文件: $CONFIG_FILE" exit 1 fi
# 确认网络存在 if ! docker network ls --format "{{.Name}}" | grep -q "^${NETWORK_NAME}$"; then echo "未找到 Docker 网络 ${NETWORK_NAME},请先创建并将 etcd 加入该网络。" exit 1 fi
# 如容器存在则删除 if docker ps -a --format "{{.Names}}" | grep -q "^${CONTAINER_NAME}$"; then echo "容器 $CONTAINER_NAME 已存在,先删除..." docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true docker rm "$CONTAINER_NAME" >/dev/null 2>&1 || true fi
CONTAINER_ID=$( docker run -d --name "$CONTAINER_NAME" \ --network "$NETWORK_NAME" \ -v "$CONFIG_FILE:/usr/local/apisix/conf/config.yaml:ro" \ -p 9000:9080 \ -p 9443: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 已成功启动:" echo " 业务入口: http://1.2.3.4:9000" echo " 管理 / UI: http://1.2.3.4:9180" else echo "容器启动失败,查看日志:" docker logs "$CONTAINER_NAME" 2>/dev/null | tail -20 fi else echo "容器启动失败: $CONTAINER_ID" fi}
status() { if docker ps --format "{{.Names}}" | grep -q "^${CONTAINER_NAME}$"; then echo "APISIX 运行中,端口映射:" docker port "$CONTAINER_NAME" else echo "APISIX 未运行" fi}
case "$1" in start) start ;; stop) stop_all ;; restart) stop_all sleep 2 start ;; status) status ;; *) echo "用法: $0 {start|stop|restart|status}" exit 1 ;;esac6. Spring Boot 双节点部署
6.1 目录结构建议
/home/backend/ ├── demo-node1/ │ ├── app.jar # 节点 1 运行 jar(软链接) │ ├── logs/ │ └── zero-deploy-node.sh # 通用零停机脚本(本节后面给出) └── demo-node2/ ├── app.jar ├── logs/ └── zero-deploy-node.sh云效在两个节点上的部署脚本仅负责解压制品并调用 zero-deploy-node.sh。
6.2 Spring Boot 运行约定
- 节点 1:端口
8081,配置spring.profiles.active=prod-node1 - 节点 2:端口
8082,配置spring.profiles.active=prod-node2
示例启动命令(脚本中会自动执行):
nohup java -jar app.jar \ --server.port=8081 \ --spring.profiles.active=prod-node1 \ > logs/app.log 2>&1 &7. 在 APISIX 中配置 Upstream 与 Route(使用内置 Dashboard)
APISIX 3.14.1 默认在 Admin API 端口上提供管理 UI,通过 enable_admin_ui: true 打开后即可在浏览器中通过 9180 端口访问。
7.1 创建 Upstream(双节点)
-
登录内置 Dashboard。
-
在 Upstream 页面中新建 upstream,ID 设置为
java-app-upstream(自定义字符串 ID 即可)。 -
类型选择
roundrobin,Nodes 配置为:1.2.3.4:8081,weight = 11.2.3.4:8082,weight = 1
-
保存。
7.2 创建 Route
-
在 Route 页面新建路由,例如:
- 请求路径:
/api/* - Host:按业务需要(如
api.example.com) - 绑定 Upstream:选择
java-app-upstream
- 请求路径:
-
保存后,访问:
curl -i "http://1.2.3.4:9000/api/health"确认可以命中两节点之一。
8. 零停机发布脚本
本节脚本仅在需要做“无感发布”时使用,因此仍通过 Admin API(脚本调用)修改 Upstream 节点权重;平时的路由管理均使用 Dashboard 即可。
8.1 通用节点脚本:zero-deploy-node.sh
分别拷贝到:
- 节点 1:
/home/backend/demo-node1/zero-deploy-node.sh - 节点 2:
/home/backend/demo-node2/zero-deploy-node.sh
并执行:
chmod +x /home/backend/demo-node1/zero-deploy-node.shchmod +x /home/backend/demo-node2/zero-deploy-node.sh脚本内容(两节点通用,通过Shell变量区分):
#!/usr/bin/env bash## zero-deploy-node.sh - Spring Boot 单节点零停机发布脚本(Alibaba Cloud Linux 3 适配)# 变量通过脚本内 Shell 变量配置,无需依赖环境变量。
set -euo pipefail
###################### 配置区(按需修改) #########################
# 节点名称,仅用于日志标识NODE_NAME="demo-node1"
# 应用目录APP_HOME="/home/backend/demo-node1"
# 制品路径(例如云效下载到本机后的 tar 包)PACKAGE_PATH="/home/flowapp/demo.tgz"
# 制品内的 JAR 文件名JAR_NAME="demo-0.0.1-SNAPSHOT.jar"
# 当前节点端口和对端节点端口NODE_PORT=8081PEER_PORT=8082
# Spring Boot profileSPRING_PROFILE="prod-node1"
# APISIX Admin API 配置UPSTREAM_ID="java-app-upstream"ADMIN_BASE="http://127.0.0.1:9180/apisix/admin"ADMIN_KEY="CKjcGDoqxcJfpIXWigyqjdUSACvwzEJy" # 与 APISIX config.yaml 中保持一致
# 健康检查及连接耗尽相关HEALTH_PATH="/health"HEALTH_TIMEOUT=60KEEPALIVE_IDLE_TIMEOUT=30IDLE_TIMEOUT_BUFFER=$((KEEPALIVE_IDLE_TIMEOUT * 2))
############################################################
# 基本校验,防止变量遗漏check_config() { if [ -z "$NODE_NAME" ] || [ -z "$APP_HOME" ] || [ -z "$PACKAGE_PATH" ] || \ [ -z "$JAR_NAME" ] || [ -z "$SPRING_PROFILE" ]; then echo "配置错误:请在脚本顶部正确设置 NODE_NAME/APP_HOME/PACKAGE_PATH/JAR_NAME/SPRING_PROFILE 等变量" exit 1 fi
if [ -z "${NODE_PORT:-}" ] || [ -z "${PEER_PORT:-}" ]; then echo "配置错误:请在脚本顶部正确设置 NODE_PORT 和 PEER_PORT" exit 1 fi}
LOG_DIR="$APP_HOME/logs"mkdir -p "$LOG_DIR"
AUTH_HEADER=(-H "X-API-KEY: $ADMIN_KEY")
log() { printf '[%s] [%s] %s\n' "$(date '+%F %T')" "$NODE_NAME" "$*" | tee -a "$LOG_DIR/deploy.log"}
get_host_ip() { if command -v ip >/dev/null 2>&1; then ip route get 1.1.1.1 | awk '{print $7; exit}' else hostname -I | awk '{print $1; exit}' fi}
HOST_IP="$(get_host_ip)"
stop_app() { local port=$1 log "准备停止端口 $port 上的应用"
local pid="" if command -v lsof >/dev/null 2>&1; then pid=$(lsof -t -i:"$port" -sTCP:LISTEN || true) elif command -v ss >/dev/null 2>&1; then pid=$(ss -lntp | awk -v p=":$port" '$4 ~ p {print $6}' | awk -F',' '{print $2}' | awk -F'=' '{print $2}' | head -n1) elif command -v netstat >/dev/null 2>&1; then pid=$(netstat -lntp 2>/dev/null | awk -v p=":$port" '$4 ~ p {print $7}' | cut -d'/' -f1 | head -n1) fi
if [ -z "$pid" ]; then log "端口 $port 未发现运行中的进程,跳过停止" return 0 fi
log "发现进程 PID=$pid,发送 TERM 信号" kill -TERM "$pid" || true
local wait_sec=0 while kill -0 "$pid" 2>/dev/null; do if [ $wait_sec -ge 30 ]; then log "进程未在 30 秒内退出,发送 KILL" kill -KILL "$pid" || true break fi sleep 1 wait_sec=$((wait_sec + 1)) done
log "端口 $port 已空闲"}
start_app() { cd "$APP_HOME" ln -snf "$JAR_NAME" app.jar
log "启动新版本应用: app.jar,端口 $NODE_PORT,profile=$SPRING_PROFILE" nohup java -jar app.jar \ --server.port="$NODE_PORT" \ --spring.profiles.active="$SPRING_PROFILE" \ > "$LOG_DIR/app.log" 2>&1 &
local start_time=0 local url="http://127.0.0.1:${NODE_PORT}${HEALTH_PATH}"
log "等待应用健康检查通过,超时时间 ${HEALTH_TIMEOUT}s,URL=$url"
while true; do if curl -fsS "$url" >/dev/null 2>&1; then log "健康检查通过" break fi if [ $start_time -ge $HEALTH_TIMEOUT ]; then log "健康检查超时(${HEALTH_TIMEOUT}s),失败" exit 1 fi sleep 2 start_time=$((start_time + 2)) done}
update_upstream_only_peer() { local url="${ADMIN_BASE}/upstreams/${UPSTREAM_ID}" log "将当前节点下线:仅保留对端节点 ${HOST_IP}:${PEER_PORT}"
local payload payload=$(cat <<EOF{ "type": "roundrobin", "nodes": { "${HOST_IP}:${PEER_PORT}": 1 }}EOF)
local code code=$(curl -sS -w "%{http_code}" -o /tmp/zero-upstream-peer.json \ "${AUTH_HEADER[@]}" \ -H "Content-Type: application/json" \ -X PUT "$url" \ -d "$payload")
if [[ "$code" != 2* ]]; then log "下线当前节点失败,HTTP 状态码: $code" cat /tmp/zero-upstream-peer.json || true exit 1 fi
log "当前节点权重置为 0,仅对端节点接收流量,等待连接耗尽 ${IDLE_TIMEOUT_BUFFER}s" sleep "$IDLE_TIMEOUT_BUFFER"}
update_upstream_both_nodes() { local url="${ADMIN_BASE}/upstreams/${UPSTREAM_ID}" log "将当前节点重新加入 upstream:${HOST_IP}:${NODE_PORT} & ${HOST_IP}:${PEER_PORT}"
local payload payload=$(cat <<EOF{ "type": "roundrobin", "nodes": { "${HOST_IP}:${NODE_PORT}": 1, "${HOST_IP}:${PEER_PORT}": 1 }}EOF)
local code code=$(curl -sS -w "%{http_code}" -o /tmp/zero-upstream-both.json \ "${AUTH_HEADER[@]}" \ -H "Content-Type: application/json" \ -X PUT "$url" \ -d "$payload")
if [[ "$code" != 2* ]]; then log "重新加入 upstream 失败,HTTP 状态码: $code" cat /tmp/zero-upstream-both.json || true exit 1 fi
log "upstream 已恢复为双节点轮询"}
deploy_artifact() { log "检查制品文件: $PACKAGE_PATH" if [ ! -f "$PACKAGE_PATH" ]; then log "制品文件不存在: $PACKAGE_PATH" exit 1 fi
mkdir -p "$APP_HOME" cd "$APP_HOME"
log "解压制品到 $APP_HOME" tar -zxf "$PACKAGE_PATH" -C "$APP_HOME" log "解压完成"}
main() { check_config
log "===== 零停机发布开始 =====" log "主机 IP: $HOST_IP, 本节点端口: $NODE_PORT, 对端端口: $PEER_PORT"
# 1. 先将当前节点从 upstream 中“摘除” update_upstream_only_peer
# 2. 停止当前节点 stop_app "$NODE_PORT"
# 3. 部署新制品 deploy_artifact
# 4. 启动新版本并通过健康检查 start_app
# 5. 将当前节点重新加入 upstream update_upstream_both_nodes
log "===== 零停机发布完成 ====="}
main "$@"8.2 云效:节点 1 部署脚本
云效节点 1 部署命令脚本示例,保存为(与原文一致):
云效/节点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
# 为通用脚本准备的参数export NODE_NAME="demo-node1"export APP_HOMEexport PACKAGE_PATHexport JAR_NAMEexport NODE_PORT=8081export PEER_PORT=8082export SPRING_PROFILE="prod-node1"
log() { echo "[$(date '+%F %T')] [node1] $*"; }
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 "解包完成"}
cd "$APP_HOME" || { echo "目录不存在: $APP_HOME"; exit 1; }
# 部署最新制品deploy_package
# 调用通用零停机脚本log "执行零停机发布(节点 1)"bash "$APP_HOME/zero-deploy-node.sh"
log "节点 1 部署脚本执行完毕"8.3 云效:节点 2 部署脚本
云效/节点2/部署脚本.txt:
set -e # 遇到错误立即退出
sleep 5 # 适当延迟,确保节点 1 已基本完成更新(可按需要调整)
# ==== 环境变量配置 ====PACKAGE_PATH=/home/flowapp/demo.tgzAPP_HOME=/home/backend/demo-node2JAR_NAME=demo-0.0.1-SNAPSHOT.jar
export NODE_NAME="demo-node2"export APP_HOMEexport PACKAGE_PATHexport JAR_NAMEexport NODE_PORT=8082export PEER_PORT=8081export SPRING_PROFILE="prod-node2"
log() { echo "[$(date '+%F %T')] [node2] $*"; }
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 "解包完成"}
cd "$APP_HOME" || { echo "目录不存在: $APP_HOME"; exit 1; }
deploy_package
log "执行零停机发布(节点 2)"bash "$APP_HOME/zero-deploy-node.sh"
log "节点 2 部署脚本执行完毕"