信号、Shell与Docker:层层嵌套的"陷阱枪"剖析

本文深入探讨了在Linux环境下,信号(如SIGINT、SIGTERM)如何通过Shell(交互式与非交互式)、Docker及docker compose组成的复杂进程链进行传递。文章通过一个CI测试被意外取消但子进程仍继续运行的真实调试案例,揭示了信号转发机制中的关键细节与潜在陷阱,并对相关工具的行为差异进行了分析。

信号、Shell与Docker:层层嵌套的"陷阱枪"

在调试POSIX信号(SIGINT、SIGTERM等)的若干场合中,总免不了涉及Shell。有一天,我们在调试信号、Shell和容器之间一些奇怪的交互行为时,被某些现象彻底搞糊涂了。那些自认为精通Linux的人,也对我们调查中的一些细节感到惊讶。如果你也对这类问题感兴趣(而不是想把笔记本电脑扔出窗外然后去当个养羊驼的隐士),请继续往下读。

案发现场

在Benchling,我们有一个相当标准的测试/持续集成(CI)设置:当你向拉取请求分支推送代码时,我们会为你运行测试。几年前,我们添加了一个小优化:如果你再次推送代码,而前一次提交的测试仍在运行,我们会取消前一次测试运行。你可能不关心那次运行,而且我们还能省点钱……真的吗?

运行我们测试的代码基本上是这样的:

1
2
3
4
5
def test_pipeline() -> int:
    test_result = subprocess.run(["pytest", ])
    report_test_metrics()
    upload_artifacts()
    return test_result.returncode

所以我们的进程树是:

1
2
test_pipeline
└── pytest

subprocess.run会阻塞,直到子进程退出,所以它应该占用几乎所有时间。我们在CI日志中看到测试运行到一半被中断,然后就不再看到日志了,这看起来确实像是优化生效了。但我们却能够获取到被取消运行的指标和构件,这讲不通。后来我们发现,虽然我们报告了运行被取消并停止转发日志,但pytest实际上一直在运行。

回到基础

考虑到问题可能在于没有将信号从test_pipeline转发给pytest,我们首先思考了基础的信号处理。在一个运行zsh的终端中,我们可以用以下命令获取zsh的pid:

1
2
$ echo $$
20147

然后,我们可以在zsh内运行bash,并在bash内运行sleep infinity(就像我们的测试一样,一个非常慢的命令)。

1
2
$ bash
$ sleep infinity

从另一个Shell,我们可以看到进程树:

1
2
$ pstree -p 20147
zsh(20147)───bash(65453)───sleep(65904)

(在Debian/Ubuntu上,pstree位于psmisc包中;在brew中则是pstree formula。)这显示了zsh运行着bash,bash运行着sleep,正如预期。如果我们现在用ctrl+c发送一个SIGINT信号,sleep会停止。

为什么会这样?终端将ctrl+c解释为“发送SIGINT”。zsh接收到SIGINT并将其转发给前台进程,而前台进程恰好是bash。bash接收到信号并将其转发给sleep。sleep没有为SIGINT设置自己的信号处理程序,默认的信号处理程序会退出(SIGINT具有“term”处置方式)。

在调查开始时,这就是我们对Shell信号处理的心理模型。

非交互式Shell

实际问题出现在使用bash运行Shell脚本时(我们在bash脚本中运行上述Python代码)。

1
2
3
bash
  └─ test_pipeline
      └─ pytest

考虑到交互式Shell(会读取stdin等差异)的行为可能与非交互式Shell或“脚本”不同,我们将两行代码写入一个文件:

1
2
sleep infinity
echo done

并运行:

1
$ ./test.sh

在另一个Shell中,我们可以看到相同的进程树:

1
2
$ pstree -p 20147
zsh(20147)───bash(65910)───sleep(65911)

然后,我们尝试直接向bash发送信号:

1
$ kill -s INT 65910

但什么也没发生。在bash文档(man bash)中埋藏着一个“signals”部分,其中写道:

当作业控制未启用时,[…] Shell和命令与终端处于同一进程组,’^C’会向该进程组中的所有进程发送SIGINT。[…] 当Bash在未启用作业控制的情况下运行并接收到SIGINT[…]时,它会等待该前台命令终止,然后[自行退出]。

