Dockerfile 里的 ENTRYPOINT 要是写成不带中括号的 shell 形式,docker stop 发出的 SIGTERM 到不了 Java 进程,10 秒后进程直接被 SIGKILL。Spring Boot 默认开着的优雅停机,这时一点用都没有。ENTRYPOINT 写对了也还有一关:Docker 默认只等 10 秒,Spring Boot 默认最多等 30 秒,超过 10 秒的请求照样被掐断。下面用 Spring Boot 4.1.1 在 Docker 里把四种情况各跑一遍,日志原样贴出。

实验准备

项目从 start.spring.io 生成,Spring Boot 4.1.1,依赖只有 web 和 actuator,镜像基于 eclipse-temurin:21-jre,Docker 运行在 Linux 上。application.properties 没动,全用默认值。

加了一个故意很慢的接口,用来模拟停机时还在处理的请求:

@RestController
public class SlowController {

    private static final Logger log = LoggerFactory.getLogger(SlowController.class);

    @GetMapping("/slow")
    public String slow(@RequestParam(defaultValue = "5") int sec) throws InterruptedException {
        log.info("slow request start, sec={}", sec);
        Thread.sleep(sec * 1000L);
        log.info("slow request done");
        return "done after " + sec + "s\n";
    }
}

同一个 jar 打两个镜像,区别只在 ENTRYPOINT 那一行。shell 形式:

FROM eclipse-temurin:21-jre
COPY target/stopdemo-0.0.1-SNAPSHOT.jar /app/app.jar
ENTRYPOINT java -jar /app/app.jar

exec 形式:

FROM eclipse-temurin:21-jre
COPY target/stopdemo-0.0.1-SNAPSHOT.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

每个场景都是先起容器,发一个慢请求,请求进行中执行 docker stop,再看请求结果、退出码和日志。

先确认一下默认值。从这个 jar 里带的配置元数据看,server.shutdown 默认是 graceful,spring.lifecycle.timeout-per-shutdown-phase 默认是 30s。Spring Boot 文档也写明了,Tomcat、Jetty、Reactor Netty 三种内嵌服务器默认都开着优雅停机。

shell 形式,信号没送到

$ docker run -d --name stop-shell -p 18091:8080 stopdemo:shell
$ docker exec stop-shell ps -o pid,args -p 1
    PID COMMAND
      1 /bin/sh -c java -jar /app/app.jar
$ curl -s -w ' http=%{http_code}\n' 'localhost:18091/slow?sec=5' &
$ time docker stop stop-shell
real    0m10.242s
# curl result:
done after 5s
 http=200
curl exit=0
$ docker inspect -f '{{.State.ExitCode}}' stop-shell
137

1 号进程是 /bin/sh -c,Java 只是它的子进程,日志里的进程号是 7。Docker 文档对 shell 形式说得很直接,可执行文件不是 1 号进程,收不到 Unix 信号。docker stop 把 SIGTERM 发给了 sh,sh 没有转给 Java,于是整整等满 10 秒,再用 SIGKILL 收场,退出码 137。

这个 5 秒的请求能拿到 200,只是因为它刚好在 10 秒内跑完了。日志能说明问题:

