在Bash中将SIGTERM转发给子进程。

我有一个类似于这样的Bash脚本:
#!/bin/bash
echo "Doing some initial work....";
/bin/start/main/server --nodaemon

现在,如果运行脚本的bash shell接收到SIGTERM信号,它也应该向正在运行的服务器发送SIGTERM信号(由于服务器阻塞,所以无法使用trap)。这是否可能?
5个回答

尝试:

#!/bin/bash 

_term() { 
  echo "Caught SIGTERM signal!" 
  kill -TERM "$child" 2>/dev/null
}

trap _term SIGTERM

echo "Doing some initial work...";
/bin/start/main/server --nodaemon &

child=$! 
wait "$child"

通常情况下,当子进程正在执行时,bash会忽略任何信号。使用&启动服务器将其放入shell的作业控制系统中,并使用$!保存服务器的PID(可与waitkill一起使用)。调用wait将等待具有指定PID(即服务器)的作业完成,或者等待任何信号被触发。
当shell接收到SIGTERM(或服务器独立退出)时,wait调用将返回(以服务器的退出代码退出,或者在接收到信号时以信号编号+128退出)。之后,如果shell接收到SIGTERM,它将在退出之前调用作为SIGTERM陷阱处理程序指定的_term函数(其中我们进行任何清理并手动将信号传递给服务器进程使用kill)。

