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

来源:互联网 发布:淘宝商城捡漏 编辑:程序博客网 时间:2024/05/16 12:57
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 implementsTomcatConnectorCustomizer, 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应用服务 
阅读全文
0 0
原创粉丝点击