信号、Shell与Docker:一个层层嵌套的“陷阱”

本文详细探讨了在Linux环境下调试CI/CD流程中信号(如SIGINT、SIGTERM)传递问题的过程。文章深入分析了交互式与非交互式Shell对信号处理的差异、进程组的作用、容器环境(如Docker Compose)对信号转发的影响,并揭示了在复杂进程树中信号链断裂的根本原因。

信号、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)

(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代码)。

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中默认开启,在脚本中默认关闭(参见关于“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
/* 我们能避免fork吗?例如,脚本或子Shell中的最后一条命令不需要fork,
   我们可以直接exec它。 */

所以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 链接到 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镜像中运行 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 exec 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

在最终构建出这棵树后,我们才能够复现这个问题。它只发生在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 设计