9但是exec 用给定的程序替换shell,我不清楚为什么后续需要wait调用? - iruvar
@1_CR:等待是我们脚本的需要...等待子进程完成。我们希望确保在子进程终止后,脚本才会退出。 - cuonglm
6我认为1_CR的观点是正确的。要么你只是使用exec /bin/start/main/server --nodaemon(在这种情况下,shell进程被服务器进程替换,你不需要传播任何信号),要么你使用/bin/start/main/server --nodaemon &,但是exec实际上没有什么意义。 - Andreas Veithen
@1_CR 和 @Andreas 是正确的 - 我已经从脚本中移除了 exec,所以 Bash 进程将保留在服务器进程周围,以便在信号处理时进行清理。 - Stuart P. Bentley
此外,“exec”是一个完全合理的解决方案,因为原始问题是这样提出的;我已经将其作为一个单独的答案提交,并明确了这个答案的功能。 - Stuart P. Bentley
@StuartP.Bentley:这里的重点是使用exec command &会在子shell中启动命令,并且在新的shell中,exec将用主程序替换当前的shell。我不记得最后一次编辑时删除了exec部分,导致我的解释不正确。 - cuonglm
6如果你希望你的shell脚本只在子进程终止后才终止,那么在_term()函数中,你应该再次使用wait "$child"。这可能是必要的,如果你有其他监控进程在等待shell脚本死亡之前重新启动它,或者如果你也捕获了EXIT来进行一些清理工作,并且需要它在子进程完成后才运行。 - LeoRochael
2根据man和我的本地测试,我认为bash只在交互模式下忽略SIGTERM信号。其他方面答案很好。 - akostadinov
肯定有一个我们可以在Bash中设置的标志,来为我们完成这个任务。例如,set -o forwardsignals,或者其他任何方式。 - Alexander Mills
2@AlexanderMills 请阅读其他答案。你可能要找的是exec命令(https://unix.stackexchange.com/a/196053/7733),或者你想要设置陷阱(https://unix.stackexchange.com/a/444676/7733)。 - Stuart P. Bentley
谢谢@StuartP.Bentley,直到你提到我才注意到这个。 - Alexander Mills
脚本的编写方式很冒险。如果在bash启动后立即发送TERM信号给后台作业,但在child=$!之前发送,子进程将无法设置并且kill命令会报错。为了修复这个问题,陷阱处理程序应该直接使用$! - Igor Bukanov
@IgorBukanov 如果在bash启动后立即发送TERM到后台作业会发生什么?请查看我的答案获取完整解决方案。 - SensorSmith
这个脚本在SIGTERM上工作,但在SIGQUIT上不工作,因为shell在后台子进程中忽略了SIGQUIT。请参考https://stackoverflow.com/q/45106725/921859。 - Fumisky Wells

Bash不会将类似SIGTERM的信号转发给它当前正在等待的进程。如果你想通过“接力”到你的服务器来结束你的脚本(允许它处理信号和其他任何事情,就像你直接启动了服务器一样),你应该使用exec命令,它将“用被打开的进程替换掉shell”:
#!/bin/bash
echo "Doing some initial work....";
exec /bin/start/main/server --nodaemon

如果你因为某些原因需要保留外壳(例如,在服务器终止后需要进行一些清理工作),你应该使用 trapwaitkill 的组合。请参考SensorSmith的回答

3这是正确的答案!更加简洁明了,完全满足了楼主最初的要求。 - BrDaHa
1我对被接受的答案感到非常高兴,因为exec有一个限制:它替换了进程镜像,所以在进程退出后你无法执行代码(例如清理工作,这对我来说很重要)。 - Ercksen

Andreas Veithen指出,如果您不需要从调用中返回(就像在OP的示例中一样),只需通过exec命令进行调用即可({{link2:@Stuart P. Bentley的答案}})。否则,“传统”的trap 'kill $CHILDPID' TERM(@cuonglm的答案)是一个起点,但wait调用实际上在陷阱处理程序运行之后返回,这可能仍然在子进程实际退出之前。因此,建议“额外”调用wait({{link3:@user1463361的答案}})。
虽然这是一个改进,但仍存在竞争条件,这意味着进程可能永远不会退出(除非信号发送者重试发送TERM信号)。漏洞的时间窗口在注册陷阱处理程序和记录子进程PID之间。
以下消除了这种漏洞(封装在可重用的函数中)。
prep_term()
{
    unset term_child_pid
    unset term_kill_needed
    trap 'handle_term' TERM INT
}

handle_term()
{
    if [ "${term_child_pid}" ]; then
        kill -TERM "${term_child_pid}" 2>/dev/null
    else
        term_kill_needed="yes"
    fi
}

wait_term()
{
    term_child_pid=$!
    if [ "${term_kill_needed}" ]; then
        kill -TERM "${term_child_pid}" 2>/dev/null 
    fi
    wait ${term_child_pid} 2>/dev/null
    trap - TERM INT
    wait ${term_child_pid} 2>/dev/null
}

# EXAMPLE USAGE
prep_term
/bin/something &
wait_term

5非常出色的工作 - 我已经更新了我的答案中的链接,指向这里(除了这是一个更全面的解决方案外,我还有点生气的是StackExchange界面没有在cuonglm的答案中为我[修复脚本使其实际执行所需动作](https://unix.stackexchange.com/revisions/146770/4)和[几乎写了所有解释性文本](https://unix.stackexchange.com/revisions/146770/5)之后的OP [他甚至不明白](https://unix.stackexchange.com/questions/146756/forward-sigterm-to-child-in-bash/196053#comment328666_146770)又进行了一些小的重新编辑)。 - Stuart P. Bentley
5@StuartP.Bentley,谢谢。我对这个的组装感到惊讶,需要两个(不被接受的)答案和一个外部参考,然后我还得解决竞争条件。我将把我的参考升级为链接,并尽力提供一点额外的赞扬。 - SensorSmith
这个解决方案只适用于Bash吗? - Torsten Bronger
2@TorstenBronger它“应该”是可移植的,但我只在Bash下测试过。我没有使用任何故意的Bashisms(没有'function'关键字,没有双括号条件语句,在输出重定向中没有花哨的技巧,并且陷阱语法是Posix)。 - SensorSmith
然而,这仅在确保孩子不会退出(无论是故意还是出错)的情况下才有效。然后,第二个wait会抛出一个错误。 - Torsten Bronger
2@TorstenBronger 在 Ubuntu 18.04 Bash 4.4.20 下重新测试(不是我原本的目标),并得到了带有行号和“Terminated”的 Bash 调试输出,但当子进程在陷阱之前尚未退出时(奇怪),它可能在第一个等待后被遗忘是合法的。但在某些系统上第二个等待是必要的,所以没有好答案。(在这个测试中,退出代码仍然可用。)我编辑了将“错误”输出重定向到空值的部分,适用于发生该情况的系统。 - SensorSmith
我别无选择,只能假设该子程序在没有发送信号的情况下从不使用143或130退出。我将其清晰地记录下来,并建议将其放入子shell中执行。 - Torsten Bronger
2在https://gist.github.com/bronger/acce7736141b3fa118b0d47f1a2035ac#file-signal_propagation-sh-L45上,你可以看到在我的情况下(zsh)需要的内容。虽然它仍然不能涵盖所有边缘情况,但这些可能被视为编程错误。 - Torsten Bronger

提供的解决方案对我无效,因为在等待命令实际完成之前,进程已被终止。我发现了这篇文章http://veithen.github.io/2014/11/16/sigterm-propagation.html,最后一段代码片段适用于我的情况,即在使用自定义sh运行程序的OpenShift中启动应用程序。需要sh脚本,因为我需要能够获取线程转储,而如果Java进程的PID为1,则不可能实现该功能。
trap 'kill -TERM $PID' TERM INT
$JAVA_EXECUTABLE $JAVA_ARGS &
PID=$!
wait $PID
trap - TERM INT
wait $PID
EXIT_STATUS=$?

对上面的答案补充几点:

  1. 根据@cuonglm的建议,使用'&'在后台启动进程并等待其pid,可以使处理程序在子进程运行时执行,但子进程将失去捕获任何输入的能力,因为stdin会在子进程分离后关闭。为了强制stdin保持打开状态,您可以添加一个无限循环,并将其管道传输给子进程。请参见this post

然后,在当前shell中读取输入并将其写入子进程的proc文件,以便将其发送到其stdin:

(while true; do sleep 10000; done) | /bin/start/main/server --nodaemon &
child_pid=$!
while :
do
    result=$(kill -0 $mypid > /dev/null 2>&1)
    if [ $? -ne 0 ] ; then
        # process is gone
        break
    else
        # read input in the current shell and store it in a variable. The timeout only works with Bash, not with Bourne-Shell. You will need to find a way to read stdin instead and sleep 1 sec between each loop
        read -t 1 input 
        
        # echo the input to the proc file of the runuser process so it goes to its stdin
        echo $input > /proc/$child_pid/fd/0 2>/dev/null 
    fi
done
wait $child_pid

注意:这在Linux上运行得很好,但可能需要一些调整才能在其他Unix平台上使用。
编辑:一个更简单的方法是将stdin复制到一个文件描述符中,然后可以将其作为后台进程的stdin使用。
exec 3<&0
/bin/start/main/server --nodaemon <&3 &
  1. exec是第二个解决方案,如@Stuart P. Bentley所建议的,但有时您需要使用新的进程ID创建进程,或者所使用的命令可能不让您选择并创建带有新PID甚至新PGID的进程(例如,对于带有-l选项的runuser)。

  2. 除了针对特定PID进行定位外,另一种方法是向进程组发送信号。

可以通过在子进程的PID之前使用减号(-)来实现:

kill -TERM -$child_pid

Bash确实可以让您捕获针对进程组的信号,而无需将进程放在后台运行。此方法不会丧失子进程读取stdin的能力。如果子进程在不同的进程组中运行,这也是一个很好的解决方案,因为处理程序将允许您将信号转发给子进程。限制是组中的其他成员也将接收到该信号,这可能是一个问题,也可能不是,具体取决于场景。