背景

kill -9

一个jar包形式部署的spring boot项目,每次更新都是kill -9 pid直接杀死老进程再用新jar包重新启动。由于一直以来更新都是在半夜进行,倒也无事发生。

最近有一个紧急更新要求立刻上线,无奈在下午进行了更新操作。9号信号量SIGKILL 强力的效果导致有一个正在进行中的事务直接中断并且没有发生回滚,处理完脏数据之后痛定思痛决定优化更新流程。

优雅关机 graceful shutdown

好在从2.3.0起,spring boot支持了优雅关机特性。启用优雅关机非常简单,只要在配置文件中简单配置即可。

server:
  # server.shutdown支持两个可选值:immediate graceful
  #   immediate 默认值,和之前一样,服务直接关闭
  #   graceful 启用优雅关机,使用spring.lifesycle.timeout-per-shutdown-phase属性给定的超时时间
  shutdown: graceful

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

优雅关机意味着在关闭应用程序之前,可以设置一个超时期限,来让正在进行中的请求继续处理。在这段时间内,应用将不允许新的请求进入。

spring boot优雅关机的触发依赖于java的ShutdownHook事件处理,所以需要使用2号SIGINT中断信号量(相当于在前台应用中使用Ctrl + C)。

kill -2

改好之后手动测试没有问题,不过提交之后走正常发布流程通过jenkins部署就无法正常关机。

经过排查,发现是对通过远程命令启动的后台进程SIGINT并不生效。

shell脚本/远程命令启动的后台进程

问题抽象

写一个死循环程序app.c代表我们的服务进程

int main() {
    while (1) {};
    return 0;
}

编译

gcc app.c -o app

在当前shell后台运行,然后kiill -2,是可以正常杀死的,也是上述我的手动测试过程

➜  tmp ./app &
[1] 10722
➜  tmp ps -f
  UID   PID  PPID   C STIME   TTY           TIME CMD
  501 10722  8802   0  5:47下午 ttys000    0:02.65 ./app

➜  tmp kill -2 10722
[1]  + 10722 interrupt  ./app
➜  tmp ps -f
  UID   PID  PPID   C STIME   TTY           TIME CMD

写一个简单的脚本start.sh

#!/bin/bash
./app &

用脚本启动再kill -2,进程却不会退出,使用kill -9可以使进程退出

➜  tmp ./start.sh
➜  tmp ps -f
  UID   PID  PPID   C STIME   TTY           TIME CMD
  501 10847     1   0  5:51下午 ttys000    0:01.91 ./app

➜  tmp kill -2 10847
➜  tmp ps -f
  UID   PID  PPID   C STIME   TTY           TIME CMD
  501 10847     1   0  5:51下午 ttys000    0:11.54 ./app

➜  tmp kill -9 10847
➜  tmp ps -f
  UID   PID  PPID   C STIME   TTY           TIME CMD

一些题外话

细心的话可能会发现,直接在shell启动和通过脚本启动虽然都启动成功了,但是他们的父进程id ppid并不一样,特别是通过脚本启动的后台进程,它的ppid竟然是1(init进程)。

shell脚本在执行的时候本身其实也是一个后台进程,它并不像前台进程那样在执行期间独占终端。其实是由当前bash进程(假设当前shell使用的是bash)创建了一个子shell环境为脚本提供bash运行时,脚本启动的app进程的父进程其实就是这个子bash进程。当脚本执行结束后,子bash进程退出,app进程就变成了孤儿进程,交由1号进程(所有其他用户进程的祖先进程)收养。

信号传给进程了吗

使用strace命令跟踪进程接收的信号

在当前shell后台运行,使用strace跟踪,然后kill -2

➜  tmp ./app &
[1] 4879
➜  tmp ps -f
UID        PID  PPID  C STIME TTY      STAT   TIME CMD
root      4879  4706 99 10:32 pts/0    R      0:09 ./app
➜  tmp strace -p 4879
➜  tmp kill -2 4879
➜  tmp ps -f
UID        PID  PPID  C STIME TTY      STAT   TIME CMD

strace完整输出如下

strace: Process 4879 attached
--- SIGINT {si_signo=SIGINT, si_code=SI_USER, si_pid=4706, si_uid=0} ---
+++ killed by SIGINT +++

进程收到了SIGINT信号并且被正常杀死了

用脚本启动先kill -2,然后再kill -9

➜  tmp ./start.sh
➜  tmp ps -f
UID        PID  PPID  C STIME TTY          TIME CMD
root      4888     1 99 10:39 pts/0    00:00:02 ./app
➜  tmp strace -p 4888
➜  tmp kill -2 4888
➜  tmp ps -f
UID        PID  PPID  C STIME TTY          TIME CMD
root      4888     1 99 10:39 pts/0    00:00:23 ./app

➜  tmp kill -9 4888
➜  tmp ps -f
UID        PID  PPID  C STIME TTY          TIME CMD

strace完整输出如下

strace: Process 4888 attached
--- SIGINT {si_signo=SIGINT, si_code=SI_USER, si_pid=4706, si_uid=0} ---
+++ killed by SIGKILL +++

可以看到进程确实收到了SIGINT信号,但是却没有做出任何反应,随后收到SIGKILL信号被杀死了

