阿里云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 tar
4.2 安装 Docker(Alibaba Cloud Linux 3)
按照阿里云官方文档,在 Alibaba Cloud Linux 3 上安装 Docker CE:
#!/usr/bin/env bash
#
# install-docker-alinux3.sh
# 适用于 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.repo
sudo 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 docker
sudo 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
;;
esac
6. 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.sh
chmod +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=8081
PEER_PORT=8082
# Spring Boot profile
SPRING_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=60
KEEPALIVE_IDLE_TIMEOUT=30
IDLE_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_HOME
export PACKAGE_PATH
export JAR_NAME
export NODE_PORT=8081
export PEER_PORT=8082
export 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.tgz
APP_HOME=/home/backend/demo-node2
JAR_NAME=demo-0.0.1-SNAPSHOT.jar
export NODE_NAME="demo-node2"
export APP_HOME
export PACKAGE_PATH
export JAR_NAME
export NODE_PORT=8082
export PEER_PORT=8081
export 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 部署脚本执行完毕"