SpringBoot 1.X 优雅停机 ( shutdown gracefully )

1:常规的关闭方式
该方式主要依赖Spring Boot Actuator的endpoint特性,具体步骤如下:
1) 在pom.xml中引入actuator依赖
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

2) 开启shutdown endpoint
Spring Boot Actuator的shutdown endpoint默认是关闭的,因此在application.properties中开启shutdown endpoint:
    #启用shutdown
     endpoints.shutdown.enabled=true
    #禁用密码验证
    endpoints.shutdown.sensitive=false

3) 发送shutdown信号
shutdown的默认url为host:port/shutdown,当需要停止服务时,向服务器post该请求即可,如:
    curl -X POST host:port/shutdown
将得到形如{"message":"Shutting down, bye..."}的响应

4) 安全设置
可以看出,使用该方法可以非常方便的进行远程操作,但是需要注意的是,正式使用时,必须对该请求进行必要的安全设置,比如借助spring-boot-starter-security进行身份认证:
a)pom.xml添加security依赖
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
b)开启安全验证,在application.properties中变更配置,并
    #开启shutdown的安全验证
     endpoints.shutdown.sensitive=true
    #验证用户名
    security.user.name=admin
    #验证密码
    security.user.password=secret
    #角色
    management.security.role=SUPERUSER
c)指定路径、IP、端口
    #指定shutdown endpoint的路径,这样一般的漏洞扫描就无法扫描到 shutdown 端口了。
    #也可以统一指定所有endpoints的路径`management.context-path=/manage`
     endpoints.shutdown.path=/your_custom_shutdown_path
    #指定管理端口和IP
     management.port=8081
    management.address=127.0.0.1

5) 安全设置方法2
    由于通常服务部属在机器上之后,是通过反向代理对公网提供服务的。而很多服务本身是无状态的,并不需要单独增加权限验证,因此为了停机而增加一个security是没有很大必要的。
    那么另一个思路就是,将停机的 endpoint 在反向代理层限制访问,这样只有内网才能停机。这就大大增加了安全性。
    以Nginx为例,配置起来也非常简单:

upstream sdkserver {
    server 127.0.0.1:8274;
    server 127.0.0.1:8277;
    keepalive 40;
}

server {
    listen  8888;
    server_name sdk_back;
     location /your_custom_shutdown_path {
        deny    all;
    }
    location / {
          root  /;
          proxy_pass http://sdkserver;
    }
}
这个配置中,有两个active-active的服务,对外端口为8888

同时这个服务直接拒绝任何对于  your_custom_shutdown_path 的访问,这样就只能在内网上进行停机了。

而一般生产网络是跟办公网络隔离的,所以相当于将安全托管给了生产网络。这个网络的访问都是追溯的,也不会有挖空心思的安全攻击。


2:常规做法中的潜在问题
    实时上,利用 shutdown endpoint 直接进行关闭是非常粗暴的做法。为了验证这一点,给出样例程序。直接是最简单的SpringBoot样例。
当前 SpringBoot 版本 1.5.9。

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
</dependencies>
server.port=8080

#启用shutdown
endpoints.shutdown.enabled=true
#是否密码验证
endpoints.shutdown.sensitive=false
#是否启用密码校验
management.security.enabled=false
@ComponentScan(basePackages = {"qi.tech"})
@SpringBootApplication
public class Main {
    public static void main(String[] args) {
        ApplicationContext context = SpringApplication.run(Main.class, args);
        Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
            @Override
            public void run() {
                HomeController one = context.getBean(HomeController.class);
                System.out.println( one + " -> started: " + one.started.get() + " ended:" + one.ended.get() );
            }
        }));
    }
}
@Controller
public class HomeController {
    // 计数器
    public AtomicInteger started = new AtomicInteger();
    public AtomicInteger ended = new AtomicInteger();

