00 前言
微服务部署是一个非常严谨的话题,微服务开发完成需要上线部署,在整个部署过程中怎么保证业务的连续性,怎么能让服务的客户端无感知,这是一个具有一定挑战性的问题。
为了达到不同目的,微服务的部署方式有很多种方式:滚动部署、蓝绿部署、灰度/金丝雀部署。无论是哪一种部署方式,都需要三步操作:停止老版本应用、部署新版本应用、切流量,这三步操作可能是手动也可能是自动,而且它们的顺序也不一定。这其中的两步是非常关键:切流量和停止老版本应用,要想保证业务的连续性和客户端无感知,需要在这两个步骤上下功夫。
在上线部署过程中保证业务连续性的问题,在软件行业是一直存在,只是在不同的时期解决方案不一样。
单体应用:依靠负载均衡器(例如nginx)手动切流量,逐步实现多节点部署;
微服务(分布式):服务客户端自动同步服务端节点在线情况,以及丰富的容错机制;
微服务(service Mesh):service Mesh 组件的智能负载均衡和容错机制;
上面的操作只是让服务调用方避开正在部署的节点,这样就能保证应用部署过程中业务的连续性了吗?不能。在这个过程忽略了一个关键点,应用停止的过程,想象一个场景:客户端刚发送完请求,到达服务端,服务端正在处理的过程中(还没有完成并响应给客户端),这时重新部署触发了停机操作。在这个场景中可以想象到,这时立即停止应用,这部分服务端正在处理的业务操作就会中断,这样的错误往往是很严重的。如果能解决这个问题,才能真正地在部署应用的时候保证业务的连续性,客户端无感知。
上面说到这个问题其实就是优雅停机解决的问题,前面已经有一篇文章从 Java 和 Spring boot 的角度介绍了优雅停机,里面包含了很多基础知识,详细请参见文章 Spring boot 2.0 之优雅停机
。这里总结一下这片文章的知识点:
优雅停机的概念:在对应用进程发送停止指令之后,能保证正在执行的业务操作不受影响;
优雅停机的测试方案;
Java语言是如何支持优雅停机的;
为什么 Spring boot 的 actuator/shutdown 不支持优雅停机;
Spring boot 2.0 + tomcat(undertow)如何支持优雅停机的;
阅读本文之前最好先阅读一下上面这篇文章,了解一下基础知识。本文换个姿势再说优雅停机,主要从容器云平台(DCOS)、service Mesh组件(Linkerd)和应用开发框架(Spring boot)结合的角度介绍优雅停机,以及微服务的部署。
01 准备知识
在做下面的实现、测试和验证之前需要了解一些基础知识:
1. Spring boot 优雅停机
我们使用的开发框架组合方案是:Spring boot 2.0 + tomcat8,我们的应用进程需要实现优雅停机,我们的实现方式:
package com.epay.demox.unipay.provider;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.event.ContextClosedEvent;import org.springframework.stereotype.Component;import java.util.concurrent.Executor;import java.util.concurrent.ThreadPoolExecutor;import java.util.concurrent.TimeUnit;/**
* @Author: guoyankui
* @DATE: 2018/5/20 12:59 PM
*
* 优雅关闭 Spring Boot tomcat
*/@Componentpublic class GracefulShutdownTomcat implements TomcatConnectorCustomizer, ApplicationListener{ private final Logger log = LoggerFactory.getLogger(GracefulShutdownTomcat.class); private volatile Connector connector; private final int waitTime = 30; @Override
public void customize(Connector connector) { this.connector = connector;
} @Override
public void onApplicationEvent(ContextClosedEvent contextClosedEvent) { if (connector == null) { return;
} 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();
}
}
}
}
spring boot配置:
@Autowiredprivate GracefulShutdownTomcat gracefulShutdownTomcat;@Beanpublic ServletWebServerFactory servletContainer() {
TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory();
tomcat.addConnectorCustomizers(gracefulShutdownTomcat); return tomcat;
}
2. kill 命令
命令格式:kill[参数][进程号]
命令功能:
发送指定的信号到相应进程。不指定型号将发送SIGTERM(15)终止指定进程。如果任无法终止该程序可用“-KILL” 参数,其发送的信号为SIGKILL(9) ,将强制结束进程,使用ps命令或者jobs 命令可以查看进程号。root用户将影响用户的进程,非root用户只能影响自己的进程。
kill 命令的信号:共有64个信号值,其中常用的是 2(SIGINT:中断,ctrl+c)、15(SIGTERM:终止,默认值)和 9(SIGKILL:强制终止)。
3. Docker 进程管理
Docker鼓励“一个容器一个进程(one process per container)”的方式,这种方式非常适合以单进程为主的微服务架构的应用。在Docker中,进程管理的基础就是Linux内核中的PID namespace技术。每个Container都是Docker Daemon的子进程,每个Container进程缺省都具有不同的PID namespace。
在ENTRYPOINT和CMD指令中,提供两种不同的进程执行方式 shell 和 exec,shell的方式启动PID1进程不是你的应用进程,子进程是你的应用进程,要想应用进程是PID1,需要使用exec方式。
当执行docker stop命令时,docker会首先向容器的PID1进程发送一个SIGTERM信号,用于容器内程序的退出。如果容器在收到SIGTERM后没有结束, 那么Docker Daemon会在等待一段时间(默认是10s)后,再向容器发送SIGKILL信号,将容器杀死变为退出状态。这种方式给Docker应用提供了一个优雅的退出(graceful stop)机制,允许应用在收到stop命令时清理和释放使用中的资源。而docker kill可以向容器内PID1进程发送任何信号,缺省是发送SIGKILL信号来强制退出应用。强制停止的等待时间可以通过docker stop命令的-t参数设置。
容器的PID1进程需要能够正确的处理SIGTERM信号来支持优雅退出。
如果容器中包含多个进程,需要PID1进程能够正确的传播SIGTERM信号来结束所有的子进程之后再退出。
确保PID1进程是期望的进程。缺省sh/bash进程没有提供SIGTERM的处理,需要通过shell脚本来设置正确的PID1进程,或捕获SIGTERM信号。
参考文章。
4. DCOS 基本操作
在DCOS平台上,针对某一个容器的操作:restart、scale、stop等,还可以通过marathon docker管理工具后台重新部署容器。
5. 模拟待测试的业务功能
@ApiOperation(value = 模拟长时间处理业务)@GetMapping(value = /sleep/one, produces = application/json)public ResultEntitysleepOne(String systemNo){
logger.info(模拟长时间业务处理,请求参数:{}, systemNo);
Long serverTime = System.currentTimeMillis(); while (System.currentTimeMillis() serverTime + sleepTime) {
logger.info(正在处理业务,处理时间设置:{},当前时间:{},开始时间:{}, sleepTime, System.currentTimeMillis(), serverTime);
}
ResultEntityresultEntity = new ResultEntity(serverTime);
logger.info(模拟长时间业务处理,响应参数:{}, resultEntity); return resultEntity;
}@ApiOperation(value = 设置业务处理时间)@GetMapping(value = /biz/time/set, produces = application/json)public ResultEntitybizTime(Long sleepTime){
logger.info(设置业务处理时间,请求参数:{}, sleepTime); this.sleepTime = sleepTime;
ResultEntityresultEntity = new ResultEntity(sleepTime);
logger.info(设置业务处理时间,响应参数:{}, resultEntity); return resultEntity;
}
02 优雅停机测试结果
产生下述测试结果的测试方发是:业务处理时间设置40s,使用jmeter工具发起连续性测试(模拟10个用户,进行10轮测试),然后从测试环境、应用是否实现优雅停机、停止方法、jmeter客户端失败原因几个维度进行对比。测试环境选择了本地和DCOS容器云平台对比,应用是是否添加优雅停机的配置。
测试结果数据解释:
先说明一下,客户端报出的这几种错误的含义:
failed to respond:客户端和服务端建立了socket连接,并发送了数据,但是没有收到响应,客户端会报该错误。
connecttion reset:客户端和服务端建立了socket连接,在发送数据之前,服务端关闭了连接,客户端再发送数据就会报该错误。
connecttion refused:客户端连接服务端的时候,服务端ip或端口不存在,客户端会报该错误。所以,要实现了优雅停机之后,客户端报错不能有failed to respond。从测试结果来看,只有本地环境测试实现了优雅停机,以及DCOS环境下使用docker kill命令停止实现了优雅停机。
为什么在DCOS平台上正常操作容器停止不能实现优雅停机?分析原因,DCOS上容器停止操作发送的是 docker stop 命令,根据上面 docker stop 命令的实现原理(docker kill -s 15之后,等待一段时间(默认10s)之后,如果还不能停止,会在发送docker kill -s 9强制停止),容器应用是被kill -9强制停止了,应用实现的优雅停机是不能hook信号9,而应用的业务处理时间是40s,所以客户端不能收到响应。
于是,开始寻找解决办法,后来发现DCOS中有个配置来控制这个时间,在marathon.json中优雅的时间区间设置方式:taskKillGracePeriodSeconds: 50。设置这个参数之后,在DCOS上再次测试,就能正常实现优雅停机了。
03 微服务部署
这时,回头看看我们的目标:整个部署过程中保证业务的连续性,让服务的客户端无感知。
1. 要做到应用容器停止不影响正在执行的业务
需要将 marathon 中的配置taskKillGracePeriodSeconds配合业务处理时间做调整,建议这个参数最大设置为30s,因为设置时间过大的化,而且你的业务处理时间又很长的话,会导致应用容器停止需要很长时间。一般的应用不会有这样的问题,一般的处理时间都在10s以内。
需要重点关注批处理应用可能处理时间比较长,如果业务处理时间确实特别长的话,需要在接收到停止指令之后,在30s内做一些善后的处理,比如记录一下任务执行到的位置,下次启动的时候重新从此开始。
2. 负载均衡组件能自动感知服务节点下线和上线
比如,如果请求发送到了一个已经停止了的服务节点,客户端会收到connecttion reset或者connecttion refused,这时该负载均衡组件能自动尝试别的在线节点,有了这种容错机制就能保证请求的成功率了。或者负载均衡组件实时自动更新了在线的服务节点列表,直接不会将请求发往已经下线的服务节点了。
有了以上两点的保证就能完美实现我们微服务部署的目标了。