superleeyom / blog

:bookmark: 个人博客仓库,用于记录一些幼稚的想法和脑残的瞬间,欢迎 star、watch,该仓库为个人博客,请不要提 issue ,该仓库后端参考了 @yihong0618 的 gitblog 项目,前端参考了@LoeiFy 的 Mirror 项目,感谢!
https://blog.leeyom.top
220 stars 20 forks source link

k8s实现Spring Cloud服务平滑升级解决方案 #27

Open superleeyom opened 3 years ago

superleeyom commented 3 years ago

背景

目前公司的服务是用 Spring Cloud 框架,且服务采用 k8s 进行部署,但是有新的服务需要升级的时候,虽然采用目前采用的滚动更新的方式,但是由于服务注册到 Eureka 上去的时候,会有30秒到1分钟左右不等的真空时间,这段时间会造成线上服务短时间的不能访问,所以在服务升级的时候,让服务能平滑升级达到用户无感的效果这是非常有必要的。

原因分析

在 Spring Cloud 的服务中,用户访问的一般都是网关(Gateway 或 Zuul),通过网关进行一次中转再去访问内部的服务,但是通过网关访问内部服务时需要一个过程,一般流程是这样的:服务启动好了后会先将自己注册信息(服务名->ip:端口)注册(上报)到 Eureka 注册中心,以便其他服务能访问到它,然后其他服务会定时访问(轮询 fetch 的默认时间间隔是 30s )注册中心以获取到 Eureka 中最新的服务注册列表。

那么通过k8s按照滚动更新新的方式来更新服务的话,就可能出现这样的情况:

在 T 时刻,serverA_1(老服务)已经 down 了,serverA_2(新服务)已经启动好,并已注册到了 eureka 中,但是对于 gateway 中缓存的注册列表中存在的仍是 serverA_1(老服务)的注册信息,那么此时用户去访问 serverA 就会报错的,因为serverA_1 所在的容器都已经 stop 了。

解决办法

1. Eureka参数优化

Client端

eureka:
  client:
    # 表示eureka client间隔多久去拉取服务注册信息,默认为30秒
    registryFetchIntervalSeconds: 5
ribbon:
  # ribbon本地服务列表刷新间隔时间,默认为30秒
  ServerListRefreshInterval: 5000

Server端

eureka:
  server:
    # eureka server清理无效节点的时间间隔,默认60秒
    eviction-interval-timer-in-ms: 5000
    # eureka server刷新readCacheMap(二级缓存)的时间,默认时间30秒
    response-cache-update-interval-ms: 5000

以上两个优化主要是缩短服务上线下线的时候,尽可能快的刷新 eureka client 端和 server 端服务注册列表的缓存。

2. 网关开启重试机制

因为我们用的是 zuul 网关,开启重试机制,防止在滚动更新的时候,由于网关层服务注册列表的缓存,将请求打到已下线的节点,zuul 请求失败后,会自动重试一次,重试其他可用节点,不至于直接报错给用户:

ribbon:
  # 同一实例最大重试次数,不包括首次调用
  MaxAutoRetries: 0
  # 重试其他实例的最大重试次数,不包括首次所选的server
  MaxAutoRetriesNextServer: 1
  # 是否所有操作都进行重试
  OkToRetryOnAllOperations: false
zuul:
  # 开启Zuul重试功能
  retryable: true

关于 OkToRetryOnAllOperations 属性,默认值是 false,只有在请求是 GET 的时候会重试,如果设置为 true的话,这样设置之后所有的类型的方法(GET、POST、PUT、DELETE等)都会进行重试,server 端需要保证接口的幂等性,例如发生 read timeout 时,若接口不是幂等的,则可能会造成脏数据,这个是需要注意的点!

3. 需要下线的服务主动从注册中心里移除

利用k8s的容器回调 PreStop 钩子,在容器被stop终止之前,将需要被 down 掉的服务主动从注册中心进行移除,针对容器,有两种类型的回调处理程序可供实现:

4. 延迟就绪探针首次探针时间

在服务的 k8s 的 deployment 配置文件中添加 redainessProbe 和 livenessProbe,但是这两个有什么区别呢?

这里在实际操作的时候,LivenessProbeinitialDelaySeconds 的值通常要大于 ReadinessProbeinitialDelaySeconds 的值,否则 pod 节点会起不起来,因为此时 pod 还没有就绪,存活指针就去探测的话,肯定是会失败的,这时候 k8s 会认为此 pod 已经不存活,就会把 pod 销毁重建。

5. 优雅停机保证正在执行的业务操作不受影响