查看进程对SIGINT信号的处理

修改app.c,在启动时获取进程对SIGINT信号的处理器

#include <stdio.h>
#include <signal.h>

struct sigaction sigint_hdl;

int main() {
    sigaction(SIGINT, NULL, &sigint_hdl);
    while (1) {};
    return 0;
}

在当前shell后台运行

➜  tmp ./app &
[1] 4945
➜  tmp ps -f
UID        PID  PPID  C STIME TTY          TIME CMD
root      4945  4706 93 11:10 pts/0    00:00:01 ./app

使用gdb连接到app进程,查看sigint_hdl变量

➜  tmp gdb app 4945
GNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-120.el7
Copyright (C) 2013 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-redhat-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /root/app...(no debugging symbols found)...done.
Attaching to program: /root/app, process 4945
Reading symbols from /lib64/libc.so.6...(no debugging symbols found)...done.
Loaded symbols for /lib64/libc.so.6
Reading symbols from /lib64/ld-linux-x86-64.so.2...(no debugging symbols found)...done.
Loaded symbols for /lib64/ld-linux-x86-64.so.2
0x0000000000400555 in main ()
Missing separate debuginfos, use: debuginfo-install glibc-2.17-324.el7_9.x86_64
(gdb) p sigint_hdl
$1 = 0

可以看到sigint_hdl的值为0,在signal.h源码中有如下定义

/* Fake signal functions.  */
#define SIG_ERR    ((__sighandler_t) -1) /* Error return.  */
#define SIG_DFL    ((__sighandler_t) 0)  /* Default action.  */
#define SIG_IGN    ((__sighandler_t) 1)  /* Ignore signal.  */

可知0表示对该信号的默认处理程序。

随后向进程发送SIGINT信号,进程终止

(gdb) signal SIGINT
Continuing with signal SIGINT.

Program terminated with signal SIGINT, Interrupt.
The program no longer exists.
(gdb) q

使用脚本启动,同样gdb然后查看sigint_hdl变量

➜  tmp ./start.sh
➜  tmp ps -f
UID        PID  PPID  C STIME TTY          TIME CMD
root      4962     1 92 11:31 pts/0    00:00:01 ./app

➜  tmp gdb app 4962
GNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-120.el7
Copyright (C) 2013 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-redhat-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /root/app...(no debugging symbols found)...done.
Attaching to program: /root/app, process 4962
Reading symbols from /lib64/libc.so.6...(no debugging symbols found)...done.
Loaded symbols for /lib64/libc.so.6
Reading symbols from /lib64/ld-linux-x86-64.so.2...(no debugging symbols found)...done.
Loaded symbols for /lib64/ld-linux-x86-64.so.2
0x0000000000400555 in main ()
Missing separate debuginfos, use: debuginfo-install glibc-2.17-324.el7_9.x86_64
(gdb) p sigint_hdl
$1 = 1

此时sigint_hdl的值是1,由上可知,这代表进程忽略了SIGINT信号

向进程发送SIGINT信号,正常运行不会退出;随后发送SIGKILL杀死进程

(gdb) signal SIGINT
Continuing with signal SIGINT.
^C
Program received signal SIGINT, Interrupt.
0x0000000000400555 in main ()
(gdb) signal SIGKILL
Continuing with signal SIGKILL.

Program terminated with signal SIGKILL, Killed.
The program no longer exists.
(gdb) q

得出更进一步的结论:信号都可以发送到进程,但是通过脚本启动的后台进程忽略了SIGINT信号,对该信号不做反应,所以导致了我们一开始的现象。

非交互式shell

shell命令行是交互模式,运行脚本/远程命令都是非交互模式。

非交互式shell默认禁用了job control,这时shell会设置忽略SIGINT等信号。

if(!fork()) {
  /* child */
  signal(SIGINT, SIG_IGN);
  signal(SIGQUIT, SIG_IGN);

  execve(...cmd...);
}

execve启动进程时会继承信号处理器,导致被启动的app进程也忽略了SIGINT信号

为什么要这么设计

在禁用job control之后,所有的前台进程和后台进程实际上都运行在当前shell所在的进程组(job)中。因此,当前台shell中输入Ctrl + C时,所有进程都将收到SIGINT信号。为了保证后台进程能够正常运行,只好让后台进程忽略掉SIGINT信号

而在开启job control的情况下,前台进程运行在前台进程组,后台进程运行在后台进程组,自然就不需要特意让后台进程忽略SIGINT信号了

开启job control

在脚本中使用set -m开启job control,这里贴上set命令文档中关于-m选项的部分

修改start.sh,在最前面加入set -m语句

#!/bin/bash
set -m
./app &

使用脚本启动,尝试使用kill -2关闭进程

➜  tmp ./start.sh
➜  tmp ps -f
UID        PID  PPID  C STIME TTY          TIME CMD
root      5058     1 83 13:50 pts/0    00:00:03 ./app

➜  tmp kill -2 5058
➜  tmp ps -f
UID        PID  PPID  C STIME TTY          TIME CMD

进程正常关闭,符合预期。

Last modification:August 2, 2021
如果觉得我的文章对你有用,请随意赞赏