记录一个今天遇到的小问题。这是继 Debug 一个在 uWSGI 下使用 subprocess 卡住的问题 之后又一次遇到信号问题。
我写的 chaos engineering 平台支持一个功能:立即中断正在进行的实验,并且执行 rollback 操作复原注入的操作。每一个实验都是由一个进程负责的,终止的方法是向进程发送一个 SIGINT
信号,让进程停止注入并且切换到 rollback 开始清理。
最近的一个改动从 asyncio.
create_subprocess_exec
切换到了 asyncio.
create_subprocess_shell
导致了一个 bug,现象是线上的执行器根本收不到停止的信号了,刹车失灵,险些酿成悲剧。
经过警方调查发现,asyncio.
create_subprocess_shell
其实会开一个新的 shell 来执行命令,默认使用的是 sh
,而 sh
默认是不转发它收到的信号的。(这里我是用了 killsnoop 来发现 sh
确实收到了信号,但是执行 chaos 的进程没有收到,然后查阅文档并通过实验复现确认 sh
不会转发信号。)
但是这个问题我在开发环境(Mac)并没有测试出来,因为开发环境工作的好好的。
我写了一个最小的 case 可以复现这个场景:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import asyncio async def subshell(): print("start sleep...") process = await asyncio.create_subprocess_shell( "sleep 23", ) print("sub process create, my id {}".format(process, process.pid)) await asyncio.sleep(23) loop = asyncio.get_event_loop() loop.run_until_complete(subshell()) |
在 Mac 上的表现是,python 进程的子进程就直接是 sleep 进程,并没有一个中间的 sh
进程。
1 2 3 4 5 6 |
➜ pstree -p 39656 -+= 00001 root /sbin/launchd \-+= 01751 xintao.lai tmux \-+= 38831 xintao.lai -zsh \-+= 39640 xintao.lai /Users/xintao.lai/.pyenv/versions/3.8.5/bin/python3 asy.py \--- 39656 xintao.lai sleep 23 |
而在 Linux 上的表现是:python 进程的子进程是 sh
进程,然后 sh
的子进程才是 sleep
进程。
1 2 3 4 5 6 7 8 9 10 11 12 |
vagrant@vagrant:~$ python3 asy.py start sleep... sub process create: <Process 13275>, 13275 vagrant@vagrant:~$ pstree -lasp 13275 systemd,1 └─sshd,2096 └─sshd,13174 └─sshd,13213 └─bash,13214 └─python3,13274 asy.py └─sh,13275 -c sleep 13 └─sleep,13277 13 |
经过 ./grey
指点,发现在 Mac 上 sh -c "sleep 99"
之后,sh
自己也不见了,只有 sleep 99
这个进程,父进程是我自己的 zsh
shell.
这里就真相大白了。中间进程的这个 sh
并不会转发 signal,所以在线上的 Linux 系统上收不到信号;在开发电脑上由于没有中间的 sh
,所以直接将 signal 发给了子进程。
那么 sh
在两个系统上到底有怎么样的不同呢?
在我的 Mac 上,man sh
说:
sh is a POSIX-compliant command interpreter (shell). It is implemented by re-execing as either bash(1), dash(1), or zsh(1) as determined by the symbolic link located at /private/var/select/sh. If /private/var/select/sh does not exist or does not point to a valid shell, sh will use one of the supported shells.
经过查看,可以发现其实 sh
在 Mac 上是 bash
:
1 2 |
tmp ➜ ls -l /private/var/select/sh lrwxr-xr-x 1 root wheel 9 Jan 1 2020 /private/var/select/sh -> /bin/bash |
对于 bash -c "sleep 99"
这个命令,bash
有一些优化,为了节省资源,bash
会直接通过 execve()
去执行 sleep
,这样在系统上就可以少存在一个 bash 进程。详细解释。
而在 ubuntu 上,sh
其实是 dash
:
1 2 |
vagrant@vagrant:~$ realpath $(which sh) /usr/bin/dash |
dash
(至少我们使用的版本)还没有这个优化,所以在 Python 的 subprocess shell (经过 linw1995 指点)中就会有两层进程,一个是 dash
,dash 的子进程才是运行的命令。
在 ubuntu 上 bash -c "sleep 99"
可以看到 bash
本身也是会消失的。说明这个确实是 bash
的行为。
说 bash
进程消失不太准确,它其实是换了一个形式存在而已。strace
可以证明它存在过:
1 2 3 4 5 |
strace bash -c 'sleep 99' execve("/usr/bin/bash", ["bash", "-c", "sleep 99"], 0x7ffff8ff9f90 /* 27 vars */) = 0 brk(NULL) = 0x5614ac6ae000 ... execve("/usr/bin/sleep", ["sleep", "99"], 0x5614ac6b8930 /* 27 vars */) = 0 |
反思一下这个问题,有以下几点可以做的更好:
- 换成 Linux 开发;
- 写测试用例,CI 完全可以发现这个问题;
- 还是尽量使用
asyncio.
create_subprocess_exec
来执行命令吧!
曾经被前辈要求使用在创建子进程时使用 args 而不是把命令写在一个字符串里交给 shell 解析
好像幸运地不会触发这个问题。
确实,我也是一直用 exec 传入 list 来 fork 的,只不过这个命令越来越长,某一天开始偷懒了,就改成了一个 string 的 format 用 shell 执行了……
其实 shell 里有个 exec 命令可以让命令替换掉当前的 shell 进程,这也是容器上解决 PID 1 是 bash 时退出信号不传递的常用手段。
1. 进程默认都没有转发信号这种行为的,如果需要那么父进程要自己实现子进程的管理功能,比如 gunicorn 的 [Arbiter](https://github.com/benoitc/gunicorn/blob/master/gunicorn/arbiter.py) 接收 signal 然后遍历子进程再发送 signal。
2.
asyncio.subprocess_exec
和asyncio.subprocess_shell
最后还是使用的subprocess.Popen
来创建的进程,区别是shell=True
参数。这个拿到的 process 对象理应就是 shell 进程的对象。Python 的官方镜像的 /bin/sh 也是 dash,也需要考虑这种情况。bash 只使用 exec 的这种特殊行为以前还真的没有注意到 (捂脸。3. 解决方法可以通过
psutil
来遍历 children 发送信号。或者放到同一个进程组中,然后os.killpg
。或者使用偷巧的方法,命令字符串前面加一个exec
,即dash -c "exec sleep 2"
这种可以没有dash
进程只有sleep
进程。学习了。