微服务优雅上下线的实践方法

1,882 阅读16分钟

💡 本文介绍了微服务优雅上下线的实践方法,包括适用于 Spring 应用的优雅上下线逻辑,以及使用 Docker 实现无损下线的 demo,以及服务预热。同时,本文还总结了优雅上下线的价值和挑战。

前言

image.png

微服务优雅上下线的原理是指在微服务的发布过程中,保证服务的稳定性和可用性,避免因为服务的变更而造成流量的中断或错误。

微服务优雅上下线的原理可以从三个角度来考虑:

  • 服务端的优雅上线,即在服务启动后,等待服务完全就绪后再对外提供服务,或者有一个服务预热的过程。
  • 服务端的无损下线,即在服务停止前,先从注册中心注销,拒绝新的请求,等待旧的请求处理完毕后再下线服务。
  • 客户端的容灾策略,即在调用服务时,通过负载均衡、重试、黑名单等机制,选择健康的服务实例,避免调用不可用的服务实例。

微服务优雅上下线可以提高微服务的稳定性和可靠性,减少发布过程中的风险和损失。

优雅上线

image.png

优雅上线,也叫无损上线,或者延迟发布,或者延迟暴露,或者服务预热。

优雅上线的目的是为了提高发布的稳定性和可靠性,避免因为应用的变更而造成流量的中断或错误。

优雅上线的方法

优雅上线的方法有以下几种:

  • 延迟发布:即延迟暴露应用服务,比如应用需要一些初始化操作后才能对外提供服务,如初始化缓存,数据库连接池等相关资源就位,可以通过配置或代码来实现延迟暴露。
  • QoS命令:即通过命令行或HTTP请求来控制应用服务的上线和下线,比如在应用启动时不向注册中心注册服务,而是在服务健康检查完之后再手动注册服务。
  • 服务注册与发现:即通过注册中心来管理应用服务的状态和路由信息,比如在应用启动时向注册中心注册服务,并监听服务状态变化事件,在应用停止时向注册中心注销服务,并通知其他服务更新路由信息。
  • 灰度发布:即通过分流策略来控制应用服务的流量分配,比如在发布新版本的应用时,先将部分流量导入到新版本的应用上,观察其运行情况,如果没有问题再逐步增加流量比例,直到全部切换到新版本的应用上。

上面的方法核心思想都是一个,就是等服务做好了准备再把请求放行过去。

优雅上线的实现

大部分优雅上线都是通过注册中心和服务治理能力来实现的。

对于初始化过流程较长的应用,由于注册通常与应用初始化过程同步进行,因此可能出现应用还未完全初始化就已经被注册到注册中心供外部消费者调用,此时直接调用可能会导致请求报错。

所以,通过服务注册与发现来做优雅上线的基本思路是:

  • 在应用启动时,提供一个健康检查接口,用于反馈服务的状态和可用性。

  • 应用启动后,可以采用下列方法来使新的请求暂时不进入新版的服务实例。

    • 暂时不向注册中心注册服务。
    • 隔离服务,有些注册中心支持隔离服务实例,比如北极星。
    • 将权重配置为0,比如nacos。
    • 将服务实例的enable改为false,比如nacos。
    • 让健康检查接口返回不健康的状态。
  • 在新版本的应用实例完成初始化操作后,确保了可用性后,再对应的将上述的方法取消,这样就可以让新的请求被路由到新版本的应用实例上。

  • 如果需要预热,就让流量进入新版本的应用实例时按比例的一点点增加。

这样,就可以实现优雅上线的过程,保证请求进来的时候,不会因为新版本的应用实例没有准备好而导致请求失败。

优雅上线的代码demo

我们以 Spring Cloud 和 Nacos 为例,讲一下如何通过服务注册与发现来做优雅上线的过程。

首先,我们需要创建一个 Spring Cloud 项目,并添加 Nacos 的依赖。

