基于 Nginx 的单机 Java 服务零停机部署实践

针对单台服务器上 Java 服务启动缓慢、发布期间容易中断的问题,本文通过 Nginx 双端口轮换,让旧实例持续提供服务,在新实例启动并通过健康检查后再切换流量,实现 Java 服务的零停机更新。

Java 服务在启动时通常需要完成 Spring 容器初始化和数据库连接池创建,还要加载配置等工作,项目规模稍大以后,一次启动可能需要几十秒,甚至几分钟。如果部署时直接停止旧服务,再启动新服务,那么整个启动阶段都无法对外提供接口。

在服务器资源有限、暂时无法搭建集群的情况下,可以利用 Nginx + 双端口轮换 实现单机零停机部署:旧实例继续处理请求,新实例在另一个端口启动;确认新实例可用后,通过 Nginx 将流量切换过去,最后再停止旧实例。

直接重启服务存在的问题

最常见的部署方式是先停止旧进程,再启动新进程:

# 查找并停止旧服务
kill -15 "$(lsof -t -i:8082)"

# 启动新服务
nohup java -jar app.jar --server.port=8082 > app.log 2>&1 &

这种方式虽然简单,但从旧进程退出到新应用完全就绪之间,端口上没有可用服务。此时用户访问接口,通常会收到 502 Bad Gateway。

问题的根本是部署流程把“停止旧服务”放在了“新服务准备完成”之前。只要调整顺序,让两个实例在发布过程中短暂共存,就可以消除这段不可用时间。

双端口轮换的工作原理

准备 8082 和 8083 两个端口,每次只让 Nginx 将请求转发到其中一个端口。

假设当前线上服务运行在 8082,一次完整发布过程如下:

  1. 保持 8082 上的旧实例继续处理请求。
  2. 拉取代码并完成 Maven 打包。
  3. 在 8083 上启动新实例。
  4. 循环调用新实例的健康检查接口。
  5. 健康检查通过后,将 Nginx upstream 从 8082 切换到 8083。
  6. 执行 Nginx 配置检查并平滑重载。
  7. 等待旧请求处理完成,再停止 8082 上的旧实例。

下一次发布时反向操作,重新从 8083 切换到 8082。它与蓝绿部署的思想相同,只是两个实例运行在同一台服务器的不同端口上。

发布前:客户端 -> Nginx -> 8082(旧版本)

启动中:客户端 -> Nginx -> 8082(旧版本)
                            8083(新版本,暂不接收流量)

切换后:客户端 -> Nginx -> 8083(新版本)
                            8082(确认无请求后停止)

配置 Nginx upstream

创建 /www/wwwroot/nginx/v3.conf,使用 upstream 统一管理后端地址:

upstream backend_v3 {
    server 127.0.0.1:8082;
    keepalive 32;
}

server {
    listen 80;
    server_name example.com;

    location /api/ {
        proxy_pass http://backend_v3;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Connection "";
    }
}

同时需要确认 Nginx 主配置已经加载该文件:

