目录
[1. 背景](#1. 背景)
[2. 排查](#2. 排查)
[3. 方案](#3. 方案)
[3.1. 外部脚本控制(运维层面)](#3.1. 外部脚本控制(运维层面))
[3.1.1. 优化前的sh脚本逻辑](#3.1.1. 优化前的sh脚本逻辑)
[3.1.2. 优化后的sh脚本逻辑](#3.1.2. 优化后的sh脚本逻辑)
[3.1.3. 测试结果](#3.1.3. 测试结果)
[3.2. 应用内优雅停机(代码层面)](#3.2. 应用内优雅停机(代码层面))
[3.2.1. 优雅停机实现类](#3.2.1. 优雅停机实现类)
[3.2.2. 测试结果](#3.2.2. 测试结果)
[4. 验证](#4. 验证)
[5. 上线](#5. 上线)
[6. 参考资料](#6. 参考资料)
1. 背景
项目为Spring Cloud微服务,注册中心使用nacos。在上线发布过程中发现存在feign调用失败的情况,即发布过程没有做到无损。上线发布采用灰度发布策略,即先发布一台机器,这台机器成功发布后再接着发布其他机器,理论上应该无损。
2. 排查
在上线发布微服务a第xx台机器过程中,a应该处于下线状态,不应该再请求到这台机器,但是从日志看有流量进来并调用a失败了:

3. 方案
借助AI及经验排查项目代码和停机sh脚本后发现代码层面及运维脚本层面都没有实现优雅停机,基于尽量少改动代码考虑,决定采用修改停机sh脚本的方案实现优雅停机。
3.1. 外部脚本控制(运维层面)
这是本案例采用的优雅停机方案,代码无侵入,只需完善停机sh脚本逻辑以支持优雅停机。
3.1.1. 优化前的sh脚本逻辑
停止实例时脚本没有先下线nacos注册中心服务实例,存在被调用方服务B刚停止但nacos还未感知到,此时调用方A从nacos获取到这个已停止的服务B的实例,导致调用失败。

3.1.2. 优化后的sh脚本逻辑
采取主动标记nacos实例下线 + 休眠等待 + 停止java进程方案:先标记nacos实例下线(主动下线而不是被动等待nacos自动检测下线),再设置一个休眠时间一是为了等待nacos更新,二是让正在处理的业务请求有时间完成。最后再停止java进程,避免请求丢失,实现微服务优雅停机。
bash
#!/bin/bash
# stop.sh - 优雅停机脚本
APP_NAME="provider"
APP_PORT=8082
NACOS_SERVER="127.0.0.1:8848"
echo "开始优雅停止 $APP_NAME..."
# 1. 获取服务ip
get_nacos_registered_ip() {
# 调用Nacos查询接口
local response=$(curl -s "http://${NACOS_SERVER}/nacos/v1/ns/instance/list?serviceName=${APP_NAME}")
if [ $? -ne 0 ] || [ -z "$response" ]; then
echo "查询Nacos服务失败" >&2
return 1
fi
# 解析JSON获取IP(需要jq工具)
if command -v jq >/dev/null 2>&1; then
local ip=$(echo "$response" | jq -r '.hosts[0].ip' 2>/dev/null)
if [ "$ip" != "null" ] && [ -n "$ip" ]; then
echo $ip
return 0
fi
fi
# 如果没有jq,使用grep/awk解析
local ip=$(echo "$response" | grep -o '"ip":"[^"]*' | cut -d'"' -f4 | head -1)
if [ -n "$ip" ]; then
echo $ip
return 0
fi
echo "无法从Nacos响应中解析IP" >&2
return 1
}
LOCAL_IP=$(get_nacos_registered_ip)
echo "检测到服务注册IP: $LOCAL_IP"
# 2. 从Nacos下线服务
echo "从Nacos注销服务..."
curl -X DELETE "${NACOS_SERVER}/nacos/v1/ns/instance?serviceName=${APP_NAME}&ip=${LOCAL_IP}&port=${APP_PORT}"
# 3. 等待流量切换
echo "等待流量切换(15秒)..."
sleep 15
# 4. 根据jar名称查找应用进程,比如provider2
echo "查找应用进程..."
# 更精确的进程查找方式
APP_PID=$(ps aux | grep "[j]ava.*provider2" | awk '{print $2}')
if [ -n "$APP_PID" ]; then
echo "找到应用进程,PID: $APP_PID"
# 5. 发送优雅停止信号
echo "发送优雅停止信号..."
kill -15 $APP_PID
# 6. 等待进程停止,最多30秒
for i in {1..30}; do
if kill -0 $APP_PID 2>/dev/null; then
echo "等待应用停止...($i/30)"
sleep 1
else
echo "应用已优雅停止"
exit 0
fi
done
# 7. 强制停止
echo "优雅停止超时,强制停止"
kill -9 $APP_PID
else
echo "应用未运行"
fi
echo "停止脚本执行完成"
另外,springboot项目配置文件application.yml配置优雅停机:
bash
server:
shutdown: graceful
3.1.3. 测试结果
脚本运行结果:

应用日志:

3.2. 应用内优雅停机(代码层面)
3.2.1. 优雅停机实现类
java
package com.example.provider;
import com.alibaba.cloud.nacos.registry.NacosRegistration;
import com.alibaba.cloud.nacos.registry.NacosServiceRegistry;
import org.apache.catalina.connector.Connector;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.web.embedded.tomcat.TomcatConnectorCustomizer;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
@Component
public class TomcatNacosGracefulShutdown implements TomcatConnectorCustomizer,
ApplicationListener<ContextClosedEvent> {
private static final Logger logger = LoggerFactory.getLogger(TomcatNacosGracefulShutdown.class);
private volatile Connector connector;
@Lazy
@Resource
private NacosServiceRegistry nacosServiceRegistry;
@Lazy
@Resource
private NacosRegistration nacosRegistration;
@Override
public void customize(Connector connector) {
this.connector = connector;
logger.info("Tomcat连接器自定义配置完成");
}
@Override
public void onApplicationEvent(ContextClosedEvent event) {
logger.info("=== 开始Tomcat+Nacos优雅停机 ===");
startGracefulShutdown();
}
public void startGracefulShutdown() {
try {
// 步骤1: Nacos注销
deregisterFromNacos();
// 步骤2: 等待注册中心传播
waitForRegistryPropagation();
// 步骤3: 暂停Tomcat接收新请求
pauseTomcat();
// 步骤4: 等待活跃请求完成
waitForActiveRequests();
logger.info("=== Tomcat+Nacos优雅停机完成 ===");
} catch (Exception e) {
logger.error("优雅停机失败", e);
}
}
private void deregisterFromNacos() {
if (nacosServiceRegistry != null && nacosRegistration != null) {
try {
logger.info("从Nacos注销服务实例...");
nacosServiceRegistry.deregister(nacosRegistration);
logger.info("Nacos注销成功: {}", nacosRegistration.getServiceId());
} catch (Exception e) {
logger.error("Nacos注销失败", e);
}
} else {
logger.warn("Nacos注册组件未找到,跳过注销");
}
}
private void waitForRegistryPropagation() {
try {
long waitTime = 15000;
logger.info("等待注册中心传播: {}ms", waitTime);
Thread.sleep(waitTime);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.warn("等待过程被中断");
}
}
private void pauseTomcat() {
if (connector != null) {
logger.info("暂停Tomcat连接器,停止接收新请求");
connector.pause();
} else {
logger.warn("Tomcat连接器未找到,无法暂停");
}
}
private void waitForActiveRequests() {
if (connector == null) {
logger.warn("Tomcat连接器未找到,跳过等待");
return;
}
Executor executor = connector.getProtocolHandler().getExecutor();
if (executor instanceof ThreadPoolExecutor) {
ThreadPoolExecutor threadPool = (ThreadPoolExecutor) executor;
logger.info("等待活跃请求完成...");
threadPool.shutdown();
try {
long maxWaitTime = 30000;
if (!threadPool.awaitTermination(maxWaitTime, TimeUnit.MILLISECONDS)) {
logger.warn("等待请求超时,强制关闭线程池");
threadPool.shutdownNow();
if (!threadPool.awaitTermination(5000, TimeUnit.MILLISECONDS)) {
logger.error("线程池未能正常终止");
}
} else {
logger.info("所有活跃请求处理完成");
}
} catch (InterruptedException e) {
threadPool.shutdownNow();
Thread.currentThread().interrupt();
logger.warn("等待过程被中断");
}
} else {
logger.warn("无法获取Tomcat线程池,跳过等待");
}
}
}
3.2.2. 测试结果

4. 验证
在测试环境用jemeter做接口测试,观察在执行停机脚本发布期间有无失败用例。

5. 上线
测试环境验证没问题后上线,生产发布时通过可观测平台查看发布服务的请求列表,发现错误数为0,成功率100%,且发布过程中没有日志告警,说明问题已修复。