杀死那些与我没有亲缘关系的进程

人不为己,天诛地灭。
直到这一天,某个进程也决定要这么去做。
而你,也决定做个幕后黑手,助他一臂之力。

需求

指定一个文件路径,所有访问这个文件的进程,除目标进程外,全部杀掉。
因为自己也在访问这个文件,所以不能直接lsof后全部杀死。
而因为系统机制,父进程及祖先进程也不能杀死。
而子进程可能是目标进程启动的子任务,因此也不能杀死。
悲催的是,pstree等第三包的命令不允许使用。

基本方法

不能杀死父进程,以及祖先进程,否则可能会被一窝端。
也不能杀死子孙进程:不孝有三,无后为大。
但是叔叔辈、伯伯辈以及他们的子孙分支,那就只能 say sorry 了。

祖先进程

典型的比如 shell 脚本与父进程 bash。
而我们知道,ps -ef 不仅能显示所有进程信息(包含PID),还会显示父进程(PPID)。
因此找到某个进程的所有前代进程很简单:
进程->父进程->爷进程->太爷进程->…->祖宗进程(PID为1,个别为0)。
也即按照此链路一直往上找,直到PID为0或1。
简单通过一个map即可实现。

子孙进程

很典型的例子是一个 shell 脚本,基本命令、tee日志等都会启动子进程。
而找到子孙进程要稍微复杂一些,毕竟开枝散叶了嘛。
原始进程我们称之为目标进程。在 shell 中,可以通过$$获取到自己的PID。
我们对随便一个PID按照上面相同的路径向上寻找,会有两种可能:

  1. 找到了1或0:此进程不是目标进程的子孙进程。可能和目标进程没关系,或者是它的祖先进程。
  2. 找到了目标进程:是目标进程的子孙进程,属于保护目标。

使用命令

简单的使用shell命令实现即可。
其实如果有 pstree及类似命令,一条命令即可解决。但奈何不允许装这个包。
注意使用 lsof 和 ps 命令实现。

lsof

lsof 查找访问文件的全部进程,可获取到PID。
lsof +D还会搜索目录下的目录。
如果文件不存在,lsof 会报错,因此可以先检查。
简单看了下,没找到能直接去掉 header 的选项。那就解析时过滤吧。

ps

ps -ef 查找所有的进程及与父进程的对应关系。
–noheaders 可以去掉 header。(MAC下不行)
但是发现指定列后,显示的进程不全了,可以继续深究下。

kill

暴力一点,kill -9 即可。
但需要注意,一些进程,比如lsof自身,只短暂存在。
考虑到代码执行时间很短,忽略掉误杀其它进程的情况。
但是要考虑kill不存在的进程导致的报错。

遗留问题

screen 抱养之后的问题

测试发现,screen 运行有个特殊问题,可称之为“抱养”的场景。
总结一句话:从 screen detach后,screen的父进程会变为1。因此在screen内运行此脚本,会导致外面的shell被杀。但是screen不会被杀,其内运行的脚本也不会受影响。但是使用者会被退出终端,ssh断开连接等。

场景如下:

  1. screen -S test,之后在screen内 ps -ef 可看到,screen进程的PPID为之前的bash进程

  2. 在screen内运行 bash test.sh,此脚本内容就是简单打印自己PID然后睡眠:

    echo $$
    sleep 999999
  3. 从screen detach出来,ps -ef 可看到,screen进程的PPID变为了1!
    脱离父子关系!好绝情!
    但也可以理解,screen要在外层shell退出之后依然运行。它实在忍受不了这个不靠谱的爹了。

  4. 再次screen -x test 来attach进去,执行脚本,发现父进程是之前的screen -S!

这种情况下,在 screen 内执行脚本,除非screen -S未detach过,否则会有问题。
但是万幸的是,脚本执行不会被中断。

代码实现

python+shell实现方式如下。

echo "my PID: $$"
python kill_non_relatives.py $$
echo "finish"

#!/usr/bin/env python
# -*- encoding: utf-8 -*-
import sys
import subprocess
import os
import signal

# 查找具有祖先进程和子孙进程,错误返回None
def find_related_pids(target_pid):
ps_command = subprocess.Popen("ps -ef --noheaders|awk '{print $2,$3}'",
shell=True, stdout=subprocess.PIPE)
ps_output = ps_command.stdout.readlines()
retcode = ps_command.wait()
assert retcode == 0, "ps command returned %d" % retcode

pid_to_ppid = {} # PID:PPID
for line in ps_output:
items = line.split()
if len(items) != 2:
print "parse ps -ef error"
return None
# print items[0],items[1]
pid_to_ppid[int(items[0])] = int(items[1])
# print pid_to_ppid

ppid_map = {} # save all PPID
cpid_map = {} # save all child PID
if target_pid not in pid_to_ppid:
print "not found"
return None
pid = pid_to_ppid[target_pid]
ppid_map[pid] = 0
while pid > 1:
if pid not in pid_to_ppid:
break
pid = pid_to_ppid[pid]
ppid_map[pid] = 0
print "parent pids:", ppid_map.keys()

for pid,ppid in pid_to_ppid.items():
if pid == target_pid or pid in ppid_map: # 过滤父进程
continue
while ppid > 1:
# 父进程为目标进程,或目标进程的子孙进程
if ppid == target_pid or ppid in cpid_map:
cpid_map[pid] = 0
break
ppid = pid_to_ppid[ppid]
print "child pids:", cpid_map.keys()
return list(set([target_pid] +ppid_map.keys()+cpid_map.keys()))

# 杀死访问target_file的所有进程,自己及进程树上下游除外
def kill_non_relatives(target_file, parent_pid):
print "kill_non_relatives: target_file(%s), parent_pid(%d)" % (target_file, parent_pid)
# 一定要注意grep为空的情况、文件不存在的情况,返回值为非0
lsof_command = subprocess.Popen("[[ -e %s ]] && lsof +D %s|awk '{print $2}'||:" % (
target_file, target_file), shell=True, stdout=subprocess.PIPE)
lsof_output = lsof_command.stdout.readlines()
retcode = lsof_command.wait()
assert retcode == 0, "lsof command returned %d" % retcode

white_pids = find_related_pids(parent_pid)
if white_pids is None:
print "find_related_pids error!"
sys.exit(-1)
for line in ps_output:
line = line.strip()
if not line.isdigit():
continue
pid = int(line)
if pid not in white_pids:
print "will kill %d" % pid
try:
os.kill(pid, signal.SIGKILL)
except OSError:
print "kill %d failed" % pid
continue
else:
print "skip kill %d" % pid

if __name__ == "__main__":
# 参数:目标文件路径 目标进程PID
kill_non_relatives(sys.argv[1], int(sys.argv[2]))

其它方式

  • pstree 拿到进程树
  • 读取系统文件等方式,解析出进程树
  • 拷贝执行文件到其它目录等方式,保证自己不会访问这个目录,然后直接全部杀掉