单服务器 spring cloud + nacos 灰度部署,nginx shell脚本

核心流程

#mermaid-svg-tbUY37Nw3kaABcjX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-tbUY37Nw3kaABcjX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-tbUY37Nw3kaABcjX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-tbUY37Nw3kaABcjX .error-icon{fill:#552222;}#mermaid-svg-tbUY37Nw3kaABcjX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-tbUY37Nw3kaABcjX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-tbUY37Nw3kaABcjX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-tbUY37Nw3kaABcjX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-tbUY37Nw3kaABcjX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-tbUY37Nw3kaABcjX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-tbUY37Nw3kaABcjX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-tbUY37Nw3kaABcjX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-tbUY37Nw3kaABcjX .marker.cross{stroke:#333333;}#mermaid-svg-tbUY37Nw3kaABcjX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-tbUY37Nw3kaABcjX p{margin:0;}#mermaid-svg-tbUY37Nw3kaABcjX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-tbUY37Nw3kaABcjX .cluster-label text{fill:#333;}#mermaid-svg-tbUY37Nw3kaABcjX .cluster-label span{color:#333;}#mermaid-svg-tbUY37Nw3kaABcjX .cluster-label span p{background-color:transparent;}#mermaid-svg-tbUY37Nw3kaABcjX .label text,#mermaid-svg-tbUY37Nw3kaABcjX span{fill:#333;color:#333;}#mermaid-svg-tbUY37Nw3kaABcjX .node rect,#mermaid-svg-tbUY37Nw3kaABcjX .node circle,#mermaid-svg-tbUY37Nw3kaABcjX .node ellipse,#mermaid-svg-tbUY37Nw3kaABcjX .node polygon,#mermaid-svg-tbUY37Nw3kaABcjX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-tbUY37Nw3kaABcjX .rough-node .label text,#mermaid-svg-tbUY37Nw3kaABcjX .node .label text,#mermaid-svg-tbUY37Nw3kaABcjX .image-shape .label,#mermaid-svg-tbUY37Nw3kaABcjX .icon-shape .label{text-anchor:middle;}#mermaid-svg-tbUY37Nw3kaABcjX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-tbUY37Nw3kaABcjX .rough-node .label,#mermaid-svg-tbUY37Nw3kaABcjX .node .label,#mermaid-svg-tbUY37Nw3kaABcjX .image-shape .label,#mermaid-svg-tbUY37Nw3kaABcjX .icon-shape .label{text-align:center;}#mermaid-svg-tbUY37Nw3kaABcjX .node.clickable{cursor:pointer;}#mermaid-svg-tbUY37Nw3kaABcjX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-tbUY37Nw3kaABcjX .arrowheadPath{fill:#333333;}#mermaid-svg-tbUY37Nw3kaABcjX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-tbUY37Nw3kaABcjX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-tbUY37Nw3kaABcjX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-tbUY37Nw3kaABcjX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-tbUY37Nw3kaABcjX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-tbUY37Nw3kaABcjX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-tbUY37Nw3kaABcjX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-tbUY37Nw3kaABcjX .cluster text{fill:#333;}#mermaid-svg-tbUY37Nw3kaABcjX .cluster span{color:#333;}#mermaid-svg-tbUY37Nw3kaABcjX div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-tbUY37Nw3kaABcjX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-tbUY37Nw3kaABcjX rect.text{fill:none;stroke-width:0;}#mermaid-svg-tbUY37Nw3kaABcjX .icon-shape,#mermaid-svg-tbUY37Nw3kaABcjX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-tbUY37Nw3kaABcjX .icon-shape p,#mermaid-svg-tbUY37Nw3kaABcjX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-tbUY37Nw3kaABcjX .icon-shape .label rect,#mermaid-svg-tbUY37Nw3kaABcjX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-tbUY37Nw3kaABcjX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-tbUY37Nw3kaABcjX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-tbUY37Nw3kaABcjX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 有



客户端请求
Gateway 路由判断

Header 中是否有 X-Gray-User?
转发到新服务

blog:9092
转发到旧服务

blog:9091
新服务运行测试
旧服务处理常规流量
测试无误?
Gateway 配置变更:

  1. 删除 blog:9091 路由

  2. 删除 X-Gray-User 判断条件
    全部流量转发到新服务 blog:9092
    Nacos 操作:

找到 blog 服务下端口 9091
设置元数据 autoclose=true
旧服务 blog:9091 自动关闭
灰度发布完成

nacos 配置

