基于 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,一次完整发布过程如下:
- 保持
8082上的旧实例继续处理请求。 - 拉取代码并完成 Maven 打包。
- 在
8083上启动新实例。 - 循环调用新实例的健康检查接口。
- 健康检查通过后,将 Nginx upstream 从
8082切换到8083。 - 执行 Nginx 配置检查并平滑重载。
- 等待旧请求处理完成,再停止
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,脚本会完成以下操作:
- 从
master分支拉取最新代码。 - 执行 Maven 打包,打包失败则立即终止。
- 从 Nginx 配置识别当前线上端口为
8082。 - 在空闲的
8083端口启动新实例。 - 在 180 秒内循环检查
/actuator/health。 - 健康检查通过后,将 upstream 修改为
8083。 - 运行
nginx -t,通过后再平滑重载 Nginx。 - 等待旧请求完成,向旧实例发送
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 双端口轮换是一种成本较低、实现清晰的发布方案。当业务规模继续增长,或者需要服务器级别的高可用时,再进一步升级为多节点部署、负载均衡或容器编排会更加合适。