SpringBoot应用零停机滚动更新
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服务,它有start
、stop
方法启动、关闭Web服务。
而 getWebServer
方法的参数很关键,也是第二个问题,DispatcherServlet
是如何传递给Servlet
容器的。
SpringBoot
并不像上面Tomcat
的例子一样简单的通过tomcat.addServlet
把DispatcherServlet
传递给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";
}
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· Obsidian + DeepSeek:免费 AI 助力你的知识管理,让你的笔记飞起来!
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了