服务优雅上下线

概述

优雅停机一直是一个非常严谨的话题,但由于其仅仅存在于重启、下线这样的部署阶段,导致很多人忽视了它的重要性,但没有它,你永远不能得到一个完整的应用生命周期,永远会对系统的健壮性持怀疑态度。

同时,优雅停机又是一个庞大的话题

  • 操作系统层面,提供了 kill -9 (SIGKILL)和 kill -15(SIGTERM) 两种停机策略
  • 语言层面,Java 应用有 JVM shutdown hook 这样的概念
  • 框架层面,Spring Boot 提供了 actuator 的下线 endpoint,提供了 ContextClosedEvent 事件
  • 容器层面,Docker :当执行 docker stop 命令时,容器内的进程会收到 SIGTERM 信号,那么 Docker Daemon 会在 10s 后,发出 SIGKILL 信号;K8S 在管理容器生命周期阶段中提供了 prestop 钩子方法。
  • 应用架构层面,不同架构存在不同的部署方案。单体式应用中,一般依靠 nginx 这样的负载均衡组件进行手动切流,逐步部署集群;微服务架构中,各个节点之间有复杂的调用关系,上述这种方案就显得不可靠了,需要有自动化的机制。

为避免该话题过度发散,本文的重点将会集中在框架和应用架构层面。

优雅下线操作旨在让服务消费者避开已经下线的机器,但这样就算实现了优雅停机了吗?似乎还漏掉了一步,在应用停机时,可能还存在执行到了一半的任务,试想这样一个场景:一个请求刚到达提供者,服务端正在处理请求,收到停机指令后,提供者直接停机,留给消费者的只会是一个没有处理完毕的超时请求。

结合上述的案例,我们总结出服务提供者优雅停机需要满足两点基本诉求

  1. 服务消费者不应该请求到已经下线的服务提供者;
  2. 在途请求需要处理完毕,不能被停机指令中断;

优雅停机的意义:应用的重启、停机等操作,不影响业务的连续性

Java语言层面实现优雅停机

JVM shutdown hook 是 Java 虚拟机提供的一个钩子(hook),用于在 JVM 关闭之前执行一些必要的清理操作。在 Java 中,可以通过 Runtime 类的 addShutdownHook 方法注册一个 shutdown hook,当 JVM 接收到中断信号或者调用 System.exit 方法时,就会执行注册的 shutdown hook。

JVM shutdown hook 的具体实现方式如下:

  1. 创建一个继承自 Thread 类的子类,用于实现 shutdown hook 的逻辑。
  2. 在子类中重写 run 方法,编写需要在 JVM 关闭前执行的清理操作。
  3. 在程序中使用 Runtime 类的 addShutdownHook 方法注册 shutdown hook:

实现代码如下所示:

Runtime.getRuntime().addShutdownHook(new MyShutdownHook());

在上述示例中,MyShutdownHook 是继承自 Thread 的子类,用于实现 shutdown hook 的逻辑。

shutdown hook注意事项:

  • JVM shutdown hook 的执行顺序是不确定的。当 JVM 接收到中断信号或者调用 System.exit 方法时,就会同时启动所有已注册的 shutdown hook,但是它们的执行顺序是不确定的。因此,在编写 shutdown hook 时,需要考虑到多个 shutdown hook 之间的交互和依赖关系。
  • 程序退出时,JVM 会并发执行所有的应用 Shutdown Hook,并且只有所有 Shutdown Hook 都执行完,程序才正常退出。
  • 执行 Shutdown Hook 时,应该认为应用内的各种服务、资源都已经处于不可靠状态。因此,编写 Shutdown Hook 时要特别小心,不要有死锁。Shutdown Hook 应该是线程安全的,且不依赖于应用资源,比如,假设你的 Shutdown Hook 依赖另一个服务,这个服务又注册了自己的 Shutdown Hook,已经先行一步清理完自己的资源,这时候你的 Shutdown Hook 就会有问题

操作系统层面的停机策略

操作系统层面,提供了 kill -9 (SIGKILL)和 kill -15(SIGTERM) 两种停机策略。

在 Linux 中,kill 命令用于向进程发送信号,以通知该进程执行某些特定的操作。其中,kill 命令最常用的两个参数是 -9 和 -15,它们分别表示发送 SIGKILL 和 SIGTERM 信号。