    @RequestMapping("/hello")
    @ResponseBody
    public String index() {

        System.out.println( Thread.currentThread().getName() + " -> " + this + " Get one, got: " + started.addAndGet(1) );
        try {
             Thread.sleep( 1000*10); // 模拟一个执行时间很长的任务
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println( Thread.currentThread().getName() + " -> " + this + "  Finish one, finished: " + ended.addAndGet(1) );
        return "hello";
    }

}
启动上述程序,然后在页面端刷新若干次,这样模拟后端收到多个请求。并且由于每个请求需要十秒钟来完成。然后,执行 POST /shutdown 控制SpringBoot停机。
在控制台看到类似如下输入:


上图表示收到了10个请求,并且在执行过程中收到了停机请求并产生了响应:
Closing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@14ec4505
并且在关机过程中,对执行中的线程进行了中断处理。
假设上述的过程不是暂停,而是实际进行业务操作,那么很可能出现业务异常终止。
这就不满足安全关闭的基础条件了。作为Tomcat应用,应该的关闭方式是:
暂停接受新请求,但是完成对已接收请求的处理,然后再进行关闭。

对Tomcat有一定理解的同学知道,Tomcat接受请求是通过Connector组件,而这个组件本身实现了LifeCycle接口,它是可以被暂停的,暂停过程只停止接受请求,但不会关闭容器本身。
因此,优雅停机,应该是先暂停 Tomcat 的 Connector,然后再等待执行线程执行完成,最后停止。当然,如果执行线程持续不返还,可以有一个Timeout,过了时间进行强杀。

至于为什么 shutdown endpoint 没有实现这个点,参见 GitHub 讨论: https://github.com/spring-projects/spring-boot/issues/4657

We currently stop the application context and then stop the container. We did try to reverse this ordering but it had some unwanted side effects so we're stuck with it for now at least. That's the bad news.
I think it makes sense to provide behaviour like this out of the box, or at least an option to enable it. However, that'll require a bit more work as it'll need to work with all of the embedded containers that we support (Jetty, Tomcat, and Undertow), cope with multiple connectors (HTTP and HTTPS, for example) and we'll need to think about the configuration options, if any, that we'd want to offer: switching it on or off, configuring the period that we wait for the thread pool to shutdown, etc.
从官方的解释来看,当前关闭的流程是先关闭 ApplicationContext 然后触发容器关闭,官方也认同了这个流程是反的。
但是,因为实现翻转的次序很麻烦,有很多配置和适配,所以现在还没有做。


据说 2.0 已经的计划已经增加了这部分,然而2.0还没有正式发布。


3: 优雅停机
    虽然默认没有实现真正的优雅关机,然而这个过程实现起来并不困难。直接按照上述讨论中给出来的例子融合到我们的代码里,改动很小:

@ComponentScan(basePackages = {"qi.tech"})
@SpringBootApplication
public class Main {
    public static void main(String[] args) {
        ApplicationContext context = SpringApplication.run(Main.class, args);

        Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
            @Override
            public void run() {
                HomeController one = context.getBean(HomeController.class);
                System.out.println( one + " -> started: " + one.started.get() + " ended:" + one.ended.get() );
            }
        }));
    }

    /**
     * 用于接受shutdown事件
     * @return
     */
    @Bean
    public GracefulShutdown gracefulShutdown() {
        return new GracefulShutdown();
    }

    /**
     * 用于注入 connector
     * @return
     */
    @Bean
    public EmbeddedServletContainerCustomizer tomcatCustomizer() {
        return new EmbeddedServletContainerCustomizer() {
            @Override
            public void customize(ConfigurableEmbeddedServletContainer container) {
                if (container instanceof TomcatEmbeddedServletContainerFactory) {
                    ((TomcatEmbeddedServletContainerFactory) container).addConnectorCustomizers(gracefulShutdown());
                }
            }
        };
    }

    private static class GracefulShutdown implements TomcatConnectorCustomizer, ApplicationListener<ContextClosedEvent> {

        private static final Logger log = LoggerFactory.getLogger(GracefulShutdown.class);
        private volatile Connector connector;
        private final int waitTime = 100;

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

        @Override
        public void onApplicationEvent(ContextClosedEvent event) {
             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();
                }
            }
        }
    }

}
这个代码本身是很简单的 GracefulShutdown 实现了两个接口:TomcatConnectorCustomizer 和 ApplicationListener。
TomcatConnectorCustomizer 会在 EmbeddedServletContainerCustomizerBeanPostProcessor 中被统一初始化,即注入容器。

