写点什么

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

作者:qife122
  • 2025-11-19
    福建
  • 本文字数:4445 字

    阅读完需:约 15 分钟

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

犯罪现场

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


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


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:


$ echo $$20147
复制代码


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


$ bash$ sleep infinity
复制代码


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


$ pstree -p 20147zsh(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 或“脚本”行为不同,我们将两行代码写入文件:


sleep infinityecho done
复制代码


并运行:


$ ./test.sh
复制代码


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


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


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


$ kill -s INT 65910
复制代码


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


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


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


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


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


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


$ kill -s INT -65910
复制代码


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


$ sleep infinity
复制代码


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


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

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

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


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


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


$ pstree -p 20147zsh(20147)───sleep(65920)
复制代码


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


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


但这里发生的是,bash 在 exec sleep 之前根本没有 fork。我们找不到关于这种行为的任何文档,所以我们向你提供一些 ash 源代码:


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


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。


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


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


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

容器

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


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


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


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


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


但问题仍然没有解释。

这是最后一棵树,我发誓

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


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”部分,我们有一个进程树:


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


并直接向 bash 发送信号:


$ kill -s INT -65910
复制代码


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


$ kill -s INT -20147
复制代码


但什么也没发生。


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


$ 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看到它被调用:


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


当 bash 退出时,父 shell(在我的情况下是 zsh)通过相同的调用重新声明前台状态。更多精彩内容 请关注我的个人公众号 公众号(办公 AI 智能小助手)对网络安全、黑客技术感兴趣的朋友可以关注我的安全公众号(网络安全技术点滴分享)


公众号二维码


办公AI智能小助手


公众号二维码


网络安全技术点滴分享


用户头像

qife122

关注

还未添加个人签名 2021-05-19 加入

还未添加个人简介

评论

发布
暂无评论
信号、Shell与Docker:层层嵌套的陷阱剖析_Docker_qife122_InfoQ写作社区