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

在几次调试POSIX信号(SIGINT、SIGTERM等)的过程中,我们不可避免地涉及到了shell。某天,我们在调试信号、shell和容器之间的某些奇怪交互时,被一些行为搞得晕头转向。自认为对Linux很了解的人也会对我们调查中的一些细节感到惊讶,所以如果你不想把笔记本电脑扔出窗外去当养羊驼的隐士,不妨继续读下去。

犯罪现场

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

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

python 复制代码
def test_pipeline() -> int:
    test_result = subprocess.run(["pytest", ...])
    report_test_metrics()
    upload_artifacts()
    return test_result.returncode

所以我们的进程树是:

复制代码
test_pipeline
└── pytest

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

回到基础

认为问题可能在于没有将信号从test_pipeline转发到pytest,我们首先考虑了基本的信号处理。在运行zsh的终端中,我们可以获取zsh的pid:

bash 复制代码
$ echo $$
20147

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

bash 复制代码
$ bash
$ sleep infinity

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

bash 复制代码
$ 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

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

复制代码
bash
  └─test_pipeline
      └─pytest

认为交互式shell(读取stdin等差异)可能与非交互式shell或"脚本"行为不同,我们将两行代码写入文件:

bash 复制代码
sleep infinity
echo done

并运行:

bash 复制代码
$ ./test.sh

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

bash 复制代码
$ pstree -p 20147
zsh(20147)───bash(65910)───sleep(65911)

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

bash 复制代码
$ kill -s INT 65910

但什么也没发生。bash文档(man bash)中有一个"signals"部分提到:

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

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

但其中也有关于进程组的提示。pstree也可以显示这些(除非你在macOS上):

bash 复制代码
$ pstree -pg 20147
zsh(20147,20147)───bash(65910,65910)───sleep(65911,65910)

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

bash 复制代码
$ kill -s INT -65910

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

bash 复制代码
$ sleep infinity

并看到sleep按预期获得自己的pgid。

bash 复制代码
$ pstree -p 20147
zsh(20147,20147)───sleep(65916,65916)

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

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

bash 复制代码
$ bash -c 'sleep infinity'

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

bash 复制代码
$ 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源代码:

c 复制代码
/* 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脚本,所以我们的心理模型是:

markdown 复制代码
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都实现了前一节中描述的无需fork的exec行为,但dash没有。此外,如果你尝试在Docker Hub上的官方bash镜像中运行sh(docker run -it --rm bash sh),你会得到ash(不要与ash混淆),而不是你期望的sh模式下的bash。

流程图

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

回到犯罪现场

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

markdown 复制代码
ci-agent
    └─bash
        └─test_pipeline
            └─pytest

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

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

容器

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

markdown 复制代码
ci-agent
    └─docker
        └─bash
            └─test_pipeline
                └─pytest

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

在调查我们的镜像后,结果发现我们已经在使用dumb-init,给我们留下了这个树:

csharp 复制代码
ci-agent
    └─docker
        └─dumb-init
            └─bash
                └─test_pipeline
                    └─pytest

但问题仍然没有解释。

这是最后一棵树,我发誓

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

csharp 复制代码
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"部分,我们有一个进程树:

scss 复制代码
zsh(20147)───bash(65910)───sleep(65911)

并直接向bash发送信号:

bash 复制代码
$ kill -s INT -65910

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

bash 复制代码
$ kill -s INT -20147

但什么也没发生。

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

bash 复制代码
$ 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看到它被调用:

bash 复制代码
$ ltrace -e tcsetpgrp bash
bash->tcsetpgrp(255, 0xa9850, 0, 0x7f290bdb2fe4) = 0

当bash退出时,父shell(在我的情况下是zsh)通过相同的调用重新声明前台状态。

相关推荐
文心快码BaiduComate2 小时前
Comate Figma2Code智能体升级,畅享Figma2Code不受限
人工智能·程序员·前端框架
一RTOS一2 小时前
工业AI安监超脑,为智能建造打造“安全数字底座”
人工智能·安全
云安全联盟大中华区2 小时前
构建AI原生工程组织:关于速度、文化与安全的经验
人工智能·安全·web安全·网络安全·ai·ai-native
nju_spy2 小时前
论文阅读 - 深度学习端到端解决库存管理问题 - 有限时间范围内的多周期补货问题(Management Science)
人工智能·深度学习·动态规划·端到端·库存管理·两阶段pto·多周期补货问题
u***j3242 小时前
深度学习实践
人工智能·深度学习
r***d8652 小时前
深度学习挑战
人工智能·深度学习
新加坡内哥谈技术2 小时前
迈向星际 QUIC 流量
人工智能
受之以蒙2 小时前
具身智能的“任督二脉”:用 Rust ndarray 打通数据闭环的最后一公里
人工智能·笔记·rust
强盛小灵通专卖员2 小时前
Airsim仿真、无人机、Lidar深度相机、DDPG深度强化学习
人工智能·无人机·sci·研究生·ei会议·中文核心期刊·小论文