作业控制在交互式Shell中默认开启,在脚本中默认关闭(参见关于“monitor mode”的文档)。这就解释了为什么什么都没发生:bash在等待sleep(前台命令)终止。

但其中也提到了进程组的线索。pstree也可以显示这些信息(除非你在macOS上):

1
2
$ pstree -pg 20147
zsh(20147,20147)───bash(65910,65910)───sleep(65911,65910)

这里我们看到,我们在交互式zsh中运行的bash,拥有自己的进程组。但我们在非交互式bash中运行的sleep,却与bash共享一个pgid。我们可以通过对pid取负值来向该组中的所有进程发送信号:

1
$ kill -s INT -65910

这导致sleep接收到SIGINT并退出。bash也接收到了SIGINT,并如文档所述,自行退出。回到我们的交互式zsh,我们可以运行:

1
$ sleep infinity

并看到sleep获得了自己的pgid,正如预期。

1
2
$ pstree -p 20147
zsh(20147,20147)───sleep(65916,65916)

非交互式Shell中的最后一条命令

现在我们知道了,有时Shell不会将信号转发给其子进程。有一次,有人试图通过运行bash -c 'sleep infinity'来重现这个问题。他们能够用ctrl+c停止sleep。但这是一个非交互式Shell,所以bash不应该转发SIGINT啊!怎么回事?

1
$ bash -c 'sleep infinity'

像往常一样,在另一个Shell中:

1
2
$ pstree -p 20147
zsh(20147)───sleep(65920)

等等,bash去哪了?我们运行了bash!为什么pstree说zsh在运行sleep?

当我们“运行”一个程序时,通常意味着我们fork然后exec它。fork设置新进程的父进程pid,以便像pstree这样的工具可以在事后绘制出漂亮的树状图。exec设置新进程的命令,以便pstree这样的工具可以显示有关该pid正在运行的有意义的信息。

但这里发生的情况是,bash在exec sleep之前根本就没有fork。我们找不到任何关于此行为的文档,所以我们只能给你看一些ash的源代码:

1
2
3
4
/* Can we avoid forking? For example, very last command
 * in a script or a subshell does not need forking,
 * we can just exec it.
 */

所以bash用sleep替换了自己,pstree显示现在运行sleep的进程的父进程是zsh。我们可以通过运行bash -c 'sleep infinity && done'来获得之前的行为。

这尤其令人兴奋,因为我们实际上是用sh -c来运行我们的bash脚本,所以我们的心理模型一度是:

1
2
3
4
sh
└─ bash
    └─ test_pipeline
        └─ pytest

直到我们意识到sh并没有在进程树中作为自己的pid存在。

关于sh、bash、dash和ash的简短插曲

等等,什么是ash?你刚刚是给我看了一些不相关的代码吗?(是的,有点;行为与bash相同,但源代码不那么…抽象。)

sh是Bourne shell(但通常被称为“POSIX sh”)。Bash是Bourne Again shell。历史上,许多系统将sh链接到bashbash会检查argv[0]并以sh兼容模式运行。在现代Linux系统上,sh现在通常是dash,但在macOS上,它仍然是sh模式下的bash

最初的ash是1989年为NetBSD编写的Almquist shell。它被移植到Linux并重命名为dash(Debian Almquist shell)。如今,“ash”通常指的是busybox ash,它是dash的一个衍生版本。是的,你没看错:血统是ashdashash。Shell程序员在命名方面并不是最好的。

顺便说一下,sh兼容模式下的bashash都实现了上一节描述的无forkexec行为,但dash没有。此外,如果你尝试在Docker Hub上的官方bash镜像中运行shdocker run -it --rm bash sh),你得到的不是预期的sh兼容模式下的bash,而是ash(不要与ash混淆)。

流程图

这是我们希望在开始剥离Shell信号处理这个“洋葱”之前就存在的流程图。

回到案发现场

借助我们方便的流程图,我们去阅读ci-agent的代码,发现当构建被取消时,它会向正在运行的作业发送SIGTERM。

1
2
3
4
ci-agent
    └─ bash
        └─ test_pipeline
            └─ pytest

bash以非交互方式运行,test_pipeline不是最后一条命令,所以无论如何信号都不会被转发。这能解释发生了什么吗?

