背景
在k8s+SpringCloudAlibaba技术体系下,实现后台服务的优雅停机。注册中心和配置中心都是nacos,该方案中要使用Nacos的API所以需要Nacos的版本 >= 2.2.0
目的
服务部署上线停机时,不能出现异常。为实现这样的目的,需要同时满足以下两个条件:
- 新的请求不能打到要停机的服务上。为实现这样的效果需要先切流量。
- 已经持有的请求可以在预期时间内正常返回。这需要在停机前要等待一段时间。
出发点
尽量少的改动
原理
当新Pod就绪之后,k8s会向旧Pod发送停机信号,Pod中的线程要在指定的时间内退出,否则就会被强制杀掉,所以要实现优雅停机具体流程如下:
- 接收到停机信号后将服务的权重降为0,为的是不再接收新的请求。通过
entrypoint.sh
脚本,调用NacosAPI实现,API中的参数通过Dockerfile
设置。 - 等待客户端的缓存过期。等待的逻辑也在
entrypoint.sh
脚本。这里的缓存是指负载均衡的缓存,SpringCloudAlibaba体系下默认的是LoadBalancer
,通过参数spring.cloud.loadbalancer.cache.ttl
设置,默认35s。 - 等待已经持有的线程结束。
- 执行停机逻辑,从注册中心摘除。
具体实现
出于最小改动的目的,需要了解的参数如下:
- k8s参数
terminationGracePeriodSeconds
,默认为30s,如果向Pod发出停机信号后,在30s内没有退出,会被强制杀掉。 LoadBalancer
参数spring.cloud.loadbalancer.cache.ttl
,负载缓存更新时间。- 业务允许的最大请求时延,根据业务调整,如果超过30s,就需要调整
参数1
。 - 停机等待时间T。要满足
(参数2+参数3) < T < 参数1
。
假设业务允许的最大请求时延是10s,LoadBalancer
的过期时间也可以设置为10s,这样停机等待时间可以设置为23s,这样我们只需要通过Dockerfile设置ENV SERVER_SLEEP_TIME="23"
并且添加启动参数--spring.cloud.loadbalancer.cache.ttl=10
即可实现服务的优雅停机。所需脚本如下:
Dockerfile
FROM registry.cn-hangzhou.aliyuncs.com/supx/openjdk:17-ea-slim
ENV SERVER_NAME="smooth-server"
ENV SERVER_PORT="1002"
ENV NACOS_SERVER_ADDR="main.supx.tech:8848"
ENV NACOS_NAMESPPACE_ID="f5ae6f36-5ed6-46ae-b07a-4c9f86f1f286"
# 权重降为0后等待停机时间
ENV SERVER_SLEEP_TIME="23"
#nacos 基础配置
ENV NACOS_BASE_CONF="--spring.cloud.nacos.server-addr=${NACOS_SERVER_ADDR} --spring.cloud.nacos.config.namespace=${NACOS_NAMESPPACE_ID} --spring.cloud.nacos.discovery.namespace=${NACOS_NAMESPPACE_ID}"
# nacos其他配置
ENV NACOS_CONF=""
ENV JAVA_OPTS="-Xms256m -Xmx256m"
ENV SPRING_OPTS="--spring.cloud.loadbalancer.cache.ttl=10 --spring.application.name=${SERVER_NAME} --server.port=${SERVER_PORT}"
WORKDIR /${SERVER_NAME}
COPY ./target/${SERVER_NAME}-1.0.0-SNAPSHOT.jar ${SERVER_NAME}-1.0.0-SNAPSHOT.jar
COPY entrypoint.sh entrypoint.sh
EXPOSE ${SERVER_PORT}
ENTRYPOINT ["./entrypoint.sh"]
#CMD ["/bin/bash","-c","java -jar ${SERVER_NAME}-1.0.0-SNAPSHOT.jar"]
entrypoint脚本内容如下:
shell
#!/bin/bash
# 定义应用启动命令
start_command="java ${JAVA_OPTS} -jar ${SERVER_NAME}-1.0.0-SNAPSHOT.jar ${SPRING_OPTS} ${NACOS_BASE_CONF} ${NACOS_CONF}" "$@"
echo "启动命令:$start_command"
# 启动Java应用并获取其PID
$start_command &
pid=$!
# 定义一个函数来处理信号,并将其发送给Java进程
term_handler() {
echo "Caught SIGTERM/SIGINT, stopping the application..."
#服务降级处理
ifconfig_output=$(ifconfig)
ip_address=$(echo "$ifconfig_output" | grep -oE 'inet (addr:)?([0-9]{1,3}.){3}[0-9]{1,3}' | grep -oE '([0-9]{1,3}.){3}[0-9]{1,3}' | grep -v '127.0.0.1')
curl -d "serviceName=${SERVER_NAME}" -d "ip=${ip_address}" -d "port=${SERVER_PORT}" -d "weight=0" -d "namespaceId=${NACOS_NAMESPPACE_ID}" -X PUT "${NACOS_SERVER_ADDR}/nacos/v2/ns/instance"
echo "等待${SERVER_SLEEP_TIME}s..."
sleep ${SERVER_SLEEP_TIME}
#关闭程序
kill -TERM "$pid"
wait "$pid"
exit 0
}
# 捕获SIGTERM和SIGINT信号
trap term_handler SIGTERM SIGINT
# 等待子进程结束或接收到信号
wait "$pid"
echo "Application has stopped."
优缺点
优点 很明显,只需要修改Dockerfile就能实现优雅停机,不需要改动应用。
缺点主要表现在以下几点
- NacosAPI,不能保证可用。测试时偶尔会出现API不可用的情况,需要删除
/data/protocol
数据。如果调用失败了,脚本不能阻止Pod被杀掉,也就不能实现优雅停机。这是这个方案最大的问题,依赖NacosAPI! - 基础镜像需要安装
curl net-tools
,用来获取IP、调用NacoAPI。 - 默认情况下,本地配置无法覆盖配置中心的配置,需要在配置中心添加以下配置
yaml
spring:
cloud:
config:
overrideSystemProperties: false
思考
- 因为NacosAPI存在偶发问题,可以通过添加自定义接口的方式,用代码来控制停机逻辑。也可以重写
spring-boot-starter-actuator
的shutdownEndpoint
。因为nacos的停机逻辑是在静态代码块中注册的退出钩子,因此只能通过外部调用触发停机逻辑,否则无法实现自定义停机。 - 有人建议直接使用反注册的接口,不要先降级,因为这样会有两次交互。但是实际测试发现,NacosAPI的注销实例接口达不到预期效果,请求还是会被路由到要下线的节点上,不知道用代码控制会不会好点。