新旧服务同时运行
yaml 复制代码
spring:
  cloud:
    gateway:
      routes:
        # 规则1:灰度路由 - 当请求头 X-Gray-User 存在时,转发至灰度版本
        - id: blog-gray-route
          uri: http://127.0.0.1:9092
          predicates:
            - Path=/api/manage/**
            - Header=X-Gray-User, .+   # 正则匹配:只要 Header 存在且非空
          filters:
            - StripPrefix=1
            - AddRequestHeader=gray-version, v2.0 # 可选:为下游服务添加灰度标识

        # 规则2:默认路由 - 所有其他请求,转发至稳定版本
        - id: blog-default-route
          uri: http://127.0.0.1:9091
          predicates:
            - Path=/api/manage/**
          filters:
            - StripPrefix=1
只剩新服务运行
yaml 复制代码
spring:
  cloud:
    gateway:
      routes:
        # 规则1:灰度路由 - 当请求头 X-Gray-User 存在时,转发至灰度版本
        - id: blog-gray-route
          uri: http://127.0.0.1:9092
          predicates:
            - Path=/api/manage/**
          filters:
            - StripPrefix=1
            - AddRequestHeader=gray-version, v2.0 # 可选:为下游服务添加灰度标识
如果你要路由中还保留旧服务,只是不走流量
yaml 复制代码
spring:
  cloud:
    gateway:
      routes:
        # 规则1:100% 流量走这个路由
        - id: blog-gray-route
          uri: http://127.0.0.1:9092
          predicates:
            - Path=/api/manage/**
          filters:
            - StripPrefix=1
            - AddRequestHeader=gray-version, v2.0 # 可选:为下游服务添加灰度标识
            - Weight=group1, 100   # 100% 流量走这个路由

        # 规则2:0% 流量
        - id: blog-default-route
          uri: http://127.0.0.1:9091
          predicates:
            - Path=/api/manage/**
          filters:
            - StripPrefix=1
            - Weight=group1, 0     # 0% 流量

shell

目的:新启动的程序,只会替换最新时间的那个程序。

旧程序的关闭,是监听 元数据 autoclose=true 时,自动关闭。

bash 复制代码
#!/bin/bash

# === 配置区域 ===
PORT1=9091
PORT2=9092
APP_BASE=blog
APP_PATH=/data/website/blog
JAVA_HOME=/data/jdk-25/bin/java

# 生成带端口的 jar 文件名
get_jar_name() {
    echo "${APP_BASE}.$1.jar"
}

# === 工具函数 ===
# 检查指定端口的进程是否存在,返回 PID
is_port_running() {
    local port=$1
    local jar_name=$(get_jar_name $port)
    pid=$(ps -ef | grep "$jar_name" | grep -v grep | awk '{print $2}')
    if [ -z "$pid" ]; then
        return 1
    else
        echo $pid
        return 0
    fi
}

# 获取进程的启动时间戳(秒)
get_start_time() {
    local pid=$1
    local lstart=$(ps -p $pid -o lstart= 2>/dev/null)
    if [ -n "$lstart" ]; then
        date -d "$lstart" +%s 2>/dev/null
    else
        echo 0
    fi
}

# 获取当前运行的端口及 PID(如果两个都运行,返回两个)
get_running_ports() {
    local pid1
    local pid2
    local running_ports=""
    if pid1=$(is_port_running $PORT1); then
        running_ports="${PORT1}:${pid1}"
    fi
    if pid2=$(is_port_running $PORT2); then
        if [ -n "$running_ports" ]; then
            running_ports="${running_ports},${PORT2}:${pid2}"
        else
            running_ports="${PORT2}:${pid2}"
        fi
    fi
    echo "$running_ports"
}

# === 原有函数(修改) ===
usage() {
    echo "Usage: sh $0 [start|stop|restart|status]"
    exit 1
}

# 检查是否有任何实例运行(兼容旧调用)
is_exist() {
    if is_port_running $PORT1 >/dev/null || is_port_running $PORT2 >/dev/null; then
        return 0
    else
        return 1
    fi
}

# 启动日志(仅输出启动结果)
start_log() {
    local port=$1
    local jar_name=$(get_jar_name $port)
    local new_pid=$(is_port_running $port)
    if [ -n "$new_pid" ]; then
        echo "${jar_name} 启动成功! pid=${new_pid} 端口=${port}"
    else
        echo "${jar_name} 启动失败!请检查后重试"
    fi
}

# 启动方法(核心逻辑)
start() {
    local running_info=$(get_running_ports)
    local current_port=""
    local current_pid=""

    # 情况1:两个实例都在运行
    if [[ "$running_info" == *"${PORT1}"* && "$running_info" == *"${PORT2}"* ]]; then
        echo "检测到两个实例都在运行,将比较启动时间戳并杀掉较新的一个..."
        # 解析两个端口和pid
        IFS=',' read -ra entries <<< "$running_info"
        declare -A port_pid
        for entry in "${entries[@]}"; do
            port=${entry%:*}
            pid=${entry#*:}
            port_pid[$port]=$pid
        done
        time1=$(get_start_time ${port_pid[$PORT1]})
        time2=$(get_start_time ${port_pid[$PORT2]})
        if [ $time1 -gt $time2 ]; then
            kill_pid=${port_pid[$PORT1]}
            kill_port=$PORT1
        else
            kill_pid=${port_pid[$PORT2]}
            kill_port=$PORT2
        fi
        echo "杀掉较新实例:端口 ${kill_port} PID ${kill_pid}"
        kill -9 $kill_pid
        # 杀掉后,剩下的那个就是我们要保留的
        if [ $kill_port -eq $PORT1 ]; then
            current_port=$PORT2
            current_pid=${port_pid[$PORT2]}
        else
            current_port=$PORT1
            current_pid=${port_pid[$PORT1]}
        fi
        echo "保留实例:端口 ${current_port} PID ${current_pid}"
    # 情况2:只有一个实例在运行
    elif [ -n "$running_info" ]; then
        # 解析当前运行的端口和pid
        IFS=',' read -ra entries <<< "$running_info"
        entry="${entries[0]}"
        current_port=${entry%:*}
        current_pid=${entry#*:}
        echo "当前运行实例:端口 ${current_port} PID ${current_pid}"
    # 情况3:无实例运行
    else
        echo "没有运行中的实例,将启动默认端口 ${PORT1}"
        current_port=""
    fi

    # 决定要启动的新端口
    if [ -z "$current_port" ]; then
        new_port=$PORT1
    else
        if [ $current_port -eq $PORT1 ]; then
            new_port=$PORT2
        else
            new_port=$PORT1
        fi
    fi

    echo "准备启动新实例:端口 ${new_port}"

    # 复制基础 jar 包为带端口的 jar(确保使用最新代码)
    local base_jar="${APP_PATH}/${APP_BASE}.jar"
    local target_jar="${APP_PATH}/$(get_jar_name $new_port)"
    if [ ! -f "$base_jar" ]; then
        echo "错误:基础 jar 包不存在 ${base_jar}"
        exit 1
    fi
    cp "$base_jar" "$target_jar"
    echo "已复制 ${base_jar} -> ${target_jar}"

    # 启动新实例
    nohup ${JAVA_HOME}/java -jar "$target_jar" \
        --server.port=$new_port \
        --spring.profiles.active=prod \
        --spring.cloud.nacos.config.server-addr=127.0.0.1:8848 \
        > /dev/null 2>&1 &

    start_log $new_port

    # 如果之前有旧实例在运行,等待新实例就绪后将其停止(滚动更新)
    #if [ -n "$current_port" ]; then
    #    echo "等待 5 秒,确保新实例稳定..."
    #    sleep 5
        # 再次确认新实例已启动
    #    if is_port_running $new_port >/dev/null; then
    #        echo "新实例已就绪,停止旧实例(端口 ${current_port} PID ${current_pid})"
    #        kill $current_pid
    #        echo "旧实例已停止"
    #    else
    #        echo "警告:新实例未能成功启动,旧实例未停止"
    #    fi
    #fi
}

# 停止所有实例
stop() {
    local stopped=0
    for port in $PORT1 $PORT2; do
        local pid=$(is_port_running $port)
        if [ -n "$pid" ]; then
            kill  $pid
            echo "已关闭 $(get_jar_name $port) pid=${pid}"
            stopped=1
        fi
    done
    if [ $stopped -eq 0 ]; then
        echo "没有运行中的实例"
    fi
}

# 输出运行状态
status() {
    local running_info=$(get_running_ports)
    if [ -z "$running_info" ]; then
        echo "没有运行中的实例"
    else
        IFS=',' read -ra entries <<< "$running_info"
        for entry in "${entries[@]}"; do
            port=${entry%:*}
            pid=${entry#*:}
            start_time=$(get_start_time $pid)
            start_str=$(date -d "@$start_time" "+%Y-%m-%d %H:%M:%S" 2>/dev/null)
            echo "$(get_jar_name $port) 运行中 pid=${pid} 启动时间=${start_str}"
        done
    fi
}

# 重启:先停止所有,再启动默认端口
restart() {
    stop
    echo "准备重启..."
    sleep 3
    # 清空当前运行信息,启动新实例
    start
}

# === 参数解析 ===
case "$1" in
    "start")
        start
        ;;
    "stop")
        stop
        ;;
    "status")
        status
        ;;
    "restart")
        restart
        ;;
    *)
        usage
        ;;
esac