如果使用 kill pid 则默认等价于 kill -15 pid

  • SIGKILL 信号是一个不能被阻塞、处理或忽略的信号,它会立即终止目标进程。使用 kill -9 命令发送 SIGKILL 信号可以强制终止进程,即使进程正在执行某些关键操作也会被立即终止,这可能会导致数据损坏或其他不良影响。因此,一般情况下不建议使用 kill -9 命令,除非必要情况下无法通过其他方式终止进程。
  • SIGTERM 信号是一个可以被阻塞、处理或忽略的信号,它也可以通知目标进程终止,但是它相对于 SIGKILL 信号来说更加温和,目标进程可以在接收到 SIGTERM 信号时进行一些清理操作,例如保存数据、关闭文件、释放资源等,然后再终止进程。使用 kill -15 命令发送 SIGTERM 信号通常被认为是一种优雅的方式来终止进程,因为这种方式允许进程在终止之前执行一些必要的清理操作,避免了数据损坏和其他不良影响。

可以先简单理解下这两者的区别:kill -9 pid 可以理解为操作系统从内核级别强行杀死某个进程,kill -15 pid 则可以理解为发送一个通知,告知应用主动关闭。这么对比还是有点抽象,那我们就从应用的表现来看看,这两个命令杀死应用到底有啥区别。

因此,一般情况下建议首先尝试使用 kill -15 命令发送 SIGTERM 信号来终止进程,只有在进程无法通过 SIGTERM 信号终止时才考虑使用 kill -9 命令发送 SIGKILL 信号。

SpringBoot 框架层面的优雅停机

上面解释过了,使用 kill -15 pid 的方式可以比较优雅的关闭 SpringBoot 应用,我们可能有以下的疑惑:SpringBoot/Spring 是如何响应这一关闭行为的呢?是先关闭了 tomcat,紧接着退出 JVM,还是相反的次序?它们又是如何互相关联的?

尝试从日志开始着手分析,AnnotationConfigEmbeddedWebApplicationContext 打印出了 Closing 的行为,直接去源码中一探究竟,最终在其父类 AbstractApplicationContext 中找到了关键的代码:

@Override
public void registerShutdownHook() {
  if (this.shutdownHook == null) {
    this.shutdownHook = new Thread() {
      @Override
      public void run() {
        synchronized (startupShutdownMonitor) {
          doClose();
        }
      }
    };
    Runtime.getRuntime().addShutdownHook(this.shutdownHook);
  }
}

@Override
public void close() {
   synchronized (this.startupShutdownMonitor) {
      doClose();
      if (this.shutdownHook != null) {
         Runtime.getRuntime().removeShutdownHook(this.shutdownHook);
      }
   }
}

