信号、Shell与Docker:层层嵌套的调试挑战

本文详细探讨了在调试POSIX信号、Shell脚本和Docker容器交互时遇到的复杂问题。通过分析信号传递链、进程组、前台后台进程以及Docker Compose的特定版本bug,揭示了如何一步步定位并解决CI测试环境中任务无法正确取消的故障。

在少数情况下,我们需要调试POSIX信号(SIGINT、SIGTERM等)。通常,这还会涉及到一个shell。有一天,我们在调试信号、shell和容器之间的一些奇怪交互时,被某些行为搞得晕头转向。那些自认为对Linux很了解的人,也对我们调查中的一些细节感到惊讶。所以,如果这类事情不会让你想把笔记本扔出窗外然后去当个养羊驼的隐士,那就请继续读下去。

Press enter or click to view image in full size

案发现场

在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公式。)这显示了zsh运行bash,bash运行sleep,符合预期。如果我们现在用ctrl+c发送SIGINT,sleep就会停止。

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

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

非交互式shell

实际问题出现在用bash运行shell脚本时(我们在bash脚本中运行上面的python代码)。

1
2
3
bash
  └─test_pipeline
      └─pytest

考虑到交互式shell(从标准输入读取等差异)的行为可能与非交互式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中启用,在脚本中关闭(参见关于“监控模式”的文档)。所以这解释了为什么什么都没发生: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的一个衍生版本。是的,你没看错:谱系是ash → dash → ash。shell程序员在命名方面不是最擅长的。

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

流程图

下面是我们希望在开始层层剥开shell信号处理的洋葱之前就存在的流程图。

Press enter or click to view image in full size

回到案发现场

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

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

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

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

容器

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

最终构建出这棵树后,我们能够重现这个问题。它只发生在docker compose版本v2.0.0到v2.19.0之间,在这个版本范围内,docker compose run无法转发信号。在我们报告了这个问题之后,这里修复了它。

这个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的“进程状态码”部分说: + 表示位于前台进程组

我们可以看到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 设计