信号、Shell与Docker:一个层层嵌套的“陷阱”
在少数情况下,我们需要调试POSIX信号(SIGINT、SIGTERM等)。不可避免地,这其中也涉及Shell。有一天,我们在调试信号、Shell和容器之间一些奇怪的交互行为时,被某些现象弄得晕头转向。自认为对Linux有所了解的人,也会对我们调查中的一些细节感到惊讶。如果你不是那种想扔掉笔记本去养羊驼的隐士,那就继续读下去吧。
案发现场
在Benchling,我们有一个相当标准的测试/持续集成(CI)设置:当你将代码推送到拉取请求分支时,我们会为你运行测试。几年前,我们增加了一个小小的优化:如果你再次推送代码,而上一次提交的测试仍在运行,我们会取消上一次的测试运行。你可能并不关心那次运行,而且我们也节省了一些开支……真的吗?
运行我们测试的代码基本上是这样的:
|
|
因此,我们的进程树是:
|
|
subprocess.run 会阻塞直到子进程退出,所以它应该占用几乎所有的时间。我们在CI日志中看到测试运行到一半就被中断了,之后再也看不到日志,这看起来确实像是在工作。但我们却能够获取到被取消运行的指标和构件,这说不通。我们后来发现,虽然我们报告运行已被取消并停止转发日志,但 pytest 却在继续运行。
回归基础
考虑到问题可能在于没有将信号从 test_pipeline 转发给 pytest,我们先思考一下基本的信号处理。在一个运行着zsh的终端中,我们可以用以下命令获取zsh的pid:
|
|
然后,我们可以在zsh里运行bash,并在bash里运行 sleep infinity(就像我们的测试,一个非常慢的命令)。
|
|
从另一个shell,我们可以看到进程树。
|
|
(pstree 在 Debian/Ubuntu 的 psmisc 包中,在brew中是 pstree 配方。)这显示了zsh运行bash,bash运行sleep,正如预期。如果我们现在用 ctrl+c 发送一个SIGINT,sleep就停止了。
为什么会这样?终端将 ctrl+c 解释为“发送SIGINT”。zsh接收到SIGINT并将其转发给前台进程,这个进程正好是bash。bash接收到信号并将其转发给sleep。sleep没有为SIGINT设置自己的信号处理器,默认的信号处理器会退出(SIGINT具有“term”处置方式)。
在调查开始时,这是我们关于Shell信号处理的心智模型。
非交互式Shell
实际问题出现在一个Shell脚本用bash运行时(我们在一个bash脚本中运行上面的python代码)。
|
|
考虑到交互式Shell(会读取标准输入等差异)的行为可能与非交互式Shell或“脚本”不同,我们写了两行到一个文件:
|
|
然后运行:
|
|
在另一个shell中,我们可以看到相同的进程树:
|
|
然后,我们尝试直接给bash发信号:
|
|
但什么都没发生。隐藏在bash文档(man bash)的“signals”部分有这样一段话:
当作业控制未启用时,[…] Shell和命令与终端处于同一个进程组中,
^C会向该进程组中的所有进程发送SIGINT。[…] 当Bash在没有启用作业控制的情况下运行并接收到SIGINT时,[…]它会等待那个前台命令终止,然后[自己退出]。
作业控制在交互式Shell中默认开启,在脚本中默认关闭(参见关于“monitor mode”的文档)。这就解释了为什么什么都没发生:bash在等待sleep(前台命令)终止。
但这里还有一个关于进程组的提示。pstree 也可以显示这些(除非你在macOS上):
|
|
所以在这里,我们看到我们在交互式zsh中运行的bash,拥有了自己的进程组。但我们在非交互式bash中运行的sleep,却与bash共享同一个pgid。我们可以通过将pid取反来向该组中的所有进程发送信号:
|
|
这导致sleep接收到SIGINT并退出。bash也接收到了SIGINT,并如文档所说,自行退出。回到我们的交互式zsh,我们可以运行:
|
|
并看到sleep获得了自己的pgid,正如预期。
|
|
非交互式Shell中的最后一条命令
所以现在我们知道了,有时候Shell不会将信号转发给它的子进程。有一次,有人试图通过运行 bash -c 'sleep infinity' 来复现这个问题。他们能够用 ctrl+c 停止sleep。但这是一个非交互式Shell,所以bash不应该转发SIGINT!这又是怎么回事?
|
|
和往常一样,在另一个shell中:
|
|
等等,bash去哪了?我们运行了bash!为什么 pstree 说zsh在运行sleep?
当我们“运行”一个程序时,通常意味着我们进行fork,然后exec。fork 设置新进程的父pid,以便像 pstree 这样的工具事后可以绘制出一棵漂亮的树。exec 设置新进程的命令,以便像 pstree 这样的工具可以向你展示有关该pid正在运行的有意义的信息。
但这里发生的情况是,bash在exec sleep之前根本没有fork。我们找不到关于这种行为的任何文档,所以我们提供给你一些ash源代码:
|
|
所以bash用sleep替换了自己,而 pstree 显示现在运行sleep的进程的父进程是zsh。我们可以通过运行 bash -c 'sleep infinity && done' 来获得之前的行为。
这尤其令人兴奋,因为我们实际上是用 sh -c 来运行我们的bash脚本,所以我们的心智模型曾一度是:
|
|
直到我们意识到 sh 在进程树中并不是一个独立的pid。
关于sh、bash、dash和ash的简短插曲
等等,ash是什么?你刚刚给我链接了一些不相关的代码吗?(是的,有点;行为与bash相同,但源代码没那么…抽象。)
sh 是Bourne shell(但通常被称为“POSIX sh”)。Bash 是Bourne Again shell。历史上,许多系统将 sh 链接到 bash,后者会检查 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-without-fork行为,但dash没有。此外,如果你尝试在Docker Hub上的官方bash镜像中运行 sh(docker run -it --rm bash sh),你得到的不是你所期望的sh兼容模式的bash,而是ash(不要与ash混淆)。
流程图
这是我们希望在我们开始剥开Shell信号处理这个洋葱之前就存在的流程图。 (请按回车键或点击以查看完整大小的图片)
回到案发现场
凭借我们方便的流程图,我们去阅读我们ci-agent的代码,发现当一个构建被取消时,它会向正在运行的作业发送SIGTERM。
|
|
bash是以非交互方式运行的,test_pipeline 不是最后一条命令,所以无论如何信号都不会被转发。这能解释发生了什么吗?
我们试图通过让bash exec test_pipeline.py 来把它从进程树中移除,但这并没有解决问题。那一定意味着我们的进程树仍然是错的。
容器
ci-agent实际上只是告诉docker运行我们的脚本。
|
|
信号是否正被docker转发给bash?Docker为每个容器创建一个新的pid命名空间,所以它运行的命令成为pid 1。1是一个非常特殊的pid(它通常是init进程),并且没有默认的信号处理器。一个常见的技巧是使用 tini 或 dumb-init 来作为pid 1以解决这个问题。
在调查了我们的镜像后,发现我们已经在使用 dumb-init,这给我们留下了这样的树:
|
|
以及对这个问题的无解。
这才是最后的树,我保证
实际上,我们并不是直接运行docker容器;我们使用 docker compose run。
|
|
在最终构建出这棵树后,我们才能够复现这个问题。它只发生在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”部分,我们有一个这样的进程树:
|
|
我们直接用信号给bash:
|
|
为什么我们不直接给zsh发信号呢?zsh是以交互方式运行的,所以它不应该将SIGINT转发给bash吗?我们可以试试:
|
|
但什么都没发生。
事实证明,在这种情况下,当你按下 ctrl+c 时,终端将SIGINT发送给bash,而不是zsh。这是因为zsh不再位于前台进程组中。我们可以通过运行以下命令看到这一点:
|
|
man ps 的“进程状态码”部分说:
+表示位于前台进程组
我们可以看到bash和sleep是,但zsh不是。它们也不可能同时都是,因为只能有一个前台进程组,而zsh给了bash自己的进程组(因为zsh是交互式运行的)。所以当我们说“zsh接收到SIGINT并将其转发给前台进程,这个进程正好是bash”时,事实证明那是个谎言。
但为什么bash的进程组是前台的呢?答案是 tcsetpgrp。我们可以用 ltrace 看到它的调用:
|
|
当bash退出时,父Shell(在我的例子中是zsh)用相同的调用重新获得前台状态。