SpringBoot應用零停機滾動更新

上善若泪發表於2024-07-28

目錄
  • 1 SpringBoot零停機滾動更新
    • 1.1 引言
    • 1.2 單體應用設計思路
    • 1.3 單體應用實現程式碼

1 SpringBoot零停機滾動更新

1.1 引言

在個人或者企業伺服器上,總歸有要更新程式碼的時候,普通的做法必須先終止原來程序,因為新程序和老程序埠是一個,新程序在啟動時候,必定會出現埠占用的情況,但是,還有黑科技可以讓兩個SpringBoot程序真正的共用同一個埠,這是另一種解決辦法

那麼就會出現一個問題,如果此時有大量的使用者在訪問,但是程式碼又必須要更新,這時候如果採用上面的做法,那麼必定會導致一段時間內的使用者無法訪問,這段時間還取決於專案啟動速度,那麼在單體應用下,如何解決這種事情?

一種簡單辦法是,新程式碼先用其他埠啟動,啟動完畢後,更改nginx的轉發地址,nginx重啟非常快,這樣就避免了大量的使用者訪問失敗,最後終止老程序就可以。但是還是比較麻煩,埠換來換去,即使寫個指令碼,也是比較麻煩,有沒有一種可能,新程序直接啟動,自動處理好這些事情?答案是有的。

1.2 單體應用設計思路

這裡涉及到幾處原始碼類的知識,如下。

  • SpringBoot內嵌Servlet容器的原理是什麼
  • DispatcherServlet是如何傳遞給Servlet容器的

先看第一個問題,用Tomcat來說,這個首先得Tomcat本身支援,如果Tomcat不支援內嵌,SpringBoot估計也沒辦法,或者可能會另找出路。

Tomcat本身有一個Tomcat類,沒錯就叫Tomcat,全路徑是org.apache.catalina.startup.Tomcat,我們想啟動一個Tomcat,直接new Tomcat(),之後呼叫 start() 就可以了。
並且它提供了新增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){}
    }
}

SpringBoot原始碼中,根據引入的Servlet容器依賴,透過下面程式碼可以獲取建立對應容器的工廠,拿Tomcat來說,建立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服務,它有startstop 方法啟動、關閉Web服務。
getWebServer 方法的引數很關鍵,也是第二個問題,DispatcherServlet 是如何傳遞給Servlet容器的。

SpringBoot並不像上面Tomcat的例子一樣簡單的透過tomcat.addServletDispatcherServlet傳遞給Tomcat,而是透過Tomcat主動回撥來完成的,具體的回撥透過ServletContainerInitializer介面協議,它允許我們動態地配置Servlet、過濾器。
SpringBoot在建立Tomcat後,會向Tomcat新增一個此介面的實現,類名是TomcatStarter,但是TomcatStarter也只是一堆SpringBoot內部ServletContextInitializer的集合,簡單的封裝了一下,這些集合中有一個類會向Tomcat新增DispatcherServlet

Tomcat內部啟動後,會透過此介面回撥到SpringBoot內部,SpringBoot在內部會呼叫所有ServletContextInitializer集合來初始化,
getWebServer的引數正好就是一堆ServletContextInitializer集合。
那麼這時候還有一個問題,怎麼獲取ServletContextInitializer集合?
非常簡單,注意,ServletContextInitializerBeans是實現Collection的。

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

到這裡所有用到的都準備完畢了,思路也很簡單。

  • 判斷埠是否佔用
  • 佔用則先透過其他埠啟動
  • 等待啟動完畢後終止老程序
  • 重新建立容器例項並且關聯DispatcherServlet

在第三步和第四步之間,速度很快的,這樣就達到了無縫更新程式碼的目的。

1.3 單體應用實現程式碼

@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;
        }
        ConfigurableApplicationContext run = SpringApplication.run(WebMainApplication.class, newArgs);
        if (needChangePort) {
            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);
                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;
        }
    }

    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);
    }

}

測試
我們先寫一個小demo。

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

相關文章