我们试图通过让bash执行test_pipeline.py来将其从进程树中移除,但这并没有解决问题。那一定意味着我们的进程树仍然是错误的。

容器

ci-agent实际上只是告诉docker运行我们的脚本。

1
2
3
4
5
ci-agent
    └─ docker
        └─ bash
            └─ test_pipeline
                └─ pytest

信号是否被docker转发给bash?Docker为每个容器创建一个新的pid命名空间,因此它运行的命令成为pid 1。1是一个非常特殊的pid(通常是init进程),并且没有默认的信号处理程序。一个常见的技巧是使用tinidumb-init来作为pid 1以解决这个问题。

在调查了我们的镜像后,结果发现我们已经使用了dumb-init,这让我们得到了这样的树:

1
2
3
4
5
6
ci-agent
    └─ docker
        └─ dumb-init
            └─ bash
                └─ test_pipeline
                    └─ pytest

但问题仍然没有解释。

这是最后一棵树,我保证

实际上,我们并不是直接运行docker容器;我们使用docker compose run

1
2
3
4
5
6
7
ci-agent
    └─ docker compose
        └─ docker
            └─ dumb-init
                └─ bash
                    └─ test_pipeline
                        └─ pytest

这个Bug在我们从docker-compose(v1;注意有连字符)升级到docker compose(v2)时显现出来。注意到缺失的连字符对于理解这个问题是必要的,但很难注意到,因为两个版本接受几乎相同的参数,并且行为也几乎相同。从这个故事中得出的一个教训是:命名尽管很难,但很重要。如果你发现自己写下像“更新脚本以使用Compose V2,方法是将连字符(-)替换为空格”这样的文档,那么你可能犯了一个关键的命名错误。

另一个让调试变得棘手的事情是需要理解完整的传递链。信号需要被每个进程转发给它们的子进程。理解为什么pytest没有接收到信号,需要构建出直到转发链断裂的进程树,而在这个案例中,这个链条相当长。

我们考虑过降级回docker compose v1,但我们选择跟踪由我们的CI步骤运行的容器,并在最后用docker kill杀死它们。后来,在上游修复了这个问题之后,我们的缓解措施就根本没有被触发。随着问题得到解决,我们的CI运行现在确实能在我们发出指令时停止了。当有人快速向PR分支多次推送时,我们不会浪费周期在旧的提交上运行,从而总体上实现了更快的运行!(我们也不再报告这些被取消运行的指标,这极大地帮助我们识别不稳定或失败的测试。)

关于前台进程的额外内容

回到“非交互式Shell”部分,我们有一个进程树:

1
zsh(20147)───bash(65910)───sleep(65911)

并且直接向bash发送信号:

1
$ kill -s INT -65910

为什么我们不直接向zsh发送信号呢?zsh是以交互方式运行的,难道它不应该将SIGINT转发给bash吗?我们可以试试:

1
$ kill -s INT -20147

但什么都没发生。

结果发现,当你在此情况下按ctrl+c时,终端是将SIGINT发送给bash,而不是zsh。这是因为zsh不再处于前台进程组中。我们可以通过运行以下命令看到:

1
2
3
4
5
$ ps -xO stat
   PID STAT S TTY          TIME COMMAND
 20147 Ss   S pts/0    00:00:00 zsh
 65910 S+   S pts/0    00:00:00 bash
 65911 S+   S pts/0    00:00:00 sleep

man ps的“进程状态代码”部分说:

1
+     is in the foreground process group

我们可以看到bash和sleep是前台进程组的成员,但zsh不是。它们不可能同时都是前台进程组,因为只能有一个前台进程组,而且zsh给了bash自己的进程组(因为zsh是以交互方式运行的)。所以当我们说“zsh接收到SIGINT并将其转发给前台进程,而前台进程恰好是bash”时,结果证明那是一个谎言。

但为什么bash的进程组是前台进程组呢?tcsetpgrp。我们可以用ltrace看到它被调用:

1
2
$ ltrace -e tcsetpgrp bash
bash->tcsetpgrp(255, 0xa9850, 0, 0x7f290bdb2fe4) = 0

当bash退出时,父Shell(在我的例子中是zsh)会通过相同的调用来重新获得前台状态。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计