Spring Boot 提供了多种方法来实现优雅停机(Graceful Shutdown),这意味着在关闭应用程序之前,它会等待当前正在处理的请求完成,并且不再接受新的请求。
一、优雅停机的基本概念
优雅停机的主要步骤如下:
- 停止接收新的请求:一旦收到关闭指令,服务器会停止接受新的请求。
- 处理当前请求:系统会继续处理已经在处理中的请求,确保这些请求能够正常完成。
- 释放资源:在所有请求处理完毕后,系统会释放已分配的资源,比如关闭数据库连接、断开网络连接等。
- 关闭服务:当所有资源都被正确释放之后,系统会安全地关闭服务。
二、实现优雅停机的方法
2.1、在 Spring Boot 2.3 及以上版本中启用优雅停机
从 Spring Boot 2.3 开始,默认集成了对优雅停机的支持,你只需要通过配置文件进行简单的设置即可。
在 application.yml
或 application.properties
文件中添加以下配置:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 60s # 设置最大等待时间为60秒
上述配置告诉 Spring Boot 使用优雅的方式关闭应用,并设置了最长等待时间。
2.2、 对于 Spring Boot 2.3 之前的版本
如果你使用的是 Spring Boot 2.3 之前的版本,则需要手动引入 spring-boot-starter-actuator
并利用其提供的 /shutdown
端点来实现优雅停机。
首先,在你的 pom.xml
或者 build.gradle
中添加依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
然后,在 application.yml
中开启 /shutdown
端点:
management:
endpoints:
web:
exposure:
include: "shutdown"
endpoint:
shutdown:
enabled: true
接下来,你可以通过发送 POST 请求到 /actuator/shutdown
来触发优雅停机。
2.3、 自定义优雅停机逻辑
如果默认的优雅停机行为不能满足需求,你还可以自定义优雅停机逻辑。例如,对于不同的 Web 容器(如 Tomcat、Jetty、Undertow),可以编写相应的代码来控制线程池的行为。
以 Tomcat 为例,你可以创建一个类实现 GracefulShutdownListener
和 ApplicationListener<ContextClosedEvent>
接口,来定制化优雅停机过程。
package cn.gxm.multiinstancetest.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.stereotype.Component;
/**
* @author GXM
* @version 1.0.0
* @Description TODO
* @createTime 2025年03月04日
*/
@Slf4j
@Component
public class GracefulShutdownListener implements ApplicationListener<ContextClosedEvent> {
/**
* 优雅关机是否已开始
*/
private boolean isGracefulShutdownStarted = false;
@Override
public void onApplicationEvent(ContextClosedEvent event) {
// 执行你的业务逻辑
log.info("Executing custom logic before graceful shutdown...");
this.isGracefulShutdownStarted = true;
}
public boolean isGracefulShutdownStarted() {
return isGracefulShutdownStarted;
}
}
2.4、测试
下面的测试中, spring.lifecycle.timeout-per-shutdown-phase
值为30。
2.4.1、接口测试
1、写一个接口/sleep/{seconds}
来测试。
package cn.gxm.multiinstancetest.controller;
import cn.hutool.core.date.DateUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.net.InetAddress;
import java.net.UnknownHostException;
/**
* @author GXM
* @version 1.0.0
* @Description TODO
* @createTime 2025年01月03日
*/
@RestController
@RequestMapping("/test")
@Slf4j
public class TestController {
@GetMapping("/say")
public String say() throws UnknownHostException {
InetAddress localHost = InetAddress.getLocalHost();
log.info("localhost: {}", localHost);
return localHost.getHostAddress() + "_" + localHost.getHostName();
}
@GetMapping("/sleep/{seconds}")
public String sleep(@PathVariable(value = "seconds") Integer seconds) {
String startTime = DateUtil.now();
try {
Thread.sleep(seconds * 1000);
} catch (Exception e) {
log.error("sleep:", e);
}
String endTime = DateUtil.now();
return startTime + " " + endTime;
}
}
2、第一种情况:在关机之前,请求接口http://127.0.0.1:9600/multi-instance-test/test/sleep/20
,会导致睡眠20秒,接着马上在Idea关机程序,触发优雅关机。接口正常返回,并相差20秒,并且查看程序日志也是发现等待接口完成后才关机
3、第二种情况:在关机之前,请求接口http://127.0.0.1:9600/multi-instance-test/test/sleep/50
,会导致睡眠50秒,接着马上在Idea关机程序,触发优雅关机。接口不会正常返回,并且查看程序日志发现关机就是相差30秒,所以如果你的接口处理的逻辑超过了设置的优雅关机的时间,它是不会管你的,任然会直接关机,会导致你的接口业务没有处理完成。
2.4.2、GracefulShutdownListener 测试
1、在 GracefulShutdownListener 类里通过sleep来模拟业务
@Slf4j
@Component
public class GracefulShutdownListener implements ApplicationListener<ContextClosedEvent> {
/**
* 优雅关机是否已开始
*/
private boolean isGracefulShutdownStarted = false;
@Override
public void onApplicationEvent(ContextClosedEvent event) {
// 执行你的业务逻辑
log.info("Executing custom logic before graceful shutdown...");
this.isGracefulShutdownStarted = true;
try {
Thread.sleep(60 * 1000);
} catch (Exception e) {
log.error("sleep:", e);
}
log.info("Executing custom logic before graceful shutdown,end!");
}
public boolean isGracefulShutdownStarted() {
return isGracefulShutdownStarted;
}
}
2、第一种情况:设置上述代码的睡眠时间是20秒后启动程序,接着马上在Idea关机程序,触发优雅关机。日志正常打印,并相差20秒,并且查看程序日志也是发现等待睡眠完成后才关机
3、第二种情况:设置上述代码的睡眠时间是50秒后启动程序,接着马上在Idea关机程序,触发优雅关机。日志正常打印,并相差50秒,并且查看程序日志也是发现等待睡眠完成后才关机,所以如果你的逻辑超过了设置的优雅关机的时间,它是会等你完成,再关机。
3、GracefulShutdownListener 的逻辑是,要关机了,发出信号给你,你可以先处理,比如, 你在 GracefulShutdownListener 代码里面业务处理了50秒,那它就等你50秒,直到你自己的业务处理完成即50秒之后,然后再开始spring.lifecycletimeout-per-shutdown-phase
30秒的计时,去执行自己的关机业务逻辑,为什么那么说呢,因为,你可以在GracefulShutdownListener 种处理的50秒内,请求接口,是可以接受返回的,如下
三、docker 镜像中优雅关机的配置
默认情况下,docker stop 命令会发送一个 SIGTERM 信号给容器中的主进程(PID 1),并等待一段时间(默认为 10 秒)以允许该进程正常关闭。如果这段时间内进程没有退出,Docker 会发送一个 SIGKILL 信号强制终止进程
3.1、错误示例
1、❌ 先给出一个原始的简单的"sh", "-c"
,这种方式,是收不到关机信号的,这是因为docker stop命令默认发送的是SIGTERM信号给容器的PID 1进程(在这个例子中是sh脚本),而不是直接给Java进程。由于sh脚本并不具备转发信号的能力,因此Java应用无法接收到终止信号,从而不能触发优雅关闭流程
FROM openjdk:17-jdk-alpine
VOLUME /tmp
ADD multi-instance-test-0.0.1-SNAPSHOT.jar run.jar
RUN sh -c 'touch /run.jar'
ENV JAVA_OPTS="-Xms512m -Xmx512m -server"
ENV PROFILE="test"
ENV APP_NAME="run.jar"
ENTRYPOINT [ "sh", "-c", "java $JAVA_OPTS -Djava.awt.headless=true -Duser.timezone=GMT+08 -Djava.security.egd=file:/dev/./urandom -jar /$APP_NAME --spring.profiles.active=test" ]
3.2、ENTRYPOINT 示例
2、修改上述的 ENTRYPOINT
,使用exec命令来替换当前进程(sh)为Java进程,这样Java进程就会成为PID 1,如下就可以收到信号了。
FROM openjdk:17-jdk-alpine
VOLUME /tmp
ADD multi-instance-test-0.0.1-SNAPSHOT.jar run.jar
RUN sh -c 'touch /run.jar'
ENV JAVA_OPTS="-Xms512m -Xmx512m -server"
ENV PROFILE="test"
ENV APP_NAME="run.jar"
# 使用exec命令来替换当前进程(sh)为Java进程,这样Java进程就会成为PID 1
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -Djava.awt.headless=true -Duser.timezone=GMT+08 -Djava.security.egd=file:/dev/./urandom -jar /$APP_NAME --spring.profiles.active=$PROFILE"]
3.3、使用tini 作为 init 系统(推荐)
推荐理由:
- 信号转发与僵尸进程管理:
tini
不仅能确保信号(如 SIGTERM)被正确转发给子进程,还能处理僵尸进程,这使得它非常适合用于容器化应用。 - 简化配置:相比于直接操作 ENTRYPOINT 或编写复杂的启动脚本,使用
tini
更加简洁和易于维护。 - 兼容性和稳定性:
tini
是一个广泛使用的轻量级 init 系统,在许多 Docker 容器中都有成功应用的案例。
首先,在 Dockerfile 中添加安装 tini
的步骤,并调整 ENTRYPOINT 和 CMD 以使用 tini
启动 Java 应用。
FROM openjdk:17-jdk-alpine
VOLUME /tmp
# 安装 tini
RUN apk add --no-cache tini
ADD multi-instance-test-0.0.1-SNAPSHOT.jar run.jar
RUN sh -c 'touch /run.jar'
ENV JAVA_OPTS="-Xms512m -Xmx512m -server"
ENV PROFILE="test"
ENV APP_NAME="run.jar"
# 使用 tini 启动 Java 应用
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["sh", "-c", "java $JAVA_OPTS -Djava.awt.headless=true -Duser.timezone=GMT+08 -Djava.security.egd=file:/dev/./urandom -jar /$APP_NAME --spring.profiles.active=$PROFILE"]
3.4、其他注意事项
- Spring Boot 版本:确保你使用的 Spring Boot 版本支持优雅停机功能(2.3 及以上版本默认支持)。如果你使用的是较早版本,请参考之前的建议来启用或自定义优雅停机逻辑。
- 超时设置:默认情况下,
docker stop
命令会等待 10 秒让应用程序关闭。如果需要更长的时间,可以通过-t
参数指定等待时间。例如,docker stop -t 30 my-running-app
将等待 30 秒。
其中超时设置,我再详细说一下,如果你在Spring Boot应用中设置了spring.lifecycle.timeout-per-shutdown-phase: 30s
,那么为了确保Docker有足够的时间让Spring Boot完成其优雅关闭过程,在使用docker stop
命令时,你应该设置一个至少为30秒的超时时间(通过-t
参数)。
3.4.1 、具体原因
-
Spring Boot优雅关闭机制:当Spring Boot接收到SIGTERM信号后,它会开始执行优雅关闭流程。这个过程包括但不限于停止监听HTTP请求、等待当前正在处理的请求完成、释放资源等。你配置的
timeout-per-shutdown-phase: 30s
意味着Spring Boot希望在这30秒内完成所有必要的关闭操作。 -
Docker的SIGTERM和SIGKILL行为:
- 当你执行
docker stop <container_id>
时,Docker会向容器内的主进程发送SIGTERM信号。 - 默认情况下,Docker会等待10秒(可以通过
-t
参数自定义)来允许容器内的进程自行终止。 - 如果在这个时间内进程没有退出,Docker将发送SIGKILL信号强制终止该进程。
- 当你执行
因此,如果Docker的超时时间(默认或通过-t
指定)小于Spring Boot所需的关闭时间(例如小于30秒),那么Spring Boot可能无法在Docker强制终止之前完成所有的关闭操作,导致部分关闭逻辑未被执行。
3.4.2、实践建议
为了确保Spring Boot应用能够顺利完成其优雅关闭流程,可以采取以下措施之一:
3.4.2.1、方法1:增加Docker的停止超时时间
当你使用docker stop
命令时,指定一个至少为30秒的超时时间:
docker stop -t 30 <container_id>
这将给Spring Boot足够的时间来完成其关闭流程。
3.4.2.2、方法2:在启动容器时设置停止超时时间
你也可以在启动容器时通过--stop-timeout
选项来设置超时时间:
docker run --stop-timeout 30 ...
3.4.2.3、方法3:调整Spring Boot的关闭超时时间
如果你发现30秒对于你的应用来说过长,可以考虑缩短Spring Boot的关闭超时时间。但是请注意,这需要确保在较短的时间内能够完成所有必要的关闭操作,否则可能会导致资源泄漏或其他问题。
3.4.2.4 示例
假设你已经配置了Spring Boot的关闭超时时间为30秒:
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
那么在停止容器时,你应该使用如下命令:
docker stop -t 30 my-spring-boot-container
这样,Docker会给Spring Boot足够的时间(30秒)来完成其优雅关闭流程,避免因超时导致的强制终止。
只有日志显示了完整的优雅关闭的日志才是真的没问题