然后,我们需要在 application.properties 文件中配置 Nacos 的相关信息,如注册中心地址,服务名,分组名等,例如:

spring.application.name=provider
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
spring.cloud.nacos.discovery.group=DEFAULT_GROUP

接下来,我们需要在启动类上添加 @EnableDiscoveryClient 注解,表示开启服务注册与发现功能,例如:

@SpringBootApplication
@EnableDiscoveryClient
public class ProviderApplication {

    public static void main(String[] args) {
        SpringApplication.run(ProviderApplication.class, args);
    }

}

然后,我们需要创建一个 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;
    }
}

这样,我们就完成了一个简单的服务提供者应用,并且可以通过 Nacos 来实现服务注册与发现。

接下来,我们需要创建一个服务消费者应用,并且也添加 Nacos 的依赖和配置信息。

然后,我们需要在启动类上添加 @EnableDiscoveryClient 注解,表示开启服务注册与发现功能,并且使用 RestTemplate 来调用服务提供者的接口,例如:

@SpringBootApplication
@EnableDiscoveryClient
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 来调用服务提供者的接口。

这样,我们就完成了一个简单的服务消费者应用,并且可以通过 Nacos 来实现服务注册与发现。

接下来,我们就可以通过以下步骤来实现优雅上线的过程:

  • 在发布新版本的服务提供者应用时,先启动新版本的应用实例,但是不向注册中心注册服务,或者让健康检查接口返回不健康的状态,这样就不会有新的请求进入新版本的应用实例。这可以通过配置或代码来实现,例如:
# 不向注册中心注册服务
spring.cloud.nacos.discovery.register-enabled=false
// 让健康检查接口返回不健康的状态
this.isHealthy = false;
  • 在新版本的应用实例完成初始化操作后,再向注册中心注册服务,或者让健康检查接口返回健康的状态,这样就可以让新的请求被路由到新版本的应用实例上。这可以通过配置或代码来实现,例如:
# 向注册中心注册服务
spring.cloud.nacos.discovery.register-enabled=true
// 让健康检查接口返回健康的状态
this.isHealthy = true;

这样,就可以实现优雅上线的过程,保证正在处理的请求不会被中断,而新的请求会被路由到新版本的应用上。

服务预热

服务预热是指在服务上线之前,先让服务处于一个运行状态,让其加载必要的资源、建立连接等,以便在服务上线后能够快速响应请求。如下图所示。

在流量较大情况下,刚启动的服务直接处理大量请求可能由于应用内部资源初始化不彻底从而出现请求阻塞、报错等问题。此时通过服务预热,在服务刚启动阶段通过小流量帮助服务在处理大量请求前完成初始化,可以帮助发现服务上线后可能存在的问题,例如资源不足、连接数过多等,从而及时进行调整和优化,确保服务的稳定性和可靠性。

image.png

Spring Boot实现服务预热

我们可以通过使用 Spring Boot Actuator 来实现服务预热。

  1. 添加 Spring Boot Actuator 依赖。
  2. 配置了将所有 Actuator 端点暴露出来,并启用了预热端点。
management.endpoints.web.exposure.include=*
management.endpoint.warmup.enabled=true
  1. 这时我们就可以调用warmup接口来实现预热了。默认的接口如下:http://localhost:8080/actuator/warmup

这里spring的warmup 端点会做以下几件事情:

  1. 加载 Spring 上下文
  2. 初始化连接池
  3. 加载缓存数据
  4. 发送测试请求

如果我们想自定义预热逻辑,我们也可以通过实现warmup接口来自定义预热的逻辑。代码如下:

@Component
public class MyWarmup implements Warmup {

    @Override
    public void warmup() {
        // 实现预热逻辑
    }
}

优雅下线

image.png

无损下线、优雅下线都是同一个意思。都是为了避免服务下线的时候由于请求没有处理完导致请求失败的情况。

优雅下线的方法

