最近在线上遇到了一个问题:本地写了一个spring-boot服务,想实现一个优雅关闭的逻辑,有两种可能的实现,一种是通过Spring的注解:

@PreDestroy
public void destroy() {
    // some exit logic
}

还有一种是注册一个jvm的钩子:

Runtime.getRuntime().addShutdownHook(new Thread(this::destroy));

public void destroy() {
    // some exit logic
}

理论上这两种方式都能截获到SIGTERM或者SIGINT触发优雅关闭。但是我们推荐使用第一种@PreDestroy的方式。因为在触发jvm的shutdownhook时某些bean已经被spring关闭了,此时代码中有些逻辑可能无法正常执行。

本地IDEA运行时,@PreDestroy能够正确生效,但是发布到kubernetes环境中,执行kill时发现程序并没有按照预期正常触发@PreDestroy,而是在30s后被突然结束进程。

后来发现是image镜像里的命令有问题:

# start client-service
java -javaagent:xxxxxxx.jar

这里的命令是直接执行java,会导致起来的java进程是shell的子进程,当pod被kill尝试发送SIGTERM时,shell会收到SIGTERM而jvm收不到,从而30秒后达到terminationGracePeriodSeconds设定的默认值被强制被SIGKILL

此处应该修改成:

# start client-service
exec java -javaagent:xxxxxxx.jar

这样就可以避免这个问题,因为exec不会起一个子进程,而是在保持PID不变的情况下,让jvm进程替代shell进程,这样收到SIGTERM信号的就是jvm本身了,可以正常触发优雅关闭。

我们这里用echo命令在本地试验一下。

直接运行

先开启一个shell tab,运行echo $$看一下这个shell的PID:

get shell pid

可以看到PID是63855。

我们tail -f进入一个长期运行命令:

tail -f

我们另启一个shell,运行ps -ef | grep tail看一下有哪些进程:

ps -ef grep tail

可以看到有两个进程,一个是PID为63855的shell进程,另一个是PID为64663tail进程,且通过PPID可以看到,shell进程是tail进程的父进程,这符合预期。

我们尝试在第二个shell中SIGINT也就是kill -2一下第一个shell进程,看看会发生什么:

kill -2 63855
tail alive

可以看到tail进程并未退出,那么如果直接对tail进程执行SIGINT呢:

kill -2 64663
tail exit

这次tail正常结束了,且回到了父进程shell的交互界面。可以看到,以这种方式直接启动的进程是收不到来自系统的信号的。

exec运行

接下来进行第二次试验,回到第一个shell界面,以exec前缀的方式启动tail命令:

tail -f with exec

接下来回到第二个shell并尝试kill -2 63855(第一个shell在上一个实验中并没有退出,所以PID还是63855没变):

kill -2 63855 2nd
Process completed

可以看到这次tail直接结束了,而且与第一次实验不同的是,不仅tail结束了,而是直接显示Process completed,并没有返回到shell的交互界面。这两个实验验证了之前线上的jvm收不到pod被kill的SIGTERM信号的case和猜测。

额外知识

kill -l可以查看操作系统支持的kill参数,在mac上运行结果如下:

➜ ~ kill -l
HUP INT QUIT ILL TRAP ABRT EMT FPE KILL BUS SEGV SYS PIPE ALRM TERM URG STOP TSTP CONT CHLD TTIN TTOU IO XCPU XFSZ VTALRM PROF WINCH INFO USR1 USR2

➜ ~ kill -l | wc -w
31

说明mac上支持31种信号,以下是其中3种常见信号:

INT # kill -2 interrupt 相当于ctrl+c
KILL # kill -9
TERM # kill -15