$ docker logs stop-shell 2>&1 | tail -6 | cut -c1-200
2026-10-17T00:09:25.011Z  INFO 7 --- [stopdemo] [           main] c.example.stopdemo.StopdemoApplication   : Started StopdemoApplication in 1.521 seconds (process running for 1.885)
2026-10-17T00:09:25.199Z  INFO 7 --- [stopdemo] [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
2026-10-17T00:09:25.200Z  INFO 7 --- [stopdemo] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
2026-10-17T00:09:25.202Z  INFO 7 --- [stopdemo] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 2 ms
2026-10-17T00:09:25.457Z  INFO 7 --- [stopdemo] [nio-8080-exec-2] com.example.stopdemo.SlowController      : slow request start, sec=5
2026-10-17T00:09:30.457Z  INFO 7 --- [stopdemo] [nio-8080-exec-2] com.example.stopdemo.SlowController      : slow request done

从头到尾没有一行停机日志。应用不知道自己要被关掉,那 10 秒里照常干活。SIGKILL 落下时,正在跑的请求全部断掉,JVM 的 shutdown hook 一个都不执行,关连接池、从注册中心下线这类收尾也都不会发生。

exec 形式,优雅停机生效

$ docker run -d --name stop-exec -p 18092:8080 stopdemo:exec
$ docker exec stop-exec ps -o pid,args -p 1
    PID COMMAND
      1 java -jar /app/app.jar
$ curl -s -w ' http=%{http_code}\n' 'localhost:18092/slow?sec=5' &
$ time docker stop stop-exec
real    0m4.407s
# curl result:
done after 5s
 http=200
curl exit=0
$ docker inspect -f '{{.State.ExitCode}}' stop-exec
143
$ docker logs stop-exec 2>&1 | tail -6 | cut -c1-200
2026-10-17T00:09:40.797Z  INFO 1 --- [stopdemo] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
2026-10-17T00:09:40.799Z  INFO 1 --- [stopdemo] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 2 ms
2026-10-17T00:09:40.998Z  INFO 1 --- [stopdemo] [nio-8080-exec-3] com.example.stopdemo.SlowController      : slow request start, sec=5
2026-10-17T00:09:42.021Z  INFO 1 --- [stopdemo] [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown         : Commencing graceful shutdown. Waiting for active requests to complete
2026-10-17T00:09:45.999Z  INFO 1 --- [stopdemo] [nio-8080-exec-3] com.example.stopdemo.SlowController      : slow request done
2026-10-17T00:09:46.040Z  INFO 1 --- [stopdemo] [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown         : Graceful shutdown complete

Java 成了 1 号进程,SIGTERM 一到就打出 Commencing graceful shutdown,等手上的请求做完,再打 Graceful shutdown complete。docker stop 只花了 4.4 秒,不用干等 10 秒。退出码 143 就是 128 加 SIGTERM 的 15,说明进程是自己响应信号退出的。

如果必须用脚本启动,比如要先拼一些环境变量,脚本最后一行用 exec java ...,让 Java 替换掉 shell 成为 1 号进程,效果和 exec 形式一样。

请求比 10 秒长

把慢请求改成 15 秒,还是 exec 形式,docker stop 不带参数:

$ docker run -d --name stop-exec-15s -p 18093:8080 stopdemo:exec
$ curl -s -w ' http=%{http_code}\n' 'localhost:18093/slow?sec=15' &
$ time docker stop stop-exec-15s
real    0m10.292s
# curl result:
 http=000
curl exit=52
$ docker inspect -f '{{.State.ExitCode}}' stop-exec-15s
137

日志最后一行停在 Commencing graceful shutdown,没有 slow request done,也没有 Graceful shutdown complete。Spring Boot 本来愿意等 30 秒,可 Docker 在 Linux 容器上默认只给 10 秒,时间一到就 SIGKILL,curl 拿到的是空响应,退出码 52。

两边的等待时间要对齐,Docker 给的宽限期得比 Spring Boot 的停机超时长一点:

$ docker run -d --name stop-exec-15s-t40 -p 18094:8080 stopdemo:exec
$ curl -s -w ' http=%{http_code}\n' 'localhost:18094/slow?sec=15' &
$ time docker stop -t 40 stop-exec-15s-t40
real    0m14.319s
# curl result:
done after 15s
 http=200
curl exit=0
$ docker inspect -f '{{.State.ExitCode}}' stop-exec-15s-t40
143

宽限期给到 40 秒,15 秒的请求完整返回 200,停机用了 14.3 秒,退出码 143。

除了每次手动带 -t,也可以在 docker run 时用 --stop-timeout 定好,Compose 里对应的是 stop_grace_period。反过来,也可以把 spring.lifecycle.timeout-per-shutdown-phase 调到比平台宽限期短,保证应用先超时退出,不至于被 SIGKILL。放到 K8s 上道理一样,Pod 的 terminationGracePeriodSeconds 默认是 30 秒,和 Spring Boot 的默认 30 秒正好一样长,两边之间没有留余量。

资料来源

默认值和信号行为核对自 Spring Boot 文档 Graceful Shutdown、Dockerfile reference 的 ENTRYPOINT 一节、docker container stop 和 Kubernetes Pod Lifecycle,四种情况的输出都来自 2026-10-17 在本机的实跑。

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。