private void postProcessBeforeInitialization(
        ConfigurableEmbeddedServletContainer bean) {
    for (EmbeddedServletContainerCustomizer customizer : getCustomizers()) {
         customizer.customize(bean);
    }
}

private Collection<EmbeddedServletContainerCustomizer> getCustomizers() {
    if (this.customizers == null) {
         // Look up does not include the parent context
        this.customizers = new ArrayList<EmbeddedServletContainerCustomizer>(
                this.beanFactory
                        .getBeansOfType(EmbeddedServletContainerCustomizer.class,
                                false, false)
                        .values());
        Collections.sort(this.customizers, AnnotationAwareOrderComparator.INSTANCE);
        this.customizers = Collections.unmodifiableList(this.customizers);
    }
    return this.customizers;
}
AbstractApplicationEventMulticaster 中则会对各种事件系统事件进行传递,GracefulShutdown 则响应 ContextClosedEvent。并执行关闭处理。关闭的流程是先暂停 connector, 然后再停止后续的执行线程池。
我们再来看一下实验结果。

所有请求正常执行完,没有任何异常中断发生。


参考:
https://github.com/spring-projects/spring-boot/issues/4657  Shut down embedded servlet container gracefully
https://github.com/corentin59/spring-boot-graceful-shutdown  Spring-Boot-Graceful-Shutdown-Starter
https://www.cnblogs.com/lobo/p/5657684.html  正确、安全地停止SpringBoot应用服务 
### 如何在Spring Boot中实现自定义线程池的优雅关闭 为了确保应用程序能够正常处理请求并安全地终止正在运行的任务,在Spring Boot应用中实现自定义线程池时,应当考虑其优雅关闭机制。这不仅有助于防止数据丢失或损坏,还能提升系统的稳定性和可靠性。 #### 定义Bean以创建自定义线程池 首先,需通过`@Configuration`类来声明一个或多个用于构建不同用途线程池实例的方法,并将其注册为Spring容器中的bean对象: ```java import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class ThreadPoolConfig { @Bean(name = "customTaskExecutor") public TaskExecutor customTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // 设置基本属性 executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(25); // 初始化前调用此方法完成其他设置 executor.initialize(); return executor; } } ``` 上述代码片段展示了如何基于默认配置初始化一个新的`ThreadPoolTaskExecutor`实例[^1]。 #### 注册Shutdown Hook以便于优雅退出 为了让程序能够在接收到停止信号后有序结束所有活动任务而不影响服务的整体可用性,可以在启动阶段向JVM注册一个shutdown hook。当进程准备终止时,该hook会被触发从而允许我们执行必要的清理工作: ```java import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class GracefulShutdownHook { private static final Logger logger = LoggerFactory.getLogger(GracefulShutdownHook.class); @Autowired private ThreadPoolTaskExecutor taskExecutor; @PostConstruct public void init() { Runtime.getRuntime().addShutdownHook(new Thread(() -> { try { logger.info("Starting graceful shutdown..."); // 停止接收新任务并将现有任务排队等待完成 taskExecutor.shutdown(); // 等待一段时间让已提交的任务有机会被执行完毕 if (!taskExecutor.awaitTermination(30, TimeUnit.SECONDS)) { logger.warn("Not all tasks have completed execution after the timeout."); // 如果超时则强制中断剩余未完成的工作项 List<Runnable> droppedTasks = taskExecutor.getRejectedExecutionHandler() .getUnprocessedTasks(taskExecutor); logger.error("{} tasks did not complete before termination.", droppedTasks.size()); } logger.info("Application has been shut down gracefully."); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // Restore interrupted status logger.error("Interrupted during graceful shutdown", e); } })); } @PreDestroy public void destroy() throws InterruptedException { logger.debug("Executing PreDestroy method"); } } ``` 这段逻辑实现了对指定线程池组件的安全销毁过程管理。 #### 处理异常情况下的快速失败模式 除了正常的停机流程外,还应该考虑到某些情况下可能无法给予足够的时间去等待所有的后台作业都顺利完成的情形。此时可以通过调整线程池的相关参数(如最大存活时间和队列容量),以及采用合适的拒绝策略(例如AbortPolicy)来尽早发现潜在的问题并采取相应的措施加以应对[^3]。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值