Spring cloud+eureka是目前微服务主流解决方案之一,kubernetes则是广泛应用的发布工具,两者结合使用很常见。而两者结合时如何优雅启停从而实现无感发布很关键。
下面将从不做特殊处理时启停存在的问题、业务代码设计要求、spring cloud+eureka本身停机处理机制、k8s滚动发布如何关联spring程序的启停机制 几点分析和提出解决方案。
1、不做特殊处理时启停存在的问题
1.1、启动服务时的问题
(1)spring boot只要对Application类做EnableDiscoveryClient注解,服务启动后会自动向eureka注册。如果服务需要预加载较多数据,预加载完成前的请求可能会失败。要解决需要控制注册发生在预加载后。
(2)spring程序启动和预加载数据需要时间。但k8s会在启动Pod后很快将外部请求导向服务,导致异常。解决这个问题需要k8s知道服务是否就绪。
1.2、停止服务时的问题
最原始的关闭服务方法是使用kill指令,常用的信号选项:
(1) kill -2 pid 向指定 pid 发送 SIGINT 中断信号, 等同于 ctrl+c.
(2) kill -9 pid, 向指定 pid 发送 SIGKILL 立即终止信号.
(3) kill -15 pid, 向指定 pid 发送 SIGTERM 终止信号.
(4) kill pid 等同于 kill 15 pid
SIGINT/SIGKILL/SIGTERM 信号的区别:
(1) SIGINT (ctrl+c) 信号(信号编号为2),信号会被当前进程树接收到,也就说, 不仅当前进程会收到该信号,而且它的子进程也会收到.
(2) SIGKILL信号(信号编号为 9),程序不能捕获该信号,最粗暴最快速结束程序的方法.
(3) SIGTERM 信号 (信号编号为 15), 信号会被当前进程接收到, 但它的子进程不会收到, 如果当前进程被 kill 掉, 它的的子进程的父进程将变成 init 进程 (init 进程是那个 pid 为 1 的进程)
结束某个服务,应该尽量使用 kill pid,而不是 kill -9 pid。如果服务提供关闭通知接口,在完全退出之前,可以先做一些善后处理。
k8s关闭pod时,相当于执行kill -15指令,且在一定时间后如果仍未关闭就执行kill -9 强制杀死进程,这个时间可以配置,默认30s。
Java提供了注册监听器接收SIGTERM信号的机制 —— 示例 。spring服务在接收到SIGTERM信号后,框架会回收线程,终止定时任务,而spring cloud的服务发现模块客户端会主动向eureka请求下线。但仍会存在以下问题。
(1) 处理中的任务会被直接打断,无法做妥善处理。比如sleep阻塞的线程会直接报异常 InterruptedException,执行中的定时任务会被直接终止退出。
(2) kill指令发出SIGTERM信号后,微服务会自动向eureka发送下线通知,但这个过程毕竟是网络请求,有延时。其他微服务仍然会有持续发送一些请求到要关停的服务,而这时服务内部spring框架底层已经在做停止服务的处理,容易产生异常。
这两个问题前者需要提前通知服务要被关停,并留足够时间做善后处理;后者需要微服务得到服务关停预告后主动向eureka发送下线请求。
2、代码设计要求
为了主动停止服务,业务代码要做到以下要求
- 定时任务的业务处理如果时间太长,要拆分成多个短时子任务,执行每个子任务前检查服务是否要终止,如果是则主动保存数据并不再执行后续任务。
- 未执行的任务在服务重新启动后或者由其他实例自动检测并执行,避免因停机产生的异常数据无法消除。
3、Spring cloud+eureka的优雅启停方案
只基于spring cloud+eureka体系做开发也很常见,所以先了解这个体系本身解决优雅启停的方案。
3.1微服务下线快速感知
要做到优雅启停,微服务下线后快速被其他微服务节点感知至关重要。快速感知才能避免微服务下线后其他服务还长时间继续请求。
spring cloud+eureka要做到下线快速感知有以下参数要注意配置。
eureka server端需要配置如下:
eureka:
server:
evictionIntervalTimerInMs: 5000 #启用主动失效,并且每次主动失效检测间隔为5000ms
responseCacheUpdateIntervalMs: 5000 #从ReadWriteMap刷新节点信息到ReadOnlyMap的时间,client读取的是后者,默认30s
eureka server端对微服务节点信息的记录有两层,ReadWriteMap和ReadOnlyMap。最新的变化在ReadWriteMap,经过一段时间后才会更新到 ReadOnlyMap。而提供给client读取的是 ReadOnlyMap,所以 responseCacheUpdateIntervalMs一般设置为3-5s较好。
另外提一下,很多文章解释快速感知时,会提到一个配置——enableSelfPreservation,即是自我保护开关,这个配置大意是当eureka发现节点的心跳大面积异常时就持续一段时间(默认5分钟,可配置)不更新节点信息。因为这时很可能是eureka server的网络出问题了,保留错误的数据也要避免彻底瘫痪。此机制触发时管理页面会有红字警告。但实测自我保护机制不会干扰服务主动下线时启停的感知,所以自我保护开关可以自行按需设置。
eureka client端需要配置如下
eureka:
instance:
#服务刷新时间,每隔这个时间会主动心跳一次
leaseRenewalIntervalInSeconds: 3
#服务过期时间,超过这个时间没有接收到心跳EurekaServer就会将这个实例剔除
leaseExpirationDurationInSeconds: 10
client:
fetchRegistry: true #定期更新eureka server拉去服务节点清单,快速感知服务下线
registryFetchIntervalSeconds: 5 #eureka client刷新本地缓存时间,默认30s
这四个配置前两者保证服务上下线都快速被eureka server感知,后两者保证快速感知其他微服务的上下线。
但leaseRenewalIntervalInSeconds 和 leaseExpirationDurationInSeconds两个配置的时间对于主动下线的感知没有影响。后面提到的优雅启停方案都是属于主动下线,所以这两个配置的值可以自行调整。
3.2启动问题处理方案
启动问题中的第一个问题:微服务自动向eureka注注册时数据预加载还未完成,是spring cloud本身就要处理的问题,第二个问题结合k8s时才会出现。
这里先说明第一个问题的解决方案。我们可以直接将预加载代码放在static代码块或者PostConstruct标注的代码块,这样可以使得预加载成为spring程序启动的一部分,从而使得自动注册必然发生在预加载后。同时为了方便对外提供状态,我们在Application类main方法最后一行记录加载完成状态为true,并记录时间。下面为示例代码:
/**
* 服务是否已经启动
*/
public volatile static boolean isStart = false;
/**
* 服务启动的时间
*/
public volatile static long startTime = 0;
/**
* 服务是否处于准备关闭的状态,这个时候一些定时任务就应该及时退出了
*/
public volatile static boolean isPreStop = false;
public static void main(String[] args) {
SpringApplication.run(xxxApplication.class, args);
logger.info("服务启动完毕");
startTime = System.currentTimeMillis();
isStart = true;
}
@PostConstruct
public void preload(){
logger.info("预加载完毕");
}
之后再提供一个接口给查询是否就绪,如果没就绪就返回500的http code。由于服务启动完毕后还要一定时间才能稳定注册到eureka并被其他微服务感知到,所以需要睡眠一定时间。这个时间应该至少是 responseCacheUpdateIntervalMs + registryFetchIntervalSeconds,如果要更保险,可以再增加几秒。如果要更严谨的话还可以增加判断注册是否成功。
示例代码如下,其中isPreStop变量在后面停止方案部分会赋值,主要是为了避免服务已经处于停机准备阶段了ifReady仍然返回true。
public boolean ifReady() {
long timeCur = System.currentTimeMillis();
if (isStart && !isPreStop && timeCur - startTime > 16000) {
return true;//success
} else {
//TODO:throw Exception在外层做异常处理并返回500的http code
}
}
3.3停止问题解决方案
停止服务两个问题的关键在于
- 主动通知服务要停止,并预留时间做处理工作;
- 主动向eureka发送下线通知
对于上面两个要求spring cloud+eureka体系有多种解决方案实现,下面只介绍最简单易用的方案。
- 服务提供一个http接口专门用于下线通知并在接口里做服务终止处理,调用接口返回成功后预留一定时间,且在查看日志确定所有请求都结束后再调用kill -9杀死服务。这个http接口最好不对外暴露或者限制ip白名单才能调用。
- 在上一步提到的http接口中向eureka发送下线通知,比如下面的代码就可以做到向eureka发送下线通知。由于其他微服务接收到该服务下线要一定时间,所以发送下线通知服务仍然要运行一段时间。这个时间和启动时的等待时间类似,至少要是 responseCacheUpdateIntervalMs + registryFetchIntervalSeconds ,为了保险可以多几秒。
@Autowired
private EurekaAutoServiceRegistration autoServiceRegistration;
public boolean preStop() {
preStopStartTime = System.currentTimeMillis();
isPreStop = true;
autoServiceRegistration.stop();
try {
Thread.sleep(16000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//TODO:这里才能做回收http接口要用到的资源的操作,包括数据缓存等
return true;
}
上面的方案http接口可以在服务器通过curl指令手工调用也可以结合shell脚本、jenkins脚本等使用做到自动化处理。
另外提一下,网上很多文章使用的是 EurekaClient对象的 shutDown方法,但是我实验到这种方式无法真正下线服务,在eurekaServer端会报以下异常,其中xxx为实例的名称,最后也没有搞清楚原因。
Cancelled instance xxx (replication=false)
DS: Registry: cancel failed because Lease is not registered for: xxxx
Not Found (Cancel): xxx
3.4小结
3.1提到的eureka client端fetchRegistry 、registryFetchIntervalSeconds两个配置和后面两个章节提到的就绪检测接口、prestop接口的预留时间也就是sleep的时间注意同一个系统的微服务要保持一致。
prestop接口的预留时间和eureka client的配置要一致很好理解,其他服务要感知到它已经下线不再向其发送请求,这个服务才能关闭。这些时间如果不统一可能出现在服务关停后其他微服务还没拉取到新的节点列表的情况。
就绪检测接口的时间和eureka client的配置要一致需要举例说明。假设eureka server responseCacheUpdateIntervalMs的值为5000,有两个微服务A和B,A有两个实例,A1和A2。A在就绪接口的预留时间是15s,而B服务更新服务列表的时间registryFetchIntervalSeconds为20s。这种情况下发布A1服务:
- A1服务启动完15s后就绪接口返回成功
- 发布管理脚本认为A1服务就绪完毕,别的节点也已经感知到A1的启动
- 发布管理脚本开始调用A2服务的prestop接口,等返回后成功后重启A2服务
- 但A1的发布实际上要20+5=25s才能被B感知到,剩下的时间B服务只能看到A2服务并继续向其发送请求
为了避免上面这种不可控的情况,各个微服务的相关配置还是直接设置为一致较好。
4、k8s发布时关联spring cloud启停方案
使用k8s发布服务默认使用的滚动发布方案,这个方案本身已经有一定机制减少发布的影响。滚动发布时发布完一个新版本的pod后才会下线一个旧的pod,并把指向sevice的请求经负载均衡指向新pod,直到所有旧的pod下线,新的pod全部发布完毕。
所以只要k8s在pod的启停时做到和微服务联动,就可以做到无感发布。关键在于探知微服务是否准备好了、通知服务将要停止、配置启停过程预留的时间。这几个方面k8s都有相关的机制,所以我们先了解这些机制,再整合得出解决思路。
4.1 k8s相关机制或配置
4.1.1 启动后就绪时间
这个机制相对简单粗暴,通过一个属性 minReadySeconds 设置pod启动后多长时间才被认为就绪,默认为0s。
4.1.2 探针机制
k8s提供了是两种探针的机制,分别为就绪探针 readinessProbe、存活探针 livenessProbe。
探针机制可以通过http接口、shell指令、tcp确认容器的状态。探针还可以配置延迟探测时间、探测间隔、探测成功或失败条件延后时间等参数。使用http接口探测时,可以配置header参数,如果响应的状态码大于等于200 且小于 400,则诊断被认为是成功的。
- 存活探针,主要用于检测pod是否异常,如果k8s通过健康探针检测到服务异常后会替换或重启容器
- 就绪探针,这个探测通过时才会将其加入到service匹配的endpoint列表中,并向该容器发送http请求,否则会将pod从列表移除直到就绪探针再次通过
就绪探针和存活探针比较类似,都会持续执行检测,只是检测会导致的结果不一样,一个会导致容器重启或被替换,一个会导致http请求停止分发到容器。
探针机制详细介绍可以查看 这里 。
详细的配置可以查看 这里。但是要注意这个文档里还提到启动探针机制,但笔者尝试配置无发生效,不确定是否和版本有关,所以对其不做介绍。
4.1.3 terminationGracePeriodSeconds 配置延迟关闭时间
该属性默认30s,只配置terminationGracePeriodSeconds这个属性而没有配置prestop时,k8s会先发送SIGTERM信号给主进程,然后然后等待terminationGracePeriodSeconds 属性的时间,会被使用SIGKILL杀死。这个机制相对简单粗暴。
4.1.4 prestop 机制
prestop机制,为容器生命周期钩子中的一种,也就是在准备关闭pod时会调用的。这个接口调用是至少一次,有可能调用多次,需要做好幂等处理。官方文档有详细介绍,可以参考查看 这里。这个机制和就绪探针类似,也可以通过http接口、shell指令两种方式进行。
这个机制跟terminationGracePeriodSeconds会有关联,k8s会根据prestop设置的方式通知服务将要关闭Pod,如果在GracePeriod时间后prestop钩子仍然还在运行,就会直接发送SIGTERM信号,等待2s后使用SIGKILL杀死主进程。更详细的步骤说明可以查看 这里
prestop对应的接口或者指令的指令执行时间不宜过长,且尽量少做资源消耗大的操作,30s以内为宜。因为k8s在pod关闭处理时会先陆续新建新的pod,如果prestop过长,会导致出现两倍数量的pod,如果prestop嗲用的接口或指令处理不当,可能会导致资源消耗达到正常值的两倍。
4.1.5 以上几类机制的配置示例
提供上述几个机制的deployment文件配置示例如下
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: review-demo
namespace: scm
labels:
app: review-demo
spec:
replicas: 3
# minReadySeconds: 60 #滚动升级时60s后认为该pod就绪
strategy:
rollingUpdate: ##由于replicas为3,则整个升级,pod个数在2-4个之间
maxSurge: 1 #滚动升级时会先启动1个pod
maxUnavailable: 1 #滚动升级时允许的最大Unavailable的pod个数
template:
metadata:
labels:
app: review-demo
spec:
terminationGracePeriodSeconds: 60 ##k8s将会给应用发送SIGTERM信号,可以用来正确、优雅地关闭应用,默认为30秒
containers:
- name: review-demo
image: library/review-demo:0.0.1-SNAPSHOT
imagePullPolicy: IfNotPresent
lifecycle:
preStop:
httpGet:
path: /prestop
port: 8080
scheme: HTTP
livenessProbe: #kubernetes认为该pod是存活的,不存活则需要重启
httpGet:
path: /health
port: 8080
scheme: HTTP
httpHeaders:
- name: Custom-Header
value: Awesome
initialDelaySeconds: 60 ## equals to the max startup time of the application + couple of seconds
timeoutSeconds: 10
successThreshold: 1
failureThreshold: 5
periodSeconds: 5 # 多少秒执行一次检测
readinessProbe: #kubernetes认为该pod是准备好接收http请求了的
httpGet:
path: /ifready
port: 8080
scheme: HTTP
httpHeaders:
- name: Custom-Header
value: Awesome
initialDelaySeconds: 30 #equals to min startup time of the app
timeoutSeconds: 10
successThreshold: 1
failureThreshold: 5
periodSeconds: 5 # 多少秒执行一次检测
resources:
# keep request = limit to keep this container in guaranteed class
requests:
cpu: 50m
memory: 200Mi
limits:
cpu: 500m
memory: 500Mi
env:
- name: PROFILE
value: "test"
ports:
- name: http
containerPort: 8080
4.2、基于以上机制解决启动问题
使用k8s的就绪探针配合3.2提到的spring cloud+eureka程序本身的启动和注册机制就可以完美的解决1.1提到的微服务启动未完成或预加载未完成就被注册到eureka以及被k8s导入外部流量的问题。
4.3、基于机制解决停机问题
1.2提到的k8s发布spring cloud服务时停机过程的问题只需结合3.3提到的方案,使用k8s preStop机制调用微服务的服务终止处理接口即可解决。但是prestop存在一个超时时间,如果设置的超时时间内prestop调用的接口没有完成,则服务存在强制退出的可能,从而产生异常数据。但绝大部分系统对结束处理的时长和严谨程度也没那么严格,而且由于异常宕机难以避免,一般程序本身也必须要有异常数据处理的方案,所以这个方案基本够用。
4.4、更严谨的方案
4.3提供的方案可以满足绝大部分场景了,但对高要求的场景而然有一定的缺陷。比Pod在关闭过程中终止或暂停各种在途任务和向eureka下线服务时很可能遇到异常情况,导致超出预期时间后服务仍未终止,可能需要继续保持pod运行,中断pod重启或升级方案等。
这个情况可以考虑使用k8s的ApiServer(可以通过接口管理pod等的增删查改和启停)结合WebHook机制访问自定义接口确认服务是否可以关闭。这种使用方法比较高级了,如果微服务体系比较大时可以考虑这种方案。具体参考此文章 的 另辟蹊径:解耦 Pod 删除的控制流 部分。
5、试验
为了试验这个优雅启停方案,基于k8s环境,使用两个spring cloud+eureka服务,其中一个服务A为认证服务,提供token有效性的检查接口,A服务使用两个实例。另外一个服务B调用A的token校验接口。
尝试不使用以上的优雅启停方案时,使用压测工具wrk次序访问B服务的接口,使其调用A服务的checkToken接口。然后发布新版本的A服务,结果在B服务的日志中发现大量访问A服务接口不通的错误日志,时间和A服务的发布时间符合。说明光靠k8s本身的滚动发布机制,无法保证微服务体系对内部的高可用。
而是使用以上的优雅启停方案后,服务B请求访问A服务的token校验接口失败的情况不再存在。
6、总结
前面4.2、4.3提到的方案虽然在一些极端情况,可能还会产生异常数据,但对于非金融场景已经够用。