如何实现 Spring Boot 不停机更新?附完整源码

777
类别: 
开发交流

目录

  1. 设计思路
  2. 实现代码
  3. 测试

一、前言

在个人或者企业服务器上,总归有要更新代码的时候。普通做法必须先终止原有进程,因为新进程和老进程端口冲突,新进程启动会报端口占用。
还有一种黑科技可以让两个 SpringBoot 进程真正共用同一个端口,该方案下文暂不展开,下回分解。

传统停机更新存在痛点:
线上大量用户正在访问时,如果直接停服务更新,会出现一段服务不可用窗口,不可用时长取决于项目启动速度。

现有折中方案

新代码先用其他端口启动,启动完成后修改 Nginx 转发地址,Nginx 重载速度极快,减少用户报错,最后再杀掉老进程。
缺点:需要频繁切换端口,即使写脚本也比较繁琐。

本文方案

新进程直接启动,程序自动完成端口切换、老进程销毁、容器热切换,实现近乎无缝更新。

二、设计思路

方案底层依赖 SpringBoot 内嵌 Tomcat 容器相关源码,先梳理核心知识点:

  1. SpringBoot 内嵌 Servlet 容器底层原理
  2. DispatcherServlet 如何注入 Servlet 容器

1. Tomcat 原生启动示例

Tomcat 提供 org.apache.catalina.startup.Tomcat 类,可直接手动创建、启动容器,手动添加 Servlet、配置连接器:

public class Main {
    public static void main(String[] args) {
        try {
            Tomcat tomcat = new Tomcat();
            tomcat.getConnector();
            tomcat.getHost();
            Context context = tomcat.addContext("/", null);
            tomcat.addServlet("/","index",new HttpServlet(){
                @Override
                protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
                    resp.getWriter().append("hello");
                }
            });
            context.addServletMappingDecoded("/","index");
            tomcat.init();
            tomcat.start();
        }catch (Exception e){}
    }
}

2. SpringBoot 创建 Web 容器工厂

SpringBoot 根据引入的 Servlet 容器依赖,自动创建对应容器工厂,以 Tomcat 为例,工厂类为 TomcatServletWebServerFactory

private static ServletWebServerFactory getWebServerFactory(ConfigurableApplicationContext context) {
    String[] beanNames = context.getBeanFactory().getBeanNamesForType(ServletWebServerFactory.class);
    return context.getBeanFactory().getBean(beanNames[0], ServletWebServerFactory.class);
}

调用 ServletWebServerFactory.getWebServer() 可获取 Web 服务实例,提供 start()stop() 启停容器。

3. DispatcherServlet 注入容器原理

SpringBoot 不会像原生 Tomcat 示例手动 addServlet,依靠 ServletContainerInitializer 回调接口实现动态注册 Servlet、Filter:

  1. SpringBoot 内置实现类 TomcatStarter,存入容器;
  2. TomcatStarter 聚合所有 ServletContextInitializer
  3. Tomcat 启动后回调接口,批量执行初始化逻辑,完成 DispatcherServlet 注册;
  4. getWebServer 入参就是 ServletContextInitializer 集合。

4. 获取 ServletContextInitializer 集合

ServletContextInitializerBeans 实现 Collection 接口,可直接从上下文获取所有初始化器:

protected static Collection<ServletContextInitializer> getServletContextInitializerBeans(ConfigurableApplicationContext context) {
    return new ServletContextInitializerBeans(context.getBeanFactory());
}

完整执行流程

  1. 判断目标端口是否被占用;
  2. 端口占用则先用备用端口启动新版本程序;
  3. 新版本完全启动后,杀掉占用目标端口的老进程;
  4. 修改容器端口为默认端口,重新创建 WebServer 并绑定 DispatcherServlet;
  5. 关闭备用端口容器,对外仅保留默认端口提供服务。

三、完整实现代码

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext;
import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
import org.springframework.boot.web.servlet.server.WebServer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.scheduling.annotation.EnableScheduling;

import java.io.IOException;
import java.net.ServerSocket;
import java.lang.reflect.Method;
import java.util.Collection;

@SpringBootApplication()
@EnableScheduling
public class WebMainApplication {
    public static void main(String[] args) {
        String[] newArgs = args.clone();
        int defaultPort = 8088;
        boolean needChangePort = false;
        // 判断默认端口是否占用
        if (isPortInUse(defaultPort)) {
            newArgs = new String[args.length + 1];
            System.arraycopy(args, 0, newArgs, 0, args.length);
            newArgs[newArgs.length - 1] = "--server.port=9090";
            needChangePort = true;
        }
        // 启动Spring上下文
        ConfigurableApplicationContext run = SpringApplication.run(WebMainApplication.class, newArgs);
        if (needChangePort) {
            // 杀掉占用默认端口的老进程(Linux/macOS)
            String command = String.format("lsof -i :%d | grep LISTEN | awk '{print $2}' | xargs kill -9", defaultPort);
            try {
                Runtime.getRuntime().exec(new String[]{"sh", "-c", command}).waitFor();
                // 等待端口释放
                while (isPortInUse(defaultPort)) {
                }
                // 获取容器工厂,修改端口
                ServletWebServerFactory webServerFactory = getWebServerFactory(run);
                ((TomcatServletWebServerFactory) webServerFactory).setPort(defaultPort);
                // 获取初始化器,重新创建Web服务
                WebServer webServer = webServerFactory.getWebServer(invokeSelfInitialize(((ServletWebServerApplicationContext) run)));
                webServer.start();
                // 关闭备用端口服务
                ((ServletWebServerApplicationContext) run).getWebServer().stop();
            } catch (IOException | InterruptedException ignored) {
            }
        }
    }

    /**
     * 反射获取上下文自初始化方法
     */
    private static ServletContextInitializer invokeSelfInitialize(ServletWebServerApplicationContext context) {
        try {
            Method method = ServletWebServerApplicationContext.class.getDeclaredMethod("getSelfInitializer");
            method.setAccessible(true);
            return (ServletContextInitializer) method.invoke(context);
        } catch (Throwable e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 判断端口是否被占用
     */
    private static boolean isPortInUse(int port) {
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            return false;
        } catch (IOException e) {
            return true;
        }
    }

    /**
     * 获取所有Servlet初始化Bean
     */
    protected static Collection<ServletContextInitializer> getServletContextInitializerBeans(ConfigurableApplicationContext context) {
        return new ServletContextInitializerBeans(context.getBeanFactory());
    }

    /**
     * 获取内嵌容器工厂
     */
    private static ServletWebServerFactory getWebServerFactory(ConfigurableApplicationContext context) {
        String[] beanNames = context.getBeanFactory().getBeanNamesForType(ServletWebServerFactory.class);
        return context.getBeanFactory().getBean(beanNames[0], ServletWebServerFactory.class);
    }
}

四、测试验证

1. 测试接口

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController()
@RequestMapping("port/test")
public class TestPortController {
    @GetMapping("test")
    public String test() {
        return "1";
    }
}

2. 打包两个版本

  1. v1版本:接口返回 1
  2. 修改代码返回 2,打包为 v2 新版本 jar。

3. 测试步骤

  1. 先启动 v1 旧版本 jar,使用接口工具正常访问,返回 1
  2. 不关闭 v1 进程,直接启动 v2 新版本 jar;
  3. 持续循环调用接口,观察返回值变化;

4. 效果

新版本启动完成后,接口会快速切换返回 2,服务中断时间极短,不超过1秒,实现近乎零停机更新。

妙不妙?

评论0
/ 1000
8
0
收藏