0%

如何将SIGTERM信号传播到Bash脚本中的子进程

Supervisord 要求程序不能配置为守护进程,必须在前台运行并响应停止信号。只有由Supervisord或Docker直接创建的进程才能收到TERM信号,而且他们有责任正确地停止运行子进程。

这是一个问题,如果实际的服务器进程是由shell脚本产生的,就像Java服务通常情况一样:

1
2
3
4
5
6
7
8
9
#!/bin/bash

# Prepare the JVM command line
...

$JAVA_EXECUTABLE $JAVA_ARGS

# Clean up
...

在这种情况下,TERM信号由shell进程接收,但是Bash不会将该信号转发给子进程。这意味着shell进程将停止,但JVM将继续运行。

如果创建子进程的命令是shell脚本中的最后一个命令,则可以使用exec以下方法轻松解决问题:

1
2
3
#!/bin/bash
...
exec $JAVA_EXECUTABLE $JAVA_ARGS

而不是创建一个新进程,这将取代JVM中的shell进程。在这种情况下,TERM信号由JVM直接接收,问题得到解决。

如果shell脚本需要在JVM终止后执行一些清理,那么事情会更加复杂。在这种情况下,除了创建子进程之外,我们别无选择,但是我们需要找到一种方法来传递TERM信号给该子进程,并在执行清除代码之前等待其完成。在这里,trap内置 的拯救:它允许配置一个命令执行时shell接收到特定的信号。但是,有一个重要的限制:

当Bash在等待命令完成时收到trap已设置的信号时,在命令完成之前不会执行trap。

这使得有必要执行JVM作为后台进程(使用&)并等待其完成,以便shell进程可以在子进程仍在运行时执行trap。经常在Web呈现的解决方案是使用wait 内置函数等待子进程的完成来编写脚本:

1
2
3
4
5
6
7
#!/bin/bash
...
trap 'kill -TERM $PID' TERM
$JAVA_EXECUTABLE $JAVA_ARGS &
PID=$!
wait $PID
...

但是,这是不正确的。要了解为什么,让我们来看看彼此之间的相互作用trapwait互动:

当Bash通过wait内置等待异步命令时,接收到已设置trap的信号将导致wait内置程序立即返回,退出状态大于128,紧接着执行trap。

这意味着wait在子进程终止之前,shell将开始执行命令之后的指令(甚至可能退出)。一个解决方案是等待trap中子进程的终止:

1
2
3
4
5
6
7
#!/bin/bash
...
trap 'kill -TERM $PID; wait $PID' TERM
$JAVA_EXECUTABLE $JAVA_ARGS &
PID=$!
wait $PID
...

这是因为trap在与正常控制流程相同的线程中执行,因此挂起脚本的执行。但是,如果脚本需要检索子进程的退出状态,则解决方案仍然不完整。通常,该信息 由 命令返回wait

wait [jobspecorpid...]

等到每个进程ID所指定的子进程的PID或作业规范JOBSPEC 退出并返回退出状态的最后一个命令等待。如果给出了工作规范,则等待工作中的所有进程。如果没有给出参数,则等待所有当前活动的子进程,并且返回状态为零。

除非wait被信号中断,否则这一功能将立即返回,退出状态大于128(实际上是128加上信号的数字值,SIGTERM为15)。人们可能想使用这些信息区分两种情况。然而,这种方法将是有缺陷的,因为子进程的退出状态本身可能大于128(特别是SIGTERM的默认信号处理程序导致进程以退出代码143终止)。这个问题的一个解决方案是调用wait两次:

1
2
3
4
5
6
7
8
9
#!/bin/bash
...
trap 'kill -TERM $PID; wait $PID' TERM
$JAVA_EXECUTABLE $JAVA_ARGS &
PID=$!
wait $PID
wait $PID
EXIT_STATUS=$?
...

这是因为wait如果进程已经退出,则立即返回(具有进程的退出状态)。然后不再需要等待trap,脚本可以简化如下:

1
2
3
4
5
6
7
8
9
#!/bin/bash
...
trap 'kill -TERM $PID' TERM
$JAVA_EXECUTABLE $JAVA_ARGS &
PID=$!
wait $PID
wait $PID
EXIT_STATUS=$?
...

但是,解决方案仍然不完善,因为它不能正确处理SIGINT。当按CTRL + C时,终端终端的前台进程组发送一个SIGINT(在这种情况下,这里包括shell进程及其子进程)。问题是Bash配置在后台启动的命令忽略SIGINT。这意味着CTRL + C只会停止脚本,而不会停止JVM。为了解决这个问题并模拟原始脚本的行为(它只是$JAVA_EXECUTABLE $JAVA_ARGS在前台执行),为INT信号配置相同的trap是足够的:

1
2
3
4
5
6
7
8
9
#!/bin/bash
...
trap 'kill -TERM $PID' TERM INT
$JAVA_EXECUTABLE $JAVA_ARGS &
PID=$!
wait $PID
wait $PID
EXIT_STATUS=$?
...

最后,在收到第一个信号或由于其他原因停止JVM之后,删除trap也可能是个好主意:

1
2
3
4
5
6
7
8
9
10
#!/bin/bash
...
trap 'kill -TERM $PID' TERM INT
$JAVA_EXECUTABLE $JAVA_ARGS &
PID=$!
wait $PID
trap - TERM INT
wait $PID
EXIT_STATUS=$?
...

exec命令

shell的内建命令exec将并不启动新的shell,而是用要被执行命令替换当前的shell进程,并且将老进程的环境清理掉,而且exec命令后的其它命令将不再执行。

不过,要注意一个例外,当exec命令来对文件描述符操作的时候,就不会替换shell,而且操作完成后,还会继续执行接下来的命令。
exec 3<&0:这个命令就是将操作符3也指向标准输入。