【Spring Boot】【优雅停机一】Spring Boot 停机的正确方式
1 前言
这节我们来看看 SpringBoot 该怎么停机,怎么优雅的停机。
2 何为优雅关机
就是为确保应用关闭时,通知应用进程释放所占用的资源
- 线程池,shutdown(不接受新任务等待处理完)还是shutdownNow(调用
Thread.interrupt
进行中断) - socket 链接,比如:netty、mq
- 告知注册中心快速下线(靠心跳机制客服早都跳起来了),比如:eureka
- 清理临时文件,比如:poi
- 各种堆内堆外内存释放
总之,进程强行终止会带来数据丢失或者终端无法恢复到正常状态,在分布式环境下还可能导致数据不一致的情况。
3 kill指令
kill -9 pid
可以模拟了一次系统宕机,系统断电等极端情况,而kill -15 pid
则是等待应用关闭,执行阻塞操作,有时候也会出现无法关闭应用的情况(线上理想情况下,是bug就该寻根溯源)
#查看jvm进程pid jps #列出所有信号名称 kill -l # Windows下信号常量值 # 简称 全称 数值 # INT SIGINT 2 Ctrl+C中断 # ILL SIGILL 4 非法指令 # FPE SIGFPE 8 floating point exception(浮点异常) # SEGV SIGSEGV 11 segment violation(段错误) # TERM SIGTERM 5 Software termination signal from kill(Kill发出的软件终止) # BREAK SIGBREAK 21 Ctrl-Break sequence(Ctrl+Break中断) # ABRT SIGABRT 22 abnormal termination triggered by abort call(Abort) #linux信号常量值 # 简称 全称 数值 # HUP SIGHUP 1 终端断线 # INT SIGINT 2 中断(同 Ctrl + C) # QUIT SIGQUIT 3 退出(同 Ctrl + \) # KILL SIGKILL 9 强制终止 # TERM SIGTERM 15 终止 # CONT SIGCONT 18 继续(与STOP相反, fg/bg命令) # STOP SIGSTOP 19 暂停(同 Ctrl + Z) #.... #可以理解为操作系统从内核级别强行杀死某个进程 kill -9 pid #理解为发送一个通知,等待应用主动关闭 kill -15 pid #也支持信号常量值全称或简写(就是去掉SIG后) kill -l KILL
jvm是如何接受处理linux信号量的?
当然是在jvm启动时就加载了自定义SignalHandler
,关闭jvm时触发对应的handle。
public interface SignalHandler { SignalHandler SIG_DFL = new NativeSignalHandler(0L); SignalHandler SIG_IGN = new NativeSignalHandler(1L); void handle(Signal var1); } class Terminator { private static SignalHandler handler = null; Terminator() { } //jvm设置SignalHandler,在System.initializeSystemClass中触发 static void setup() { if (handler == null) { SignalHandler var0 = new SignalHandler() { public void handle(Signal var1) { Shutdown.exit(var1.getNumber() + 128);//调用Shutdown.exit } }; handler = var0; try { Signal.handle(new Signal("INT"), var0);//中断时 } catch (IllegalArgumentException var3) { ; } try { Signal.handle(new Signal("TERM"), var0);//终止时 } catch (IllegalArgumentException var2) { ; } } } }
4 JVM的优雅退出
JVM的优雅退出机制,主要是通过 Hook实现的。
jvm有shutdwonHook机制,中文习惯叫优雅退出。
VM的优雅退出Hook,和linux系统中执行SIGTERM(kill -15 或者 svc -d)时,退出前执行的一些操作。
4.1 JVM 退出的钩子函数
首先看看,JVM 退出的钩子函数 的应用场景。
我们的java程序运行在JVM上,有很多情况可能会突然崩溃掉,比如:
- OOM
- 用户强制退出
- 业务其他报错
- 等
一系列的问题,可能导致我们的JVM 进程挂掉。
JVM 退出的钩子函数是指在 JVM 进程即将退出时,自动执行用户指定的代码段。
这个功能的应用场景比较广泛,例如:
- 资源释放:在 JVM 退出时,需要释放一些资源,比如关闭数据库连接、释放文件句柄等,可以使用钩子函数来自动执行这些操作。
- 日志记录:在 JVM 退出时,可以记录一些关键信息,比如程序运行时间、内存使用情况等,以便后续分析问题。
- 数据持久化:在 JVM 退出时,可以将一些重要的数据持久化到磁盘上,以便下次启动时可以恢复状态。
- 安全退出:在 JVM 退出时,可以执行一些清理操作,比如删除临时文件、关闭网络连接等,以确保程序的安全退出。
总之,钩子函数可以在 JVM 退出时执行一些自定义的操作,以便更好地管理和控制程序的运行。至少,能知道咱们的JVM进程,什么时间,什么原因发生过异常退出。
4.2 JVM 退出的钩子函数的使用
在java程序中,可以通过添加关闭钩子函数,实现在程序退出时关闭资源、优雅退出的功能。
如何做呢?主要就是通过Runtime.addShutDownHook(Thread hook)来实现的。
Runtime.addShutdownHook(Thread hook) 是 Java 中的一个方法,用于在 JVM 关闭时注册一个线程来执行清理操作。
Runtime.addShutdownHook(Thread hook) 每一次调用,就是注册一个线程,参考代码如下:
// 添加hook thread,重写其run方法 Runtime.getRuntime().addShutdownHook(new Thread(){ @Override public void run() { System.out.println("this is hook demo..."); // jvm 退出的钩子逻辑 } });
Runtime.addShutdownHook(Thread hook) 可以调用多次,从而注册多个线程。
当 JVM 即将关闭时,会按照注册的顺序依次执行这些线程,以便进行一些资源释放、日志记录或其他清理操作。
这个方法可以在应用程序中用来确保在程序退出前执行一些必要的清理工作,例如关闭数据库连接或释放文件句柄等。
下面我们来简单看一个Runtime.addShutDownHook(Thread hook) 使用示例:
// 创建HookTest,我们通过main方法来模拟应用程序 public class HookTest { public static void main(String[] args) { // 添加hook thread,重写其run方法 Runtime.getRuntime().addShutdownHook(new Thread(){ @Override public void run() { System.out.println("this is hook demo..."); // jvm 退出的钩子逻辑 } }); int i = 0; // 这里会报错,jvm回去执行hook thread int j = 10/i; System.out.println("j" + j); } }
执行之后,结果如下:
Exception in thread "main" java.lang.ArithmeticException: / by zero at hook.HookTest.main(HookTest.java:23) this is hook demo... Process finished with exit code 1
总结:我们主动写了一个报错程序,在程序报错之后,钩子函数还是被执行了。
经验证,我们是可以通过对Runtime添加钩子函数,来完成退出时的善后工作。
4.3 Runtime.addShutDownHook(Thread hook)触发场景
既然JDK提供的这个方法可以注册一个JVM关闭的钩子函数,那么这个函数在什么情况下会被调用呢?
上述我们展示了在程序异常情况下会被调用,还有没有其他场景呢?
- 程序正常退出
- 使用System.exit()
- 终端使用Ctrl+C触发的中断
- 系统关闭
- OutofMemory宕机
- 使用Kill pid杀死进程(使用kill -9是不会被调用的)
- ..等等
Runtime.addShutDownHook(Thread hook)触发场景,详见下图
4.4 Runtime.addShutdownHook 源码
我们来细细看下Runtime.getRuntime().addShutdownHook(shutdownHook):
public class Runtime { public void addShutdownHook(Thread hook) { SecurityManager sm = System.getSecurityManager(); if (sm != null) { sm.checkPermission(new RuntimePermission("shutdownHooks")); } ApplicationShutdownHooks.add(hook); } } class ApplicationShutdownHooks { /* The set of registered hooks */ private static IdentityHashMap<Thread, Thread> hooks; static synchronized void add(Thread hook) { if(hooks == null) throw new IllegalStateException("Shutdown in progress"); if (hook.isAlive()) throw new IllegalArgumentException("Hook already running"); if (hooks.containsKey(hook)) throw new IllegalArgumentException("Hook previously registered"); hooks.put(hook, hook); } } //它含数据结构和逻辑管理虚拟机关闭序列 class Shutdown { /* Shutdown 系列状态*/ private static final int RUNNING = 0; private static final int HOOKS = 1; private static final int FINALIZERS = 2; private static int state = RUNNING; /* 是否应该运行所以finalizers来exit? */ private static boolean runFinalizersOnExit = false; // 系统关闭钩子注册一个预定义的插槽. // 关闭钩子的列表如下: // (0) Console restore hook // (1) Application hooks // (2) DeleteOnExit hook private static final int MAX_SYSTEM_HOOKS = 10; private static final Runnable[] hooks = new Runnable[MAX_SYSTEM_HOOKS]; // 当前运行关闭钩子的钩子的索引 private static int currentRunningHook = 0; /* 前面的静态字段由这个锁保护 */ private static class Lock { }; private static Object lock = new Lock(); /* 为native halt方法提供锁对象 */ private static Object haltLock = new Lock(); static void add(int slot, boolean registerShutdownInProgress, Runnable hook) { synchronized (lock) { if (hooks[slot] != null) throw new InternalError("Shutdown hook at slot " + slot + " already registered"); if (!registerShutdownInProgress) {//执行shutdown过程中不添加hook if (state > RUNNING)//如果已经在执行shutdown操作不能添加hook throw new IllegalStateException("Shutdown in progress"); } else {//如果hooks已经执行完毕不能再添加hook。如果正在执行hooks时,添加的槽点小于当前执行的槽点位置也不能添加 if (state > HOOKS || (state == HOOKS && slot <= currentRunningHook)) throw new IllegalStateException("Shutdown in progress"); } hooks[slot] = hook; } } /* 执行所有注册的hooks */ private static void runHooks() { for (int i=0; i < MAX_SYSTEM_HOOKS; i++) { try { Runnable hook; synchronized (lock) { // acquire the lock to make sure the hook registered during // shutdown is visible here. currentRunningHook = i; hook = hooks[i]; } if (hook != null) hook.run(); } catch(Throwable t) { if (t instanceof ThreadDeath) { ThreadDeath td = (ThreadDeath)t; throw td; } } } } /* 关闭JVM的操作 */ static void halt(int status) { synchronized (haltLock) { halt0(status); } } //JNI方法 static native void halt0(int status); // shutdown的执行顺序:runHooks > runFinalizersOnExit private static void sequence() { synchronized (lock) { /* Guard against the possibility of a daemon thread invoking exit * after DestroyJavaVM initiates the shutdown sequence */ if (state != HOOKS) return; } runHooks(); boolean rfoe; synchronized (lock) { state = FINALIZERS; rfoe = runFinalizersOnExit; } if (rfoe) runAllFinalizers(); } //Runtime.exit时执行,runHooks > runFinalizersOnExit > halt static void exit(int status) { boolean runMoreFinalizers = false; synchronized (lock) { if (status != 0) runFinalizersOnExit = false; switch (state) { case RUNNING: /* Initiate shutdown */ state = HOOKS; break; case HOOKS: /* Stall and halt */ break; case FINALIZERS: if (status != 0) { /* Halt immediately on nonzero status */ halt(status); } else { /* Compatibility with old behavior: * Run more finalizers and then halt */ runMoreFinalizers = runFinalizersOnExit; } break; } } if (runMoreFinalizers) { runAllFinalizers(); halt(status); } synchronized (Shutdown.class) { /* Synchronize on the class object, causing any other thread * that attempts to initiate shutdown to stall indefinitely */ sequence(); halt(status); } } //shutdown操作,与exit不同的是不做halt操作(关闭JVM) static void shutdown() { synchronized (lock) { switch (state) { case RUNNING: /* Initiate shutdown */ state = HOOKS; break; case HOOKS: /* Stall and then return */ case FINALIZERS: break; } } synchronized (Shutdown.class) { sequence(); } } }
5 SpringBoot应用如何优雅退出
5.1 Spring如何添加钩子函数
Spring/SpringBoot应用,如何手动添加钩子函数呢?
Spring/SpringBoot 提供了一个方法:
// 通过这种方式来添加钩子函数 ApplicationContext.registerShutdownHook();
ApplicationContext.registerShutdownHook() 方法是 Spring 框架中的一个方法,用于注册一个 JVM 关闭的钩子(shutdown hook)。
当 JVM 关闭时,Spring 容器可以优雅地关闭并释放资源,从而避免了可能的资源泄漏或其他问题。
ApplicationContext.registerShutdownHook() 方法应该在 Spring 应用程序的 main 方法中调用,以确保在 JVM 关闭时 Spring 容器能够正确地关闭。
下面是 Spring 官方的原文介绍:
5.2 registerShutdownHook()源码分析
spring通过JVM实现注册退出钩子的源码如下:
// 通过源码可以看到, @Override public void registerShutdownHook() { if (this.shutdownHook == null) { // No shutdown hook registered yet. this.shutdownHook = new Thread() { @Override public void run() { synchronized (startupShutdownMonitor) { doClose(); } } }; // 也是通过这种方式来添加 Runtime.getRuntime().addShutdownHook(this.shutdownHook); } } // 重点是这个doClose()方法 protected void doClose() { // Check whether an actual close attempt is necessary... if (this.active.get() && this.closed.compareAndSet(false, true)) { if (logger.isInfoEnabled()) { logger.info("Closing " + this); } LiveBeansView.unregisterApplicationContext(this); try { // Publish shutdown event. publishEvent(new ContextClosedEvent(this)); } catch (Throwable ex) { logger.warn("Exception thrown from ApplicationListener handling ContextClosedEvent", ex); } // Stop all Lifecycle beans, to avoid delays during individual destruction. if (this.lifecycleProcessor != null) { try { this.lifecycleProcessor.onClose(); } catch (Throwable ex) { logger.warn("Exception thrown from LifecycleProcessor on context close", ex); } } // Destroy all cached singletons in the context's BeanFactory. destroyBeans(); // Close the state of this context itself. closeBeanFactory(); // Let subclasses do some final clean-up if they wish... onClose(); // Switch to inactive. this.active.set(false); } }
spring里registerShutdownHook的源码所示,就是注册一个jvm的shutdownHook钩子函数。jvm退出前会执行这个钩子函数。
通过源码,可以看到:doClose()方法会执行bean的destroy(),也会执行SmartLifeCycle的stop()方法,我们就可以通过重写这些方法来实现对象的关闭,生命周期的管理,实现平滑shutdown、优雅关闭。
spring 为何在容器销毁时自动 调用destroy()等方法? 就是这里的 destroyBeans() 方法的执行。 所以,这里特别关注的是 destroyBeans() 方法。
destroyBeans() 是 Spring 框架中的一个方法,它是在 Spring 容器关闭时调用的方法,用于销毁所有的单例 bean。
在 Spring 容器关闭时,会依次调用所有单例 bean 的 destroy() 方法,而 destroyBeans() 方法就是用于触发这个过程的。
在 destroy() 方法中,我们可以释放资源、关闭连接等操作,以确保应用程序正确地关闭。
5.3 destroy() 方法如何使用
两个方式:
- 方式一: 实现了 DisposableBean 接口,并重写了其中的 destroy() 方法
- 方式二:使用 @PreDestroy 注解来指定销毁方法
destroy() 方法是在 Spring 容器销毁时调用的方法,用于释放资源或执行清理操作。
方式一: 实现了 DisposableBean 接口,并重写了其中的 destroy() 方法
下面是destroy() 方法如何使用的一个示例:
public class MyBean implements DisposableBean { private Resource resource; public void setResource(Resource resource) { this.resource = resource; } // 实现 DisposableBean 接口中的 destroy() 方法 @Override public void destroy() throws Exception { // 释放资源 if (this.resource != null) { this.resource.release(); } } }
在这个示例中,MyBean 实现了 DisposableBean 接口,并重写了其中的 destroy() 方法。
在 destroy() 方法中,我们释放了 resource 资源。
当 Spring 容器销毁时,会自动调用 MyBean 的 destroy() 方法,从而释放资源。
方式二:使用 @PreDestroy 注解来指定销毁方法
除了实现 DisposableBean 接口,还可以使用 @PreDestroy 注解来指定销毁方法。例如:
public class MyBean { private Resource resource; public void setResource(Resource resource) { this.resource = resource; } // 使用 @PreDestroy 注解指定销毁方法 @PreDestroy public void releaseResource() { if (this.resource != null) { this.resource.release(); } } }
在这个示例中,MyBean 没有实现 DisposableBean 接口,而是使用 @PreDestroy 注解指定了销毁方法 releaseResource()。当 Spring 容器销毁时,会自动调用这个方法。
5.4 Springboot 如何自动注册的钩子函数的
实际上, registerShutdownHook() 钩子方法,在新的Springboot 版本中,不需要手动调用,已经被自动的执行了。
6 SpringCloud 微服务实例优雅的下线方式
在分布式微服务场景下, SpringCloud 微服务实例 都是通过 注册中心如 Eureka /nacos 进行实例管理的。
- Eureka 微服务实例优雅下线方式
- nacos 微服务实例优雅下线方式
6.1 Eureka 微服务实例优雅下线方式
如果服务发现组件使用的是 Eureka,那么默认最长会有 90 秒的延迟,其他应用才会感知到该服务下线,
这意味着:该实例下线后的 90 秒内,其他服务仍然可能调用到这个已下线的实例。
Spring Boot 应用 退出的时候,如何在 Eureka 进行实例的主动删除呢?
可以借助 Spring Boot 应用的 Shutdown hook,结合 Eureka 的Client API,达到微服务实例优雅下线的目标。
Eureka 的两个核心的 Client API 如下:
- 执行eurekaAutoServiceRegistration.start()方法时,当前服务向 Eureka 注册中心注册服务;
- 执行eurekaAutoServiceRegistration.stop()方法时,当前服务会向 Eureka 注册中心进行反注册,注册中心收到请求后,会将此服务从注册列表中删除。
借助 Spring Boot 应用的 Shutdown hook ,微服务实例优雅下线的目标, 源码如下:
执行的结果,如下:
6.2 Nacos 微服务实例优雅下线方式
那么,Nacos 是否支持微服务实例的 上线和下线呢?
理论上,Nacos 不仅仅支持优雅的上线和下线,而且可以通过控制台、API 或 SDK 进行操作。
在控制台上线服务时,可以选择“上线方式”,有“快速上线”和“灰度发布”两种方式。当然,这个是需要运维人员配合的。其中,“快速上线”会直接将服务实例上线,而“灰度发布”则会先将服务实例发布到灰度环境,等待一段时间后再逐步将其发布到生产环境。
这里不关注运维人员的活,咱们专注的是开发、架构师的活儿。
在 API 或 SDK 中,可以使用以下方法进行上线和下线操作:
- 上线服务实例:调用 registerInstance 接口,指定服务名、IP、端口等信息即可。
- 下线服务实例:调用 deRegisterInstance 接口,指定实例 ID 即可。
可以借助 Spring Boot 应用的 Shutdown hook,结合 Eureka 的nacos API,达到微服务实例优雅下线的目标。
实现的方式和 前面的Eureka 类似, 仅仅是API的调用上的区别。
7 云原生场景下, SpringCloud 微服务实例,如何优雅退出
一般来说,咱们的线上应用,都是在 Kubernetes部署的。问题来了,云原生场景下, SpringCloud 微服务实例,如何优雅退出?还是使用 JVM关闭的钩子函数吗?
7.1 云原生场景下,JVM关闭钩子的所存在的问题
Kubernetes 是如何优雅的停止 Pod 的?
当 kill 掉一个 Pod 的时候, Pod 的状态为 Terminating,开启终止流程。
- 首先 Service 会把这个 Pod 从 Endpoint 中摘掉,这样Service 负载均衡不会再给这个 Pod 流量,
- 然后,他会先看看是否有 preStop钩子,如果定义了,就执行他,
- 最后之后给 Pod 发 SIGTERM 信号让 Pod 中的所有容器优雅退出。
JVM的优雅退出,发生在最后一步。
但是实际情况中,我们可能会遇到以下情况,导致最后一步发生了意外,比如:
- 容器里的SpringBoot代码有很多处理优雅退出的逻辑,但是其中部分处理逻辑问题,导致一直退出不了
- 容器里的SpringBoot已经处理优雅已经卡死,导致资源耗尽,处理不了微服务优雅下线的代码逻辑,或需要很久才能处理完成
Kubernetes 怎么进行超时处理的呢?Kubernetes 还有一个 terminationGracePeriodSeconds 的硬停止时间,默认是 30s,如果 30s 内还是无法完成上述的过程,那就就会发送 SIGKILL,强制干掉 Pod。
在POSIX兼容的平台上,SIGKILL是发送给一个进程来导致它立即终止的信号。SIGKILL的符号常量在头文件signal.h中定义。因为在不同平台上,信号数字可能变化,因此符号信号名被使用,然而在大量主要的系统上,SIGKILL是信号#9
Runtime.addShutDownHook(Thread hook)触发场景,详见下图
在实际情况中,如果会遇到以下情况,导致最后一步发生了意外,比如:
- 容器里的SpringBoot代码有很多处理优雅退出的逻辑,但是其中部分处理逻辑问题,导致一直退出不了
- 容器里的SpringBoot已经处理优雅已经卡死,导致资源耗尽,处理不了微服务优雅下线的代码逻辑,或需要很久才能处理完成
JVM的优雅退出就失效了。
怎么办呢?比较直接、有效的策略是:
利用优雅的停止 Pod 的前置钩子, 定义preStop钩子接口优先执行核心的下线逻辑
7.2 定义preStop钩子接口优先执行核心的下线逻辑
定义preStop钩子接口优先执行核心的下线逻辑,比如这里的 微服务实例下线
具体如何操作呢?实操上有个步骤:
- step1:在SpringBoot应用中,定义WEB接口,实现核心线下逻辑
- step2:在 pod的preStop钩子接口的设置上,设置为SpringBoot应用的下线钩子链接
step1: 在SpringBoot应用中,定义WEB接口,实现核心线下逻辑
先看第一步:
step2:在 pod的preStop钩子接口的设置上,设置为SpringBoot应用下线钩子链接
具体的做法是:为容器加上PreStop配置项,设置为SpringBoot应用的下线钩子链接
/demo-provider/graceful/service/offline
当 kill 掉一个 Pod 的时候, Pod 的状态为 Terminating,开启终止流程。
- step1: Service 会把这个 Pod 从 Endpoint 中摘掉,这样Service 负载均衡不会再给这个 Pod 流量, 这样,这个微服务实例,就收不到用户的请求
- 然后,他会先看看是否有 preStop钩子,如果定义了,就执行他, 这样,从注册中心下线,也就收不到 其他微服务的 rpc 请求
- 最后,之后给 Pod 发 SIGTERM 信号让 Pod 中的所有容器优雅退出。
preStop钩子 执行完成后, 最后一步, Pod 发 SIGTERM 信号让 Pod 中的所有容器优雅,SIGTERM 信号发给 jvm后,Jvm会执行优雅退出逻辑,主要是:
- 处理没有完成的请求,注意,不再接收新的请求
- 池化资源的释放:数据库连接池,HTTP 连接池
- 处理线程的释放:已经被连接的HTTP请求
这些,咱们就先用放在前面介绍的 JVM 处理钩子方法里边去了。
7.3 微服务的无损下线小结
我们在应用服务下线前,通过HTTP钩子调用,主动通知注册中心下线该实例
不论是Dubbo还是Cloud 的分布式服务框架,都关注的是怎么能在服务停止前,先将提供者在注册中心进行下线,然后在停止服务提供者,
唯有这样,才能保证业务微服务之间RPC远程调用,不会产生各种503、timeout等现象。
8 小结
好啦,本节我们就看到这里哈,有理解不对的地方欢迎指正哈。