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

news/2025/11/19 6:08:17/文章来源:https://www.cnblogs.com/qife122/p/19240216

在几次调试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 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或“脚本”行为不同,我们将两行代码写入文件:

sleep infinity
echo done

并运行:

$ ./test.sh

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

$ pstree -p 20147
zsh(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 20147
zsh(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 20147
zsh(20147,20147)───sleep(65916,65916)

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

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

$ bash -c 'sleep infinity'

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

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

/* 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 statPID STAT S TTY          TIME COMMAND20147 Ss   S pts/0    00:00:00 zsh65910 S+   S pts/0    00:00:00 bash65911 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 bash
bash->tcsetpgrp(255, 0xa9850, 0, 0x7f290bdb2fe4) = 0

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

公众号二维码

公众号二维码

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/969560.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

应用安全 --- 代码块保护

应用安全 --- 代码块保护有的商业加密会将一个函数内的指令打乱分散在各个文件区域以块的形式存在 比如 这标红的区域表示这段代码不在这个函数内但是被这个函数使用了

linux cpu中断

在 Linux 中,CPU 中断(CPU Interrupt)是操作系统内核处理硬件事件的一种机制。当 CPU 的某个硬件设备(如网卡、磁盘、GPU 等)发生事件时,会向 CPU 发送一个中断信号,CPU 会暂停当前正在执行的程序,转而处理该中…

linux cpu 超过100

在 Linux 中,如果你想监控 CPU 使用率是否超过 100%,可以使用以下几种方法:? 方法一:使用 top 命令 top 是一个实时的系统监控工具,可以显示 CPU 使用情况。 top -b -n 1 | grep + #引号 + Cpu(s) + #引号 +…

linux cpu内存使用情况

要查看 Linux 系统中的 CPU 和内存使用情况,可以使用以下常用命令和脚本:一、查看实时 CPU 使用情况 top功能:显示实时的 CPU 使用情况,包括 CPU 核心、进程、内存等信息。 适用场景:需要实时监控系统负载和进程资…

linux cpu 占用高

在 Linux 系统中,当发现 CPU 占用率高时,可以通过以下几种方式来诊断和分析问题:一、常用命令查看 CPU 占用率 1. top 命令功能:实时监控系统 CPU 使用情况。 使用方法:top输出说明: %CPU:CPU 使用率(百分比)…

linux cpu 使用率过高

当 Linux 系统的 CPU 使用率过高时,可能是由于进程资源争用、后台进程异常、系统负载过高或系统服务未正确关闭等原因引起的。以下是一些常见的排查和解决方法,帮助你诊断和处理 CPU 使用率过高的问题:一、查看 CPU…

linux cpu mysql

当然可以!你提到的“Linux CPU”和“MySQL”是两个不同的主题,我将分别解释,并提供相关命令和脚本。一、Linux CPU 使用情况查看 1. 常用命令查看 CPU 使用情况 1.1 使用 top 命令功能:实时查看系统 CPU 使用情况。…

linux cp 并创建目录

在 Linux 中,使用 cp 命令复制文件或目录时,如果需要创建目标目录,可以使用 -r(递归复制)和 -d(防止覆盖已有的目录)选项,或者使用 mkdir 命令配合 cp 来实现。 方法 1:使用 cp 和 mkdir(推荐) cp -r sourc…

linux count

在 Linux 中,count 命令通常用于统计文件中某一行或某部分内容的出现次数。常见的 count 命令有以下几种:1. grep + wc 统计某行出现次数 grep -c + #引号 + pattern + #引号 + filename-c:统计匹配行的数量。…

linux cos()

你提到的 cos() 函数在 Linux 系统中并不是内置的命令或脚本,而是一个数学函数,通常在编程语言(如 Python、bash、Perl 等)中使用。在 Linux 命令行中,没有直接的 cos() 函数。 如果你是在询问如何在 Linux 中计算…

vivo 手机 拍照 特殊jpg格式 批量压缩

https://github.com/tjko/jpegoptim Windows (setargv.obj "link option" is needed for wildcards expansion to work)JPEGOPTIM(1) General Commands Manual JPEGOPTIM(1)NA…

linux cluster

在 Linux 系统中, + #引号 + cluster + #引号 + 通常指的是 集群(Cluster),即一组相互协作的计算机(节点)组成的一个系统,用于提高性能、可靠性、可扩展性等。Linux 系统中,集群技术常用于负载均衡、高可…

linux chrome 下载

在 Linux 系统中,如果你想要下载 Chrome 浏览器,通常有以下几种方法:方法一:使用官方下载链接(推荐) Chrome 浏览器的官方下载地址是: https://www.google.com/chrome/ 你可以直接访问该网站,点击“下载 Chrom…

linux chmod 目录

在 Linux 中,chmod 命令用于修改文件或目录的权限。对于 目录,chmod 的使用方式和文件类似,但需要注意目录的权限设置。? 一、chmod 命令的基本用法 chmod [options] mode file...mode:权限模式,可以是数字或符号…

linux checkout

在 Linux 系统中,checkout 并不是一个标准的命令,它通常与版本控制系统(如 Git)相关。如果你是在使用 Git,checkout 是一个常用的命令,用于切换分支或恢复工作目录。 1. Git 中的 checkout 命令 1.1 切换分支 gi…

linux cer

您提到的“linux cer”可能是输入错误或不完整。如果您是想询问与 Linux 系统 或 Linux 命令 相关的问题,请提供更具体的内容,例如:Linux 命令(如 ls, grep, sort 等) Linux 系统管理(如 systemd, init, cron 等…

linux centos 版本查看

在 CentOS 系统中,查看 Linux 版本(包括内核版本和系统发行版版本)的常用方法如下:方法 1:查看系统发行版版本(CentOS) cat /etc/redhat-release 输出示例: CentOS release 7.9.2009 (Core)方法 2:查看内核版…

linux cdc

您提到的 + #引号 + Linux cdc + #引号 + 可能是指 CDC (Communication Device Class),这是 USB 接口的一个标准类,用于定义 USB 设备与主机之间的通信协议。CDC 类常用于 USB 音频、键盘、鼠标等设备。 如果您…

linux catalina

您提到的 + #引号 + Catalina + #引号 + 可能是指 Apache Tomcat 的一个版本,即 Tomcat 9,也被称为 Catalina 9。在 Linux 系统中,Tomcat 是一个常见的 Java Web 服务器。 如果您是在 Linux 系统中安装、配置…

linux caffe

您提到的 + #引号 + Linux Caffe + #引号 + 可能是指在 Linux 系统上使用 Caffe 深度学习框架进行开发或部署。Caffe 是一个基于 C++ 的深度学习框架,常用于图像分类、目标检测等任务。? 一、Caffe 在 Linux 上…