protected void doClose() {
   if (this.active.get() && this.closed.compareAndSet(false, true)) {
      LiveBeansView.unregisterApplicationContext(this);
      // 发布应用内的关闭事件
      publishEvent(new ContextClosedEvent(this));
      // Stop all Lifecycle beans, to avoid delays during individual destruction.
      if (this.lifecycleProcessor != null) {
         this.lifecycleProcessor.onClose();
      }
      // spring 的 BeanFactory 可能会缓存单例的 Bean 
      destroyBeans();
      // 关闭应用上下文 &BeanFactory
      closeBeanFactory();
      // 执行子类的关闭逻辑
      onClose();
      this.active.set(false);
}

为了方便排版以及便于理解,我去除了源码中的部分异常处理代码,并添加了相关的注释。在容器初始化时,ApplicationContext 便已经注册了一个 Shutdown Hook,这个钩子调用了 Close()方法,于是当我们执行 kill -15 pid 时,JVM 接收到关闭指令,触发了这个 Shutdown Hook,进而由 Close() 方法去处理一些善后手段。具体的善后手段有哪些,则完全依赖于 ApplicationContextdoClose() 逻辑,包括了注释中提及的销毁缓存单例对象,发布 close 事件,关闭应用上下文等等,特别的,当 ApplicationContext 的实现类是 AnnotationConfigEmbeddedWebApplicationContext 时,还会处理一些 Tomcat/Jetty 一类内置应用服务器关闭的逻辑。

窥见了 SpringBoot 内部的这些细节,更加应该了解到优雅关闭应用的必要性。JAVA 和 C 都提供了对 Signal 的封装,我们也可以手动捕获操作系统的这些 Signal,在此不做过多介绍,有兴趣的朋友可以自己尝试捕获下。

Actuator

spring-boot-starter-actuator 模块提供了一个 restful 接口,用于优雅停机。

添加依赖:

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

添加配置

#启用 shutdown
endpoints.shutdown.enabled=true
#禁用密码验证
endpoints.shutdown.sensitive=false

生产中请注意该端口需要设置权限,如配合 spring-security 使用。

执行 curl -X POST host:port/shutdown 指令,关闭成功便可以获得如下的返回:

{"message":"Shutting down, bye..."}

虽然 SpringBoot 提供了这样的方式,但按我目前的了解,没见到有人用这种方式停机,kill -15 pid 的方式达到的效果与此相同,将其列于此处只是为了方案的完整性。

线程池销毁

尽管 JVM 关闭时会帮我们回收一定的资源,但一些服务如果大量使用异步回调,定时任务,处理不当很有可能会导致业务出现问题,在这其中,线程池如何关闭是一个比较典型的问题。

@Service
public class SomeService {
    ExecutorService executorService = Executors.newFixedThreadPool(10);
    public void concurrentExecute() {
        executorService.execute(new Runnable() {
            @Override
            public void run() {
                System.out.println("executed...");
            }
        });
    }
}

我们需要想办法在应用关闭时(JVM 关闭,容器停止运行),关闭线程池。

初始方案:什么都不做。在一般情况下,这不会有什么大问题,因为 JVM 关闭,会释放之,但显然没有做到本文一直在强调的两个字,没错 —- 优雅。

方法一的弊端在于线程池中提交的任务以及阻塞队列中未执行的任务变得极其不可控,接收到停机指令后是立刻退出?还是等待任务执行完成?抑或是等待一定时间任务还没执行完成则关闭?

@Service
public class SomeService implements DisposableBean{

    ExecutorService executorService = Executors.newFixedThreadPool(10);

    public void concurrentExecute() {
        executorService.execute(new Runnable() {
            @Override
            public void run() {
                System.out.println("executed...");
            }
        });
    }

    @Override
    public void destroy() throws Exception {
        executorService.shutdownNow();
        //executorService.shutdown();
    }
}

紧接着问题又来了,是 shutdown 还是 shutdownNow 呢?这两个方法还是经常被误用的,简单对比这两个方法。

ThreadPoolExecutor 在 shutdown 之后会变成 SHUTDOWN 状态,无法接受新的任务,随后等待正在执行的任务执行完成。意味着,shutdown 只是发出一个命令,至于有没有关闭还是得看线程自己。

ThreadPoolExecutor 对于 shutdownNow 的处理则不太一样,方法执行之后变成 STOP 状态,并对执行中的线程调用 Thread.interrupt() 方法(但如果线程未处理中断,则不会有任何事发生),所以并不代表“立刻关闭”。

查看 shutdown 和 shutdownNow 的 java doc,会发现如下的提示:

shutdown():Initiates an orderly shutdown in which previously submitted tasks are executed, but no new tasks will be accepted.Invocation has no additional effect if already shut down.This method does not wait for previously submitted tasks to complete execution.Use {@link #awaitTermination awaitTermination} to do that.

shutdownNow():Attempts to stop all actively executing tasks, halts the processing of waiting tasks, and returns a list of the tasks that were awaiting execution. These tasks are drained (removed) from the task queue upon return from this method.This method does not wait for actively executing tasks to terminate. Use {@link #awaitTermination awaitTermination} to do that.There are no guarantees beyond best-effort attempts to stop processing actively executing tasks. This implementation cancels tasks via {@link Thread#interrupt}, so any task that fails to respond to interrupts may never terminate.

两者都提示我们需要额外执行 awaitTermination 方法,仅仅执行 shutdown/shutdownNow 是不够的。

最终方案:参考 spring 中线程池的回收策略,我们得到了最终的解决方案。

public abstract class ExecutorConfigurationSupport extends CustomizableThreadFactory
      implements DisposableBean{
    @Override
	public void destroy() {
		shutdown();
	}

	/**
	 * Perform a shutdown on the underlying ExecutorService.
	 * @see java.util.concurrent.ExecutorService#shutdown()
	 * @see java.util.concurrent.ExecutorService#shutdownNow()
	 * @see #awaitTerminationIfNecessary()
	 */
	public void shutdown() {
		if (this.waitForTasksToCompleteOnShutdown) {
			this.executor.shutdown();
		}
		else {
			this.executor.shutdownNow();
		}
		awaitTerminationIfNecessary();
	}

	/**
	 * Wait for the executor to terminate, according to the value of the
	 * {@link #setAwaitTerminationSeconds "awaitTerminationSeconds"} property.
	 */
	private void awaitTerminationIfNecessary() {
		if (this.awaitTerminationSeconds > 0) {
			try {
				this.executor.awaitTermination(this.awaitTerminationSeconds, TimeUnit.SECONDS));
			}
			catch (InterruptedException ex) {
				Thread.currentThread().interrupt();
			}
		}
	}
}

总结

本文从操作系统、语言、框架层面分别阐述了一个Java应用优雅停机流程,从进程层面,优先推荐使用 SIGTERM 信号通知进程进入销毁流程,JVM虚拟机进程会监听该事件,并执行注册的ShutdownHook。在框架层面不同的框架都会自行注册自身框架的ShutdownHook,从而保证各种框架的正常销毁退出。

参考

Java应用的优雅停机

[线程池中shutdown()和shutdownNow()方法的区别](https://www.cnblogs.com/aspirant/p/10265863.html)

posted @ 2024-11-27 21:26  雩娄的木子  阅读(11)  评论(0编辑  收藏  举报