无损下线的一些常用的工具或框架有:

  • Dubbo-go:支持多种注册中心、负载均衡、容灾策略等,可以实现优雅上下线的设计与实践。
  • Spring Cloud:提供了多种组件来实现服务的配置、路由、监控、熔断等,可以通过监听 ContextClosedEvent 事件来实现优雅下线的逻辑。
  • Docker:可以通过 docker stopdocker kill 命令来停止容器,前者会发送 SIGTERM 信号给容器的 PID1 进程,后者会发送 SIGKILL 信号。如果程序能响应 SIGTERM 信号,就可以实现优雅下线的操作。

Spring Cloud优雅下线的原理

ContextClosedEvent 是 Spring 容器在关闭时发布的一个事件,可以通过实现 ApplicationListener 接口来监听这个事件,并在 onApplicationEvent 方法中执行一些自定义的逻辑。

对于 Spring Cloud 中的微服务来说,当收到 ContextClosedEvent 事件时,可以做以下几件事情:

  • 从注册中心注销当前服务,这样就不会再有新的请求进入。
  • 拒绝或者延迟新的请求,这样就可以保证正在处理的请求不会被中断。
  • 等待一段时间,让旧的请求处理完毕,或者超时。
  • 关闭服务,释放资源。

这样就可以实现优雅下线的逻辑,避免因为服务的变更而造成流量的中断或错误。

Spring boot优雅下线的demo

在旧版本里面,我们需要实现 TomcatConnectorCustomizerApplicationListener<ContextClosedEvent> 接口,然后就可以在 customize 方法中获取到 Tomcat 的 Connector 对象,并在 onApplicationEvent 方法中监听到 Spring 容器的关闭事件。

在2.3及以后版本,我们只需要在application.yml中添加几个配置就能启用优雅关停了。

# 开启优雅停止 Web 容器,默认为 IMMEDIATE:立即停止
server:
  shutdown: graceful

# 最大等待时间
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

这个开关的具体实现逻辑在我们在 GracefulShutdown 里。

然后我们需要添加actuator依赖,然后在配置中暴露actuatorshutdown接口。

# 暴露 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);

总结

image.png

优雅上下线的价值

在微服务实践中,实现优雅上下线能给我们带来以下好处:

  1. 最小化服务中断:通过优雅上下线,可以最小化服务中断的时间和影响范围,从而确保服务的可用性和稳定性。
  2. 避免数据丢失:优雅下线可以确保正在处理的请求能够完成,避免数据丢失和请求失败。
  3. 提高用户体验:优雅上下线可以确保用户在使用服务时不会遇到任何中断或错误,从而提高用户体验和满意度。
  4. 简化部署流程:通过使用自动化工具和流程,可以简化部署流程,减少人工干预和错误,提高部署效率和质量。
  5. 提高可维护性:通过使用监控和日志记录工具,可以及时发现和解决问题,提高服务的可维护性和可靠性。

这些好处可以帮助企业提高服务质量和效率,提升用户满意度和竞争力。

优雅上下线的挑战

但同时,优雅上下线也面临一些挑战:

  1. 复杂性增加:微服务架构通常由多个服务组成,每个服务都有自己的生命周期和依赖关系,因此优雅上下线需要考虑多个服务之间的交互和协调,增加了系统的复杂性。
  2. 部署流程复杂:优雅上下线需要使用自动化工具和流程,这需要投入大量的时间和资源来构建和维护,增加了部署流程的复杂性。
  3. 数据一致性问题:优雅下线需要确保正在处理的请求能够完成,但这可能会导致数据一致性问题,需要采取措施来解决这个问题。
  4. 人员技能要求高:微服务架构需要具备更高的技术水平和技能,需要拥有更多的开发和运维经验,这对企业的人员要求较高。

综上所述,企业需要认真考虑这些挑战,并采取相应的措施来解决这些问题,以确保在微服务实践中更好的落地优雅上下线。