http {
    include /www/wwwroot/nginx/*.conf;
}

修改完成后先检查配置,再重载:

nginx -t
nginx -s reload

nginx -s reload 是平滑重载。新 worker 会读取新配置并接收请求,旧 worker 会继续处理已经建立的连接,然后再退出,因此切换配置本身不会直接中断客户端连接。

应用健康检查

仅通过 lsof 判断端口是否监听并不可靠。端口开始监听时,Spring 容器、数据库连接或业务依赖不一定已经准备完成。更稳妥的方式是访问一个能够代表应用真实状态的 HTTP 接口。

如果项目已经引入 Spring Boot Actuator,可以开放健康检查端点:

management:
  endpoints:
    web:
      exposure:
        include: health
  endpoint:
    health:
      show-details: never

启动完成后,访问下面的地址进行验证:

curl --fail http://127.0.0.1:8083/actuator/health

只有接口返回成功状态码,部署脚本才允许 Nginx 切换流量。如果项目没有使用 Actuator,也可以提供一个轻量级的 /health 接口,但该接口至少应该反映应用初始化是否完成。

编写零停机部署脚本

#!/bin/bash

set -Eeuo pipefail

# Java 安装目录
export JAVA_HOME=/www/server/java/jdk1.8.0_371

# 当前脚本使用的命令搜索路径
export PATH="$JAVA_HOME/bin:$PATH"

# 项目根目录
PROJECT_DIR="/www/wwwroot/aigc-middle-platfrom-backend"

# Maven 打包生成的 JAR 文件
JAR_FILE="$PROJECT_DIR/target/ele-admin-api-1.5.0.jar"

# 应用日志目录
LOG_DIR="/www/wwwroot/logs"

# Nginx upstream 配置文件
NGINX_CONF="/www/wwwroot/nginx/v3.conf"

# 第一个轮换端口
PORT_A=8082

# 第二个轮换端口
PORT_B=8083

# 应用健康检查路径
HEALTH_PATH="/actuator/health"

# 等待新实例启动的最长时间,单位为秒
START_TIMEOUT=180

# 切流后为旧请求预留的处理时间,单位为秒
DRAIN_TIME=15

# 创建日志目录
mkdir -p "$LOG_DIR"

echo "开始部署后端服务..."

# 进入项目目录,后续 Git 与 Maven 命令都在该目录执行
cd "$PROJECT_DIR"

echo "正在拉取最新代码..."
git pull --ff-only origin master

echo "正在打包项目..."
mvn clean package -Dmaven.test.skip=true

# 当前正在接收 Nginx 流量的端口
if grep -Eq "server[[:space:]]+127\.0\.0\.1:${PORT_A}([[:space:];]|$)" "$NGINX_CONF"; then
    OLD_PORT=$PORT_A
    NEW_PORT=$PORT_B
elif grep -Eq "server[[:space:]]+127\.0\.0\.1:${PORT_B}([[:space:];]|$)" "$NGINX_CONF"; then
    OLD_PORT=$PORT_B
    NEW_PORT=$PORT_A
else
    echo "无法从 Nginx 配置识别当前服务端口,部署终止"
    exit 1
fi

# 新实例的日志文件
NEW_LOG_FILE="$LOG_DIR/backend-${NEW_PORT}.log"

# 新实例的健康检查地址
HEALTH_URL="http://127.0.0.1:${NEW_PORT}${HEALTH_PATH}"

echo "旧服务端口:$OLD_PORT"
echo "新服务端口:$NEW_PORT"

# 防止目标端口存在未清理的残留进程
if lsof -iTCP:"$NEW_PORT" -sTCP:LISTEN >/dev/null 2>&1; then
    echo "目标端口 $NEW_PORT 已被占用,部署终止"
    exit 1
fi

echo "正在启动新实例..."
nohup java \
    -Dfile.encoding=UTF-8 \
    -Dsun.jnu.encoding=UTF-8 \
    -Ddruid.mysql.usePingMethod=false \
    -jar "$JAR_FILE" \
    --spring.profiles.active=prod \
    --server.port="$NEW_PORT" \
    > "$NEW_LOG_FILE" 2>&1 &

# 新实例的进程号
NEW_PID=$!

# 新实例健康检查的截止时间
DEADLINE=$((SECONDS + START_TIMEOUT))

echo "等待新实例通过健康检查..."
while (( SECONDS < DEADLINE )); do
    if ! kill -0 "$NEW_PID" >/dev/null 2>&1; then
        echo "新实例已异常退出,请检查日志:$NEW_LOG_FILE"
        exit 1
    fi

    if curl --silent --show-error --fail "$HEALTH_URL" >/dev/null; then
        echo "新实例健康检查通过"
        break
    fi

    sleep 3
done

# 超时后再次检查,避免在不可用状态下切换流量
if ! curl --silent --show-error --fail "$HEALTH_URL" >/dev/null; then
    echo "新实例启动超时,停止新进程并终止发布"
    kill -15 "$NEW_PID" >/dev/null 2>&1 || true
    exit 1
fi

# 本次发布使用的 Nginx 配置备份
NGINX_BACKUP="${NGINX_CONF}.deploy-backup"

echo "正在切换 Nginx upstream..."
cp "$NGINX_CONF" "$NGINX_BACKUP"
sed -i "s/127\.0\.0\.1:${OLD_PORT}/127.0.0.1:${NEW_PORT}/g" "$NGINX_CONF"

# 配置检查或重载失败时恢复旧配置,并停止尚未接流量的新实例
if ! nginx -t || ! nginx -s reload; then
    echo "Nginx 切换失败,正在恢复旧配置"
    cp "$NGINX_BACKUP" "$NGINX_CONF"
    nginx -t && nginx -s reload
    kill -15 "$NEW_PID" >/dev/null 2>&1 || true
    exit 1
fi

rm -f "$NGINX_BACKUP"
echo "Nginx 已切换到端口:$NEW_PORT"

echo "等待旧请求处理完成..."
sleep "$DRAIN_TIME"

# 旧实例的进程号
OLD_PID="$(lsof -t -iTCP:"$OLD_PORT" -sTCP:LISTEN || true)"

if [[ -n "$OLD_PID" ]]; then
    echo "正在优雅停止旧实例:$OLD_PID"
    kill -15 "$OLD_PID"

    # 等待旧实例自行退出的次数
    for ((i = 0; i < 30; i++)); do
        if ! kill -0 "$OLD_PID" >/dev/null 2>&1; then
            break
        fi
        sleep 1
    done

    # 超时仍未退出时才强制结束进程
    if kill -0 "$OLD_PID" >/dev/null 2>&1; then
        echo "旧实例未在规定时间内退出,执行强制停止"
        kill -9 "$OLD_PID"
    fi
fi

echo "部署完成,当前服务端口:$NEW_PORT"

部署脚本执行过程

为脚本添加执行权限:

chmod +x deploy-v3.sh

执行发布:

./deploy-v3.sh

假设 Nginx 当前指向 8082,脚本会完成以下操作:

  1. 从 master 分支拉取最新代码。
  2. 执行 Maven 打包,打包失败则立即终止。
  3. 从 Nginx 配置识别当前线上端口为 8082。
  4. 在空闲的 8083 端口启动新实例。
  5. 在 180 秒内循环检查 /actuator/health。
  6. 健康检查通过后,将 upstream 修改为 8083。
  7. 运行 nginx -t,通过后再平滑重载 Nginx。
  8. 等待旧请求完成,向旧实例发送 SIGTERM。

部署过程中任何发生在切流之前的失败,都不会影响 8082 上正在运行的旧服务。Nginx 配置检查或重载失败时,脚本也会恢复备份配置,不会继续停止旧实例。

为什么不能直接使用 kill -9

kill -9 会立即终止进程,应用没有机会完成正在处理的请求,也无法正常释放线程、数据库连接和临时资源。部署脚本应该先发送 SIGTERM:

kill -15 "$OLD_PID"

如果使用的 Spring Boot 版本支持优雅停机,还可以在生产配置中增加:

server:
  shutdown: graceful

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

这样应用收到 SIGTERM 后会停止接收新请求,并为正在执行的请求预留完成时间。只有进程长时间无法退出时,才将 kill -9 作为兜底方案。

验证部署是否真的零停机

在另一台机器上持续请求业务接口,同时执行部署脚本:

while true; do
    curl --silent \
        --output /dev/null \
        --write-out '%{http_code}\n' \
        https://example.com/api/health
    sleep 0.2
done

发布期间应持续返回正常状态码,不能出现 502、503 或连接失败。还需要检查以下内容:

  • 新实例启动失败时,Nginx 是否仍然指向旧端口。
  • 健康检查失败时,脚本是否会停止发布。
  • Nginx 配置错误时,旧配置是否能够恢复。
  • 连续发布两次后,端口是否能在 8082 和 8083 之间正确轮换。
  • 长请求执行期间切流,旧进程是否会等待请求结束后再退出。

其他

这种方案解决的是应用发布过程中的服务中断。由于新旧实例会短暂同时运行,服务器还必须拥有足够的 CPU 和内存承载两个 Java 进程。

对于只有一台服务器的小型项目,Nginx 双端口轮换是一种成本较低、实现清晰的发布方案。当业务规模继续增长,或者需要服务器级别的高可用时,再进一步升级为多节点部署、负载均衡或容器编排会更加合适。