阿里云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.49000(业务) / 9180(管理)Docker 容器内 /usr/local/apisix
Spring Boot node11.2.3.48081/home/backend/demo-node1
Spring Boot node21.2.3.48082/home/backend/demo-node2

说明:APISIX 通过 Docker 启动,容器内默认监听 9080/9443/9180,通过端口映射对外暴露为 9000/9443/9180


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

流水线整体思路保持不变:
1)构建 Spring Boot 可执行 jar 并上传到制品库;
2)流水线依次在 demo-node1demo-node2 上执行部署脚本;
3)部署脚本调用 APISIX Admin API 动态调整 upstream,保证只有健康节点接收流量,从而实现零停机。

构建配置

阿里云云效构建配置

节点 1 部署配置

阿里云云效节点1配置

节点 2 部署配置

阿里云云效节点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(双节点)

  1. 登录内置 Dashboard。

  2. 在 Upstream 页面中新建 upstream,ID 设置为 java-app-upstream(自定义字符串 ID 即可)。

  3. 类型选择 roundrobin,Nodes 配置为:

    • 1.2.3.4:8081,weight = 1
    • 1.2.3.4:8082,weight = 1
  4. 保存。

7.2 创建 Route

  1. 在 Route 页面新建路由,例如:

    • 请求路径:/api/*
    • Host:按业务需要(如 api.example.com
    • 绑定 Upstream:选择 java-app-upstream
  2. 保存后,访问:

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