导语
本文介绍了微服务优雅上下线的实践方法及原理,包括适用于 Spring 应用的优雅上下线逻辑和服务预热,以及使用 Docker 实现无损下线的 Demo。同时,本文还总结了优雅上下线的价值和挑战。
作者简介
颜松柏
腾讯云微服务架构师
拥有超过10年的 IT 从业经验,精通软件架构设计,微服务架构,云架构设计等多个领域,在泛互、金融、教育、出行等多个行业拥有丰富的微服务架构经验。
前言
微服务优雅上下线的原理是指在微服务的发布过程中,保证服务的稳定性和可用性,避免因为服务的变更而造成流量的中断或错误。
微服务优雅上下线的原理可以从三个角度来考虑:
-
服务端的优雅上线,即在服务启动后,等待服务完全就绪后再对外提供服务,或者有一个服务预热的过程。
-
服务端的无损下线,即在服务停止前,先从注册中心注销,拒绝新的请求,等待旧的请求处理完毕后再下线服务。
-
客户端的容灾策略,即在调用服务时,通过负载均衡、重试、黑名单等机制,选择健康的服务实例,避免调用不可用的服务实例。
微服务优雅上下线可以提高微服务的稳定性和可靠性,减少发布过程中的风险和损失。
优雅上线
[外链图片转存中…(img-rvW8Rj8q-1689158362085)]
优雅上线,也叫无损上线,或者延迟发布,或者延迟暴露,或者服务预热。
优雅上线的目的是为了提高发布的稳定性和可靠性,避免因为应用的变更而造成流量的中断或错误。
优雅上线的方法
优雅上线的方法有以下几种:
-
延迟发布:即延迟暴露应用服务,比如应用需要一些初始化操作后才能对外提供服务,如初始化缓存,数据库连接池等相关资源就位,可以通过配置或代码来实现延迟暴露。
-
QoS 命令:即通过命令行或 HTTP 请求来控制应用服务的上线和下线,比如在应用启动时不向注册中心注册服务,而是在服务健康检查完之后再手动注册服务。
-
服务注册与发现:即通过注册中心来管理应用服务的状态和路由信息,比如在应用启动时向注册中心注册服务,并监听服务状态变化事件,在应用停止时向注册中心注销服务,并通知其他服务更新路由信息。
-
灰度发布:即通过分流策略来控制应用服务的流量分配,比如在发布新版本的应用时,先将部分流量导入到新版本的应用上,观察其运行情况,如果没有问题再逐步增加流量比例,直到全部切换到新版本的应用上。
上面的方法核心思想都是一个,就是等服务做好了准备再把请求放行过去。
优雅上线的实现
大部分优雅上线都是通过注册中心和服务治理能力来实现的。
对于初始化过流程较长的应用,由于注册通常与应用初始化过程同步进行,因此可能出现应用还未完全初始化就已经被注册到注册中心供外部消费者调用,此时直接调用可能会导致请求报错。
所以,通过服务注册与发现来做优雅上线的基本思路是:
-
在应用启动时,提供一个健康检查接口,用于反馈服务的状态和可用性。
-
应用启动后,可以采用下列方法来使新的请求暂时不进入新版的服务实例。
- 暂时不向注册中心注册服务。
- 隔离服务,有些注册中心支持隔离服务实例,比如北极星。
- 将权重配置为0。
- 将服务实例的 Enable 改为 False。
- 让健康检查接口返回不健康的状态。
-
在新版本的应用实例完成初始化操作后,确保了可用性后,再对应的将上述的方法取消,这样就可以让新的请求被路由到新版本的应用实例上。
-
如果需要预热,就让流量进入新版本的应用实例时按比例的一点点增加。
这样,就可以实现优雅上线的过程,保证请求进来的时候,不会因为新版本的应用实例没有准备好而导致请求失败。
优雅上线的北极星代码 Demo
我们以 Spring Cloud 和 北极星 为例,讲一下如何通过服务注册与发现来做优雅上线的过程。
首先,我们需要创建一个 Spring Cloud 项目,并添加北极星的依赖。
然后,我们需要在 application.properties 文件中配置北极星的相关信息,如注册中心地址,服务名,分组名等,例如:
spring:
application:
name: ${application.name}
cloud:
polaris:
address: grpc://${修改为第一步部署的 Polaris 服务地址}:8091
namespace: default
然后,我们需要创建一个 Controller 类,提供一个简单的接口,用于返回服务的信息,例如:
@RestController
public class ProviderController {
@Value("${server.port}")
private int port;
@GetMapping("/hello")
public String hello() {
return "Hello, I am provider, port: " + port;
}
}
最后,如果需要我们可以重写健康检查接口,用于反馈服务的状态和可用性。这里我们需要引入 Actuator。
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
@Override
public Health health() {
if (isDatabaseConnectionOK()) {
return Health.up().build();
} else {
return Health.down().withDetail("Error Code", "DB-001").build();
}
}
private boolean isDatabaseConnectionOK() {
// 检查数据库连接、缓存等
return true;
}
}
这样,我们就完成了一个简单的服务提供者应用,并且可以通过北极星来实现服务注册与发现。
接下来,我们需要创建一个服务消费者应用,并且也添加北极星的依赖和配置信息。
然后,使用 RestTemplate 来调用服务提供者的接口,例如:
@SpringBootApplication
public class ConsumerApplication {
public static void main(String[] args) {
SpringApplication.run(ConsumerApplication.class, args);
}
@Bean
@LoadBalanced // 开启负载均衡
public RestTemplate restTemplate() {
return new RestTemplate();
}
@RestController
public class ConsumerController {
@Autowired
private RestTemplate restTemplate;
@GetMapping("/hello")
public String hello() {
// 使用服务名来调用服务提供者的接口
return restTemplate.getForObject("<http://provider/hello>", String.class);
}
}
}
这里我们使用了 @LoadBalanced 注解来开启负载均衡功能,并且使用服务名 provider 来调用服务提供者的接口。
这样,我们就完成了一个简单的服务消费者应用,并且可以通过北极星来实现服务注册与发现。
接下来,我们就可以通过以下步骤来实现优雅上线的过程:
- 在发布新版本的服务提供者应用时,先启动新版本的应用实例,但是不向注册中心注册服务,或者让健康检查接口返回不健康的状态,这样就不会有新的请求进入新版本的应用实例。这可以通过配置或代码来实现,例如:
# 不向注册中心注册服务
spring.cloud.polaris.discovery.register=false
// 让健康检查接口返回不健康的状态
this.isHealthy = false;
- 在新版本的应用实例完成初始化操作后,再向注册中心注册服务,或者让健康检查接口返回健康的状态,这样就可以让新的请求被路由到新版本的应用实例上。这可以通过配置或代码来实现,例如:
# 向注册中心注册服务
spring.cloud.polaris.discovery.register=true
// 让健康检查接口返回健康的状态
this.isHealthy = true;
这样,就可以实现优雅上线的过程,保证正在处理的请求不会被中断,而新的请求会被路由到新版本的应用上。
不过,如果对优雅上线的极致要求不高,北极星本身就是支持优雅上线的,无须做额外的操作。因为北极星的逻辑是,当 Spring 的 Bean 全部加载完成后,Controller 能访问后才会去注册服务。所以,在绝大多数的场景下,它已经满足了优雅上线的要求。
服务预热
服务预热是指在服务上线之前,先让服务处于一个运行状态,让其加载必要的资源、建立连接等,以便在服务上线后能够快速响应请求。如下图所示。
在流量较大情况下,刚启动的服务直接处理大量请求可能由于应用内部资源初始化不彻底从而出现请求阻塞、报错等问题。此时通过服务预热,在服务刚启动阶段通过小流量帮助服务在处理大量请求前完成初始化,可以帮助发现服务上线后可能存在的问题,例如资源不足、连接数过多等,从而及时进行调整和优化,确保服务的稳定性和可靠性。
云原生 API 网关实现服务预热
云原生 API 网关是腾讯云基于开源微服务网关推出的一款高性能高可用的云上网关托管产品。我们可以通过简单的几个配置就能实现服务预热。
首先我们在网关新建后端服务的时候,可以打开下图中的慢启动开关。同时可以设置慢启动的时间。
开启后,服务端有新的服务节点上线后,会在设置的慢启动的时间内,将新节点的权重从1逐步增加到目标值。这个新节点的流量会慢慢增加。
如果有多个新增节点,那所有新增的节点都会慢启动。
针对后端来源是 K8S 服务 、注册中心、IP 列表的服务都可以实现慢启动,也就是服务预热。
优雅下线
无损下线、优雅下线都是同一个意思。都是为了避免服务下线的时候由于请求没有处理完导致请求失败的情况。
优雅下线的方法
无损下线的一些常用的工具或框架有:
-
Dubbo-go:支持多种注册中心、负载均衡、容灾策略等,可以实现优雅上下线的设计与实践。
-
Spring Cloud:提供了多种组件来实现服务的配置、路由、监控、熔断等,可以通过监听 ContextClosedEvent 事件来实现优雅下线的逻辑。
-
Docker:可以通过 Docker Stop 或 Docker Kill 命令来停止容器,前者会发送 SIGTERM 信号给容器的 PID1 进程,后者会发送 SIGKILL 信号。如果程序能响应 SIGTERM 信号,就可以实现优雅下线的操作。
Spring Cloud 优雅下线的原理
ContextClosedEvent 是 Spring 容器在关闭时发布的一个事件,可以通过实现 ApplicationListener 接口来监听这个事件,并在 onApplicationEvent 方法中执行一些自定义的逻辑。
对于 Spring Cloud 中的微服务来说,当收到 ContextClosedEvent 事件时,可以做以下几件事情:
-
从注册中心注销当前服务,这样就不会再有新的请求进入。
-
拒绝或者延迟新的请求,这样就可以保证正在处理的请求不会被中断。
-
等待一段时间,让旧的请求处理完毕,或者超时。
-
关闭服务,释放资源。
这样就可以实现优雅下线的逻辑,避免因为服务的变更而造成流量的中断或错误。
Spring Boot 优雅下线的 Demo
在旧版本里面,我们需要实现 TomcatConnectorCustomizer 和 ApplicationListener接口,然后就可以在 Customize 方法中获取到 Tomcat 的 Connector 对象,并在 onApplicationEvent 方法中监听到 Spring 容器的关闭事件。
在2.3及以后版本,我们只需要在 application.yml 中添加几个配置就能启用优雅关停了。
# 开启优雅停止 Web 容器,默认为 IMMEDIATE:立即停止
server:
shutdown: graceful
# 最大等待时间
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
这个开关的具体实现逻辑在我们在 GracefulShutdown 里。
然后我们需要添加 Actuator 依赖,然后在配置中暴露 Actuator 的 Shutdown 接口。
# 暴露 shutdown 接口
management:
endpoint:
shutdown:
enabled: true
endpoints:
web:
exposure:
include: shutdown
这个时候,我们调用 http://localhost:8080/actuator/shutdown 就可以执行优雅关停了,它会返回如下内容:
{
"message": "Shutting down, bye..."
}
优缺点
我觉得这种方法有以下的优点和缺点:
优点:
-
简单易用,只需要实现两个接口,就可以实现优雅下线的逻辑。
-
适用于 Tomcat 作为内嵌容器的 Spring Boot 应用,不需要额外的配置或依赖。
-
可以保证正在处理的请求不会被中断,而新的请求不会进入,避免了服务的变更造成流量的中断或错误。
缺点:
-
只适用于 Tomcat 作为内嵌容器的 Spring Boot 应用,如果使用其他的容器或部署方式,可能需要另外的实现。
-
需要等待一定的时间,让正在处理的请求完成或超时,这可能会影响服务的停止速度和资源的释放。
-
如果正在处理的请求过多或过慢,可能会导致线程池无法优雅地关闭,或者超过系统的终止时间,造成强制关闭。
Docker 优雅下线的 Demo
这里用一个简单的 JS 应用来演示 Docker 实现无损下线的过程。
首先,我们需要创建一个 Dockerfile 文件,用于定义一个简单的应用容器,代码如下:
# 基于 node:14-alpine 镜像
FROM node:14-alpine
# 设置工作目录
WORKDIR /app
# 复制 package.json 和 package-lock.json 文件
COPY package*.json ./
# 安装依赖
RUN npm install
# 复制源代码
COPY . .
# 暴露 3000 端口
EXPOSE 3000
# 启动应用
CMD [ "node", "app.js" ]
然后,我们需要创建一个 app.js 文件,用于定义一个简单的 Web 应用,代码如下:
// 引入 express 模块
const express = require('express');
// 创建 express 应用
const app = express();
// 定义一个响应 /hello 路径的接口
app.get('/hello', (req, res) => {
// 返回 "Hello, I am app" 字符串
res.send('Hello, I am app');
});
// 监听 3000 端口
app.listen(3000, () => {
// 打印日志信息
console.log('App listening on port 3000');
});
接下来,我们需要在终端中执行以下命令,来构建和运行我们的应用容器,并查看页面结果。
# 构建镜像,命名为 app:1.0.0
docker build -t app:1.0.0 .
# 运行容器,命名为 app-1,映射端口为 3001:3000
docker run -d --name app-1 -p 3001:3000 app:1.0.0
# 查看容器运行状态和端口映射信息
docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a8a9f9f7c6c4 app:1.0.0 "docker-entrypoint.s…" 10 seconds ago Up 9 seconds 0.0.0.0:3001->3000/tcp app-1
# 在浏览器中访问 <http://localhost:3001/hello> ,可以看到返回 "Hello, I am app" 字符串
这个时候假设我们要发布一个新版本的应用,我们需要修改 app.js 文件中的代码,把返回的字符串修改为 “Hello, I am app v2”。
然后,我们需要在终端中执行以下命令,来构建和运行新版本的应用容器:
# 构建镜像,命名为 app:2.0.0
docker build -t app:2.0.0 .
# 运行容器,命名为 app-2,映射端口为 3002:3000
docker run -d --name app-2 -p 3002:3000 app:2.0.0
# 查看容器运行状态和端口映射信息
docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
b7b8f8f7c6c4 app:2.0.0 "docker-entrypoint.s…" 10 seconds ago Up 9 seconds 0.0.0.0:3002->3000/tcp app-2
a8a9f9f7c6c4 app:1.0.0 "docker-entrypoint.s…" 2 minutes ago Up 2 minutes 0.0.0.0:3001->3000/tcp app-1
# 在浏览器中访问 <http://localhost:3002/hello> ,可以看到返回 "Hello, I am app v2" 字符串
接下来,需要优雅地下线旧版本的应用容器,让它完成正在处理的请求,然后停止接收新的请求,最后退出进程。
# 向旧版本的应用容器发送 SIGTERM 信号,让它优雅地终止
docker stop app-1
# 查看容器运行状态和端口映射信息
docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
b7b8f8f7c6c4 app:2.0.0 "docker-entrypoint.s…" 2 minutes ago Up 2 minutes 0.0.0.0:3002->3000/tcp app-2
# 在浏览器中访问 <http://localhost:3001/hello> ,可以看到无法连接到服务器的错误
这样,我们就实现了通过 Docker 来做优雅下线的过程,保证正在处理的请求不会被中断,而新的请求会被路由到新版本的应用上。
这里主要用到了 Docker Stop 命令。Docker Stop 命令会向容器发送 SIGTERM 信号,这是一种优雅终止进程的方式,它会给目标进程一个清理善后工作的机会,比如完成正在处理的请求,释放资源等。如果目标进程在一定时间内(默认为 10 秒)没有退出,Docker Stop 命令会再发送 SIGKILL 信号,强制终止进程。
所以,使用 Docker Stop 命令能实现优雅下线的前提是,容器中的应用能够正确地响应 SIGTERM 信号,并在收到该信号后执行清理工作。如果容器中的应用忽略了 SIGTERM 信号,或者在清理工作过程中出现异常,那么 Docker Stop 命令就无法实现优雅下线的效果。
让容器中的应用正确地响应 SIGTERM 信号的方法,主要取决于容器中的 1 号进程是什么,以及它如何处理信号。如果容器中的 1 号进程就是应用本身,那么应用只需要在代码中为 SIGTERM 信号注册一个处理函数,用于执行清理工作和退出进程。例如,在 Node.js 中,可以这样写:
// 定义一个处理 SIGTERM 信号的函数
function termHandler() {
// 执行清理工作
console.log('Cleaning up...');
// 退出进程
process.exit(0);
}
// 为 SIGTERM 信号注册处理函数
process.on('SIGTERM', termHandler);
北极星的优雅下线
北极星的心跳默认是5秒维持一次,客户端的缓存默认是2秒刷新一次。理论上,在极致情况下,服务下线会有2秒的不可用时间。但客户端都有重试机制,且大部分客户端的超时时间都是大于2秒的。因此大部分情况下,服务在北极星下线是不会造成业务感知的。
北极星的优雅下线有多种方式。其中上面的 Spring Boot 与 Docker 的方式是其中两种。
另外一种是可以在服务下线的时候,在 PreStop 的时候去做服务隔离与反注册。
这样的隔离操作可以手动做,也可以通过脚本来自动做。
[外链图片转存中…(img-SxCnVoCD-1689158362089)]
如上图,被隔离的实例将不会被主调方发现,这样就不会有新的需求进来,在处理完成现有的请求后,就可以执行下线操作了。
总结
优雅上下线的价值
在微服务实践中,实现优雅上下线能给我们带来以下好处:
-
最小化服务中断:通过优雅上下线,可以最小化服务中断的时间和影响范围,从而确保服务的可用性和稳定性。
-
避免数据丢失:优雅下线可以确保正在处理的请求能够完成,避免数据丢失和请求失败。
-
提高用户体验:优雅上下线可以确保用户在使用服务时不会遇到任何中断或错误,从而提高用户体验和满意度。
-
简化部署流程:通过使用自动化工具和流程,可以简化部署流程,减少人工干预和错误,提高部署效率和质量。
-
提高可维护性:通过使用监控和日志记录工具,可以及时发现和解决问题,提高服务的可维护性和可靠性。
这些好处可以帮助企业提高服务质量和效率,提升用户满意度和竞争力。
优雅上下线的挑战
但同时,优雅上下线也面临一些挑战:
-
复杂性增加:微服务架构通常由多个服务组成,每个服务都有自己的生命周期和依赖关系,因此优雅上下线需要考虑多个服务之间的交互和协调,增加了系统的复杂性。
-
部署流程复杂:优雅上下线需要使用自动化工具和流程,这需要投入大量的时间和资源来构建和维护,增加了部署流程的复杂性。
-
数据一致性问题:优雅下线需要确保正在处理的请求能够完成,但这可能会导致数据一致性问题,需要采取措施来解决这个问题。
-
人员技能要求高:微服务架构需要具备更高的技术水平和技能,需要拥有更多的开发和运维经验,这对企业的人员要求较高。
综上所述,企业需要认真考虑这些挑战,并采取相应的措施来解决这些问题,以确保在微服务实践中更好的落地优雅上下线。