首先先明确旧 Pod 是怎么下线的,如果是 linux 系统,会默认执行kill -15的命令,通知 web 应用停止,最后 Pod 删除。那什么叫优雅停机?他的作用是什么?简单说就是,在对应用进程发送停止指令之后,能保证正在执行的业务操作不受影响。应用接收到停止指令之后的步骤应该是,停止接收访问请求,等待已经接收到的请求处理完成,并能成功返回,这时才真正停止应用。SpringBoot 2.3 目前已支持了优雅停机,当使用server.shutdown=graceful启用时,在 web 容器关闭时,web 服务器将不再接收新请求,并将等待活动请求完成的缓冲期。但是我们公司使用的 SpringBoot 版本为 2.1.5.RELEASE,需要通过编写部分额外的代码去实现优雅停机,根据 web 容器的不同,有分为tomcatundertow 的解决方案:

tomcat 方案

/**
 * 优雅关闭 Spring Boot tomcat
 */
@Slf4j
@Component
public class GracefulShutdownTomcat implements TomcatConnectorCustomizer, ApplicationListener<ContextClosedEvent> {
    private volatile Connector connector;
    private final int waitTime = 30;

    @Override
    public void customize(Connector connector) {
        this.connector = connector;
    }

    @Override
    public void onApplicationEvent(ContextClosedEvent contextClosedEvent) {
        this.connector.pause();
        Executor executor = this.connector.getProtocolHandler().getExecutor();
        if (executor instanceof ThreadPoolExecutor) {
            try {
                ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor;
                threadPoolExecutor.shutdown();
                if (!threadPoolExecutor.awaitTermination(waitTime, TimeUnit.SECONDS)) {
                    log.warn("Tomcat thread pool did not shut down gracefully within " + waitTime + " seconds. Proceeding with forceful shutdown");
                }
            } catch (InterruptedException ex) {
                Thread.currentThread().interrupt();
            }
        }
    }
}
@EnableDiscoveryClient
@SpringBootApplication
public class ShutdownApplication {

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

    @Autowired
    private GracefulShutdownTomcat gracefulShutdownTomcat;

    @Bean
    public ServletWebServerFactory servletContainer() {
        TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory();
        tomcat.addConnectorCustomizers(gracefulShutdownTomcat);
        return tomcat;
    }
}

undertow方案

/**
 * 优雅关闭 Spring Boot undertow
 */
@Component
public class GracefulShutdownUndertow implements ApplicationListener<ContextClosedEvent> {

    @Autowired
    private GracefulShutdownUndertowWrapper gracefulShutdownUndertowWrapper;

    @Autowired
    private ServletWebServerApplicationContext context;

    @Override
    public void onApplicationEvent(ContextClosedEvent contextClosedEvent) {
        gracefulShutdownUndertowWrapper.getGracefulShutdownHandler().shutdown();
        try {
            UndertowServletWebServer webServer = (UndertowServletWebServer)context.getWebServer();
            Field field = webServer.getClass().getDeclaredField("undertow");
            field.setAccessible(true);
            Undertow undertow = (Undertow) field.get(webServer);
            List<Undertow.ListenerInfo> listenerInfo = undertow.getListenerInfo();
            Undertow.ListenerInfo listener = listenerInfo.get(0);
            ConnectorStatistics connectorStatistics = listener.getConnectorStatistics();
            while (connectorStatistics.getActiveConnections() > 0){}
        } catch (Exception e) {
            // Application Shutdown
        }
    }
}
@Component
public class GracefulShutdownUndertowWrapper implements HandlerWrapper {
    private GracefulShutdownHandler gracefulShutdownHandler;
    @Override
    public HttpHandler wrap(HttpHandler handler) {
        if(gracefulShutdownHandler == null) {
            this.gracefulShutdownHandler = new GracefulShutdownHandler(handler);
        }
        return gracefulShutdownHandler;
    }
    public GracefulShutdownHandler getGracefulShutdownHandler() {
        return gracefulShutdownHandler;
    }
}
public class UnipayProviderApplication {
    public static void main(String[] args) {
        SpringApplication.run(UnipayProviderApplication.class);
    }
    @Autowired
    private GracefulShutdownUndertowWrapper gracefulShutdownUndertowWrapper;
    @Bean
    public UndertowServletWebServerFactory servletWebServerFactory() {
        UndertowServletWebServerFactory factory = new UndertowServletWebServerFactory();
        factory.addDeploymentInfoCustomizers(deploymentInfo -> deploymentInfo.addOuterHandlerChainWrapper(gracefulShutdownUndertowWrapper));
        factory.addBuilderCustomizers(builder -> builder.setServerOption(UndertowOptions.ENABLE_STATISTICS, true));
        return factory;
    }
}

ok,经过以上的优化后,基本上就能做到在用户无感知的情况下,进行滚动更新。

参考资料